
做Unity客户端三年以上的人几乎都会遇到一个现象项目开发阶段一切正常但第一次进入某个新场景时画面会愣住几百毫秒甚至一两秒然后才恢复正常。再严重一点打了新包上真机进入战斗首帧直接白屏好几秒。这个问题十有八九出在Shader变体上——不是资源加载慢是GPU在运行时现编译Shader而“变体处理与预加载”就是专门解决这类问题的整套方案。这篇文章从变体的生成原理讲起一直写到收集、剪裁、预加载的完整落地流程适合正在做Unity项目优化、被首帧卡顿和包体膨胀折磨的开发者参考。内容以内置渲染管线为主但URP、HDRP的思路完全一致照着迁移即可。1. 变体到底是个什么东西1.1 一句话解释变体以及为什么要用关键字Shader源码写完之后交给GPU执行的其实是一段经过编译的着色器程序。但同一个Shader往往需要适配多种情况有没有阴影、有没有雾效、用不用法线贴图、光照模型是PBR还是Blinn-Phong、平台是移动端还是PC端。如果每来一种情况就复制一份Shader文件项目资源会直接爆炸维护更是灾难。Unity的解决方案是“关键字 变体”在Shader里用#pragma multi_compile或#pragma shader_feature声明一组开关编译时Unity会把不同开关组合编译成多个版本每个版本就是一个变体Variant。运行时通过Material.EnableKeyword(NAME)或DisableKeyword来切换本质上是换一个变体来用。举个例子#pragma multi_compile _ _DIRECTIONAL_LIGHT #pragma multi_compile _ _SHADOWS_ENABLED这两行声明会产生四种变体组合无光照无阴影、有方向光无阴影、无方向光有阴影、方向光阴影。运行时根据灯光情况动态切换而不需要重新编译。这个机制本身是为性能设计的——GPU不用在运行时做分支判断直接拿编译好的最优程序执行。从开发者的角度看关键字像是一堆“旋钮”变体就是把所有旋钮的可能组合提前“烤”好。旋钮越多烤出来的组合数量就是指数级的增长这也是后面所有麻烦的根源。1.2 multi_compile 和 shader_feature 的分工与陷阱这两个声明方式很容易被混用但处理逻辑完全不同。multi_compile声明的关键字构包时全部变体都会被保留。优点是不会丢缺点是不会丢——没法自动裁剪包体里到处都是用不到的组合。shader_feature声明的关键字则受“场景中使用过”的限制如果某个关键字在场景里的材质上被启用过那么这个变体就会被打进包里如果整个项目没有任何材质启用它对应的变体就会被裁掉。这非常适合美术在材质面板上手动开关的功能比如“是否启用detail贴图”“是否启用顶点动画”。陷阱在于shader_feature的保留逻辑是Unity编译时静态扫描场景和材质引用来决定的。如果你的关键字不是挂在材质上而是运行时通过代码EnableKeyword动态开启而场景资源里又没有任何材质显式勾选过它那构建时这个变体就会被当成“未使用”裁掉运行时一开关键字Shader直接丢失变体表现就是材质变粉或渲染错误。之前我接过一个项目角色技能特效用的是运行时开关键字的方式所有带该关键字的变体都在打包时被裁掉了上线后大量玩家反馈技能效果“凭空消失”排查了一整天才定位到是shader_feature裁剪。后来统一改成美术可控的开关用shader_feature。代码动态控制、运行时才决定开不开的用multi_compile。如果非要用shader_feature必须在GraphicsSettings里把相关Shader加进Always Included Shaders或者自己写变体收集器把那个组合保留下来。1.3 局部关键字和全局关键字Unity 2019.1之后引入了局部关键字Local Keywords支持。旧版的Material.EnableKeyword操作的是全局关键字表同一个关键字只要被一个材质打开所有Shader在做变体选择时都会受到影响这会带来两个问题一是不该开启该关键字的其他Shader可能被错误地选中对应变体二是排查关键字状态的时候很难定位到底是谁动的手。局部关键字通过Material.EnableKeyword配合#pragma multi_compile_local或#pragma shader_feature_local使用关键字状态只存储在当前材质上互不干扰。内置渲染管线里URP给很多功能都用了局部关键字比如_NORMALMAP、_ALPHATEST_ON这些。实际编码时我的习惯是功能关键字优先用局部声明能让问题范围缩小很多全局关键字只留给那些真正全局影响渲染的状态比如“全局雾效开关”“昼夜切换”这类需要所有物体一起响应的全局状态。2. 变体膨胀怎么拖垮项目2.1 组合爆炸的数学与实测数据一个Shader的变体数量是所有关键字组数的笛卡尔积。这句听起来绕举例子就明白了#pragma multi_compile _ _DIRECTIONAL _POINT _SPOT // 4种 #pragma multi_compile _ _SHADOWS_SOFT // 2种 #pragma multi_compile _ _FOG_EXP2 // 2种变体总数等于 4 × 2 × 2 16 种。看起来不多但一套标准PBR Shader通常会接入光照方向、点光/聚光/方向光、阴影质量、雾效、HDR、光照贴图、GI、反射探针这些关键字组随便拼一下就是几百上千个变体。URP工程里写一个基础Lit Shader构建后看变体数量大概在900到2000之间。如果是地形Shader或者特效Shader叠了顶点动画、溶解、扭曲、柔边这几种功能变体数量翻到5000很常见。变体数量的增长不是线性的这是它最可怕的地方。每加一组关键字总量是乘上去的不是加出来的。对这个有敬畏心是最重要的。2.2 变体过多在构建、内存、首帧卡顿三方面的代价构建时间项目刚启动Shader没有变体缓存时Unity会全量编译所有变体。几千个变体的Shader编译起来构建机CPU直接跑满一个平台的构建时间可能从10分钟涨到40分钟。包体和内存每个变体在GPU驱动层都是一段编译后的二进制代码。以移动端为例一个变体的代码段通常是几百字节到几KB几千个变体加起来单个Shader就是几十MB的包体增量。更麻烦的是Shader资源在AssetBundle里很难按变体拆开瘦身基本是“用不用都背着”。管线里加载Shader时变体代码还要进内存移动端内存以百MB计的年代这很伤的。运行时首帧卡顿玩家实际游玩路径中每个Draw Call使用的变体一旦没有被预先加载和编译GPU驱动就会在那一帧现编译。表现就是画面突然掉帧、卡住、甚至屏幕冻结几百毫秒。热词里搜“unity阴影问题”搜出来的很多首帧卡顿帖子背后的原因其实就是阴影ShadowCaster相关的变体没预编译。2.3 容易被忽略的阴影与渲染路径关键字阴影相关的关键字是变体膨胀的重灾区。原因在于Unity很多内置Shader默认透传几个阴影关键字组比如_SHADOWS_SOFT、_SHADOWS_HARD、SHADOWS_CUBE场景里一旦有不同的阴影类型需求这些组合会全部编译出来。还有一个坑是multi_compile_fwdbase、multi_compile_fwdadd、multi_compile_fwdaddfullshadows这类内置宏展开。它们背后其实是好几组预置关键字开发者的Shader里只要写了一个实际上等于引入了二三十个组合但没有Shader源码的人很难意识到这一点。我们之前接手的项目一个很简单的Unlit特效Shader因为多写了一行multi_compile_fwdadd变体从8个直接涨到180多个而且实际项目里根本没有用到逐顶点加光。排查这类问题没有捷径只能靠变体统计脚本。后面在工具章节会展开讲。这里先说结论慎重使用内置的multi_compile_fwd*家族使用时一定确认项目里真的有对应的多光源需求。3. 变体收集与剪裁实操3.1 用 ShaderVariantCollection 把运行时要用的变体“点名”出来Unity提供了ShaderVariantCollection资源专门用来记录“哪些Shader的哪些变体必须保留”。这个资源可以直接在编辑器里创建但手工维护不现实一般采用两种补充方式。第一种编辑器内收集。写一个编辑器脚本遍历所有场景和Prefab上挂着的材质读取每个材质当前启用的关键字组合把对应变体Add进ShaderVariantCollectionusing UnityEngine; using UnityEditor; using System.Collections.Generic; public static class VariantCollector { [MenuItem(Tools/Shader/Collect Variants From Scenes)] public static void Collect() { ShaderVariantCollection collection AssetDatabase.LoadAssetAtPathShaderVariantCollection(Assets/ShaderVariants/CollectedVariants.shadervariants); if (collection null) { collection new ShaderVariantCollection(); AssetDatabase.CreateAsset(collection, Assets/ShaderVariants/CollectedVariants.shadervariants); } string[] sceneGuids AssetDatabase.FindAssets(t:Scene); foreach (string guid in sceneGuids) { string scenePath AssetDatabase.GUIDToAssetPath(guid); foreach (var root in UnityEditor.SceneManagement.EditorSceneManager.GetSceneAt(0).GetRootGameObjects()) { // 遍历Renderer和Material取出Material记录 } } // 遍历Prefab / ScriptableObject 里引用到的Material string[] allMatGuids AssetDatabase.FindAssets(t:Material); foreach (string matGuid in allMatGuids) { var mat AssetDatabase.LoadAssetAtPathMaterial(AssetDatabase.GUIDToAssetPath(matGuid)); if (mat null || mat.shader null) continue; var keywords mat.enabledKeywords; collection.Add(new ShaderVariantCollection.ShaderVariant(mat.shader, (ShaderPassType)0, keywords)); } EditorUtility.SetDirty(collection); AssetDatabase.SaveAssets(); } }注意ShaderVariantCollection.ShaderVariant的构造函数需要传ShaderPassType在Unity 2021以上的版本里是ShaderPassType.Surface或ShaderPassType.ShadowCaster等。核心思路就是把你运行时要用的那些关键字组合显式“点名”记录让构建时Unity知道这些变体必须保留。但碍于场景路径和材质绑定方式各不相同很多项目会采取自动化管线遍历所有Asset的材质引用而不是上面这种粗粒度遍历这点根据自己的项目结构来定。第二种运行时记录。开启ShaderVariantTracker之类的机制在开发版本里跑项目流程把所有使用过的变体输出成日志再离线生成ShaderVariantCollection。Unity官方文档里有现成的ShaderVariantCollector示例不少项目也把它集成到自己的自动化测试用例里。运行时收集有个好处能覆盖到代码动态开关关键字的路径坏处也很直接需要有人去把玩法流程都跑一遍否则漏场景就漏变体。3.2 自定义 IPreprocessShaders 做变体剪裁ShaderVariantCollection解决的是“保留哪些”但已经生成的变体总量还是那么多构建时间和包体里的冗余依然存在。真正的裁量方案是写IPreprocessShaders在构建前拦截并删除变体。using UnityEditor.Build; using UnityEditor.Rendering; using UnityEngine; using UnityEngine.Rendering; using System.Collections.Generic; public class VariantStripper : IPreprocessShaders { public int callbackOrder 0; public void OnProcessShader(Shader shader, ShaderSnippetData snippet, IListShaderCompilerData data) { // 剪裁掉所有非移动端平台不需要的雾效变体 for (int i data.Count - 1; i 0; i--) { ShaderCompilerData item data[i]; if (item.shaderCompilerPlatform ShaderCompilerPlatform.GLES3x) continue; // 如果关键字包含 FOG_EXP2 且不是 OpenGL ES 平台直接删掉 if (item.shaderKeywordSet.IsEnabled(new ShaderKeyword(FOG_EXP2))) { data.RemoveAt(i); } } } }这个脚本的作用是在Unity为某个平台编译Shader时逐个检查变体的关键字集合如果确定项目在这个平台上用不到直接把它从编译列表里删掉。构建时间和包体大小都会显著下降。踩过坑的提醒移动端用到的变体集合一定比PC端少但不会少到离谱。比如阴影软硬件、雾效开关这些按平台剪裁的思路是对的。IPreprocessShaders里读ShaderCompilerData的关键字是有接口变化的Unity 2022.3 和 2023.1 的 API 有差异网上搜的旧脚本要对照自己的编辑器版本改。千万别把所有_ALPHATEST_ON变体都剪了AlphaTest是很多植被、草地材质在用的剪了就是一堆透明草变粉色。剪之前一定要先在编辑器里跑一遍VariantCollection收集流程确认哪些是实际用到的再写死剪裁规则。这俩是配套关系不能偏废。3.3 Always Included Shaders 的正确用法Graphics Settings - Always Included Shaders这个列表里的Shader无论场景和资源里有没有引用都会无条件全量打进包。适合放那些运行时动态创建、但确实一定会用到的Shader比如UI默认Shader、后处理Shader。这个选项的使用教训是它是“全量变体”进包的不是一个白名单机制。如果你把一个变体很多的Shader放进去等于放弃了所有剪裁优化。正确姿势是放那些你真的确定全量变体加起来也没多少的Shader。动态要用的复杂Shader优先用ShaderVariantCollection来保变体而不是往Always Included Shaders里塞。定期巡检这个列表看有没有冗余项因为很多人加完之后就再也不管了。4. 预加载流程的设计与实现4.1 什么时候预加载、加载什么收集和裁剪做完只是保证“该用的变体都在包里了”。运行时第一次使用某个变体时GPU驱动依然需要编译一次编译可能耗时几十到几百毫秒。预加载的目标就是把这段编译时间提前到空闲阶段让玩家在关键场景里不会遇到帧间隔。按项目的实际加载时机我一般划分成三个层次启动预加载App启动、进入游戏主界面时加载那些全局必定用到的Shader和变体比如UI默认Shader、天空盒Shader、后处理Shader。这个阶段通常是Loading画面卡一点不致命。关卡预加载进入战斗/关卡前加载当前场景、角色、特效、UI特殊效果所依赖的Shader变体。这些预加载做完才能真正保证游戏过程中没有瞬时编译。按需预加载个别特殊玩法比如某个Boss第一次出现时才启用的特殊后处理可以在玩法触发前提前加载不用全量摊到关卡加载里。每个层级的加载内容需要根据项目情况来定黄金指标是“玩家可感知卡顿的场景必须被某个层级的预加载覆盖到”。4.2 ShaderVariantCollection.WarmUp() 与 AssetBundle 的配合预加载的引擎API核心是两个// 方式一预热整个 ShaderVariantCollection ShaderVariantCollection svc assetBundle.LoadAssetShaderVariantCollection(shaderVariants); svc.WarmUp(); // 方式二强行加载所有已加载Shader的所有变体慎用 Shader.WarmupAllShaders();ShaderVariantCollection.WarmUp()会让Unity把集合里记录的每个变体都提交给GPU驱动编译一次编译结果缓存起来之后同变体再次使用就直接命中缓存不会再卡。关键细节在于它要求Shader资源已经在内存中。如果你的Shader放在AssetBundle里必须先把那个AB加载完再拿到ShaderVariantCollection去WarmUp。顺序反了或者AB卸载了预热结果就失效了。这里给出一份可用的预加载流程示例基于AssetBundlepublic class ShaderPreloader : MonoBehaviour { [SerializeField] private AssetBundle shaderBundle; public IEnumerator PreloadShaders() { var request AssetBundle.LoadFromFileAsync(Path.Combine(Application.streamingAssetsPath, shaders)); yield return request; shaderBundle request.assetBundle; // 让Shader资源进入内存 var shader shaderBundle.LoadAssetShader(MyLitShader); Resources.UnloadAsset(shader); // 变体集合预热集合中记录了这个Shader所需的所有变体 ShaderVariantCollection svc shaderBundle.LoadAssetShaderVariantCollection(CollectedVariants); svc.WarmUp(); // 可选卸载不再需要的AB但注意Shader可能依赖其他AB资源 } }实际操作中我通常把变体集合拆成两组全局组和关卡组。全局组放启动必用的几个Shader关卡组按关卡划分进关卡前加载当前关卡对应的SVG避免一次性预热上千个变体导致Loading时间过长。4.3 进度条与预加载解耦很多团队用“预加载进度 资源加载进度 变体预热进度”的方式拼进度条这种方式有色噪且容易卡壳。变体热身是个同步阻塞方法进度条根本没法反映它。我的做法是把变体预热放进独立阶段它的进度按“已预热变体数 / 总变体数”来算但不能每炫一帧都调WarmUp里的某个回调。实际操作中可以用分段预热IEnumerator WarmUpInChunks(ShaderVariantCollection svc, int chunkSize) { var variants GetAllVariants(svc); // 把集合拆成列表 int processed 0; foreach (var variant in variants) { svc.WarmUpVariant(variant); // 单个变体预热 processed; if (processed % chunkSize 0) { // 让出主线程更新Loading进度条 progress (float)processed / variants.Count; yield return null; } } }注意WarmUpVariant是Editor-only的API运行时不能用。想要“按批次预热”通用做法是运行时把一个大集合拆成多个小的ShaderVariantCollection顺序WarmUp每个之间yield return null。虽然麻烦但比卡死UI好太多了。4.4 微信小游戏与移动端平台的特殊性热搜里提到了微信小游戏打包这个平台对Shader变体更敏感。小游戏本质跑在浏览器环境Shader编译走WebGL/WebGPU底层性能和缓存策略更不稳定首帧编译卡顿往往被放大而且开发者工具和真机行为差异极大。针对小游戏平台我有几点实际建议变体集合里的条目能少则少能用shader_feature裁掉的坚决不用multi_compile保底。预加载时机要在引擎初始化完成、适配信息拿到之后再开始否则容易拿到错误的SystemInfo引发各种兼容问题。小游戏平台对WarmUp的支持和编译优化不如原生平台预加载也可能加载出一个较长的“假卡死”。这时候需要做成“loading画面 分帧预热”的方式避免一次性同步预热过多。移动端和Web端最理想的变体管理是收集出来的集合非常精准剪裁规则非常严格。不要指望运行时玄学不存在的。5. 常见问题与排查技巧5.1 粉色材质的排查场景里出现亮粉色的材质是第一大Shader事故。原因分很多层面排查路径如下先看Console有没有Shader error或Material doesnt have a shader的报错有的话定位到具体Shader。如果是运行到一半变粉色基本是变体丢失某个代码启用了关键字的变体没有被保留。用Frame Debugger选中那个Draw Call看Shader Keywords那一栏列的关键字组合再对照项目里的ShaderVariantCollection看这个组合在不在。在移动平台出现“编辑器正常、真机粉色”通常是平台Shader编译变体差异。用Profiler的ShaderCompiler事件和File - Build Settings - Build with Profiler可以抓到具体是哪个Shader在真机编译失败。排查工具上Frame Debugger是最直接的面板能让你看到单帧内每个Draw Call使用的Shader变体和关键字。现在的Frame Debugger在URP下功能非常全建议每个Shader优化人都把它的快捷键背下来。5.2 阴影异常与关键字冲突搜“unity阴影问题”能搜出一堆帖子很多根因都在关键字冲突。例如一个材质同时启用了_RECEIVE_SHADOWS和_ALPHATEST_ON而Shader里multi_compile的组合没有同时声明这两个关键字那么阴影接收就会静默失效。此时材质不会变粉但看起来就像“阴影穿透”或“影子不见了”。这种问题的排查思路用Frame Debugger查看当前Draw Call的关键字组合。用上一节里的变体收集脚本跑一遍看ShaderVariantCollection里是不是缺了这类组合。如果是本地发现的直接在Material面板手动勾选关键字组合再导出变体集合看能否复现。很多时候阴影异常不是ShadowMap出了问题而是变体缺失导致Shader选择了错误的Pass或错误的关键字分支。5.3 变体收集遗漏怎么兜底就算有自动化收集项目总有遗漏的情况某个怪物只在特定关卡用到一个材质但那个材质的关键字没被收集到。我的做法是加运行时兜底在Shader里为shader_feature声明的功能加默认行为分支让缺失变体不至于完全渲染错误。在开发版本开启ShaderVariantTracker接入CI流程每次跑回归测试时自动收集新出现的变体缺的自动反馈到项目。发布前强制“全量素材扫描”把所有Shader的所有变体做成集合后对打包产物做比对差的变体直接让构建失败。实际操作中这三步能覆盖95%以上的漏收集情况。剩下的5%属于代码里动态生成了Shader或用Shader.Find加载了未引用变体的Shader只能靠提升代码review质量来堵。5.4 常用检查工具清单工具用途入口/命令Frame Debugger查看每帧Draw Call的变体与关键字Window - Analysis - Frame DebuggerProfiler抓Shader.Load和编译事件Window - Analysis - ProfilerBuildReport查看构建产物中Shader大小与变体数量构建日志中查看ShaderVariantCollection显式保留运行时要用的变体Project窗口创建IPreprocessShaders构建前自动剪裁变体自定义编辑器代码ShaderCompiler 日志查真机编译报错构建日志 logcat6. 我实际用下来觉得最有价值的小技巧最后分享一个我在项目里始终坚持的习惯把变体统计做成CI里的硬性检查。每次构建结束之后跑一个脚本统计所有Shader编译出来的变体总数和上次构建对比增量超过阈值就直接失败不允许合入主干。为什么要这么做因为Shader变体膨胀是典型的“温水煮青蛙”问题单次改动总是觉得就加了几个关键字、没大碍但三个月后回来看变体数量早已悄悄翻了一倍。有CI盯着开发者在加关键字的时候就会多问自己一句“这组开关真的需要这么多组合吗”这个问题一多问项目的Shader变体管理就成功了一大半。再补充一个排查卡帧的实用技巧真机上遇到卡顿先用adb shell dumpsys gfxinfo或者Profile抓帧看有没有Shader.CreateGPUProgram的调用耗时。如果这个函数出现在冷启动和前几帧那就是变体预加载没覆盖到的地方把对应的变体补进集合就行。如果这个函数只在某一次玩法切换时出现说明集合已经有效只是特定变体漏了针对性补漏即可不要随便全量预加载。变体处理这件事说到底是管住两个量包体里有多少变体运行前预编译了多少变体。前者靠裁剪后者靠预加载两条腿缺一条都会出问题。把这两个量盯住项目的渲染首帧基本就稳了。