← ALL ARTICLES
Shader编程8 MIN READ

Shader Variant管理

全库目录221
本文目录19

给材质加几个开关,编辑器里只是多了几个选项,构建却可能突然变慢。参与条件编译的开关会组合成不同GPU程序,还与Pass、平台和质量档位一起扩展。管理变体就是先说清哪些组合会出现,再保证构建保留它们,首次使用时也有正确的准备路径。

着色程序构建

典型过程:

text
Shader 源码
  → 预处理和宏展开
  → 引擎生成 Pass / 平台代码
  → 前端编译到中间表示
  → 平台后端或驱动生成 GPU 指令
  → 创建 Pipeline State

Unity/Unreal 可能在构建时完成大部分编译,但驱动仍可能在运行时创建或缓存平台程序和 Pipeline State。不同平台的缓存边界不同。

Variant 和 Permutation

假设有 nn 个互相独立的二元 Feature,理论组合数是 2n2^n。如果每组 Keyword 有不同选项,组合数是各组数量的乘积。

并不是源码中写了十个 if 就一定有 2102^{10} 个变体。只有参与编译期 Keyword/Permutation 的条件才会展开组合。

变体数量还会乘上:

  • Pass;
  • 光照、阴影、雾等引擎 Keyword;
  • 平台和图形 API;
  • 质量等级;
  • Instancing、XR 等系统选项。

Unity 中的 multi_compile

multi_compile 用于运行时可能出现的 Keyword 组合。构建时通常先生成声明的全部组合,再由构建剥离规则删除确认不需要的部分。

适合:

  • 引擎全局功能;
  • 运行时确实会切换、无法由材质静态确定的功能;
  • 需要明确保证变体存在的场景。

风险是组合数快速增长。

Unity 中的 shader_feature

shader_feature 更适合由材质决定的功能。构建系统可以根据项目中使用该 Shader 的材质剥离未使用组合。

风险:运行时通过脚本启用一个构建时没有被任何已收集材质使用的 Keyword,目标变体可能已经被剥离。

不同 Unity 版本还区分 Local/Global Keyword、shader_feature_local 等形式。使用前应按项目版本核对官方文档。

动态分支与编译变体

选择优点代价
编译变体当前程序没有未选功能代码,可做更强编译优化编译、包体、内存和预热压力
运行时分支减少程序数量,功能组合简单可能增加指令、寄存器和 Wave 发散
分拆 Shader责任清楚,变体更少材质和管线管理更复杂,可能破坏共享

决定时要看条件是否在 Draw 内一致、两边代码多重、切换频率和平台瓶颈。没有一种方案永远更快。

Ubershader通常指承载大量材质功能的统一Shader设计,具体实现既可生成静态变体,也可用Uniform控制动态路径。统一源码不等于只有一个编译程序,更不能据此断言包体最小。应分别统计程序数量、单程序大小、寄存器和运行成本。

Unreal 的变体构成

Unreal 不用 Keyword 字符串描述变体,而是把它拆成几个正交维度的乘积。理解这个乘法结构才能判断从哪里削减。

text
材质相关候选组合上界 =
  ShaderPlatform 数
  × (母材质数 × 每个母材质的静态开关组合 × 材质质量级别数)
  × (VertexFactory 数 × 其静态开关组合)
  × (ShaderType 数 × 其静态开关组合)

这份乘积描述材质相关程序的候选集合,部分项互相依赖,实际生成受编译筛选约束;引擎全局Shader按自己的平台、类型与置换维度另外统计。Shader Library还会去重编译结果相同的Shader。但它清楚地说明了问题性质:这是连乘,任何一项增长都会放大全局。

各维度的含义:

  • ShaderPlatform:引擎的目标着色平台枚举,综合图形后端与目标能力。Cook 时为所有支持平台各生成一份,运行时由 RHI 决定用哪一份;
  • 材质静态开关与质量级别:美术在材质图中设置,Cook时按实际材质静态参数集合与编译策略生成所需组合;
  • VertexFactory:决定顶点数据如何进入 Shader。VF类型与其编译条件共同影响可用Shader组合;
  • ShaderType:Base Pass、Shadow Depth 等不同 Pass 使用不同 Shader 类,同样支持静态开关。

会产生新变体的改动:平台变化、材质图结构变化、网格类型变化、绘制 Pass 变化、静态开关变化。

动态标量、向量与纹理参数值,以及保持顶点布局不变的网格数据,通常只改变输入。静态开关、静态材质参数和顶点工厂选择属于编译维度,需要按组合计数。

这条界线很实用——美术调整动态材质参数不增加变体,但引入一个两种状态都需使用的独立静态维度,可能使相关组合翻倍;只使用其中一种状态或存在互斥条件时应按实际集合计数。

内存与包体构成

两者的构成不同,优化手段也不同:

占用项性质说明
ShaderMap 与Shader元数据CPU内存按加载范围、对象数量和序列化结构测量
Shader字节码与压缩库包体及加载内存格式与解压驻留依赖平台和构建配置
反射与资源绑定信息CPU内存使用生成映射还是运行时反射由后端决定
PSO及驱动对象CPU/驱动内存缓存创建、保留和淘汰策略分别统计

报告应分开记录磁盘压缩字节、解压后字节、当前驻留Shader和PSO数量。某个平台的Native Library行为、常驻范围或LRU配置,需要标出引擎版本和源码位置后才能作为该工程结论。

四个优化方向

  1. 减少变体数量:从连乘公式的各项入手——删除材质用不到的 Usage 标记、合并母材质、减少静态开关、收敛质量级别数、控制 VF 与 ShaderType 的开关数量。材质 Usage 会允许相应用途参与候选组合,最终数量还由目标平台和编译过滤条件决定,应以构建统计定位主要来源;
  2. 压缩:对 Shader Library 压缩,分别测量包体收益、解压时间和解压后的驻留内存;
  3. 按机型分包:提前判断各机型实际需要的 Shader 集合,分包下发,同时减小包体与内存;
  4. 按需加载与剔除:按平台、材质级别、画质、场景分别剔除,按目标引擎公开的缓存策略与预算管理 PSO 驻留。

变体削减、压缩与内容分包主要在构建期规划;运行时再按加载策略和缓存预算消费结果。四者需要共同覆盖实际玩法组合。

变体收集范围

扫描当前打开场景的材质,只能覆盖静态可见的一部分。还可能遗漏:

  • Addressables、AssetBundle 或 DLC 中的材质;
  • 运行时实例化的 Prefab;
  • 脚本动态开启的 Keyword;
  • 粒子、UI、后处理和引擎内部 Pass;
  • 不同 Quality、Lightmap、Fog、Shadow、XR 组合;
  • 编辑器没直接引用,但运行时按名称加载的 Shader。

可靠做法需要把内容依赖、代码路径、平台配置和运行时采样记录结合起来。

剥离流程

一个实用流程:

  1. 建立 Keyword 所有者和使用约定。
  2. 从材质、场景、可寻址资源和构建内容收集静态组合。
  3. 从代码中登记运行时可能切换的组合。
  4. 根据平台和质量等级剥离不可能出现的组合。
  5. 输出构建前后数量和剥离原因。
  6. 在真机构建中覆盖关键场景。
  7. 对缺失变体、粉色材质和首次出现卡顿做自动检查。

剥离规则不能只写“删除没见过的组合”。它还要能解释为什么一个组合在目标版本中不可能出现。

ShaderVariantCollection 和 Warmup

Unity 的 Shader Variant Collection 可以记录一组 Shader、Pass 和 Keyword 组合,并请求 Warmup。

Warmup 的作用是提前触发部分 Shader/平台对象准备,减少第一次出现时的卡顿。但它有几个限制:

  • 收集内容不完整仍会漏变体;
  • 预热太多会增加启动时间和内存;
  • 驱动 Pipeline Cache 和引擎 Shader 缓存不是同一个层次;
  • 不同 API、驱动和 Unity 版本的实际编译时机不同。

因此需要在目标设备上测量启动、场景进入和首次特效出现的帧时间。

第三条值得展开:引擎层面的 Shader 预热与驱动层面的 Pipeline State 编译是两件事。预热了变体不代表 PSO 已就绪,反之亦然。首次出现卡顿到底来自哪一层,需要分别验证。

剥离规则的实现位置

Unity 在构建期通过 IPreprocessShaders 回调逐 Pass 提供变体列表,允许实现方删除条目:

csharp
// Unity Editor接口片段;项目提供已验证的targetFeatureDisabled配置
class VariantStripper : IPreprocessShaders
{
    public int callbackOrder => 0;

    public void OnProcessShader(Shader shader, ShaderSnippetData snippet,
                                IList<ShaderCompilerData> data)
    {
        var keyword = new ShaderKeyword(shader, "_EXPENSIVE_FEATURE");
        for (int i = data.Count - 1; i >= 0; i--)
        {
            if (!data[i].shaderKeywordSet.IsEnabled(keyword)) continue;
            if (targetFeatureDisabled)
                data.RemoveAt(i);
        }
    }
}

targetFeatureDisabled由构建配置提供,含义是本次目标内容的全部运行路径均禁用此功能,不能只用“移动平台”作为删除理由。关键字构造须匹配Shader本地或全局声明;本例采用Shader本地关键字。

回调按 Shader 与 Pass 分批调用,data 是可变列表,移除即代表该变体不进入构建。倒序删除可以避免索引移动导致漏检;正序实现则必须相应调整索引。

剥离必须给出理由而非“没见过就删”。安全的判据是平台能力、质量档位、功能开关的互斥关系这类可静态证明的约束。删错的表现是运行时找不到变体,可能回退到相似变体而显示错误,也可能出现错误Shader或不绘制,而且往往只在特定机型的特定场景复现。

验证方法是记录剥离前后的数量与每条规则的命中数,并在真机构建中跑一遍关键场景,检查是否出现粉色材质与首次加载卡顿。

组合覆盖的集合检查

设一个Pass有阴影开关和三种雾模式,候选组合为 2×3=62\times3=6。目标内容允许三种雾模式,但仅在无雾时允许关闭阴影,所需集合就是四项:(关,无雾)与(开,无雾/线性雾/指数雾)。剥离后的集合S必须包含所需集合R,即 R⊆SR\subseteq S;记录R中每项来自哪个材质或运行开关,比单看“剩余4项”更可靠。

实际键还要包含Shader、Pass、阶段、平台、质量档位和规范化关键字集合。将缺失集合 R−SR-S 作为构建失败项,将多余集合 S−RS-R 作为可继续分析的体积项。后者可能包含未被采集覆盖的合法路径,应先核查来源。

一个运行时开关的完整去向

设角色材质默认关闭_EXPENSIVE_FEATURE,进入剧情时脚本才开启。只收集场景当前材质会得到关闭态;若构建据此剥掉开启态,编辑器测试正常也不能证明包内有对应程序。功能清单应登记两态都可到达,候选键同时包含Shader、Pass、平台、质量与关键字集合。

构建剥离把允许集合与保留集合求差,开启态缺失时立即报出该键和剧情路径。集合完整以后,首次剧情卡顿还需查驱动PSO是否已经准备:保留字节码只证明程序进入包,并不证明目标设备的管线对象已经创建。由此把功能可达、程序保留、平台对象创建分成可定位的三个状态。

变体预算和报表

构建报告至少记录:

  • 每个 Shader、SubShader、Pass 的变体数;
  • 剥离前后数量;
  • 哪些 Keyword 贡献最大;
  • 编译耗时和缓存命中;
  • 包体中的 Shader 数据;
  • 目标设备首次使用卡顿。

只看总变体数不够。少数超大 Shader、常用但预热遗漏的变体,往往比很多从不加载的小 Shader 更危险。

构建职责

功能所有者登记允许的关键字组合,内容构建收集材质与资源依赖,剥离规则输出每项删除理由,运行验证覆盖场景和首次使用路径。每个报表条目关联Shader、Pass、目标配置和规则版本,出现缺失时才能追溯到具体输入与判定。

验证方法

  • 构建后用变体报表确认总数在预算内,按Shader和Keyword定位超出预算的组合。
  • 对启用剥离规则的构建在真机跑核心场景,确认没有粉色材质或运行时编译卡顿;同时检查实际选中的关键字和变体,Unity可能选择相似变体,不能只以无粉色为通过。
  • 用 ShaderVariantCollection 预热后在首场景计时,分别观察变体准备与PSO创建的耗时;变体集合本身要用真实玩法路径采集,只扫当前场景会漏。
  • 修改剥离逻辑后跑全量构建并对日志做 diff,确认被剥掉的都是目标变体而非误伤。
  • 切换 Keyword 的材质在编辑器与真机各跑一次,确认两端状态一致;常见错误是编辑器用全局开关而构建端走材质本地关键字。

相关主题

实现状态:C#展示Unity Editor剥离接口及本地关键字判定,项目补齐命名空间、构建配置与规则报表。集合覆盖示例给出可直接枚举的四项预期;Unity/Unreal构建、预热与PSO运行未执行。

参考资料