ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Unity Shader Keyword完全指南:从变体编译到工程化避坑

Unity Shader Keyword完全指南:从变体编译到工程化避坑 做了这么多年渲染我在项目里无数次被Shader Keyword这件事坑到怀疑人生。看着美术同学在材质面板上点了一个开关结果场景里几百个物件集体翻车或者明明代码里写了EnableKeyword渲染效果却纹丝不动最后排查了半天发现是shader_feature被Unity的变体裁减悄悄干掉了。这篇文章就好好把Shader Keyword的来龙去脉、正确用法和坑位整理一遍希望能帮做渲染、TA、甚至是只写过几句Shader的同学少走点弯路。1. 先搞清楚Shader Keyword到底解决了什么问题1.1 Keyword本质上是“预编译开关”如果你写过#ifdef、#if这类预处理指令那么你已经理解了Shader Keyword的一半。Unity在编译Shader时并不是把一份代码原封不动地丢给GPU而是把每一个关键字组合都当成一次独立的“代码快照”来编译。比如Shader里写了#pragma multi_compile _ _ENABLE_FOG那么Unity会编译出两个版本一个是没有_ENABLE_FOG的版本另一个是定义了这个关键字的版本。这两个版本拥有各自的指令、各自的寄存器分配、各自的指令流水线优化互不干扰。这个过程在游戏引擎里通常叫“变体编译”也就是每个组合对应一个Variant。你把多个关键字写在同一个Shader文件里运行时通过开关选择不同的变体本质上跟C里用宏做条件编译是一个思路。好处是GPU执行的时候不需要做复杂的分支判断坏处是每多一个开关变体数量都可能翻倍这个后面细说。1.2 GPU为什么不能直接在像素里写“if”有人会问我直接在片元着色器里写个if (flag)不行吗为什么非要搞出这么多个变体理论上是可行的但GPU是个极度并行的家伙。一个线程束里的几十个像素如果走的分支方向不一样显卡就得把它们拆开执行先跑满足条件的一批再跑剩下的一批这就是典型的“线程束发散”。一旦发生发散执行效率直接打折扣而且GSGeometry Shader和片元阶段尤其敏感。预编译成不同变体之后整段指令从头到尾是线性的没有分叉这样GPU就能满负荷跑满带宽。拿生活里的事情类比一下如果食堂窗口的菜单是“今天有没有红烧肉有就排A队没有就排B队”那食堂得先统计再分流排队效率很低。如果干脆开两个窗口一个窗口只卖红烧肉饭另一个窗口只卖套餐B每个窗口的人进去就能直接打饭不需要现场判断这就是Keyword在Shader世界做的事情。1.3 一个真实小例子迷雾开关网上经常有人问“Cocos Shader游戏迷雾做法”其实在Unity里做迷雾思路也是一样最快的方式就是用Keyword做开关。核心代码长这样#pragma multi_compile _ _ENABLE_HEIGHT_FOG float4 frag(v2f i) : SV_Target { float4 color tex2D(_MainTex, i.uv); #ifdef _ENABLE_HEIGHT_FOG float fogFactor saturate((_FogTop - i.worldPos.y) / (_FogTop - _FogBottom)); color.rgb lerp(_FogColor.rgb, color.rgb, fogFactor); #endif return color; }编译时Unity会生成两个版本。开迷雾的游戏物体走带雾效代码的变体不开迷雾的走裸版变体。性能不受影响代码也很直观。2. Unity两套关键字体系shader_feature与multi_compile怎么选2.1 multi_compile我全都要一个都不许删multi_compile这个关键字组Unity在打包时会把所有变体无条件保留下来不管你有没有材质球在用不管场景里有没有物体引用它都原封不动打进AssetBundle或者安装包里。这个特性让它适合那些“运行时完全动态决定”的效果。比如你在代码里根据天气系统切换晴天、雨天、雪天或者在游戏里由玩家实时切换高画质、低画质用multi_compile最稳因为不管什么时候切换变体一定还在。但它的代价也摆在明面上变体全留包体膨胀。假如你写了三个multi_compile开关每个开关有开和关两种状态那就是2×2×28个变体。如果Shader复杂一点、顶点数多一点光是编译时间和包体可能就让你头疼了。#pragma multi_compile _ _QUALITY_LOW _QUALITY_HIGH上面这行表示三个变体无关键词、_QUALITY_LOW、_QUALITY_HIGH。注意如果第一档是不带关键字的写法上就用一个_来占位这是Unity里比较典型的小坑。2.2 shader_feature让引擎帮你做减法与multi_compile相反shader_feature关键字组只有在确实有材质球引用对应变体时打包器才把它保留下来。没被引用的变体会被从发布包里剔除。这个设计初衷是给“美术在材质面板上勾选某个功能”这种场景用的。比如一个角色Shader里写了描边关键字、溶解关键字、冰冻关键字但不代表所有美术都会去用。如果全保留包里就会有一堆永远不会被加载的变体白白浪费空间。shader_feature正好做了这个自动裁减。}告诉Unity这个关键字只由材质球的Keyword状态决定并且在打包时只保留被实际用到的组合。#pragma shader_feature _ _DISSOLVE_ON #pragma shader_feature _ _RIM_LIGHT_ON写法上注意如果关键字后面不需要“无关键字”的那一档可以直接写#pragma shader_feature _DISSOLVE_ONUnity默认会补一个无关键字变体不过为了让逻辑更清晰建议大家还是显式写出下划线。shader_feature只认材质球上真正勾选过的Keyword状态。如果你的Shader是靠脚本动态下载AB后激活的那需要在打包前用“变体收集器Variant Collector”手动把需要用到的变体拉到资产列表里否则就等着看变体被裁光的灵异现场吧。2.3 全局关键字和本地关键字local与不local的区别Unity里可以给关键字加上local后缀比如#pragma multi_compile_local _ _ENABLE_FOG带local的关键字只对当前材质球生效不会污染其他材质不带local的是全局关键字只要用Shader.EnableKeyword开启场景里所有Shader里用到同样名字关键字的材质都会被影响。这里的设计逻辑是全局关键字适合那些“整个摄像机/全局后处理/天空盒共享”的状态比如你基于同一个PostProcess Shader做多种后处理那全局关键字确实方便一个开关就能影响所有后处理材质。但全局关键字也是事故高发区。我见过一个项目TA为了做某个特效引了个全局关键字_USE_RIM结果所有Shader里写了这个关键字的材质在运行时全都亮了描边美术还以为是电脑显存坏了排查了半宿。所以日常开发能用local就用local尤其是美术可控的材质特性让状态老老实实待在自己的材质球里。3. 代码、材质球、多PassKeyword的运行时操作细节3.1 EnableKeyword与DisableKeyword到底影响谁在C#侧你有两种开启关键字的方式// 只影响当前材质球配合local关键字使用 material.EnableKeyword(_ENABLE_FOG); material.DisableKeyword(_ENABLE_FOG); // 影响全局所有带这个关键字的材质非local Shader.EnableKeyword(_USE_RIM); Shader.DisableKeyword(_USE_RIM);这两行代码容易混用最简单的记忆方式是material.开头的只管当前材质Shader.开头的是全局广播。还有个小细节如果Shader里定义的是shader_feature而你的C#代码用的是Shader.EnableKeyword那么这个全局开启状态不会自动记录到任何材质球上变体收集器也就认为这个组合没有被使用。这也是很多时候你代码里明明开了关键字打包后就是没效果的重要原因之一。3.2 在材质球Inspector面板上手动开Keyword没写任何C#代码美术也照样能在Inspector里操作Keyword。方法是在材质球的右上角打开“Shader Properties”折叠菜单里面会列出所有关键字对应的Toggle。这里有个坑很多同学在Shader里写[Toggle]属性时属性名和关键字名没对齐。看下面这个片段[Toggle(_DISSOLVE_ON)] _DissolveEnable(Dissolve, Float) 0[Toggle(_DISSOLVE_ON)]才是指定关键字的字段_DissolveEnable是材质面板上显示的属性名两者不要搞混。如果你在C#里改了_DissolveEnable却不动_DISSOLVE_ON材质面板看起来是变了但Shader里的#ifdef _DISSOLVE_ON根本不会生效。3.3 多Pass和多Shader切换时关键字状态会串场吗会而且很容易串。Unity的Shader文件可以包含多个Pass同一个关键字组在每个Pass里都可以声明。如果A Pass声明了_ENABLE_FOGB Pass没声明那么B Pass运行时其实不会去管这个关键字的状态除非你在B Pass里也写了对应指令。更麻烦的是用UsePass复用其他Shader的Pass时关键字状态是跟着材质球走的不是跟着Pass走的。如果你在A材质上开了_RIM_LIGHT_ON然后它在渲染时调用了另一个Shader里通过UsePass引用的Pass那个Pass如果也写了#ifdef _RIM_LIGHT_ON它一样会被影响。所以我的习惯是每个需要多Pass配合的关键字要么在所有相关Pass里统一声明要么做好命名隔离不要指望Pass之间天然隔离状态。这东西踩一次坑绝对忘不掉。3.4 用枚举和常量表管理关键字字符串比较可怕活得够久的人一定经历过“大小写写错”“下划线多打少打”然后排查半小时的事故。我现在习惯把每个Shader文件对应的关键字集中到一个静态类里public static class ShaderKeywords { public const string FOG _ENABLE_FOG; public const string DISSOLVE _DISSOLVE_ON; public const string RIM _RIM_LIGHT_ON; public const string SKIN _SKIN_SSS_ON; public const string MATCAP _MATCAP_ON; }然后在代码里就用material.EnableKeyword(ShaderKeywords.FOG)而不是手敲字符串。虽然Unity底层还是要做字符串匹配但至少你的代码层不容易写错而且重构的时候只要改一个地方。更进一步可以写个[Flags]枚举把多个Keyword映射成位运算方便一次性开关一组特性[Flags] public enum EffectFlags { None 0, Fog 1 0, Dissolve 1 1, Rim 1 2, SkinSSS 1 3 }拿到枚举后遍历设置关键字比一行行重复调EnableKeyword清爽很多。4. 我踩过的坑与常用排查工具4.1 “开了KeyWord但没效果”的三个常规原因这种问题几乎每个写Shader的人都遇过我在项目里总结下来99%的问题逃不出三件事。第一拼写不一致。Shader里写的是#pragma shader_feature _FOG_ONC#里开的是_FOG_OFF或者大小写差一位。这种问题最坑因为不会报错就是没效果。排查时可以打开材质球Inspector面板看“Shader Properties”里的关键字列表到底有没有对应项被打勾。第二shader_feature被变体裁减器干掉了。开发环境跑起来一切正常一打AssetBundle就丢效果十有八九是这个问题。因为开发环境Unity会加载全部变体打包时才按引用情况裁剪。解决办法是给Shader加一个“Always Included Shaders”或者用ShaderVariantCollection把用到的变体打进去。第三关键字开了但代码里#ifdef写错位置。比如你把它写在#ifndef里或者在Unity 2020的Shader Graph里生成的关键字没在正确节点上生效。这种属于逻辑层面的问题只能老老实实看Shader代码一步步下断点到帧调试器里看变体到底切到了哪个。4.2 变体爆炸数学题算清楚再设计组合每次新加一个开关变体数量就可能翻倍。你写了两个开关那就是4个变体三个就是8个四个就是16个。看着数字不算大但如果每个Pass都带这些组合再有多个Pass、多个Shader数学就很恐怖了。假设你有10个Shader每个Shader 3个Pass、4个关键字组合打包时Unity为了生成变体可能要花上几十分钟AssetBundle的大小也会涨得离谱。更麻烦的是运行时首次加载Shader时的Stall时间也可能变长手机上一卡一顿就是变体太多在搞鬼。所以在设计关键字体系前先拿纸笔算一算组合数。如果某些特性在实际游戏里根本不会组合出现那就不要用自由排列改成“一个关键字代表一个整体方案”反而更省。比如用一个_QUALITY_HIGH代替_LIGHT_ON加_SHADOW_ON加_REFLECTION_ON这种细粒度组合。4.3 全局关键字污染一个开关点亮半个场景全局关键字用起来方便但污染起来也毫不含糊。我在项目里遇到过一个典型情况某个后处理Shader用了_BLOOM_ON作为全局关键字结果场景里所有带这个关键字的水面Shader、角色Shader全部跟着开启了Bloom相关的代码路径导致整个画面过曝。这种问题的排查要点是先看材质球Inspector里的关键字列表排除材质自身设置问题。再用Frame Debugger抓一帧看各自材质实际选择的变体是不是异常。最后在代码里搜Shader.EnableKeyword把所有全局开启的地方全部列出来仔细核对有没有不该全局化的关键字。解决方式也很简单把那些不需要全局生效的Shader关键字从multi_compile改成multi_compile_local从shader_feature改成shader_feature_local。这样它们就只能影响当前材质不会再动到别人的蛋糕。4.4 借助帧调试器看VariantUnity的Frame Debugger是排查Shader问题最好用的工具之一。打开Window Analysis Frame Debugger选中某一Draw Call在右侧Shader Properties面板里能看到当前这个Draw Call使用的Shader、Pass、以及启用的Keyword列表。下次再遇到效果不对先别急着改代码打开Frame Debugger看一眼这张材质实际跑的变体是不是你预期的那一个。如果连变体都没选对后面所有讨论都白搭。5. 工程化整理规范的名单比写Shader更重要5.1 用表格给每个Shader列一张关键字段落表我现在的习惯是在写Shader之前先画一张表把要用的关键字全部登记写好类型、作用、所属Shader、打包时是否需要收录。这样一是方便自己做决定二是团队协作时其他人也能快速理解。关键字类型作用所属Shader是否需要Variant Collector_ENABLE_FOGmulti_compile_local雾效开关场景/角色通用否_DISSOLVE_ONshader_feature_local溶解特效怪物死亡是_RIM_LIGHT_ONshader_feature_l_local描边光角色是_GLOBAL_FOGmulti_compile全局雾效后处理/天空否这样一张表贴在项目Wiki里美术和程序都清楚哪个开关动的是哪个效果避免“我明明打开了描边但为什么没反应”这类无效沟通。5.2 用脚本扫描工程里的关键字残留等到项目后期Shader数量动辄几百个再靠肉眼去查关键字清单已经不太现实。我写过一个小工具原理很朴素继承AssetPostprocessor监听.shader文件的导入事件用正则把#pragma行里所有关键字提取出来然后汇总输出到一张表格里。using System.Collections.Generic; using System.IO; using System.Text.RegularExpressions; using UnityEditor; using UnityEngine; public class ShaderKeywordScanner : AssetPostprocessor { private const string ShaderPattern #pragma\s(?:shader_feature|multi_compile)(?:_local)?\s(.?)(?:\r|\n); private static void OnPostprocessAllAssets( string[] importedAssets, string[] deletedAssets, string[] movedAssets, string[] movedFromAssetPaths) { Liststring report new Liststring(); foreach (string path in importedAssets) { if (path.EndsWith(.shader)) { string text File.ReadAllText(path); MatchCollection matches Regex.Matches(text, ShaderPattern); foreach (Match m in matches) { report.Add(${path} {m.Groups[1].Value.Trim()}); } } } if (report.Count 0) { File.WriteAllLines(ShaderKeywordReport.txt, report); Debug.Log($扫描到 {report.Count} 条关键字记录请查看 ShaderKeywordReport.txt); } } }这段脚本只是个雏形写成这样已经能应付中小型项目。工程大了之后可以把它挂到CI里每次构建前跑一遍自动检查关键字清单和实际Shader代码是否一致能省下大量人肉Review的时间。5.3 团队协作的关键字约定经验告诉我规范这东西越早定越省心。下面几条是我现在带项目时一定会要求团队遵守的红线新加关键字必须先在关键字总表里登记再动Shader代码不允许“写完了再补表”。每个关键字最多服务一个明确的视觉功能不要让一个关键字既管雾效又管描边出了Bug没法定位。默认情况下新增关键字都用local版本除非确认它必须影响全局否则不给污染其他材质的机会。所有靠动态加载AB激活关键字的Shader必须同时维护一份ShaderVariantCollection不然就等着线上特效随机消失。这些约定看起来像是在给团队套紧箍咒但做渲染这东西脏Shader比脏代码还难查早一点立规矩项目后期能少失眠好几个晚上。说点实在的真正把Shader Keyword这套机制摸熟不是靠看一遍文档就行的。它更像是你在项目里反复被坑、反复调试、反复深夜改bug之后才慢慢形成的一套肌肉记忆。我个人的体会是别把关键字当“运行时开关”来用把它当成“编译器交给你的资产包管理工具”来用你会更谨慎地对待每一个组合、每一次变体收集和每一份打包配置。最后分享一个小技巧也是我自己一直在做的每次给一个Shader加完关键字后先在Editor里手动打一个AB然后看生成的Manifest里这个Shader的变体数量是否和你的预期一致。如果数量比预期多说明可能引入了多余组合如果数量比预期少那小心shader_feature已经把某个你用得到的变体吃了。把这个“查变体数量”当成收尾动作写进习惯清单能帮你躲掉一大半的线上渲染事故。
返回列表