平时在项目里做Shader优化,经常听到“不要在Shader里写if”“条件分支会拖垮GPU”这类说法。起初我也把这些当成铁律,后来在移动端做一个PBR角色材质时,发现同样的分支在不同机型上表现天差地别,才意识到条件分支这件事远不是“能用”和“不能用”那么简单。今天就把Unity Shader里条件分支的底层原理、开销来源、以及实际项目里到底该怎么权衡,一次说清楚。
这篇内容适合正在做渲染优化、或者刚接触Shader编程想知道“为什么大家都让我别用if”的Unity开发者。我会从GPU的执行模型讲起,再落到Unity里几种分支写法的差异,最后给一套可以直接套用的取舍标准。
1. 条件分支慢在哪:GPU不像CPU那样“跳过”
很多人对分支的理解是CPU层面的:遇到if就判断,条件满足就走A,不满足就走B。这套模型放在GPU上基本是错的。GPU设计的目标是大规模并行,动辄几千上万个线程同时在跑,但如果每个线程都在执行不同的指令,硬件就没办法统一调度了。
1.1 并行执行单元与“锁步”机制
GPU里真正执行指令的最小单位是“线程束”(NVIDIA叫warp,AMD叫wavefront),一个线程束通常包含32个或64个线程。这些线程在硬件上是绑在一起、按照同一条指令流推进的。你可以把线程束想象成一队必须齐步走的士兵,队长喊“左转”,所有人必须左转;喊“右转”,所有人必须右转。
一旦代码里出现条件分支,问题就来了:如果这32个线程里有一部分满足条件要走A分支,另一部分要走B分支,队长只能先喊“左转”,让需要走A的线程执行,剩下不需要的线程虽然不干活,但也得站在那里等;然后队长再喊“右转”,让需要走B的线程执行,另一部分人再站着等。这就是典型的“分支发散”。
分支发散意味着两件事:一是本该一次并行完成的工作被拆成了两次,二是有一半线程在“空转”但依然占用执行周期。
1.2 三种分支代价等级
根据分支条件在编译期、运行期是否可知,GPU对分支的处理策略完全不同,我会在下一节展开,这里先给一个整体感受:
| 分支类型 | 判断时机 | 典型开销 |
|---|---|---|
| 编译期分支 | 编译时已确定 | 几乎为零 |
| 统一分支(uniform分支) | 渲染前已确定,整个绘制批次一致 | 较低,多数GPU可直接跳过或只执行一次单侧路径 |
| 动态分支 | 像素/顶点数据相关,各线程不一致 | 高,可能引发线程束内多次执行 |
关键结论先放在这里:分支本身不一定是性能杀手,“发散”才是。
我实际测试过一个非常简单的片元着色器,只是判断UV的x值是否大于0.5,然后输出两种颜色。在PC的RTX显卡上几乎看不出差异,因为编译器和驱动做了优化,把分支变成了基于插值结果的select指令;但在某款低端Mali GPU的手机上,帧率直接从60掉到45。同一个Shader,换了个平台,结果完全不同。
所以在Unity里写条件分支,第一步不是背优化口诀,而是搞清楚你写的是哪种分支,以及目标平台的GPU是怎么处理它的。
2. 三种分支形态:宏、uniform分支与动态分支
Shader里的条件分支,根据“条件在什么时机确定”可以分成三大类。每一类的CPU端传递方式不同,GPU端的执行代价也不同。很多人抱怨Shader分支性能差,通常是把三类混为一谈,结果该用宏的地方用了动态分支,或者反过来。
2.1 编译期分支:本质是“代码裁剪”
编译期分支指的是在Shader编译阶段,条件就已经被确定下来。Unity里最典型的就是#if、#elif、#endif这样的预处理指令,以及#pragma multi_compile和#pragma shader_feature生成的多个shader变体。
#if defined(_ENABLE_SPECULAR) color += specularColor * specularIntensity; #endif这种分支在执行时完全不存在。_ENABLE_SPECULAR如果没有定义,这块代码根本不会出现在最终的GPU指令里。这相当于C#里的#if DEBUG,编译器在生成最终代码之前就完成了裁剪。
它不是“快”,而是“根本不存在”。所以你在运行时测它的性能,当然是零开销。但它的代价藏在另一个维度:编译时间、包体大小、加载时间,这些我们后面单独说。
2.2 uniform分支:整个批次走同一路径
uniform分支的条件在CPU端设置,并且对整个绘制批次里的所有像素都是一样的。比如设置一个_QualityLevel参数,根据它选择不同的光照计算方式:
if (_QualityLevel > 0.5) { // 完整PBR计算 color = CalculatePBR(...); } else { // 简化漫反射计算 color = Albedo * _LightColor0; }因为条件在同一个Draw Call内恒定,GPU可以很聪明地处理:有些架构会一次性把两个分支都执行一遍,再通过位掩码选择最终结果;有些架构则会做一次分支预测,整个线程束走同一个方向,完全不会产生发散。
所以uniform分支通常是可以放心用的。代价主要是多花了寄存器或执行带宽,但不会出现“一半线程空转”这种最糟糕的情况。
2.3 动态分支:与像素数据相关的发散
动态分支的条件来自顶点属性、法线方向、UV坐标、贴图采样结果等跟具体像素相关的数据。这是最危险的一类:
if (worldNormal.y > 0.5) { color = _ColorA; } else { color = _ColorB; }同一个Draw Call里的不同像素,条件结果可能各不相同。GPU无法提前预判,只能按线程束内“多数派”来调度,或者把两个分支都走一遍并屏蔽掉不需要的写操作。
动态分支的性能,取决于你的画面里分支结果的空间分布。如果判断区域很“块状”——比如半边屏幕走A、半边屏幕走B,那么几乎不产生发散;如果判断结果像噪声一样横跳,性能就会被严重拖累。
我在项目里踩过一个典型坑:在片元着色器里根据tex2D(_ControlMap, uv).r的值判断要不要采样环境贴图。那个控制贴图是标准噪声纹理,结果每个线程束内的32个像素几乎都在跳变,最后这条分支把帧耗时直接翻了一倍。
2.4 三种分支的适用边界
| 分支形态 | 条件来源 | 性能风险 | 适用场景 |
|---|---|---|---|
| 编译期分支 | 宏定义、变体开关 | 无执行开销,但增加包体和编译时间 | 平台差异、功能开关、质量档位 |
| uniform分支 | 材质参数、全局Uniform | 低 | 同一材质内所有像素一致的控制 |
| 动态分支 | 顶点/UV/法线/采样结果 | 高,取决于发散程度 | 慎用;需保证判断空间连续、分支内代码量足够大 |
判断准则很简单:优先把“所有像素都一样的决策”放到编译期或uniform分支里,把“每个像素都不同的决策”控制到最小规模,或者干脆用替代方案(后面第5节会讲)。
3. Unity Shader里的分支写法:从变体到指令
讲完原理,来看Unity的具体语法。Unity Shader支持多种控制条件分支的方式,选型不同,性能特征和工程成本都不一样。
3.1multi_compile与shader_feature:变体裁剪
这是Unity里最常用的编译期分支手段。#pragma multi_compile_local _ _ENABLE_SPECULAR会生成两套着色器变体,一套定义了_ENABLE_SPECULAR,一套没定义。
#pragma multi_compile_local _ _ENABLE_SPECULAR // 或:shader_feature_local _ _ENABLE_SPECULAR两者的区别在于:multi_compile不考虑是否被引用,始终把变体打进包体;shader_feature会在Unity打包时自动剔除没有被任何材质引用的变体(Material版本直接剔除,_local后缀是对应在材质球上的属性版本)。
实际选择逻辑:
- 如果是全局渲染特性,比如雾效开关、阴影类型,用
multi_compile,因为它跟具体材质无关,任何Shader都可能被运行时切换。 - 如果是材质球上的某个功能开关,比如某块石头要不要启用视差贴图,用
shader_feature,让打包器把没用到的变体剔除。
但变体数量是指数膨胀的。一个Shader里有4个multi_compile开关,就是2的4次方等于16个变体,再多加几个就变成几百个。变体太多会让首次编译卡顿、shader加载变慢、包体变大。所以Unity 2021 LTS开始默认用multi_compile_local,配合strip(裁剪)机制做变体剔除。
3.2[branch]和[flatten]:给GPU的提示
在CG/HLSL里,可以在条件判断前加上[branch]或[flatten]指令,提示编译器希望怎样处理这个分支。
[branch] if (_EnableSpecular) { spec = SpecularBRDF(...); } [flatten] if (i.uv.x < 0.5) { color = _ColorA; } else { color = _ColorB; }[branch]:希望生成真正的分支指令,只执行满足条件的一侧。好处是跳过不走的代码,节省ALU开销。坏处是有可能产生分支发散。一般用在控制流复杂、你确定GPU执行的是统一分支的场合。[flatten]:希望编译器生成“两侧都执行,再通过选择指令取结果”的代码。好处是避免发散,坏处是两侧代码都会执行,如果分支里是昂贵的PBR计算,相当于白白算了一遍。
写[branch]被忽略、写[flatten]被展开,都是常态。因为最终决定权在驱动和编译器手里,尤其在移动端,驱动经常无视你的提示。
3.3ifvs?:的细微差别
很多开发者用color = condition ? colorA : colorB;来替代if,其实这两者在生成的指令上常常是一样的。三元运算符在HLSL里最终也会被编译成分支或select指令,并不存在“三元运算符一定比if快”的神话。
真正重要的是条件表达式的来源。我曾经把gl_FragCoord或SV_Position参与条件判断的写法当成普通动态分支,结果因为屏幕空间坐标在不同像素间天然连续,这样的分支反而比基于噪声贴图采样的分支快得多。所以动态分支的代价,完全取决于你的“条件场”是否连续。
4. 实操案例:给角色着色器加一个“高光开关”
理论讲了那么多,放一个实际项目里的例子。需求是给一个移动端角色的PBR着色器增加高光开关,美术希望能独立控制每个材质是否有高光,同时不能影响性能。三个候选方案:动态分支、uniform分支、变体。
4.1 方案一:动态分支(不推荐)
fixed4 frag (v2f i) : SV_Target { float3 albedo = tex2D(_MainTex, i.uv).rgb; float3 normal = UnpackNormal(tex2D(_BumpMap, i.uv)); float3 lightDir = normalize(_WorldSpaceLightPos0.xyz); float ndl = saturate(dot(normal, lightDir)); float3 color = albedo * _LightColor0 * ndl; if (_SpecularOn > 0.5) { float3 halfDir = normalize(lightDir + normalize(_WorldSpaceCameraPos - i.worldPos)); float spec = pow(saturate(dot(normal, halfDir)), _Gloss); color += spec * _SpecularColor * _SpecularIntensity; } return fixed4(color, 1.0); }这个写法有一个隐患:_SpecularOn虽然是uniform,对所有像素一致,但如果编译器把它当成动态分支处理,还是可能让线程束内“多走一趟”。更重要的是,高光计算这段代码本身就包含几次pow和normalize,即使执行了也是开销,如果能在编译期裁掉,显然更划算。
4.2 方案二:uniform分支(可用)
把_SpecularOn声明为uniform,用[branch]引导:
[branch] if (_SpecularOn > 0.5) { // high quality specular }大多数情况下这能跳过高光计算。但有两点需要接受:一是即使在关闭时,顶点着色器里如果还有相关的插值器计算,仍然会执行;二是某些移动驱动不会真的“跳过”,而是执行完后用掩码选值。
4.3 方案三:变体分支(最终选择)
#pragma shader_feature_local _ _SPECULAR_ON fixed4 frag (v2f i) : SV_Target { float3 color = albedo * _LightColor0 * ndl; #ifdef _SPECULAR_ON float3 halfDir = normalize(lightDir + normalize(_WorldSpaceCameraPos - i.worldPos)); color += spec * _SpecularColor * _SpecularIntensity; #endif return fixed4(color, 1.0); }这才是“不需要的时候代码完全不存在”的方案。相同材质球数量的前提下,包体增加一个变体的大小,总比动态分支在每帧GPU上白白跑一遍高光指令要好得多。
我在项目里用Frame Debugger对比过:动态分支方案在高光关闭时,块大小和指令数跟开启时完全一样;而变体方案关闭时,GPU指令数明显减少。对于只有一个开关的功能,变体方案在绝大多数情况下都更优。
5. 分支之外:那些被忽略的替代方案
很多情况下,分支想解决的问题其实不一定需要分支。图形学里大量“根据不同条件选择不同结果”的需求,都可以用数学运算平滑过渡。
5.1lerp+step:把离散选择变成连续插值
比如要根据粗糙度切换漫反射模型,很多人会写:
if (_Roughness > 0.5) diffuse = OrenNayar(...); else diffuse = Lambert(...);这个分支的问题是:粗糙度是连续变化的,边界处容易出现硬切。改成:
float t = step(0.5, _Roughness); diffuse = lerp(Lambert(...), OrenNayar(...), t);这样代码里没有分支,GPU执行两个漫反射模型然后混合。缺点是两个模型都算了,如果每个模型都特别昂贵,反而不划算。所以这个技巧适合用在“单侧计算量都不大”的场景。
更进阶一点,可以用smoothstep做平滑过渡,并用saturate把混合系数控制在0到1:
float t = smoothstep(0.3, 0.7, _Roughness); diffuse = lerp(lambertDiff, orenNayarDiff, t);5.2 查表:把复杂判断变成一次采样
复杂的if-else链往往对应一个函数查找表。比如根据视角方向判断边缘光强度,与其写多个分支,不如直接采样一张预烘焙的渐变纹理:
float rim = 1.0 - saturate(dot(viewDir, normal)); float rimIntensity = tex2D(_RimCurve, float2(rim, 0.5)).r;这属于用“内存访问”换“逻辑判断”。纹理采样在GPU上有专门的缓存单元,开销往往比一堆标量运算更可控。但要注意纹理LOD和mipmap的选取,采样点坐标要确保在0到1区间,否则会出现纹理拉伸。
5.3 把分支上移:CPU分段绘制或Pass分离
如果分支内代码足够重,与其在GPU上做选择,不如在CPU端把物体拆成两组,分开设置材质,分批绘制。渲染同一个网格两次,每组用不同材质球,比在一个材质里写两个昂贵分支更稳:
if (HasHighQuality) { renderer.material = highQualityMat; } else { renderer.material = lowQualityMat; }这个思路的本质是:把“像素级别的差异”提升到“物体级别的差异”。既然它们本来就是分明的不同效果,没必要在GPU里每帧做选择。换材质这种做法在Unity里代价是增加Draw Call,所以更适合合批友好的小物件,或者直接在合批前把网格分组。
5.4 逐分支版本的适用性判断
| 方案 | 适用场景 | 代价 |
|---|---|---|
| lerp/step混合 | 条件边界模糊、单侧计算量小 | 两侧都执行 |
| 纹理查表 | 函数关系复杂但连续 | 纹理采样带宽 |
| CPU分组/换材质 | 结果差异大、对象可分类 | Draw Call增加 |
| 变体分支 | 功能开关明确、需要极致性能 | 包体膨胀、编译时间 |
我自己的习惯是:先问“这个开关是美术在材质面板上手动控制的吗”,如果是,基本用变体或换材质;再问“这个值连续变化吗”,如果是,用lerp或查表;只有前两个都说不通时,才考虑动态分支。
6. 平台差异与真正的坑
同样一段带分支的Shader,在不同硬件上的表现可能天差地别。做跨平台项目时,分支相关的坑经常出现在你没预料到的地方。
6.1 桌面GPU与移动GPU的思路不同
NVIDIA和AMD的桌面驱动非常激进,很多if都会被编译器优化成select指令(相当于自动flatten),所以你写动态分支在PC上往往测不出问题。但移动端GPU(Mali、Adreno、PowerVR)的驱动相对保守,更容易“老实”地生成分支指令。
Mali的Thread Serialization机制特别明显:一旦线程束内出现发散,多个分支路径会被串行执行,最坏情况是耗时乘以分支路径数量。Adreno好一些,但对pow、exp这类高开销指令的分支尤其敏感。
所以给移动端写Shader时,建议直接在真机上做Profile,而不是拿PC上的帧率做判断。我见过不止一次:PC上20%的性能提升,真机上完全反过来的情况。
6.2 分支内的纹理采样
分支里写纹理采样是另一个高频踩坑点,尤其是动态分支:
if (uv.x > 0.5) { color = tex2D(_TexA, uv); } else { color = tex2D(_TexB, uv); }问题在于现代GPU处理纹理采样时,会有预取和LOD计算机制。即使是未走到的分支里的纹理采样,驱动也可能提前把纹理数据从缓存读出来,导致实际带宽消耗跟两个分支都执行了差不多。这跟你是不是只走一个分支没有关系。
如果确实需要多纹理按需选择,优先考虑float4 colorA = tex2D(...); float4 colorB = tex2D(...); color = lerp(colorB, colorA, step(...)),至少驱动能正确做预取和缓存管理,比在分支里采样更可预测。
6.3 变体膨胀之后的治理方案
前面讲了shader_feature会在不用时剥离变体,但实际项目里总有人把该用shader_feature的写成了multi_compile,导致一个简单Shader打出几百个变体。排查办法是Window > Shader Inspector查看Shader的变体集合,看看有没有大量“从未使用的变体”被打进AssetBundle。
治理手段有:
- 用
#pragma skip_variants排除某些组合,比如同时开启A和B的情况在项目里不存在,可以显式跳过。 - 配合
IPreprocessShaders写Shader变体裁剪脚本,在构建管线里把不需要的变体移除。 - 多用
_local后缀的multi_compile/shader_feature,配合材质球属性控制,减少全局变体混用。
6.4disabled分支与半透明渲染顺序
还有一个Java坑:半透明物体渲染顺序不同,分支结果也可能不同。因为半透明Shader往往在片元着色器里做混合,如果条件分支基于屏幕坐标(比如SV_Position)做边缘判断,那么不同物体重叠时,它们各自基于自己位置的采样结果可能不一致,产生边缘闪烁。
解决思路是把这类基于屏幕坐标的分支结果统一放进一个_EdgeMask纹理或全局Uniform里,避免每个物体自己算一遍导致的不连续。
7. 我现在的分支选型习惯
写Shader写了这么些年,我现在判断一个需求要不要用条件分支,基本看这么几个点:
- 这个开关在运行时会不会变?不会变的话,全部用宏和变体,把代码裁剪做在编译期。
- 开关对所有像素一致吗?一致的话,考虑uniform分支,但尽量只用
[branch]写在简单代码块上;复杂的话还是退回到变体。 - 判断结果在空间上连续吗?连续则动态分支勉强可以接受,不连续则一定避免,改lerp或查表。
- 分支内代码重吗?重代码分支建议上移决策,轻代码分支用select/lerp替代更稳。
还有一个技巧:在Shader里加注释,把每个分支的意图和性能预期写清楚。Unity的Shader Inspector不会显示注释,但同事review代码时能理解为什么这个if是故意的。这比在文档里写一万字都有用。
条件分支不是洪水猛兽,无脑的“禁用所有if”和放飞的“到处都写if”都是极端。掌握了分支的三种形态、明白了GPU并行执行的基本模型、理解了分叉的代价来源,你就能在不同的场景里做出合理的选择。希望这篇能帮你少走一些我之前走过的弯路。