Unity 着色器变体优化与故障排除技巧

在 Unity 中编写着色器时,我们可以方便地在单个源文件中包含多个特性、传递和分支逻辑。在构建时,着色器源文件被编译成着色器程序,其中包含一个或多个变体。变体是该着色器的一个版本,只遵循一组条件,从而(在大多数情况下)形成一条没有静态分支条件的线性执行路径。
我们之所以使用变体,而不是在一个着色器中保留所有分支路径,是因为 GPU 擅长并行处理可预测且始终遵循相同路径的代码,从而带来更高的吞吐量。如果编译后的着色器程序中存在条件,GPU 就需要花费资源执行预测任务,等待其他路径完成,如此循环往复,导致效率低下。
虽然与动态分支相比,这能大大提高 GPU 性能,但它也有一些缺点。随着变体数量的增加,构建时间也会变长,有时每次构建甚至需要多个小时。由于需要花费更多时间加载和预热着色器,游戏启动时间也会更长。最后,如果未对着色器变体进行适当管理,您可能会发现着色器在运行时占用大量内存,有时甚至超过 1GB。
生成的变体数量会因各种因素而增加,包括定义的关键字和属性、质量设置、图形层级、启用的图形 API、 Post Processing 效果、活动渲染管道、光照和雾化模式以及是否启用 XR 等。产生大量变体的着色器通常被称为超着色器。在运行时,Unity 会加载与所需设置和关键字相匹配的变量,我们稍后会介绍。
如果考虑到我们经常看到有超过 100 个关键字的着色器,这将导致无法控制的变体数量,也就是我们常说的着色器变体爆炸,那么这一点就尤为重要。在应用任何过滤之前,着色器的初始变异空间高达数百万,这种情况并不罕见。
为了缓解这一问题,Unity 会尝试减少基于几次过滤产生的变体数量。例如,如果未启用 XR,则通常会剥离为此所需的变体。然后,Unity 会考虑到你在场景中实际使用的功能,如灯光模式、雾气等。要发现这些问题尤其棘手,因为开发人员和美工人员可能会引入一些看似安全的更改,但实际上却会导致着色器变种的显著增加,而没有任何明显的检测方法,除非您将一些保障措施作为部署管道的一部分。
虽然这很有帮助,但这一过程并不完美,我们可以做很多事情,在不影响游戏视觉质量的情况下尽可能多地剥离变体。
在这里,我想与大家分享一些实用技巧,告诉大家如何处理变体,了解变体的来源,以及一些减少变体的有效方法。您的项目构建时间和内存占用将因此大大缩短。
除其他因素外,着色器会根据着色器中使用的shader_feature和multi_compile关键字的所有可能组合生成着色器变体。标记为multi_compile的关键字始终包含在构建中,而标记为shader_feature的关键字如果在项目中被任何材质引用,也会被包含在内。因此,应尽可能使用shader_feature。
要查看着色器中定义了哪些关键字,可以选择该着色器并检查检查器。

如您所见,关键字分为可覆盖和不可覆盖两种。全局范围的 Localization 关键字(在实际着色器文件中定义的关键字)可以被名称匹配的全局着色器关键字覆盖。如果它们被定义在 Localization 范围内(通过使用 multi_compile_local 或 shader_feature_local),则无法被覆盖,并将显示在下方的 "不可覆盖 "部分。全局着色器关键字由 Unity 引擎提供,并且可以重写。由于全局关键字可以在构建过程中的任何时候添加,因此并非所有全局关键字都会显示在此列表中。
通过在同一指令中定义关键字,可以将关键字定义为相互排斥的组,称为集。这样做可以避免为永远不会同时启用的关键字组合生成变体(例如两种不同类型的灯光或雾气)。
#pragma shader_feature LIGHT_LOW_Q LIGHT_HIGH_Q
例如,要减少每个平台的关键字数量,可以使用预处理器宏,只为相关平台定义关键字:
#ifdef SHADER_API_METAL
#pragma shader_feature IOS_FOG_FEATURE
#else
#pragma shader_feature BASE_FOG_FEATURE
#endif
请注意,这些带有宏的表达式不能依赖于与构建目标无关的其他关键字或功能。
关键词还可以限制在特定的通道内,从而减少潜在的组合数量。为此,您可以在指令中添加以下后缀之一:
- _vertex
- _fragment
- _hull
- _domain
- 几何学
- _射线跟踪
例如:
#pragma shader_feature_fragment FRAG_FEATURE_1 FRAG_FEATURE_2
不同的渲染器会有不同的表现。例如,在 OpenGL 上,OpenGL ES 和 Vulkan 后缀将被忽略。
您可以使用指令 #pragma skip_variants,定义在为特定着色器生成变体时应排除的关键字。在构建播放器时,将跳过该着色器中包含其中一个关键字的所有着色器变体。
您也可以选择使用 #pragma dynamic_branch 指令定义关键字,这会强制 Unity 依赖动态分支,而不为这些关键字生成变体。虽然这会减少产生的变体数量,但根据着色器和游戏内容的不同,可能会导致 GPU 性能降低,因此建议在使用时进行相应的配置。
通常情况下,着色器变量要到实际构建游戏时才会编译。使用此选项,您可以检查针对特定构建平台或图形 API 生成的着色器变体。这样就可以提前检查错误。此外,您还可以将生成的代码粘贴到 GPU 着色器性能分析工具(如 PVRShaderEditor)中,以便进一步优化。

在底部,您会看到一个条目,说明在不应用任何脚本剥离的情况下,根据当前打开场景中存在的材质,包含了多少种变体。如果点击 "显示 "按钮,就会显示一个临时文件,其中包含一些额外的调试信息,说明在不同平台上使用或剥离了哪些关键字,包括顶点阶段变体的数量。
通过上面的 "仅预处理 "复选框,您可以在编译后的着色器代码和预处理后的着色器源代码之间进行切换,以便更轻松、更快速地进行调试。
如果您使用 "内置渲染管道"(Built-in Render Pipeline)并使用曲面着色器,您可以选择检查生成的代码,Unity 将在构建时使用这些代码替换您的简化着色器源。如果想修改输出,可以选择用生成的代码替换着色器源代码。

在构建游戏时,Unity 会根据每个着色器的特性、引擎设置和其他因素的所有可能排列组合,确定每个着色器的变体空间。然后将这些组合传递给预处理器,进行多次剥离。可以使用 IPreprocessShaders 回调对其进行扩展,以创建自定义逻辑,从构建中剥离更多变量,如下所述。
包含在始终包含的着色器 列表中的着色器 (在 "项目设置">"图形 "下),其所有变体都将包含在构建中。因此,最好只有在绝对必要时才使用,因为这很容易导致生成大量变体。
最后,构建流水线会经过一个称为重复数据删除的过程,识别同一 Pass 中的相同变体,确保它们指向相同的字节码。这将减小磁盘上的大小,但相同的变体仍会对构建时间、加载时间和运行时内存使用产生负面影响,因此不能取代适当的变体剥离。
成功构建后,我们可以查看 Editor.log 文件,收集一些有用的信息,了解构建过程中包含了哪些着色器变体。为此,请在日志文件中搜索 "编译着色器 "和着色器名称。下面就是一个例子:
Compiling shader "GameShaders/MyShader" pass "Pass 1" (vp)
Full variant space: 608
After settings filtering: 608
After built-in stripping: 528
After scriptable stripping: 528
Processed in 0.00 seconds
starting compilation...
finished in 0.02 seconds. Local cache hits 528 (0.16s CPU time), remote cache hits 0 (0.00s CPU time), compiled 0 variants (0.00s CPU time), skipped 0 variants
在某些情况下,您可能会看到变体数量在设置筛选步骤后有所增加,例如,如果您的项目启用了 XR。
如果您的游戏支持多个图形 API,您还可以找到每个支持的渲染器的信息:
Serialized binary data for shader GameShaders/MyShader in 0.00s
gles3 (total internal programs: 290, unique: 193)
vulkan (total internal programs: 290, unique: 193)
最后,你会看到这些压缩日志,它们会显示特定图形 API 着色器在磁盘上的最终大小:
Compressed shader 'GameShaders/MyShader' on vulkan from 1.35MB to 0.19MB
如果您使用的是通用渲染管道 (URP),您可以选择是否只从 SRP 着色器、所有着色器或禁用日志中生成日志。为此,请从 "项目设置">"图形">"URP 全局设置 "中选择日志级别。

此外,如果选择下面的 "导出着色器变量 "选项,将在构建后生成一个 JSON 文件,其中包含着色器变量编译报告。此功能在 Unity 2022.2 或更新版本上可用。
为了了解运行时 GPU 实际编译了哪些着色器,可以启用 "项目设置">"图形 "下的 "日志着色器编译 "选项。

这样,只要在游戏过程中编译了着色器,游戏就会在玩家日志中打印出来。如工具提示所述,它仅适用于开发构建和调试模式。
格式如下
Compiled Shader: Folder/ShaderName, pass: PASS_NAME, stage: STAGE_NAME, keywords ACTIVE_KEYWORD_1 ACTIVE_KEYWORD_2
请注意,某些平台(如 Android)会缓存已编译的着色器。因此,在进行测试通过之前,您可能需要卸载并重新安装游戏,以捕获所有已编译的着色器。
最后,您可以使用 Memory Profiler 软件包在游戏运行时对其进行快照,然后就能大致了解当前内存中加载了哪些着色器以及它们的大小。按大小排序通常能很好地说明哪些着色器带来的变体最多,值得优化。

作为剥离程序的一部分,Unity 会删除与游戏未使用的图形功能相关的着色器变体。如果使用的是内置渲染管道或 URP,流程会略有不同。
要定义这些图形,请转到 "项目设置">"图形"。在使用内置渲染管道时,您可以从这里选择游戏支持的光贴图和雾气模式。

将它们设置为 "自动 "后,Unity 会根据构建中包含的场景来决定剥离哪些变体。
如果您不确定要使用哪些功能,也可以使用 "从当前场景导入 "按钮,让 Unity 找出您需要的功能。当然,这只有在所有场景都使用相同设置的情况下才有用,因此使用该选项时请确保选择一个有代表性的场景。
如果使用 URP,其中一些选项将被隐藏。相反,您可以直接在 "管道设置 "资产中定义游戏所需的功能。
例如,禁用 "地形孔 "会导致所有 "地形孔着色器 "变体被剥离,从而缩短构建时间。
URP 提供了更细粒度的控制,让您可以确定游戏中要包含哪些功能,从而有可能优化构建,减少未使用的变体。
注意:这只有在使用内置渲染管道时才有意义。在使用 URP 等可编写脚本的渲染管道时,这些设置将被忽略。
图形层用于根据游戏运行的硬件应用不同的图形设置(不要与 "质量设置 "混淆)。游戏启动时,Unity 会根据硬件能力、图形 API 和其他因素确定设备图形层级。
它们可以在 "项目设置">"图形">"层设置 "中设置。

在此基础上,Unity 将这三个关键字添加到所有着色器中:
UNITY_HARDWARE_TIER1
UNITY_HARDWARE_TIER2
UNITY_HARDWARE_TIER3
然后,它会为定义的每个图形层生成着色器变体。如果不使用图形层并希望避免它们的相关变体,则需要确保将所有图形层设置为完全相同的设置,这样 Unity 就会跳过这些变体。
如前所述,Unity 会尝试重复删除相同的变体,因此,如果三个层中有两个层的设置相同,这将导致磁盘上的大小减小,尽管仍会生成所有变体。您可以选择使用 hardware_tier_variants 强制 Unity 为给定着色器和图形渲染器 API 生成层变体,如下所示:
// Direct3D 11/12
#pragma hardware_tier_variants d3d11
Unity 会为构建中包含的每个图形 API 编译一套着色器变体,因此在某些情况下,手动选择 API 并排除不需要的 API 是有益的。
为此,请转至项目设置 > 播放器。默认情况下,选择 "自动图形 API",Unity 将包含一组内置图形 API,并在运行时根据设备能力选择其中一个。例如,在安卓系统上,Unity 会首先尝试使用 Vulkan,如果设备不支持 Vulkan,引擎就会退回到 GLES3.2、GLES3.1 或 GLES3.0(不过这些 GLES 版本上的变体是相同的)。
相反,请禁用相关平台的自动图形 API,然后手动选择希望包含的 API。然后,Unity 会优先处理列表中的第一个。

缺点是可能会限制支持游戏的设备数量,因此在更改时一定要了解自己在做什么,并在各种设备上进行测试。
通常在运行时,如果没有完全匹配的关键字,或者播放器构建中的关键字已被删除,Unity 会尝试加载与请求的关键字集最接近的变体。虽然这很方便,但也隐藏了着色器关键字设置的潜在问题。
从 Unity 2022.3 版开始,您可以在 "项目设置">"播放器 "中选择 "严格着色器变量匹配",以确保 Unity 只尝试加载与您所需的 Localization 和全局关键字组合完全匹配的着色器。

如果找不到,它将使用错误着色器,并在控制台中打印包含着色器、子着色器索引、实际传递和请求的关键字的错误信息。当你需要查找丢失的变体时,这非常方便。与剥离一样,这只在播放器中起作用,对编辑器没有影响。
在编辑器中玩游戏时,Unity 会记录场景中当前使用的着色器和变体,并允许您将其导出到Collections 中。为此,请导航至项目设置 > 图形。在底部,你会看到 "着色器加载 "部分,显示当前有多少个着色器处于活动状态。
确保事先点击 "清除 "以获得更准确的样本,然后进入 "播放 "模式并进入场景,确保遇到需要特定着色器的所有游戏元素。这将增加跟踪计数器。然后,按下 "保存到资产... "按钮,将所有这些保存到 Collections 资产中。

着色器变体 Collections 是包含着色器和相关变体列表的资产。它们通常用于预先定义您希望在构建中包含的变体,以及预热着色器。

某些项目中使用的一种方法是为游戏中的每个关卡运行此程序,为每个关卡保存一个 Collections,然后通过使用 IPreprocessShaders 脚本(将在下一节中介绍)删除这些列表中不存在的任何变体。虽然这样做很方便,但根据我的经验,这样做也很容易出错。很难确保在一次游戏中遇到所有需要的变体,而且有些功能可能只能在特定情况下在设备上加载,因此列表并不一定准确。当您的游戏发生变化,关卡中添加了新元素或材料发生变化时,就需要更新这些 Collections。因此,我认为它主要用于调试和调查,而不是直接集成到构建管道中。
每当要将着色器编译到游戏构建中时,Unity 都会发出一个回调。播放器和资产包构建时都会出现这种情况。我们可以使用 IPreprocessShaders.OnProcessShader 和 IPreprocessComputeShaders.OnProcessComputeShader(用于计算着色器)方便地监听这些内容,并添加自定义逻辑以删除不必要的变量。这样,我们就能大大缩短构建时间、缩小构建规模,并减少进入构建的变体总数。
为此,请创建一个实现 IPreprocessShaders 接口的脚本,然后在 OnProcessShaders 中编写剥离逻辑。例如,下面的脚本将在发布版本中删除所有包含 DEBUG 着色器关键字的变量:
public class StripDebugVariantsPreprocessor : IPreprocessShaders
{
public int callbackOrder => 0;
ShaderKeyword keywordToStrip;
public StripDebugVariantsPreprocessor()
{
keywordToStrip = new ShaderKeyword("DEBUG");
}
public void OnProcessShader(Shader shader, ShaderSnippetData snippet, IList<ShaderCompilerData> data)
{
if (EditorUserBuildSettings.development)
{
return;
}
for (int i = data.Count - 1; i >= 0; i--)
{
if (data[i].shaderKeywordSet.IsEnabled(keywordToStrip))
{
data.RemoveAt(i);
}
}
}
}
通过回调顺序,您可以定义哪个预处理脚本应首先运行,从而创建多步剥离程序。优先级较低的脚本将优先执行。
请访问图形着色器论坛讨论,了解更多信息。