Unity游戏 Shader 变体搜集与剔除方案

Shader 变体搜集与剔除方案

概述

Shader 变体(Shader Variant)是 Unity 渲染体系中最重要的性能杠杆之一。每一个变体对应一组特定的#pragma关键字组合(如_MAIN_LIGHT_SHADOWS_ADDITIONAL_LIGHTS_VERTEX等),未经控制的变体会使打包后的着色器数据暴涨,轻则增大包体、加重运行时内存,重则导致构建超时甚至失败。

本教程围绕搜集(Collection)→ 分析(Analysis)→ 剔除(Stripping)三条主线展开,结合 URP 和 DOTS 项目的实际场景,提供一套可落地的方案。


1. 理解 Shader 变体的来源

1.1 变体是如何产生的

着色器源码中的#pragma multi_compile#pragma shader_feature会在构建时展开为变体列表:

#pragma multi_compile _ _MAIN_LIGHT_SHADOWS #pragma multi_compile _ _ADDITIONAL_LIGHTS_VERTEX _ADDITIONAL_LIGHTS_PIXEL #pragma shader_feature _ALPHATEST_ON
  • multi_compile:所有关键字组合都会被构建,无论项目是否使用。适合引擎级全局开关。
  • shader_feature:构建时只保留材质实际用到的关键字组合。适合材质级特性开关。

1.2 URP 的内置变体

URP 的Lit.shaderUnlit.shader等内置着色器自带大量multi_compile关键字。以 URP 14 为例:

// 光照系列 #pragma multi_compile _ _MAIN_LIGHT_SHADOWS _MAIN_LIGHT_SHADOWS_CASCADE #pragma multi_compile _ _ADDITIONAL_LIGHTS_VERTEX _ADDITIONAL_LIGHTS_PIXEL #pragma multi_compile _ _FORWARD_PLUS // 阴影系列 #pragma multi_compile _ _SHADOWS_SOFT _SHADOWS_SOFT_LOW _SHADOWS_SOFT_MEDIUM _SHADOWS_SOFT_HIGH // 雾效系列 #pragma multi_compile _ _FOG_FRAGMENT // DBuffer #pragma multi_compile _ _WRITE_RENDERING_LAYERS // 混合 lighting(用于混合渲染) #pragma multi_compile _ _LIGHT_LAYERS #pragma multi_compile _ _RENDER_PASS_ENABLED // 屏幕空间 #pragma multi_compile _ SCREEN_COORD_OVERRIDE // LOD 过渡 #pragma multi_compile _ LOD_FADE_CROSSFADE

即使不用,这些组合也会被全量构建,是变体膨胀的主要源头。


2. 变体搜集

2.1 自动化搜集管线

变体搜集的核心目标是:只保留运行时实际用到的变体。常用的方案是在构建前通过IPreprocessShaders回调进行追踪,或者通过 ShaderVariantCollection 配合预设的材质列表进行采集。

方案 A:ShaderVariantCollection + Warmup

通过ShaderVariantCollection声明项目中使用的变体,并调用WarmUp()提前加载:

// 在启动或加载场景时触发publicclassShaderVariantWarmup:MonoBehaviour{publicShaderVariantCollectionvariantCollection;privatevoidStart(){variantCollection?.WarmUp();}}

但手工维护 ShaderVariantCollection 很难。更实际的方案是通过构建时的采集管线自动生成。

方案 B:IPreprocessShaders 采集(推荐)

利用 Unity 的IPreprocessShaders回调,在 Build 编译着色器时拦截并记录所有出现的变体(本项目的 DOTS 环境已包含此能力):

usingUnityEditor;usingUnityEditor.Build;usingUnityEditor.Rendering;usingUnityEngine;usingUnityEngine.Rendering;usingSystem.Collections.Generic;usingSystem.IO;usingSystem.Linq;publicclassShaderVariantCollector:IPreprocessShaders{publicintcallbackOrder=>0;// 收集到的变体privatestaticHashSet<string>collectedVariants=newHashSet<string>();privatestaticboolenabled=true;publicvoidOnProcessShader(Shadershader,ShaderSnippetDatasnippet,IList<ShaderCompilerData>data){if(!enabled)return;for(inti=data.Count-1;i>=0;i--){varvariant=data[i];varkeywords=variant.shaderKeywordSet.GetShaderKeywords().Where(k=>shader.IsKeywordEnabled(k)).Select(k=>k.name).OrderBy(k=>k).ToList();stringkey=$"{shader.name}:{snippet.passType}/{snippet.shaderType}({string.Join(",",keywords)})";collectedVariants.Add(key);}}[MenuItem("Tools/Dump Shader Variants")]publicstaticvoidDumpVariants(){enabled=false;// 先停止采集,避免递归stringpath="ShaderVariants.txt";varlines=collectedVariants.OrderBy(v=>v).ToList();File.WriteAllLines(path,lines);Debug.Log($"Dumped{lines.Count}variants to{path}");enabled=true;}}

运行一次 Full Build(不一定要打包完成,编译到中途即可)后调用DumpVariants(),就能拿到完整的变体清单。

注意:针对 DOTS 项目,ECS 的渲染不经过传统 Material,而是通过RenderMeshArray+MaterialMeshInfo下发,所以采集时务必确保所有使用到的 Mesh/Material 组合都在场景或预制体中被引用。


3. 变体分析

3.1 分析收集结果

收集到的变体列表应分为四类:

类别示例处理方式
必用_MAIN_LIGHT_SHADOWS保留
无用_ADDITIONAL_LIGHTS_VERTEX(Forward+ 不使用)剔除
冗余LOD_FADE_CROSSFADE(全场景不用)剔除
可合并_SHADOWS_SOFT_LOW/MEDIUM/HIGH(固定用 Medium)保留一个

3.2 常见冗余关键字(DOTS 项目场景)

在 DOTS + URP 项目中,以下关键字通常是冗余的:

关键字原因
LOD_FADE_CROSSFADEDOTS 渲染不使用 LOD Crossfade
_ADDITIONAL_LIGHTS_VERTEXForward+ 只走 Pixel
_FORWARD_PLUS与 Forward+ 绑定的关键字
_WRITE_RENDERING_LAYERS项目不需要 Decal Layer / Rendering Layer
_LIGHT_LAYERS项目中未使用 Light Layer
_RENDER_PASS_ENABLEDNative RenderPass 默认关闭
_SHADOWS_SOFT_LOW / _SHADOWS_SOFT_MEDIUM / _SHADOWS_SOFT_HIGH只需保留_SHADOWS_SOFT
_FOG_FRAGMENT项目不开启逐像素雾

4. 变体剔除

4.1 URP Global Settings 自带的剔除开关

Project Settings > Graphics > URP Global Settings中(本项目已开启):

选项作用
Strip Debug Variants移除调试可视化关键字
Strip Unused Post Processing Variants仅保留已启用的后处理特效变体
Strip Unused Variants根据 Pipeline Asset 的 Quality 等级剔除变体
Strip Unused LOD Cross Fade Variants移除 LOD Crossfade 变体

这些是最基础的减负手段,但仅靠它们不足以精细控制。

4.2 自定义 Shader Preprocessor

更精准的手段是通过IPreprocessShaders直接剔除不需要的变体(本项目已接入此机制):

usingUnityEditor;usingUnityEditor.Build;usingUnityEditor.Rendering;usingUnityEngine;usingUnityEngine.Rendering;usingSystem.Collections.Generic;usingSystem.Linq;publicclassShaderVariantStripper:IPreprocessShaders{publicintcallbackOrder=>-100;// 优先执行privatestaticreadonlyDictionary<string,HashSet<string>>StripRules=new(){[string.Empty]=new()// 针对所有 Shader 的全局规则{"LOD_FADE_CROSSFADE","_WRITE_RENDERING_LAYERS","_LIGHT_LAYERS","_RENDER_PASS_ENABLED","SCREEN_COORD_OVERRIDE",},["Universal Render Pipeline/Lit"]=new(){"_ADDITIONAL_LIGHTS_VERTEX","_FORWARD_PLUS",},["Universal Render Pipeline/Unlit"]=new(){// Unlit 不需要光照相关变体},};publicvoidOnProcessShader(Shadershader,ShaderSnippetDatasnippet,IList<ShaderCompilerData>data){varshaderName=shader.name;// 找出该 Shader 适用的剔除规则varrules=newHashSet<string>();if(StripRules.TryGetValue(string.Empty,outvarglobalRules))rules.UnionWith(globalRules);if(StripRules.TryGetValue(shaderName,outvarshaderRules))rules.UnionWith(shaderRules);if(rules.Count==0)return;// 从 data 中移除匹配的变体for(inti=data.Count-1;i>=0;i--){varkeywords=data[i].shaderKeywordSet;if(rules.Any(r=>ShaderKeyword.IsKeywordEnabled(keywords,r))){data.RemoveAt(i);}}}}
注意事项
  • callbackOrder控制执行顺序,多个 Preprocessor 按 order 升序执行。
  • 剔除逻辑必须是确定性的——同一个版本的同一个 Build 应产生相同结果。
  • 如果某些材质确实用到了被剔除的变体,Unity 会在运行时在 Log 中打印警告(需要开启Shader Variant Logging),可通过反复迭代完善规则。

4.3 Conditional Stripping(按 Quality Level / 平台)

不同平台(PC vs Mobile)和不同 Quality Level 可应用不同的剔除策略:

publicvoidOnProcessShader(Shadershader,ShaderSnippetDatasnippet,IList<ShaderCompilerData>data){vartarget=EditorUserBuildSettings.activeBuildTarget;for(inti=data.Count-1;i>=0;i--){varkeywords=data[i].shaderKeywordSet;// PC 保留级联阴影,移动端只保留单级if(target==BuildTarget.Android||target==BuildTarget.iOS){if(ShaderKeyword.IsKeywordEnabled(keywords,"_MAIN_LIGHT_SHADOWS_CASCADE"))data.RemoveAt(i);}}}

4.4 辅助函数:检测 ShaderKeyword 是否启用

ShaderKeywordSet没有直接的Contains(string)方法,需要通过以下方式检测:

publicstaticclassShaderKeywordExtensions{publicstaticboolIsKeywordEnabled(thisShaderKeywordSetset,stringkeywordName){varlocalKeyword=ShaderKeyword.GetBuiltinKeyword(keywordName);// URP 内建关键字returnset.IsEnabled(localKeyword);}}

注意:Unity 2022.3+ 中,ShaderKeyword的构造方式因关键字来源(Builtin / Custom)而异。建议统一使用ShaderKeyword(string)构造。


5. Build 时自动触发

将收集和剔除整合进 Build Pipeline,确保每次构建都会执行:

usingUnityEditor.Build;// 自动在 Build 前开启采集,构建后输出报告publicclassShaderVariantBuildProcessor:IPreprocessBuildWithReport,IPostprocessBuildWithReport{publicintcallbackOrder=>0;publicvoidOnPreprocessBuild(BuildReportreport){ShaderVariantCollector.Reset();// 清空上次记录ShaderVariantCollector.Enabled=true;// 开启采集}publicvoidOnPostprocessBuild(BuildReportreport){ShaderVariantCollector.Enabled=false;ShaderVariantCollector.DumpVariants();// 输出变体清单}}

6. 验证流程

6.1 构建前验证

在编辑器中对每个 Quality Level 触发一次着色器编译,查看编译的变体数量:

Window > Analysis > Shader Variants Viewer

6.2 构建日志验证

构建后检查 Editor.log,搜索关键字:

Compiled shader '...' with N variants

对比优化前后的变体数量。

6.3 运行时验证

通过 Profiler 观察ShaderLab的内存占用,以及着色器加载耗时:

Profiler > Memory > ShaderInfo > Total Variants

6.4 运行时缺失警告

开启ShaderVariantLogLevel = 2(Full),运行游戏覆盖所有场景,检查 Console 中是否有变体缺失警告。确认无问题后在发布版本中关闭。


7. 实践经验总结

7.1 推荐的工作流

1. 建立采集管线(IPreprocessShaders 收集) 2. 做一次完整的 Build(中途即可),导出变体清单 3. 分析清单,标记冗余关键字 4. 实现剔除规则(IPreprocessShaders 剔除) 5. 再次 Build,对比变体数量 6. 运行时全量覆盖测试,确认无渲染异常 7. 将剔除规则纳入 CI,每次构建自动应用

7.2 常见陷阱

  • 过度剔除:剔除的变体如果确实被某些 Material 用到,运行时会出现紫色错误材质。跑一遍 QA 全流程就能暴露。
  • ShaderVariantCollection 的热加载WarmUp()只影响加载速度,不影响变体数量。打包时最终决定变体数量的是Stripping,不是 Collection。
  • DOTS 的渲染路径:ECS 的RenderMeshArray不受传统 Material 引用保护,先用一个 Editor 脚本收集所有 ECS 渲染所用的 Material 列表,再反推变体需求。
  • shader_featurevsmulti_compile:自定义 Shader 中优先使用shader_feature,构建系统会自动剔除无用的关键字组合。

7.3 本项目的优化目标

指标优化前(预估)优化后(目标)
URP Lit Shader 变体数3000+< 500
总着色器内存200+ MB< 60 MB
Android 包体着色器部分80+ MB< 25 MB

7.4效果


8. 参考资源

  • Unity Shader Variant Stripping 官方文档
  • URP Shader Stripping 文档
  • Unity IPreprocessShaders API