Shader 变体管理
Shader 变体爆炸与特化常量 - 编译时间与包体优化
引言
Shader 变体爆炸(Shader Variant Explosion) 是现代游戏引擎渲染中最棘手的工程问题之一。一个功能丰富的材质 Shader,为了支持各种开关(有无法线贴图、有无阴影、光源类型、雾效、蒙皮……),往往需要编译出成千上万甚至上百万个变体。
核心关注两个问题:
- 变体爆炸为什么会发生、带来什么代价。
- 特化常量(Specialization Constants) 如何解决它,以及其他引擎常用的应对手段。
一、什么是 Shader 变体爆炸
变体从哪来
为了让一个 Shader 支持多种特性,传统做法是用预处理宏(#define / #ifdef) 在编译期裁剪代码。Unity 里对应 #pragma multi_compile / #pragma shader_feature:
#pragma multi_compile _ _NORMALMAP
#pragma multi_compile _ _SHADOWS_SOFT
#pragma multi_compile _ FOG_LINEAR FOG_EXP FOG_EXP2
#pragma multi_compile _ _SKINNING
void frag() {
#ifdef _NORMALMAP
// 采样法线贴图
#endif
#ifdef _SHADOWS_SOFT
// 软阴影
#endif
...
}每一个开关都会让编译器分别生成一份独立的机器码。
组合爆炸
变体数量是各个关键字维度的乘积,随开关数量呈指数级增长:

2 (法线) × 2 (阴影) × 3 (雾) × 2 (蒙皮) = 24 个变体看起来还好,但真实项目里一个 Shader 有几十个关键字维度:
10 个二元开关 = 2¹⁰ = 1,024 变体
20 个二元开关 = 2²⁰ ≈ 100 万 变体再乘上多个 Shader、多个渲染管线、多个平台,总量轻易突破数百万。
变体爆炸的代价
| 代价 | 说明 |
|---|---|
| 构建时间爆炸 | 编译几十万变体可能耗时数小时,拖慢打包与迭代 |
| 包体膨胀 | 编译后的 Shader 二进制占据大量磁盘/包体空间 |
| 内存占用 | 加载的变体常驻显存/内存 |
| 运行时卡顿(Hitching) | 首次遇到某变体时才编译 PSO,导致画面卡顿(著名的"UE 编译着色器卡顿") |
| 加载时间长 | 预热(prewarm)大量变体拖慢启动 |
二、特化常量(Specialization Constants)
核心思想
特化常量是 Vulkan / SPIR-V 引入的机制(Metal 里对应 Function Constants)。它的核心思想是:
只编写、只分发一份 Shader(SPIR-V 中间码),把"常量的具体取值"推迟到运行时创建管线(PSO)时才确定。
也就是说,源码层和中间码层不再有组合爆炸 —— 你只有一份 SPIR-V,而不是 N 份预处理宏展开的产物。
工作原理

着色器里声明特化常量(带默认值),用它来控制数组大小、循环次数、分支:
// GLSL — constant_id 标识每个特化常量 layout(constant_id = 0) const int NUM_LIGHTS = 4; layout(constant_id = 1) const bool USE_NORMALMAP = false; layout(constant_id = 2) const int FOG_MODE = 0; void main() { if (USE_NORMALMAP) { /* 采样法线贴图 */ } for (int i = 0; i < NUM_LIGHTS; ++i) { /* 光照 */ } }编译成一份 SPIR-V:这份中间码里,特化常量是"占位符",尚未确定值。
创建管线时注入具体值(
VkSpecializationInfo):// 每个变体只是一组不同的常量值 + 同一份 SPIR-V int numLights = 8; VkBool32 useNormal = VK_TRUE; int fogMode = 2; VkSpecializationMapEntry entries[3] = { {0, offsetof(Data,numLights), sizeof(int)}, {1, offsetof(Data,useNormal), sizeof(VkBool32)}, {2, offsetof(Data,fogMode), sizeof(int)}, }; VkSpecializationInfo specInfo{3, entries, sizeof(Data), &data}; stageInfo.pSpecializationInfo = &specInfo; // 绑到管线创建驱动完成最终优化:拿到具体常量值后,驱动把它们常量折叠(constant folding),进而死代码消除、循环展开、分支裁剪 —— 生成的最终机器码和用
#define展开的版本几乎一样高效。
关键点:它省的是什么、不省的是什么
| 环节 | 传统宏变体 | 特化常量 |
|---|---|---|
| 源码 / 中间码数量 | N 份(组合爆炸) | 1 份 SPIR-V |
| 离线编译产物 | N 份二进制 | 1 份(大幅减小包体) |
| 最终机器码(ISA) | N 份 | 仍是 N 份(驱动按需生成) |
| 生成 ISA 的时机 | 离线预处理 | 运行时创建 PSO 时 |
| 运行时性能 | 最优 | 几乎等同(常量折叠后) |
结论:特化常量把"组合爆炸"从源码/包体层面消除,代价是把最终机器码的生成推迟到运行时 PSO 创建。这个运行时开销由管线缓存(Pipeline Cache) 摊销 —— 编译过的 PSO 缓存到磁盘,下次直接复用。
优点
- 源码与包体不再爆炸:只维护、只分发一份 Shader。
- 运行时性能接近静态宏:常量折叠让它比动态分支更高效。
- 灵活:可以用常量决定数组尺寸、循环次数(动态分支做不到无损展开)。
缺点 / 限制
- 值必须在 PSO 创建时确定,不能每次 Draw Call 改变(那是 uniform/push constant 的活)。
- 仍有运行时 PSO 编译开销,需要靠管线缓存和预热缓解。
- 主要是 Vulkan / Metal 的能力;DirectX 12 没有原生等价物(需用动态分支或其他手段替代)。
- 无法处理结构性差异过大的变体(如完全不同的顶点输入布局)。
三、其他解决变体爆炸的方法
特化常量不是银弹。引擎通常组合多种策略。

1. 动态分支(Dynamic Branching)
用 uniform / 常量缓冲区里的值在 Shader 内部做 if 分支,而不是用宏在编译期裁剪:
cbuffer Material { int _UseNormalMap; int _FogMode; };
void frag() {
if (_UseNormalMap != 0) { /* 法线贴图 */ }
}- 优点:零变体,一份 Shader 搞定所有组合;可在运行时(甚至逐 Draw)切换。
- 缺点:所有代码路径都在一份 Shader 里 → 寄存器压力大、占用率(occupancy)下降;即使走不到的分支也占用资源。现代 GPU 对非发散(uniform)分支处理较好,但仍不如静态裁剪极致。
- 适用:分支代价小、组合多但差异不大的情况。
Unity 的动态分支关键字(dynamic_branch)
Unity 2022.2+ 提供了专门的动态分支指令,让同一个关键字从"编译期宏"变成"运行时 uniform 布尔值",从而不产生变体。注意指令是独立的 #pragma dynamic_branch,不是 multi_compile 的参数:
#pragma dynamic_branch _EXAMPLE_FEATURE // 全局关键字
#pragma dynamic_branch_local _NORMALMAP // 局部关键字(每材质独立)三种指令的区别:
#pragma multi_compile _ _FEATURE // 静态:编译全部变体,运行时切换
#pragma shader_feature _ _FEATURE // 静态:只保留材质用到的变体
#pragma dynamic_branch _FEATURE // 动态:零变体,运行时 uniform 分支在 HLSL 中:动态关键字不再用 #ifdef,而是用运行时 if —— Unity 会为它自动生成 bool uniform:
#pragma dynamic_branch_local _NORMALMAP
void frag() {
// 静态关键字要写 #ifdef _NORMALMAP ... #endif
// 动态关键字直接当布尔变量用:
if (_NORMALMAP) { /* 采样法线贴图 */ }
else { /* 用顶点法线 */ }
}从 C# 端切换:API 与普通关键字完全一致,差别只在底层 —— 静态关键字切换可能触发变体切换(编译新 PSO、造成卡顿),动态关键字切换只是改一个 uniform 值,无编译开销:
// 局部关键字(推荐,作用于单个材质)
material.EnableKeyword("_NORMALMAP");
material.DisableKeyword("_NORMALMAP");
// 用 LocalKeyword 避免字符串查找,更高效
var kw = new LocalKeyword(material.shader, "_NORMALMAP");
material.SetKeyword(kw, true);权衡:分支两侧代码差异小、切换频繁时用 dynamic_branch(消灭变体、无卡顿);两侧代码量差异巨大或性能极敏感时,仍应保留静态变体以获得更高占用率。
2. Über-Shader(超级着色器)
把所有特性写进一个巨大的 Shader,通过动态分支或特化常量在其中开关功能。特化常量和动态分支本质都是 Über-Shader 的实现手段。
- 优点:单一源码,易维护。
- 缺点:若纯靠动态分支,占用率和性能会受影响;通常配合特化常量把 Über-Shader 在 PSO 创建时"特化"成精简版本。
3. 变体剥离与收集(Variant Stripping / Collection)
不消灭变体的产生机制,而是只保留真正用到的变体,把没用的裁剪掉:
- Unity
shader_featurevsmulti_compile:multi_compile:所有变体都会被编译并打进包(运行时可自由切换)。shader_feature:只有被某个材质实际使用的变体才会被保留(未使用的自动剥离)。
- 构建期回调剥离:Unity 的
IPreprocessShaders接口可在打包时用代码过滤掉不需要的变体组合。 - ShaderVariantCollection:收集游戏实际运行中用到的变体清单,用于精确打包和预热。
阶段限定关键字:把"乘法"变成"加法"
multi_compile 和 shader_feature(含 _local)都支持加阶段后缀,把关键字的影响限定在单一着色器阶段:
#pragma shader_feature_vertex _WIND_ANIM // 只影响顶点阶段
#pragma multi_compile_fragment _NORMALMAP // 只影响片元阶段
#pragma shader_feature_local_fragment _DETAIL_MAP支持的阶段后缀:_vertex、_fragment、_geometry、_hull、_domain、_raytracing。
为什么能减少变体:普通关键字会让所有阶段都乘以一份变体;阶段限定关键字只在指定阶段产生变体,其他阶段共享同一份 —— 把不同阶段之间的组合解耦了。
假设一个 Shader 有 _SKINNING(仅顶点相关)和 _NORMALMAP(仅片元相关):

不加阶段后缀:2(_SKINNING) × 2(_NORMALMAP) = 4 份,每阶段都生成 4 份
加阶段后缀 :顶点 2 份 + 片元 2 份 → 从 2×2 的乘法变成 2+2 的加法关键字维度越多,这种"从乘法变加法"的收益越显著。注意:关键字必须确实只在该阶段代码里被引用;C# 端开关方式不变(仍是 EnableKeyword/DisableKeyword)。
4. 运行时按需编译 + 管线缓存
- 按需(on-demand)编译:不预编译所有变体,运行时遇到才编译。
- 管线缓存(Pipeline Cache)/ DDC:把编译结果缓存到磁盘,避免重复编译。UE 用 DDC(Derived Data Cache) + PSO Cache。
- PSO 预缓存(PSO Precaching):UE5 在关卡加载时提前收集并编译可能用到的 PSO,缓解运行时卡顿。
5. 关键字分级与材质分层
- 分级关键字:把关键字分为"全局必备"和"局部可选",减少不必要的组合维度。
- 材质分层(Material Layering)/ 节点图:用可组合的材质层代替海量开关,从设计上抑制维度。
6. 引擎实践对照
| 引擎 | 主要手段 |
|---|---|
| Unity | multi_compile / shader_feature / dynamic_branch;IPreprocessShaders 剥离;ShaderVariantCollection 预热 |
| Unreal | Shader Permutation + ShouldCompilePermutation() 剥离;DDC 缓存;PSO Precaching |
| Vulkan/Metal 原生 | Specialization Constants / Function Constants + Pipeline Cache |
四、方法横向对比

| 方法 | 变体数量 | 运行时性能 | 灵活性 | 包体 | 卡顿风险 |
|---|---|---|---|---|---|
| 静态宏变体 | 爆炸 | 最优 | 编译期固定 | 大 | 高(首次编译) |
| 特化常量 | 1 份源码 | 接近最优 | PSO 创建时定 | 小 | 中(PSO 编译,可缓存) |
| 动态分支 | 零 | 较低(占用率) | 最高(逐 Draw) | 最小 | 无 |
| 变体剥离 | 大幅减少 | 最优 | 编译期固定 | 中 | 中 |
| 按需编译+缓存 | 按需 | 最优 | 灵活 | 小 | 首次有,之后无 |
五、选型建议
没有单一最优解,实践中分层组合:
- 能用特化常量的平台(Vulkan/Metal):用它替代大部分宏变体 —— 源码单一、包体小、性能好。
- 低成本、组合多的开关:用动态分支(配合 uniform),彻底消灭变体。
- 性能极度敏感、差异结构大的路径:保留静态宏变体,但用剥离只保留实际用到的。
- 对抗运行时卡顿:管线缓存 + PSO 预缓存 + 变体预热三管齐下。
- 从源头控制:用材质分层 / 关键字分级抑制组合维度,比事后补救更根本。
特化常量把"变体爆炸"从离线编译和包体中移除,用运行时 PSO 编译 + 缓存来承接;再辅以动态分支消灭低成本开关、剥离清除无用组合、缓存与预热对抗卡顿 —— 这套组合拳才是现代引擎的完整答案。
六、核心概念速查
| 概念 | 说明 |
|---|---|
| Shader Variant | 由关键字组合编译出的一份独立 Shader 机器码 |
| multi_compile | Unity:编译所有变体组合,运行时可切换 |
| shader_feature | Unity:只保留材质实际用到的变体 |
| Specialization Constant | Vulkan/SPIR-V:PSO 创建时才确定值的常量,单一中间码 |
| Function Constant | Metal 中特化常量的对应物 |
| Dynamic Branching | 用 uniform 值在 Shader 内分支,零变体 |
| PSO / Pipeline State Object | 管线状态对象,Shader 编译成机器码的载体 |
| Pipeline Cache | 缓存已编译 PSO,避免重复编译 |
| Über-Shader | 集成所有特性、靠开关裁剪的单一大 Shader |
总结
- Shader 变体爆炸源于预处理宏关键字组合的指数级增长,会导致构建时间、包体、内存膨胀和运行时卡顿。
- 特化常量(Vulkan Specialization Constants / Metal Function Constants)通过将常量值推迟到 PSO 创建时确定,将源码和包体层面的组合爆炸消除为单一 SPIR-V 中间码。
- 动态分支(含 Unity
dynamic_branch)以运行时性能为代价实现零变体,适合低成本、高灵活度的开关场景。 - 变体剥离(
shader_feature、阶段限定关键字、IPreprocessShaders)和管线缓存(Pipeline Cache、PSO Precaching)从不同环节压制变体数量或摊销编译开销。 - 实践中应根据平台能力、性能需求和开关特性分层组合多种策略,而非依赖单一方案。
