GameTech/URP 渲染实战

案例 3.8关键字:用 multi_compile 与 shader_feature 做开关进阶

目标:让顶点色、Alpha Clip、UV 来源都能在材质面板上开关,而且关掉的功能不产生任何运行时开销。

GPU 不擅长分支。虽然现代 GPU 对“所有像素都走同一边”的统一分支(uniform branch)处理得不错,但分支里用到的寄存器、插值器、贴图采样仍会占用资源,clip 更是只要存在就可能影响 Early-Z。Unity 的解决方案是着色器变体(Shader Variant):用预处理器 #if 写出不同功能的代码,用关键字(Keyword)标记,编译时把每种关键字组合各编译一份,运行时按材质上启用的关键字挑选对应的那份。

Assets/Cookbook/Ch03/C03_08_Keywords.shader(节选:props)
// [Toggle(关键字)]:勾选时启用该关键字,属性值同时变成 1
[Toggle(_VERTEX_COLOR_ON)] _UseVertexColor ("Use Vertex Color", Float) = 0
[Toggle(_ALPHATEST_ON)]    _AlphaClip ("Alpha Clip", Float) = 0
_Cutoff ("Alpha Cutoff", Range(0, 1)) = 0.5
// [KeywordEnum(A, B)]:生成关键字 _属性名大写_选项大写,即 _UVSOURCE_MESH / _UVSOURCE_WORLDXZ
[KeywordEnum(Mesh, WorldXZ)] _UVSource ("UV Source", Float) = 0
_WorldUVScale ("World UV Scale", Float) = 0.5
[Enum(UnityEngine.Rendering.CullMode)] _Cull ("Cull", Float) = 2
// 第 8 步:用关键字(Keyword)做功能开关 —— 一份源码,按需编译出多个变体。
// 这是本章的“完成版”:纹理、顶点色、Alpha Clip、雾、SRP Batcher、阴影、深度全部具备。
Shader "Cookbook/Ch03/Keywords"
{
    SubShader
    {
        Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" }

        GLSLINCLUDE
        // CBUFFER_START(UnityPerMaterial)
        uniform vec4  _BaseMap_ST;
        uniform vec4  _BaseColor;
        uniform float _Cutoff;
        uniform float _WorldUVScale;
        // CBUFFER_END

        uniform sampler2D _BaseMap;

        // 根据关键字选择 UV 来源。#if 在编译期决定,没被选中的分支根本不会进入这个变体
        vec2 ComputeUV(vec2 meshUV, vec3 positionWS)
        {
        #if defined(_UVSOURCE_WORLDXZ)
            vec2 uv = positionWS.xz * _WorldUVScale;   // 俯视投影:相邻物体的纹理无缝衔接
        #else
            vec2 uv = meshUV;
        #endif
            return TransformTex(uv, _BaseMap_ST);
        }

        #ifdef FRAGMENT
        void ClipByAlpha(vec2 uv)
        {
        #if defined(_ALPHATEST_ON)
            float a = texture(_BaseMap, uv).a * _BaseColor.a;
            if (a - _Cutoff < 0.0) discard;
        #endif
        }
        #endif
        ENDGLSL

        Pass
        {
            Name "Unlit"
            // 不需要 GBuffer 的 Unlit:用 UniversalForwardOnly,Forward / Forward+ / Deferred 三种路径都会画它
            Tags { "LightMode"="UniversalForwardOnly" }
            Cull [_Cull]

            GLSLPROGRAM
            // 材质开关:shader_feature —— 只编译“有材质用到”的组合;_local 不占用全局关键字名额
            #pragma shader_feature_local _VERTEX_COLOR_ON
            #pragma shader_feature_local_fragment _ALPHATEST_ON
            #pragma shader_feature_local _UVSOURCE_MESH _UVSOURCE_WORLDXZ
            // 全局开关:multi_compile —— 运行时可能切换,所有组合都要编译进包
            #pragma multi_compile_fog

            VARYING vec2 uv;
            VARYING float fogFactor;
        #if defined(_VERTEX_COLOR_ON)
            VARYING vec4 vColor;      // 不用顶点色的变体连插值器都省掉
        #endif

            #ifdef VERTEX
            void main()
            {
                VertexPositionInputs pos = GetVertexPositionInputs(positionOS.xyz);
                gl_Position = pos.positionCS;
                uv = ComputeUV(texcoord0.xy, pos.positionWS);
                fogFactor = ComputeFogFactor(pos.positionCS.z);
            #if defined(_VERTEX_COLOR_ON)
                vColor = color;
            #endif
            }
            #endif

            #ifdef FRAGMENT
            void main()
            {
                vec4 tex = texture(_BaseMap, uv);
            #if defined(_ALPHATEST_ON)
                if (tex.a * _BaseColor.a - _Cutoff < 0.0) discard;
            #endif
                vec3 rgb = tex.rgb * _BaseColor.rgb;
            #if defined(_VERTEX_COLOR_ON)
                rgb *= vColor.rgb;
            #endif
                rgb = MixFog(rgb, fogFactor);
                fragColor = vec4(rgb, 1);
            }
            #endif
            ENDGLSL
        }

        Pass
        {
            Name "ShadowCaster"
            Tags { "LightMode"="ShadowCaster" }
            ZWrite On
            ZTest LEqual
            ColorMask 0
            Cull [_Cull]

            GLSLPROGRAM
            #pragma shader_feature_local _ALPHATEST_ON
            #pragma shader_feature_local _UVSOURCE_MESH _UVSOURCE_WORLDXZ
            // _CASTING_PUNCTUAL_LIGHT_SHADOW 在网页版是 uniform _CastingPunctualLightShadow;ApplyShadowBias 等由 Lighting.glsl 提供

            VARYING vec2 uv;

            #ifdef VERTEX
            void main()
            {
                vec3 positionWS = TransformObjectToWorld(positionOS.xyz);
                vec3 normalWS   = TransformObjectToWorldNormal(normalOS);
                vec3 lightDirectionWS = _CastingPunctualLightShadow > 0.5
                    ? normalize(_LightPosition - positionWS)
                    : _LightDirection;
                gl_Position = TransformWorldToHClip(ApplyShadowBias(positionWS, normalWS, lightDirectionWS));
            #if UNITY_REVERSED_Z
                gl_Position.z = min(gl_Position.z, UNITY_NEAR_CLIP_VALUE);
            #else
                gl_Position.z = max(gl_Position.z, UNITY_NEAR_CLIP_VALUE);
            #endif
                uv = ComputeUV(texcoord0.xy, positionWS);
            }
            #endif

            #ifdef FRAGMENT
            void main()
            {
                ClipByAlpha(uv);
                fragColor = vec4(0.0);
            }
            #endif
            ENDGLSL
        }

        Pass
        {
            Name "DepthOnly"
            Tags { "LightMode"="DepthOnly" }
            ZWrite On
            ColorMask R
            Cull [_Cull]

            GLSLPROGRAM
            #pragma shader_feature_local _ALPHATEST_ON
            #pragma shader_feature_local _UVSOURCE_MESH _UVSOURCE_WORLDXZ

            VARYING vec2 uv;

            #ifdef VERTEX
            void main()
            {
                vec3 positionWS = TransformObjectToWorld(positionOS.xyz);
                gl_Position = TransformWorldToHClip(positionWS);
                uv = ComputeUV(texcoord0.xy, positionWS);
            }
            #endif

            #ifdef FRAGMENT
            void main()
            {
                ClipByAlpha(uv);
                fragColor = vec4(gl_FragCoord.z);
            }
            #endif
            ENDGLSL
        }
    }
}
Assets/Cookbook/Ch03/C03_08_Keywords.shader(节选:pragmas)
// 材质开关:shader_feature —— 只编译“有材质用到”的组合;_local 不占用全局关键字名额
#pragma shader_feature_local _VERTEX_COLOR_ON
#pragma shader_feature_local_fragment _ALPHATEST_ON
#pragma shader_feature_local _UVSOURCE_MESH _UVSOURCE_WORLDXZ
// 全局开关:multi_compile —— 运行时可能切换,所有组合都要编译进包
#pragma multi_compile_fog
// 材质开关:shader_feature —— 只编译“有材质用到”的组合;_local 不占用全局关键字名额
#pragma shader_feature_local _VERTEX_COLOR_ON
#pragma shader_feature_local_fragment _ALPHATEST_ON
#pragma shader_feature_local _UVSOURCE_MESH _UVSOURCE_WORLDXZ
// 全局开关:multi_compile —— 运行时可能切换,所有组合都要编译进包
#pragma multi_compile_fog

#pragma 有两大类:

multi_compileshader_feature
编译哪些变体列出的所有组合都编译进包只编译“构建时有材质用到”的组合
适合运行时由代码或引擎切换的全局状态:雾、阴影、光照贴图、渲染路径材质面板上的功能开关,美术设好后基本不变
风险变体数随关键字数指数增长,包体和编译时间膨胀运行时用脚本开启一个“没有材质用过”的关键字,构建版本里找不到对应变体,会退回到最接近的变体(效果不对)

两者都可以加后缀:_local 表示关键字只在本 Shader 内有效(Unity 对全局关键字总数有上限,材质开关一律用 local);_vertex、_fragment 表示只有该阶段的代码用到它,编译器可以让另一个阶段共享同一份程序,减少实际编译量。我们的 _ALPHATEST_ON 只在片元里用,所以颜色 Pass 写 shader_feature_local_fragment;但阴影和深度 Pass 也要裁剪,它们各自声明自己的 shader_feature_local _ALPHATEST_ON——每个 Pass 的关键字是独立声明的,没有在某个 Pass 里声明的关键字,在那个 Pass 里永远是未定义。

单个关键字的 shader_feature_local _VERTEX_COLOR_ON 是 shader_feature_local _ _VERTEX_COLOR_ON 的简写,下划线 _ 代表“都不开”。多个互斥关键字写在同一行,如 _UVSOURCE_MESH _UVSOURCE_WORLDXZ,同一时刻只会有一个生效。来算一下颜色 Pass 最多有多少变体:顶点色 2 × Alpha Clip 2 × UV 来源 2 × 雾 4 = 32 个。再加几个开关就会上百——这就是所谓的“变体爆炸”,管理方法在第 29 章。

Assets/Cookbook/Ch03/C03_08_Keywords.shader(节选:uv)
// 根据关键字选择 UV 来源。#if 在编译期决定,没被选中的分支根本不会进入这个变体
float2 ComputeUV(float2 meshUV, float3 positionWS)
{
#if defined(_UVSOURCE_WORLDXZ)
    float2 uv = positionWS.xz * _WorldUVScale;   // 俯视投影:相邻物体的纹理无缝衔接
#else
    float2 uv = meshUV;
#endif
    return TRANSFORM_TEX(uv, _BaseMap);
}

void ClipByAlpha(float2 uv)
{
#if defined(_ALPHATEST_ON)
    half a = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, uv).a * _BaseColor.a;
    clip(a - _Cutoff);
#endif
}
// 根据关键字选择 UV 来源。#if 在编译期决定,没被选中的分支根本不会进入这个变体
vec2 ComputeUV(vec2 meshUV, vec3 positionWS)
{
#if defined(_UVSOURCE_WORLDXZ)
    vec2 uv = positionWS.xz * _WorldUVScale;   // 俯视投影:相邻物体的纹理无缝衔接
#else
    vec2 uv = meshUV;
#endif
    return TransformTex(uv, _BaseMap_ST);
}

#ifdef FRAGMENT
void ClipByAlpha(vec2 uv)
{
#if defined(_ALPHATEST_ON)
    float a = texture(_BaseMap, uv).a * _BaseColor.a;
    if (a - _Cutoff < 0.0) discard;
#endif
}
#endif
Assets/Cookbook/Ch03/C03_08_Keywords.shader(节选:frag)
half4 frag (Varyings input) : SV_Target
{
    half4 tex = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv);
#if defined(_ALPHATEST_ON)
    clip(tex.a * _BaseColor.a - _Cutoff);
#endif
    half3 rgb = tex.rgb * _BaseColor.rgb;
#if defined(_VERTEX_COLOR_ON)
    rgb *= input.color.rgb;
#endif
    rgb = MixFog(rgb, input.fogFactor);
    return half4(rgb, 1);
}
#ifdef FRAGMENT
void main()
{
    vec4 tex = texture(_BaseMap, uv);
#if defined(_ALPHATEST_ON)
    if (tex.a * _BaseColor.a - _Cutoff < 0.0) discard;
#endif
    vec3 rgb = tex.rgb * _BaseColor.rgb;
#if defined(_VERTEX_COLOR_ON)
    rgb *= vColor.rgb;
#endif
    rgb = MixFog(rgb, fogFactor);
    fragColor = vec4(rgb, 1);
}
#endif

代码里用 #if defined(...) 判断关键字。被排除的分支在这个变体里根本不存在:不用顶点色的变体,Attributes 和 Varyings 里连 color 成员都没有,省下了一个插值器。世界空间俯视投影(_UVSOURCE_WORLDXZ)用 positionWS.xz 当 UV,相邻的物体会共用同一套坐标,纹理在接缝处无缝衔接,常用于地面拼块和关卡白模;代价是竖直面上会被拉成条纹(下图右的立方体侧面),第 20 章的三平面映射(Triplanar)会解决这个问题。

图 3-14同一个 Shader 的三种关键字组合。左:全部关闭(网格 UV);中:开启 _VERTEX_COLOR_ON,贴图乘上顶点色;右:再开启 _ALPHATEST_ON 与 _UVSOURCE_WORLDXZ,两个箱子顶面的波点连成一片,侧面被拉成条纹,镂空的阴影也正确

从 C# 设置关键字

在材质面板上勾选 [Toggle(_VERTEX_COLOR_ON)],Unity 会同时改属性值和关键字。但用脚本 material.SetFloat("_UseVertexColor", 1) 只改数值,不改关键字,这是新手最常踩的坑。脚本里要显式设置关键字:

// 方式一:字符串(简单)
material.EnableKeyword("_VERTEX_COLOR_ON");
material.DisableKeyword("_ALPHATEST_ON");
// 方式二:LocalKeyword(2021.2 起可用,类型安全,可缓存)
var kw = new UnityEngine.Rendering.LocalKeyword(material.shader, "_ALPHATEST_ON");
material.SetKeyword(kw, true);
material.SetFloat("_AlphaClip", 1);              // 让面板显示保持一致
material.renderQueue = (int)UnityEngine.Rendering.RenderQueue.AlphaTest;  // 镂空时顺便调整队列

最后一行也值得注意:本 Shader 的 SubShader 写的是 Queue=Geometry,开启镂空后应把材质的渲染队列改为 AlphaTest。URP 自带 Shader 是靠自定义材质面板(CustomEditor)在勾选时自动完成这些联动的。