案例 3.8关键字:用 multi_compile 与 shader_feature 做开关进阶
目标:让顶点色、Alpha Clip、UV 来源都能在材质面板上开关,而且关掉的功能不产生任何运行时开销。
GPU 不擅长分支。虽然现代 GPU 对“所有像素都走同一边”的统一分支(uniform branch)处理得不错,但分支里用到的寄存器、插值器、贴图采样仍会占用资源,clip 更是只要存在就可能影响 Early-Z。Unity 的解决方案是着色器变体(Shader Variant):用预处理器 #if 写出不同功能的代码,用关键字(Keyword)标记,编译时把每种关键字组合各编译一份,运行时按材质上启用的关键字挑选对应的那份。
// [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 } } }
// 材质开关: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_compile | shader_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 章。
// 根据关键字选择 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
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)会解决这个问题。

_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)在勾选时自动完成这些联动的。