前几天帮另一个项目做包体体检,下班前翻构建日志,发现里面列出来的shader变体加起来有32万条。光是shader编译这一步,就占掉整条构建流水线一半以上的时间。当时我就知道,这项目从立项到发布,大概没有人正儿八经统计过Unity的shader变体数量。
shader变体数量是很多Unity项目里最先爆掉的指标,但也是最容易被当成黑盒的指标。它不显示在Project窗口里,不占用美术资源的直观体积,平时根本看不见。等它变成编译时间暴涨、安装包体积失控、进游戏首帧卡顿的时候,再去收拾已经非常被动。这篇文章不讲玄学,就讲统计shader变体数量这件事本身:为什么值得统计、变体是怎么长出来的、我用过的三套统计方案、拿到数字之后怎么解读,以及我在这过程中踩过的一堆坑。如果你正在维护一个中型以上的移动项目,或者马上要发新版、但是对项目里shader的“理论爆炸量”完全没有概念,那这篇内容应该能帮你省下两三天排查时间。
1. 这个数字为什么值得盯着看:变体爆炸带来的连锁反应
先把结论放前面:shader变体数量不是一个小众的技术指标,它直接决定了几个你每天都会感知到的东西——编辑器里转菊花的时长、安装包的大小、进游戏后第一次打开界面的流畅度。
先说构建时间。shader变体本质上是一份shader源码按照不同宏定义组合编译出来的多个GPU程序。美术那边多勾一个开关,逻辑那边多用一条#pragma multi_compile,编译器就要把对应组合全部过一遍。我见过最夸张的一次,某个地表shader因为历史原因挂了四组multi_compile关键字,变体数量干到四万多,单独编译这个shader就要十几分钟。整条构建流水线里,shader编译占了超过一半的时间,很多开发同事一开始还以为是机器性能不行,其实是变体把编译调度吃满了。
包体大小更直接。每个变体在构建产物里都对应一段实际编译好的字节码。移动平台上单个变体体积虽然不大,但数量叠上去以后非常可观。几万变体对应几十MB额外体积是很常见的。更麻烦的是,这个体积不像贴图那样可以靠压缩格式和mipmap来折中,它几乎是线性增长的,想靠压缩手段把它压下来基本不可能。
运行时的副作用则最容易让线上出事故。Unity对shader变体的加载策略是“用到才编译”,如果一个变体没有被打进预编译集合或者ShaderVariantCollection里,玩家第一次遇到会触发运行时编译。这就是业界常说的shader编译卡顿:画面突然卡住,过几百毫秒到一两秒又恢复。变体数量越大,这种“踩雷”的概率越高,因为你很难靠人工把每一个可能出现的组合都提前试一遍。
所以统计变体数量不是做数据好看,是用来提前判断“这个项目到底还能不能稳住”。如果数量还在几千到一万的级别,常规的ShaderVariantCollection预加载就能兜住;如果到了十几万,那你需要担心的就不只是编译时间,而是整条渲染管线的资源管理逻辑都要重新梳理。
2. 变体是怎么涨上来的:关键字组合与编译流程的底层关系
要统计变体数量,首先得知道这个数字是从哪儿来的。很多新手把它理解成“一个shader的代码复杂度”,其实不对。变体数量本质上是数学上的排列组合问题,shader源码里每声明一组关键字,变体数量就乘一次。
Unity里最常见的开关编译器指令是multi_compile和shader_feature。比如你在shader里写了一句:
#pragma multi_compile WATER_LOW WATER_HIGH这行声明的意思是:这一份shader要编译两套程序,一套带WATER_LOW宏定义,一套带WATER_HIGH宏定义。如果你再写一句:
#pragma multi_compile FOG_OFF FOG_ON那么两组合起来,变体数量就是2乘2等于4。这类声明越多,数量按乘法叠加。一组二维关键字是2倍,两组是4倍,三组是8倍,四组就是16倍。再算上引擎内置的关键字,比如雾效、光照贴图、阴影开关、LOD、方向光、GI等等,一个看着非常简单的shader也可能轻松突破几百上千个变体。
multi_compile和shader_feature的区别是很多人会忽略的关键点。multi_compile是“无论如何都编译全部选项”,它不管项目里到底有没有材质真的启用这个关键字,所有组合都给你留下来。shader_feature则带一个“按需裁剪”的属性,构建时如果引擎认为某个组合根本没有被任何材质引用,它会把对应变体剥离掉。
| 指令类型 | 构建时行为 | 适用场景 |
|---|---|---|
| multi_compile | 生成全部组合,不做引用检测 | 运行时可能动态切换的关键字 |
| shader_feature | 按材质引用情况裁剪未使用组合 | 美术材质上静态配置的开关 |
所以如果你的shader里全是multi_compile,变体数量会按理论值全量保留;如果换成shader_feature,实际构建产物会少很多。但注意,这里说的是“构建产物少很多”,不是“编辑器里统计到的理论值少很多”——因为编辑器里的GetVariantCount统计的是源码能生成的所有可能组合,这一步恰恰会让很多人误判。
除了关键字组合,Pass也是变体数量的放大器。一份shader里如果写了ForwardBase、ForwardAdd、ShadowCaster等多个Pass,每个Pass都会按各自的关键字组合独立编译变体。统计的时候如果只看shader总数,不看Pass分布,数字会少算很多。
还有一个容易忽略的因素是平台。同一个shader在OpenGL ES、Vulkan、DX11这些不同图形API下,各自有一套关键字空间和编译目标。你在Windows编辑器上看到的变体数量,不代表打包Android之后也是这个数。所以统计之前,必须先确认自己切到的是目标平台。平台切错了,数字完全没参考意义。
3. 统计shader变体的完整实操:三套方案
统计变体数量不是只有一种方法,关键是看你想要什么口径的答案。我平时会把统计分成三层:源码理论值、构建实际产物、运行时真实用量。对应三套做法,下面挨个说。
3.1 方案一:编辑器全量扫描,拿理论最大值
这一套适合做资产体检,目的是在项目里快速找出“哪些shader的理论变体量已经失控”。
操作步骤很简单:
- 在Build Settings里把当前平台切到你要发布的目标平台。
- 打开Preferences面板,找到Shader Cache,先清一次缓存。不清缓存的话,引擎可能直接复用旧的统计信息,数字不是你CS里最新的。
- 新建一个Editor脚本,用
ShaderUtil.GetVariantCount对项目里所有shader做遍历统计,结果导出成CSV。 - 按变体数量降序排列,重点看头部shader。
下面这个脚本是我在项目里实际用的简化版,菜单挂到了Tools/Shader/统计Shader变体数量下面:
using System.Collections.Generic; using System.IO; using UnityEditor; using UnityEngine; public static class ShaderVariantCounter { [MenuItem("Tools/Shader/统计Shader变体数量")] public static void CountAllShaderVariants() { string[] guids = AssetDatabase.FindAssets("t:Shader"); var lines = new List<string> { "Shader,变体数量,路径" }; int total = 0; int totalShaders = 0; foreach (string guid in guids) { string path = AssetDatabase.GUIDToAssetPath(guid); Shader shader = AssetDatabase.LoadAssetAtPath<Shader>(path); if (shader == null) continue; int count = ShaderUtil.GetVariantCount(shader, false); total += count; totalShaders++; lines.Add($"{shader.name},{count},{path}"); if (count > 1000) { Debug.Log($"[ShaderVariant] 高危shader: {shader.name} => {count}", shader); } } File.WriteAllLines("shader_variants_total.csv", lines.ToArray()); Debug.Log($"[ShaderVariant] 统计完成: {totalShaders}个shader, 总变体数 {total}"); } }这个脚本跑完以后,CSV文件会生成在项目根目录或者工作目录,直接用Excel打开排序就行。GetVariantCount的第二个参数传false代表“统计所有可能的变体”,而不是“只统计当前已被编译的变体”。这个细节很关键,很多人传了true之后发现数字只有几百,还以为项目很健康。
这个方案的问题是它的口径是“理论值”。它统计的是shader源码在目标平台下能生成的全部组合数,构建时经过裁剪之后,实际保留的变体数通常会小一些。所以我把这个数字当作“资产健康红线”来用,用来判断是不是有某个shader写崩了,而不是当作最终包体变体数来汇报。
3.2 方案二:构建日志统计,拿实际保留量
构建产物里的变体数量才是真正打进包里的数字,这个数字在哪看?最靠谱的来源是Unity构建日志。
步骤是这样的:
- 正常打一次包,或者用命令行构建时加上
-logFile build.log把日志输出到磁盘。 - 构建结束后,在日志里搜索
variant或者shader关键字。 - Unity会把每个shader在目标平台编译后实际发生的变体编译情况打出来,包括总耗时和变体总数。
- 如果想自动化,可以写一个
IPostprocessBuildWithReport回调,在构建完成后读取日志,把日志里对应的区块解析出来存成文件。
这个方案有一个明显的好处:它看到的是Unity经过shader剥离、shader_feature裁剪、以及各种内置剥离逻辑之后真正会打进包的变体数量。你可以拿它和方案一的结果做对比,两者差距越大,说明项目里冗余关键字越多,或者说你的shader_feature用得太少了。
但这个方案的代价是慢。一次完整的平台构建可能要好几分钟,如果你只是想快速看某个shader的变化,为它单独跑一次构建性价比很低。我通常只在发布前的版本体检和流水线改动之后用它做最终确认。
3.3 方案三:运行时实测,拿真实用量
方案一和方案二回答的是“有哪些变体”,但都没法回答“玩家实际用到的变体到底有多少”。这块必须靠运行时统计。
我用的方法比较朴素:
- 在开发版里写一个诊断脚本,定期遍历当前场景里的
Renderer,把每个Material上的shader和启用关键字都记录下来。 - 如果项目里有用
MaterialPropertyBlock动态改关键字的逻辑,不能只看材质资源本身,还要把运行时MaterialPropertyBlock里的关键字也一起收进去。 - 关键信息统一发到后台或者本地日志,最后汇总出去。
- 更细的观察可以用Profiler,在Profiler里看
Shader.CreateGPUProgram的触发次数。这个触发说明有变体在运行时才被编译,触发次数越多,说明预编译覆盖越差。
运行时统计的最大价值是告诉你“预加载该往哪个方向做”。比如你有一个ShaderVariantCollection,但你可以在开发机上把所有场景都跑一遍,统计实际变体消耗,然后再决定集合里应该加入哪些变体,哪些别加。很多团队辛辛苦苦往集合里塞变体,结果塞了一堆根本不会被用到的组合,既浪费包体又没有解决卡顿问题。
三个方案的口径差异可以总结成一张表:
| 方案 | 统计口径 | 成本 | 主要用途 |
|---|---|---|---|
| 编辑器全量扫描 | 源码理论最大值 | 低,几秒到几十秒 | 资产体检、排查异常shader |
| 构建日志统计 | 构建产物实际保留值 | 高,需要完整构建 | 发版前最终确认、流水线检查 |
| 运行时实测 | 玩家实际触发编译的变体 | 中,需要开发机和场景覆盖 | 制定预编译集合、定位运行时卡顿 |
4. 拿到数字之后的解读路径:哪些变体该留,哪些该砍
统计不是目的,目的是搞清楚哪些变体是可以砍的。我在项目里处理这套数据的时候,一般走下面这几步。
第一步是排序,找出头部的shader。大部分项目的变体分布都符合二八定律,可能80%的变体都集中在少数几个shader上。把CSV按变体数量降序排一下,前十个shader基本就是重点审查对象。如果一个shader的理论变体数量超过了5000,我建议直接打开源码看看它到底声明了多少组关键字。
第二步是归因,把关键字组合拆开看。我常用的办法是单变量排除:临时注释掉shader里的一组#pragma multi_compile,重新跑一遍编辑器统计,看数字掉多少。这一招听起来笨,但非常直观,能快速定位到“到底是哪组关键字贡献了爆炸量”。我处理过的一个典型情况是,shader里同时挂了阴影相关、雾效相关、光照贴图相关、地形混合相关四组关键字,单独看每组都不离谱,合在一起就是上万变体。
第三步是判断这组关键字到底有没有必要存在。下面这几个问题在审核时一定要问:
- 这个关键字会真的在运行时切换吗?如果某个效果从场景开始到结束都是固定的,只是不同材质之间互相不同,那应该用
shader_feature而不是multi_compile。 - 这组关键字的组合是都合理吗?很多组合在逻辑上不可能出现,比如“没有光照时开启阴影贴图”,但
multi_compile会把这种没意义的组合也编译出来。 - 能不能拆shader?如果一个shader里有多个完全没交集的功能域,有时拆成两个shader反而能砍掉一半组合。
第四步才是真正的优化动作。这里有几个我亲测有效的方向。
shader_feature替代multi_compile是最基础的一步。凡是不会在运行时动态切换的关键字,全部改成shader_feature。UI上某种固定效果、某个材质上的效果开关,都属于这一类。只有像“技能开启/关闭”“低画质/高画质切换”这种真正会发生运行时切换的,才值得保留multi_compile。
用构建剥离删掉无用组合。Unity提供了IPreprocessShaders回调,可以在shader编译前直接删掉不需要的变体。思路示意如下:
void OnProcessShader( Shader shader, ShaderSnippetData snippet, IList<ShaderCompilerData> data) { for (int i = data.Count - 1; i >= 0; i--) { ShaderCompilerData variant = data[i]; if (某个不支持的组合成立) { data.RemoveAt(i); } } }这里面的判断逻辑可以很灵活,比如某个移动平台不需要雾效,或者某个配色方案在这张地图永远不会启用。写一个这样的剥离脚本,几乎可以把构建产物里的变体数量再压掉一大截。
最后是规范ShaderVariantCollection。预编译集合的目的是覆盖全部“真实会用到的变体”,而不是无脑把全部变体都装进去。我在方案三里说过,先跑运行时实测,再按实测结果建集合。每次版本有shader改动,集合需要重新验证一次。
我拿一个模拟项目的数字来说明这套流程能带来什么变化:优化前,项目里总shaader 80个,总变体数约30万,构建时shader编译耗时55分钟,包体里shader部分体积约45MB。优化后,通过shader_feature替换、无用组合剥离、关键字排查三步,总变体数降到2万出头,shader编译耗时降到5分钟,包体里shader部分体积缩到6MB。首帧卡顿从最长一秒多降到一百毫秒以内。这个项目没有换引擎,没有写更花哨的渲染效果,纯粹就是把不该编译的变体删掉了。
5. 踩过的坑和排查思路,照着抄能省两天调试时间
光有方法不够,实际执行的时候我发现每个坑都能让统计结果失真,这里整理一下最典型的几个,按排查链路排好顺序。
第一个坑是最常见的:编辑器统计口径和构建日志对不上。某天我跑完方案一,看到某个shader理论值有两万变体,立刻慌了一整天,结果构建日志里实际只编译了三千。原因很简单,大量变体被Unity的剥离机制和shader_feature过滤掉了,方案一统计的是理论值,不是最终值。反过来,也有项目只看构建日志,完全不知道某个shader理论值已经大得离谱。我的建议是把方案一当作观察趋势的指标,把方案二当作最终发布凭证,两个数字都要记录。
第二个坑和平台有关。同一个shader在Windows上可能生成一千个变体,切到Android平台可能变成两千多。这是因为不同图形API的关键字空间和处理策略不一样。有次我在编辑器上用DX11平台跑统计,数字很好看,结果打包安卓后构建日志里shader编译时间暴涨了三分之一。从那以后我养成了习惯,凡是做shader变体相关的报告,第一行先写明当前平台。
第三个坑是缓存。Unity的shader缓存会帮你跳过一部分已经编译过的变体,如果你不清缓存就跑统计脚本,GetVariantCount很可能拿到的是缓存文件里的陈旧数据。这个坑我踩过之后,已经把“清缓存”写进了统计脚本的注释里。清理方式在Preferences面板的Shader Cache项,开发机上跑完统计最好也顺手把Library下的ShaderCache清掉,避免下次打包误用。
第四个坑很隐蔽:MaterialPropertyBlock动态开启关键字。有些特效系统会在运行时通过MaterialPropertyBlock开启某个关键字,这种动态开启行为在构建期根本检测不到。构建时引擎做shader_feature裁剪的时候,只认“静态材质上有这个关键字”,动态开启的变体它管不了。最后的结果就是某个变体根本没被打进预编译集合,线上玩家一触发特效就出现一帧卡顿。这个坑光靠统计工具发现不了,必须配合运行时实测的方案三去做排查。
第五个坑是Pass数被忽略。统计时如果只看shader条目,不看每个shader内部Pass的数量,数字会比实际小很多。尤其是Surface Shader,编译出来往往不止一个Pass。我在脚本里统计的是shader整体的变体总数,真要看某一个具体shader的瓶颈,还需要到Inspector面板里逐个Pass确认,或者用更精细的日志去划分。
第六个坑是Unity版本升级带来的偏移。Unity每一次大版本更新,内置关键字空间都可能发生变化,同一个shader在2019和2022版本里统计出来的变体数量不一样。升级引擎之后,之前记录的数字不能直接当作基准,必须重新跑一遍统计,重新建立项目自己的变体健康基线。
写到这里,我还想再补一句个人体会。现在我的习惯是每个项目里都放一个菜单项,用来一键生成变体统计和趋势记录,每次做shader相关改动就顺手跑一遍,看看这个改动让总变体数涨了还是跌了。这个动作本身花不了多少时间,但它能让整个团队对“变体”这两个字保持敏感,而不是等发布前突然冒出一个大卡顿再回去查。变体数量这件事,防永远比治便宜。