1. 从首帧卡顿说起:变体收集到底在解决什么问题
做Unity项目优化做久了,你一定遇到过这样的场景:编辑器里边开发边跑一切正常,打出的包扔到真机上,前面几秒像幻灯片一样卡,过了这一阵子又恢复流畅。很多人第一反应是资源加载问题,查了半天AssetBundle、贴图压缩、模型面数,改了一圈不起作用,最后一开Profiler发现CPU时间全耗在Shader.Parse和GfxDevice.CreateShaderVariants上——罪魁祸首居然是Shader变体编译。
这个问题在Unity开发里特别有迷惑性。因为Shader变体的编译是惰性的,不是说你打包时把所有Shader都编译好塞进包里,而是运行时第一次用到某个变体组合时,图形API才临时去解析、编译、上传。这个“第一次”如果集中在游戏启动或切场景的瞬间,就会造成肉眼可见的卡顿,而且越复杂的项目、越多的材质关键字,问题越严重。
变体收集就是解决这个“第一次”问题的标准手段。它做的事情说白了就是:提前告诉Unity“我项目里可能用到哪些Shader变体”,让这些变体在打包阶段就被编译进包体,运行时直接拿来用,省掉临时编译的那一下卡顿。听起来很简单,但实际操作里坑非常多——收集全了、收集错了、收集多了,每一种情况都有一堆门道。这篇文章我从原理讲起,再给你一套可以直接落地的基础收集方案,最后附上我自己踩坑的排查记录,帮你绕开那些文档里不会写的问题。
适合谁来读?如果你的项目Shader数量超过几十个、用到了Shader关键字做功能开关、或者遇到过真机首帧卡顿但定位不到原因,这篇文章应该能帮到你。项目还在原型阶段、Shader数量个位数的话,可以先收藏,等变体膨胀了再回来看。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 变体是怎么“凭空”多出来的:关键字矩阵与编译时机的真相
2.1 一个关键字就是一个开关,一组关键字就是一张组合表
要理解变体收集,先得搞清楚变体是怎么产生的。Shader里最常见的写法是:
hlsl复制#pragma multi_compile _ _SHADOW_ON
#pragma multi_compile_fog
这两行声明了两个关键字组。multi_compile后面的_表示“不启用任何关键字”的那个状态,_SHADOW_ON表示启用该关键字的状态,所以第一组有2个变体;multi_compile_fog展开后其实是_FOG_LINEAR、_FOG_EXP、_FOG_EXP2加一个空状态,共4个变体。两组关键字做笛卡尔积,一组Pass就能编译出 2 × 4 = 8 个变体。如果Pass再多几个、关键字再多几组,数量就是乘法而不是加法了。
用大白话说:每个关键字组合都是一个独立的GPU程序,它们共用同一份Shader源码,但预处理宏定义不同,编译出来的汇编代码可能差别很大。Unity在打包时不会把这些组合全部预编译,而是做“按需编译”——运行时发现当前材质激活的关键字组合没有对应的编译产物,再临时编译一个出来。
提示:
#pragma multi_compile的特性是全局生效——材质上没有定义这个关键字时,Unity强制把第0个变体编译进包体。这里有个极易踩坑的细节:multi_compile后第一个空选项(下划线)不代表“不编译任何变体”,它本身就是一个变体,只是关键字为空。所以有_的组永远至少多一个“全关”状态。
2.2 运行时编译为什么会卡:惰性编译的代价
这里需要澄清一个常见误解:很多人以为运行时变体编译只是CPU多算几下,忍忍就过去了。实际上在移动端,变体编译是一次“CPU密集解析 + 驱动/GPU编译”的完整流程,中间还可能触发管线状态重建、Shader缓存写入等操作。PC上可能一两毫秒搞定,在低端安卓机上,一个复杂变体从解析到编译完成可能消耗几十毫秒甚至上百毫秒。
更麻烦的是卡顿不平均。首帧可能只需要编译30个变体,但如果每个变体50毫秒,串行下来就是1.5秒的卡顿,而Profiler里你只能看到一坨“Shader.CreateGpuPrograms”的耗时,很难直接定位到具体是哪个变体引起的。等首轮编译结束,后面再切场景、遇到新变体,还有可能零星地卡一下。所以“变体收集”不是一道优化题,而是一道必答题——不做的项目,发布后大概率会在某个时刻闪给你看。
2.3 关键字膨胀的三大源头
变体为什么会多到需要专门去收集?我总结下来主要三个来源:
-
内置关键字组没清理。
multi_compile_fog、multi_compile_lightpass这类内置组覆盖的平台状态很多,哪怕你项目里根本不用雾效,如果Shader里留着这个pragma,它也照样给你生成一整套变体。建议用shader_feature代替不需要全局控制的内置组,或者条件性地省略。 -
美术/TA为了省事在材质上乱挂关键字。材质Inspector里通过
[Toggle]暴露出来的Shader关键字,美术切一下开关,Shader里又是一个新变体。功能互斥的情况下经常能砍掉一半组合,但没人做梳理,就会越积越多。 -
后处理、描边、自定义渲染特性叠加。只要三层Shader分别用了两三个开关,叠加起来就是指数级膨胀。这类变体最难收集,因为不是单纯看资产就能算出来——可能某个运行分支才会激活某种组合。
理解变体是怎么多出来的,才知道收集的目标是什么:不是“把可见的全部收集一遍”,而是“把我运行时确实会用到的组合全部找出来,并且在包体里预编译”。
3. SVC基础收集方案:从声明、收集到运行时预热的完整链路
3.1 整体架构:用ShaderVariantCollection做变体清单
Unity官方提供的基础方案就是ShaderVariantCollection,简称SVC。这个资源本身不包含变体代码,它只是一份“清单”,记录了某个Shader的某个Pass、某组关键字组合需要被预编译。打包时Unity读取所有被引用的SVC(放在Resources、AssetBundle或者Always Included配置里),把清单上的变体预先编译进包内数据。
我建议的方案分四个环节:
- 声明:明确项目里有哪几类Shader需要做收集,核心Shader、后处理Shader、特效Shader分开建SVC,不要混在同一个文件里。
- 收集:编辑器下通过辅助工具收集当前场景、Prefab、AssetBundle中的实际关键字组合,写进SVC。
- 打包:确保SVC资源被构建流程包含,并通过
Player Settings -> Graphics -> Shader Preload或代码预热,让它们在游戏启动早期完成载入。 - 运行验证:真机或Editor下跑一遍核心流程,借助日志确认没有漏编译的变体。
分工角度说,SVC的创建和预编译代码建议由TA或引擎组维护,收集工具可以做成菜单按钮让策划或美术在出包前点一下,避免每次手动操作。
3.2 最直接的收集方式:Editor手动录制变体
Unity里最简单粗暴的方法,是在编辑器里用ShaderVariantCollection的上下文菜单录制。具体操作是:
- 在Project窗口右键 -> Create -> Shader Variant Collection,建一个空SVC。
- 把需要录制的Shader或材质拖进场景,确保需要覆盖的关键字组合处于激活状态。
- 选中SVC资源,Inspector面板上点击“Add”按钮,Unity会把当前场景和已加载资源里所有使用中的变体记录到SVC里。
这个方法看起来方便,但非常不可靠。最大的问题是它只记录“当前编辑器里加载并渲染过的变体”,如果你的场景没有覆盖某个Prefab、某个特效材质在那个时刻未被激活,它就不会被录进去。我见过有团队完全依赖手动录制,上线后发现主角在特定Boss战里还是会卡一帧——就是那个Boss才用到的变体在录制场景里压根没出现。
3.3 更可控的收集方式:用API在编辑器下扫描资产
手动录制之外,我更推荐写一段编辑器脚本扫描整个资产库,提取所有材质实际启用的关键字,再自动组合生成SVC。核心思路是:
csharp复制[MenuItem("Tools/Shader/Collect All Variants")]
public static void CollectAllVariants()
{
var svc = AssetDatabase.LoadAssetAtPath<ShaderVariantCollection>("Assets/ShaderVariants/AllVariants.shadervariants");
if (svc == null)
{
svc = new ShaderVariatantsCollection(); // 注意真实类型名是ShaderVariantCollection
AssetDatabase.CreateAsset(svc, "Assets/ShaderVariants/AllVariants.shadervariants");
}
svc.Clear();
var allMaterials = AssetDatabase.FindAssets("t:Material")
.Select(guid => AssetDatabase.LoadAssetAtPath<Material>(AssetDatabase.GUIDToAssetPath(guid)));
var allShaders = AssetDatabase.FindAssets("t:Shader")
.Select(guid => AssetDatabase.LoadAssetAtPath<Shader>(AssetDatabase.GUIDToAssetPath(guid)));
// 将材质启用的关键字组合取出来,逐个加入SVC
foreach (var mat in allMaterials)
{
if (mat == null || mat.shader == null) continue;
var variantKeywords = mat.enabledKeywords
.Select(k => k.name)
.ToArray();
svc.Add(new ShaderVariantCollection.ShaderVariant(mat.shader, mat.passCount > 0 ? 0 : 0, variantKeywords));
}
EditorUtility.SetDirty(svc);
AssetDatabase.SaveAssets();
}
这段脚本的思路简单说:把Assets下所有材质的启用的关键字组合读出来,对每个Shader逐一登记。但当成品方案用远远不够,原因有两个:
- 它只能扫描到“材质上静态启用”的关键字。那些运行时代码里用
Material.EnableKeyword动态开的组合,资产里看不到。 - 某些关键字组带顺序变化,同一个材质可能在不同分支启用不同组合,需要额外标记。
所以一个可靠的收集器,通常要结合两类数据源:资产静态扫描 + 运行时动态上报。后者我放在下一节讲。
3.4 运行时动态上报:找到静态扫描看不到的变体
要收集动态添加的关键字,又不想手动维护清单,可以靠运行时打印日志再回灌给工具。思路是:
-
在Shader里无所谓,但要在C#侧监听变体失败事件——
OnShaderVariantCollectionError(实际常用的是在运行时创建临时ShaderVariantCollection并添加变体,没有事件则直接在收集脚本里打日志)。 -
或者更实用:在开发期版本里开启
Shader.EnableKeyword和Material.EnableKeyword的日志钩子,把所有在代码里启用的关键字记录成文本。 -
跑一遍完整的玩法、技能、Boss战,收集日志,解析出“材质Shader名 + 关键字组合号”再合并进SVC。
这个“运行时抓取”的做法,很像覆盖率测试——你永远不知道用户会用出什么组合,但至少能让开发期跑过的主流程覆盖90%以上。剩下那些用户特殊操作触发的变体,靠的就是首帧预热名单里留兜底,实在漏了也有后续版本补录。
4. 实际跑通后的优化与检查:收集完了不代表就稳了
4.1 变体数量统计与无效项清理
SVC收集后不要急着出包,先用EditorShaderVariantCollection的统计信息看看清单里有多少变体。如果发现某个Shader的变体数远远大于材质实际用到的组合数,说明扫描过程把不该算进去的组合也算进去了。常见原因是Prefab里残留了无效材质引用、Shader里未使用的关键字组被multi_compile强行展开。
我自己的经验是:用SVC先收集一版,然后在Profiler里对比前后启动耗时。如果启用SVC后启动卡顿没有明显下降,通常说明漏了关键的Shader或关键字组合,而不是方案本身不行。这时候就要回头检查收集器的覆盖范围了。
4.2 Shader Preload与场景加载时序的配合
SVC只是清单,真正让变体在启动阶段编译还需要触发“预加载”。两种做法:
- 将SVC拖到
Player Settings -> Graphics -> Shader Preload列表中,运行时引擎启动阶段会自动处理。 - 在代码启动早期手动调用
ShaderVariantCollection.WarmUp()。
我实际使用中更推荐第二种,因为Shader Preload列表在真机上生效有一定时序不确定性,尤其是项目有自定义Splash场景的时候。WarmUp()的调用时机放在第一个场景加载前、热更代码初始化之后,效果最可控。
csharp复制public static void PrewarmShaders()
{
var svc = AssetDatabase.LoadAssetAtPath<ShaderVariantCollection>("Assets/ShaderVariants/AllVariants.shadervariants");
// 实际运行时通过Resources.Load或AssetBundle加载
svc.WarmUp();
}
注意WarmUp()是同步的,如果SVC巨大,可能在启动主线程上造成新的卡顿。对此可以把大SVC拆成几份,分帧调用,或者提前在Loading界面放一个遮罩等它完成。拆分的时机和粒度也值得调,核心Shader放第一批,特效Shader放第二批,后处理Shader甚至可以等切到战斗场景时再预热。
4.3 变体收集后包体膨胀的权衡
变体被预编译进包体,代价是包体变大。移动端上CPU编译卡顿省下来了,换成了磁盘空间的增长,这个交换是否划算得分开算。如果只是收集了几个复杂后处理Shader,涨个几百KB还能接受;如果整个项目上千个变体无脑全收,包体可能多出几MB到十几MB,在微信小游戏这类包体敏感的平台上,这个涨幅通常不可接受。
所以,变体收集和变体裁剪通常是要同时做的。如果发现有些关键字组里的某些状态项目里永远用不到,可以考虑用#pragma multi_compile换成#pragma shader_feature,这样在材质没有引用到对应关键字时,该变体会被自动排除出包体,不会因为强行收集而白白占空间。
5. 踩坑记录:那些文档里不会写的头痛时刻
5.1 收集全了却依然卡:Pass索引的陷阱
我第一次做SVC时遇到过一个诡异问题:SVC里明明已经有那个Shader的某个关键字组合,运行时Profiler还是显示触发了编译。后来排查半天,发现Shader里有多个Pass,而跑到的那个材质实际走的是Pass 2,我在代码里只把Pass 0的组合加了进去。SVC登记时passIndex参数跳过了默认值0,漏了其他Pass,导致运行时依旧重新编译。
这点特别容易在启用了ForwardAdd多Pass光照、或者自定义Pass的Shader上翻车。检查SVC时不要只看Shader名和关键字,还要确认Pass索引覆盖完整。Unity的Inspector上不会直接显示每条变体的Pass索引,得自己在收集脚本里把每个Pass拆开处理。
5.2 同一个Shader,两份材质,消耗却不同:固定功能与变体共存
还有一个容易忽略的点:固定管线Shader和老式Fallback机制。当你的Shader在某条件下无法满足时,Unity会Fallback到其他Shader,最终渲染到屏幕上的变体可能来自Fallback Shader,而它不在你收集的SVC里。这类变体在编辑器里几乎不会被触发,直到某个低端机上因为特性不支持走了Fallback路径,才会突然冒出来卡一下。
所以收集变体时,要把每个Shader声明的Fallback链路一并扫描进收集器。代码上可以加载Shader后用Shader.Find找到Fallback名字并递归加入SVC,避免运行时临时解析Fallback。
5.3 运行时动态Shader变体:怎么都收集不到的“幽灵变体”
开发URP项目时,URP内部会根据光源类型、阴影设置、平台API等级等自动开启一些内置关键字,比如_MAIN_LIGHT_SHADOWS、_ADDITIONAL_LIGHTS。这些关键字往往不是你在材质上手动Enable的,而是管线代码根据场景状态动态设置的。如果你只扫描材质启用的关键字,这些由管线内部动态设置的组合就很容易漏掉。
对付它们我有两个办法:
- 在运行时跑场景时,用工具把当前帧内激活的变体打出来,加入SVC。因为URP的关键字变化往往发生在特定的灯光配置切换时,策划多跑几种关卡就能覆盖。
- 或者干脆在URP的Renderer Feature里做一次“变体巡查”,遍历所有使用该Shader的材质,强制把管线可能设置的关键字组合都登记一遍。
这类“动态变体”是变体收集里最让人头疼的。静态扫描能解决80%的问题,剩下20%基本都在这类动态变化里,建议在项目早期就建立收集机制,而不是等上线后被用户反馈逼着补。
6. 排查与定位工具:怎样验证变体收集是否真的到位
方案落地后,最怕的就是自我感觉良好。我整理了一套验证流程,每一步都有明确的检查方式,团队内部可以照着做。
- 编辑器下全量变体统计:用工具遍历场景与Prefab,计算引用到的Shader变体数,与SVC清单对比,看覆盖率。
- 真机首帧Profiler采样:发布Development Build,连接Profiler,记录启动前10秒的
Shader.Parse和CreateGpuPrograms次数,目标值建议为0或极少量。 - 开启
Variant日志:在Player Settings -> Shader Compilation里打开Log Shader Compilation选项,运行时若有变体编译会输出日志,逐条核对有没有SVC范围外的内容。 - 运行玩法全流程:至少把主线、支线、Boss战、UI界面、后处理出现的关键场景跑一遍,回传日志分析漏网之鱼。
如果发现日志里每次启动都固定出现某条编译记录,大概率是SVC漏了这条变体,补进清单即可。如果出现偶发性的编译日志,可能是动态开关导致的,需要按前面说的运行时抓取再补一轮。
这套方法配合自动化出包流程,可以做成CI里的一个固化的质量关卡:出包前自动跑一遍场景扫描,如果SVC变体覆盖率和上次相比下降超过阈值,构建直接失败或告警。这样变体收集不会成为一次性行为,而是跟着项目演进持续更新的机制。经过几轮迭代后,你会发现首帧编译卡顿基本消失,后续加新Shader或新特性时,只要按流程更新SVC,就不会再犯“收集了但没完全收集”的错。
