首页 / 资讯中心 / 文章详情

Unity Shader变体全解析:从组合爆炸到预加载与裁剪优化

Unity Shader变体全解析:从组合爆炸到预加载与裁剪优化 ★ FEATURED ARTICLE
前两天一个做Unity项目的老朋友找我说项目马上要上线了打出来的包突然比上个版本大了快30MB进游戏第一次打开技能界面还明显卡一下。我让他先去查Shader变体果然项目里为了做风格化效果加了几组multi_compile关键字变体数量直接翻了几倍包体和加载时间全被拖住了。这种现象在Unity开发中太常见了Shader变体是个好东西但没人管理的时候它就会在后台悄悄膨胀直到你在构建日志里看到几万个变体时才开始焦虑。这篇内容我想一次性把Unity Shader变体的“从生到死”讲清楚变体是怎么产生的、怎么收集统计、哪些变体可以砍、怎么设计预加载流程、以及变体丢失之后怎么排查。主要面向项目做到中期、开始被包体和卡顿困扰的Unity开发者和技术美术也适合刚接触Shader变体、想建立系统认知的初中级开发者。文章里的方法都来自我实际处理过的项目不绕弯子直接给结论和步骤。1. 变体是怎么产生的一组关键字引发的组合爆炸1.1 先从编译指令说起在Unity Shader里我们经常看到类似这样的代码#pragma multi_compile _ _RECEIVE_SHADOW #pragma multi_compile_fog #pragma shader_feature _ALBEDO_MAP这些指令的作用是让同一个Shader根据不同的关键字组合编译出多个版本的GPU程序。每个版本就是一个变体Variant。运行时Unity根据材质上启用的关键字、当前的光照模式、雾效设置等条件从变体池里挑一个匹配的出来用。可以这么理解Shader变体等于同一个着色器逻辑的“多套配置”提前编译好避免在渲染时动态判断分支。但代价是每一套配置都是一份独立的GPU程序都要占包体、占内存、占编译时间。很多开发者对变体的认知停留在“写几个multi_compile没大事”实际上一旦关键字多起来变体数量是呈指数级增长的。这不是夸张2个关键字就是2的2次方等于4种组合3个关键字就是8种6个关键字就是64种。如果Shader里有几组关键字变体数量是各组组合数的乘积也就是组合爆炸。1.2 multi_compile和shader_feature的差别直接决定打包策略这是最基础但也最常被忽略的知识点multi_compile所有关键字组合都会被编进构建产物不管场景里有没有用到。哪怕整个项目没有一个材质启用_RECEIVE_SHADOW这个变体依然存在。shader_feature构建时Unity会检查场景和材质是否引用了这个关键字组合没引用的会被自动剥离前提是它没有被收集到ShaderVariantCollection里。用一个表格看得更清楚指令类型构建时是否自动保留运行时动态开启关键字后变体是否还在典型使用场景multi_compile是全部保留是需要运行时动态切换、且无法预判组合的情况shader_feature只在被引用或收集到时保留取决于是否被收集进变体集合美术在材质上固定开关的功能实际项目里最常见的错误是滥用multi_compile把一些本可以做成shader_feature的关键字全写成了multi_compile导致包体无谓膨胀。如果你的关键字只会在材质面板上被美术手动开关用shader_feature就够了如果需要在C#里通过Material.EnableKeyword动态开启而且开启时机完全无法预测才需要用multi_compile。1.3 变体膨胀的典型场景阴影、雾效、Lightmap全叠加我之前接手过一个项目单是内置渲染管线的一个PBR Shader变体数量就到了两千多个。拆开看其实就三组关键字在叠加阴影相关DIRECTIONAL、SHADOWS_SCREEN、SHADOWS_SOFT三个关键字乘出来8种雾效相关Unity内置的multi_compile_fog会生成4种雾效组合光照贴图相关LIGHTMAP_ON和DYNAMICLIGHTMAP_ON再乘4种8乘4乘4基础组合就是128个变体如果Shader里再有皮肤、头发、细节贴图等特性关键字乘以2乘2轻松破千。这还没算不同PassForwardBase、ForwardAdd、ShadowCaster、Deferred之间变体还会各来一遍。变体膨胀带来的问题不只是包体变大更明显的是构建时间变长、运行时首次加载Shader变体时卡顿、内存里GPU程序占用量高。如果你发现打一次包要半小时以上或者切场景时明显顿一下变体数量过高是一个很可能的元凶。2. 把变体家底盘清楚收集与构建期统计2.1 先看看到底有多少变体动手优化之前先搞清楚项目当前的变体规模。最直接的方式是打一个开发包构建完成后看Editor.log里Shader相关的日志。Unity会把所有Shader的变体统计写进日志搜索关键词shader和variants能看到类似这样的记录Compiled shader: PBRStandard (forwardbase pass) - 256 variants也可以在构建脚本里自己抓日志解析出每个Shader的变体数量求和得到项目变体总数。另外Build Report Inspector这类插件也能在构建后给出Shader变体数量和内存估算比手工看日志省力。社区里还能找到把构建日志变体信息可视化的小工具团队里可以定期跑一次把变体总数放到CI报告里做趋势监控。2.2 用ShaderVariantCollection把“实际要用的变体”固化下来仅仅知道数量还不够关键是把项目真正用到的变体记录到一个ShaderVariantCollection简称SVC资源里。SVC的本质是一个清单记录“哪些Shader 哪些关键字组合 哪些Pass”是项目需要的。构建时Unity会根据这份清单保留shader_feature对应的变体运行时我们也能用这个集合做预加载。创建SVC有两种方式在Project窗口右键 → Create → Shader Variant Collection手动添加Shader再勾选需要的关键字组合。用脚本自动生成遍历项目里的材质、场景里引用的材质读取Material上启用的关键字加入SVC。第二种方式适合项目中期手动维护不现实。我常用的一个工具型脚本长这样using UnityEngine; using UnityEditor; using System.Collections.Generic; public class ShaderVariantCollector : EditorWindow { [MenuItem(Tools/ShaderVariant/Collect From Materials)] public static void CollectFromMaterials() { string svcPath Assets/ShaderVariantCollections/GameVariants.shadervariants; ShaderVariantCollection svc AssetDatabase.LoadAssetAtPathShaderVariantCollection(svcPath); if (svc null) { svc ScriptableObject.CreateInstanceShaderVariantCollection(); AssetDatabase.CreateAsset(svc, svcPath); } // 所有Prefab、场景资产都会被这个接口过滤到 string[] guids AssetDatabase.FindAssets(t:Material, new[] { Assets }); HashSetstring shaderPaths new HashSetstring(); foreach (string guid in guids) { string path AssetDatabase.GUIDToAssetPath(guid); Material mat AssetDatabase.LoadAssetAtPathMaterial(path); if (mat null || mat.shader null) continue; ShaderVariantCollection.ShaderVariant variant new ShaderVariantCollection.ShaderVariant { shader mat.shader, keywords mat.shaderKeywords, passType PassType.Normal }; if (!svc.Contains(variant)) { svc.Add(variant); } } EditorUtility.SetDirty(svc); AssetDatabase.SaveAssets(); Debug.Log($[ShaderVariantCollector] Collected variants, total: {svc.variantCount}); } }这个脚本有几个需要注意的点mat.shaderKeywords只包含当前材质上启用的关键字如果某些变体需要靠Shader.EnableKeyword全局开启而不是挂在材质上脚本里就抓不到。这时就该把项目里所有调用EnableKeyword的关键字整理成一份清单一起补进SVC。2.3 在构建管线上卡一道“安检门”收集SVC只是第一步更靠谱的做法是把它接到构建流程里。利用IPreprocessShaders接口在构建每个Shader变体之前做一次拦截可以统计出哪些变体不在SVC清单里、哪些变体数量超预算。using UnityEditor.Build; using UnityEditor.Rendering; using UnityEngine; using UnityEngine.Rendering; public class ShaderVariantBuildValidator : IPreprocessShaders { public int callbackOrder 0; public void OnProcessShader(Shader shader, ShaderSnippetData snippet, IListShaderCompilerData data) { ShaderVariantCollection svc AssetDatabase.LoadAssetAtPathShaderVariantCollection( Assets/ShaderVariantCollections/GameVariants.shadervariants); for (int i data.Count - 1; i 0; i--) { ShaderCompilerData compilerData data[i]; // 这里可以按关键字组合过滤变体但谨慎使用 } } }这个接口如果只用来统计不打日志可以做得比较轻量如果用来强行剔除变体风险很高我通常只用在已经充分验证的项目里。更常见的安全做法是构建后输出一份“不在SVC里的变体”报告人工确认是遗漏还是无用变体再决定下一步。3. 该砍的变体别手软剥离策略与内置Shader裁剪3.1 先从关键字类型和命名规范逐步收敛变体数量过高最先动刀的地方是控制关键字本身。我见过一些项目一个Shader里写着10个multi_compile关键字其中四五个是功能上线后美术根本没用过的。给团队定一条规矩新增multi_compile之前必须说明为什么不能用shader_feature而且要预估变体增量。关键字命名也值得统一。比如所有美术可开关的特性统一用_FEATURE_前缀运行时切换的统一用RUNTIME_前缀和后处理、光照模式、阴影等内置系统相关的关键字单独放在一个文件里维护。这样排查变体问题时扫一眼关键字就能判断来源。3.2 内置Shader的裁剪设置Unity内置渲染管线里Player Settings→Graphics→Shader Stripping提供了几个剥离选项很多项目从来没动过设置项控制内容建议Fog Modes雾效模式变体项目只用Linear雾就只保留对应模式Lightmap Modes光照贴图模式变体不用动态光照贴图就关掉Directional Lightmap方向性光照贴图老项目如不用可以关Shadow Modes阴影相关变体不用Shadowmask就关掉对应选项Built-in Light Modes内置光照模式如不用Deferred可剔除相关变体举个例子如果项目是纯Forward管线、不用Deferred那Deferred相关Pass和关键字组合产生的变体完全可以剔除变体数量能少一截。不过内置Shader是Unity的灰色地带这些设置会直接影响包含内置Pass的Shader改之前最好全局搜一遍项目里有没有用Deferred相关的API。3.3 针对自研Shader的变体剥离如果你的项目已经迁到URP或者自研Shader较多那么IPreprocessShaders就是正式的剥离入口。可以按Shader命名规则过滤掉项目里不用的Pass或者按关键字组合过滤掉不受支持的组合。我在一个项目里做过比较激进的尝试把阴影关键字的组合固定成只保留SHADOWS_SOFT开和关两种其他组合全部剔除变体数量直接砍掉40%帧率和包体都有明显改善。做法是在OnProcessShader里对compilerData.shaderKeywordSet做判断不在白名单里的就从列表里移除。但有一点要提醒剥离必须和SVC清单保持同步。如果你剥掉了某个变体同时SVC里还有这个变体记录构建时依旧可能被保留。两个流程要共用一份关键字白名单避免自相矛盾。3.4 一个真实的优化数据参考之前做一款3D卡牌项目特效Shader很多变体总数从一万六千多降到七千多。包体从420MB降到385MB构建时间从40分钟缩短到25分钟进入战斗场景的首帧卡顿从700毫秒降到200毫秒左右。具体数字会因项目而异但变体优化的收益通常落在构建时间、包体和运行时加载三个维度上效果非常直接。4. 让变体在需要的时候随叫随到预加载调度设计4.1 为什么要显式预加载Unity的Shader变体不是一进游戏就全部加载到显存的而是等渲染到对应的DrawCall时发现当前变体还没有创建GPU程序临时编译。这个临时编译只在主线程上执行表现就是界面卡一下卡顿长短取决于着色器复杂程度和变体数量。预加载的通用做法是在关键时刻主动触发变体的GPU程序创建把“临场编译”提前到Loading阶段避免在战斗、转场时卡顿。但要注意不是所有变体都需要一次性全加载这是一笔很重的开销应该把预加载的资源控制在SVC清单内。4.2 三种预热方式的取舍Unity提供了几种触发变体加载的接口实际项目里要根据场景严格挑选方式接口适用场景风险全量预热Shader.WarmupAllShaders仅适合启动测试或极小项目所有Shader变体全部编译时长不可控SVC预热ShaderVariantCollection.WarmUp()项目确定需要的变体集合必须先把SVC资源加载进内存否则不生效手动创建运行时创建临时材质并渲染一次某种特效/某类角色专用变体需要额外代码管理临时对象我推荐的做法是项目维护一份核心SVC里面只放所有场景都通用的基础变体再按玩法模块拆出若干份子SVC比如“战斗特效SVC”“主城角色SVC”。进入战斗前异步加载战斗SVC并调用WarmUp而不是启动时把所有变体全塞进显存。对移动端这一点尤其重要显存和加载时间都耗不起。4.3 一个可用的异步预加载调度器下面是一个简化版的预加载调度器支持把多个SVC分成多帧预热配合Loading进度条使用using System.Collections; using System.Collections.Generic; using UnityEngine; public class ShaderVariantPreloader : MonoBehaviour { [SerializeField] private ListShaderVariantCollection coreVariantCollections; private float totalVariants; private float loadedVariants; public IEnumerator PreloadAll(System.Actionfloat onProgress, System.Action onComplete) { totalVariants 0f; foreach (var svc in coreVariantCollections) { if (svc ! null) totalVariants svc.variantCount; } foreach (var svc in coreVariantCollections) { if (svc null) continue; svc.WarmUp(); loadedVariants svc.variantCount; onProgress?.Invoke(loadedVariants / Mathf.Max(1f, totalVariants)); yield return null; // 每预热完一个SVC让出一帧保持UI响应 } onComplete?.Invoke(); } }注意WarmUp本身就是同步接口一个SVC变体太多时可能会卡一段时间所以拆成多个SVC每处理完一个再让出一帧用户体验会好很多。如果在AssetBundle环境下还得先加载SVC所在的Bundle确保SVC资源已经加载到内存再调WarmUp才有意义。4.4 启动期和过场期的调度时机优化预加载时机不是越早越好。移动端启动时引擎本身就有大量工作如果再叠加一堆Shader变体编译很容易触发系统Watchdog杀进程。我的做法是启动场景只预热最基础的一批变体保证UI和主界面不卡。进入具体玩法前利用Loading界面预热该玩法对应的子SVC。战斗结束回到主城时把战斗专用变体从缓存中释放减轻内存压力。ShaderVariantCollection在运行时不支持从集合中移除单个变体所以为了释放内存一般用Resources.UnloadUnusedAssets或直接依赖AB的卸载来释放。如果项目对内存极敏感可以考虑按玩法拆Bundle让SVC跟着玩法Bundle一起进出内存。4.5 用Profiler验证预热前后差异预加载做没做对不能凭感觉。在Profiler里抓取Player Loop的Shader.CreateGPUProgram耗时预热前这个函数会在战斗场景首次刷怪时出现一个明显峰值预热后峰值应该消失或者只出现在Loading阶段。还可以在游戏运行时打开Frame Debugger检查某个用了特殊变体的物体如果显示“Shader is compiling”或异常状态说明预热没覆盖到。再回到SVC里检查这个Shader关键字组合是否漏掉了。5. 变体丢失不靠猜完整的定位与预防排查链路5.1 症状识别粉红材质背后的几种情况Shader变体丢失最典型的症状是某个物体在场景里变成粉红色或者整个物体不可见控制台出现类似“Material doesnt have a shader variant”或“Shader warning: GL_INVALID_ENUM”的日志。但粉红色不全等于变体丢失先分清三种情况Shader整个没打进包Material引用空壳渲染异常。Shader在包里但当前关键字组合对应的变体被剥离运行时找不到。Shader和变体都在但运行时因为某种原因没有定位到变体。三种情况排查方向完全不同。先用Build报告确认Shader本身是否在包内再查变体是否被SVC记录最后才考虑运行时切换逻辑问题。5.2 复盘链路从Frame Debugger到SVC的反查流程我总结了一套比较顺的排查顺序遇到变体丢失问题按这个走基本都能定位打开Frame Debugger选中出问题的物体查看当前DrawCall使用的Shader和启用的关键字。去Shader源码里搜这些关键字的编译指令确认它们是multi_compile还是shader_feature。打开项目的SVC确认这个Shader关键字组合是否被记录。没有记录shader_feature变体在构建时就被剥掉了补上记录即可。如果SVC里有记录检查构建管线里是否加了自定义剥离逻辑把变体过滤掉了。如果都没问题检查运行时是否有代码在SetPass之前临时改了关键字或者ShaderVariantCollection资源是否在AB中被后续加载的包覆盖了引用。最后在真机上单独打个Log把material.shader.name和material.shaderKeywords一起打出来对比Editor和真机差异。这个链路核心思路是先确认变体到底存不存在再确认是谁把它从构建产物里拿掉了。5.3 防漏机制把问题挡在提交之前等到上线后才发现变体丢失是最难受的我习惯在开发期就设三道保险构建体检报告每次出包后扫描SVC的覆盖率列出“项目材质用到了但SVC里没有”的关键字组合。通常用FindAssets遍历所有材质和Prefab中对关键字的引用脚本比对后输出报告。CI强制检查变体总数和SVC数量作为CI指标超过阈值直接警告甚至失败防止某个策划或美术加了一个Shader关键字后悄悄把变体数量推爆。运行时启动检查开发版本启动时遍历当前场景中所有活跃渲染器把渲染器所用材质的关键字组合与SVC对比发现缺失立刻输出警告并打印是哪个物体、哪个模型导致的。这三道保险不复杂但能大大减少“变体丢失”类问题的排查时间。5.4 AssetBundle和热更新场景下的额外坑如果项目用了AssetBundle变体问题还会多几个坑。最典型的是Shader和Material被打进同一个ABSVC又被放到另一个AB加载顺序不对WarmUp时SVC还没进入内存。预热失效不说变体丢失时日志里也不会直接告诉你“SVC没加载”只会表现为粉色。另一个坑是热更新某个玩法更新后新增了变体但SVC没有跟着更新进包。老版本客户端下载更新内容后新特效Shader的关键字组合不在SVC里shader_feature变体被打包工具剥掉线上直接粉红。解决办法是把SVC纳入热更新依赖并且发版前强制跑一遍“SVC覆盖检查”。6. 平台差异与工程化补充移动端、微信小游戏、SRP环境6.1 移动端和微信小游戏平台的变体预处理移动端的Shader编译比编辑器慢得多变体预加载做不做体验差距非常明显。尤其微信小游戏这类基于WebGL方案的环境Shader编译更耗时首次创建GPU程序卡顿可能达到秒级。这类平台上不能等进入游戏后边编译边跑必须在加载进度条阶段把核心变体全部预热完。实践中我还会刻意减少微信小游戏平台的Shader变体总数比如阴影关键字只保留软阴影开和关两档雾效只保留Exponential Squared模式。小游戏包体和运行环境都非常敏感变体少一个是一个。6.2 AssetBundle中Shader与SVC的依赖管理Shader和SVC的依赖关系要在一开始就设计好不然后期调整成本很高。一个朴素的规则Shader放哪个AB依赖这个Shader变体的SVC就放同一个AB或者放到一个总是先加载的公共AB里。比如“common”AB放所有基础Shader和对应SVC“battle”AB放特效相关Shader和对应子SVC战斗开始前先加载“battle_asset”再触发WarmUp。用AssetBundleManifest的依赖接口可以自动获取SVC依赖的Shader Bundle但要注意依赖链比较长时加载完依赖要逐个确认。建议在预加载代码里对SVC的加载状态做一个轮询确认AssetBundle.isStreamedSceneActivated这样的状态没问题再调用WarmUp。6.3 下沉到SRPURP/HDRP的做法差异如果项目已经在URP或HDRP管线里变体的数量和来源会更复杂。URP的Lit Shader默认变体数量非常大官方提供了IPreprocessShaders和CustomShaderStripping两个机制来控制变体。URP自己有一套关键字系统比如_MAIN_LIGHT_SHADOWS、_ADDITIONAL_LIGHTS和内置管线的关键字不通用。从内置管线迁移到URP时项目原有的SVC基本全部作废需要重新收集。我做过一次迁移踩得最深的坑就是SVC里的PassType和关键字名称和URP不匹配运行时大部分变体都找不到。建议迁移后不要直接沿用旧的SVC而是用URP的Shader Variant收集工具重新生成。6.4 团队协作方面的一点建议变体管理说到底是工程规范问题不是某个人写了段脚本就能一劳永逸的。我在团队里会做这几件事把变体数量统计和SVC覆盖率检查挂进CI在Shader代码评审清单里加上“确认新增关键字后变体数量增量”每年或每季度做一次变体总量的大扫除把已经下线的功能关键字清掉。代码评审方面要求所有新增multi_compile必须附带说明变体增量如果增量超过一个预算值必须补做对应子SVC和预热流程。这样变体就不会突然出现在某个周五下午的构建日志里而是从一开始就是可控的。从我个人的经验来看Shader变体管理并不是一个“出事后再修”的东西它更像项目的健康指标。只要你定期关注变体总量、维护好SVC、把预加载做成流程的一部分变体就只是个普通的技术细节一旦放任不管它就会变成那类让人连续加班排查的线上问题。希望这篇文章能帮你把变体从黑盒变成可控项少走一些我当年走过的弯路。
阅读完成 · 觉得有帮助?
咨询建站