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

在Unity中编写着色器时,我们可以方便地在单个源文件中包含多个特性、通道和分支逻辑。在构建时,着色器源文件被编译成着色器程序,这些程序包含一个或多个变体。变体是遵循一组条件的着色器版本,通常导致线性执行路径,而没有静态分支条件。
我们使用变体的原因,而不是将所有分支路径保留在一个着色器中,是因为GPU擅长并行化可预测的代码,这些代码总是遵循相同的路径,从而提高吞吐量。如果编译的着色器程序中存在条件,GPU将需要花费资源进行预测任务,等待其他路径完成等,从而引入低效。
虽然这导致与动态分支相比显著更好的GPU性能,但也有一些缺点。随着变体数量的增加,构建时间会变得更长,有时每次构建甚至会增加几个小时。游戏启动也会变得更慢,因为它需要花费更多时间加载和预热着色器。最后,如果变体没有得到妥善管理,您可能会注意到着色器的显著运行时内存使用,有时超过1GB。
生成的变体数量取决于多种因素,包括定义的关键字和属性、质量设置、图形层级、启用的图形API、后处理效果、活动渲染管线、照明和雾模式,以及是否启用XR等。生成大量变体的着色器通常被称为超级着色器。在运行时,Unity加载与所需设置和关键字匹配的变体,稍后我们将详细介绍。
考虑到我们经常看到具有超过100个关键字的着色器,这一点尤其重要,这会导致不可管理的变体数量,通常被称为着色器变体爆炸。在应用任何过滤之前,看到初始变体空间达到数百万并不罕见。
为了解决这个问题,Unity会尝试根据一些过滤过程减少生成的变体数量。例如,如果未启用XR,则通常会剥离所需的变体。然后,Unity会考虑您在场景中实际使用的特性,例如照明模式、雾等。这些特别难以发现,因为开发人员和艺术家可能会引入看似安全的更改,实际上会导致着色器变体的显著增加,除非您在部署管道中设置一些保护措施,否则没有明显的方法来检测。
虽然这很有帮助,但这个过程并不完美,我们可以做很多事情来剥离尽可能多的变体,而不影响游戏的视觉质量。
在这里,我想分享一些实用的技巧,关于如何处理变体,了解它们的来源,以及一些有效的减少变体的方法。您的项目构建时间和内存占用将因此大大受益。
有关剥离着色器变体的更多信息,请参阅Unity手册中的减少着色器变体。
着色器变体是基于您着色器中使用的所有可能的着色器特性和多重编译关键字的组合生成的,此外还有其他因素。标记为多重编译的关键字始终包含在您的构建中,而标记为着色器特性的关键字将在您的项目中被任何材质引用时包含。因此,您应该尽可能使用着色器特性。
要查看着色器中定义了哪些关键字,您可以选择它并检查检查器。

正如您所看到的,关键字分为可覆盖和不可覆盖。具有全局范围的本地关键字(在实际着色器文件中定义的关键字)可以被具有匹配名称的全局着色器关键字覆盖。如果它们是在本地范围内定义的(通过使用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
- _geometry
- _raytracing
例如:
#pragma shader_feature_fragment FRAG_FEATURE_1 FRAG_FEATURE_2这可能会根据您使用的渲染器而表现不同。例如,在OpenGL、OpenGL ES和Vulkan上,后缀将被忽略。
您可以使用指令#pragma skip_variants来定义在为特定着色器生成变体时应排除的关键字。在制作玩家构建时,包含这些关键字的着色器的所有变体将被跳过。
您还可以选择使用#pragma dynamic_branch指令定义关键字,这将强制Unity依赖动态分支,而不为这些关键字生成变体。虽然这减少了生成的变体数量,但根据着色器和游戏内容,这可能会导致GPU性能下降,因此建议在使用时进行相应的性能分析。
有关着色器关键字的更多信息,请参阅使用关键字更改着色器工作方式在Unity手册中。
通常,着色器变体在您实际构建游戏之前不会被编译。使用此选项,您可以检查特定构建平台或图形API的生成着色器变体。这使您可以提前检查错误。此外,您可以将生成的代码粘贴到GPU着色器性能分析工具中,例如PVRShaderEditor,以进行进一步优化。

在底部,您会注意到一条条目,说明根据当前打开场景中存在的材料包含了多少变体,而没有应用任何可脚本化的剥离。如果您点击显示按钮,它将显示一个临时文件,其中包含有关在各种平台上使用或剥离了哪些关键字的额外调试信息,包括顶点阶段变体的数量。
上面的仅预处理复选框允许您在编译的着色器代码和预处理的着色器源之间切换,以便更轻松和更快速地调试。
如果您使用的是内置渲染管线并且正在处理表面着色器,您可以选择检查Unity在构建时将用于替换您的简化着色器源的生成代码。如果您希望修改输出,您可以选择用生成的代码替换您的着色器源。
有关更多信息,请参阅检查您有多少着色器变体在Unity手册中。

在构建游戏时,Unity 将根据其特性、引擎设置和其他因素的所有可能排列来确定每个着色器的变体空间。这些组合随后被传递给预处理器进行多次剥离处理。这可以通过使用 IPreprocessShaders 回调来扩展,以创建自定义逻辑,从构建中剥离更多变体,如下所述。
作为 始终包含的着色器 列表的一部分包含的着色器(在项目设置 > 图形下)将包含其所有变体。因此,最好仅在严格必要时使用此功能,因为这可能会导致生成大量变体。
最后,构建管道将经过一个称为去重的过程,识别同一通道内的相同变体,并确保它们指向相同的字节码。这将导致磁盘上的大小减少,但相同的变体仍会对构建时间、加载时间和运行时内存使用产生负面影响,因此这并不能替代适当的变体剥离。
成功构建后,我们可以查看 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请记住,一些平台,如安卓,会缓存编译的着色器。因此,您可能需要在进行测试之前卸载并重新安装游戏,以捕获所有编译的着色器。
最后,您可以使用内存分析器包在游戏运行时拍摄快照,然后查看当前加载在内存中的着色器及其大小。按大小排序通常可以很好地指示哪些着色器带来了最多的变体,并值得优化。

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

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

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

缺点是您可能会限制支持您游戏的设备数量,因此在更改此设置时请确保您知道自己在做什么,并在多种设备上进行测试。
通常在运行时,如果没有可用的精确匹配或已从玩家构建中剥离,Unity 会尝试加载与请求的关键字集最接近的变体。虽然这很方便,但它也隐藏了您着色器关键字设置中的潜在问题。
从 Unity 2022.3 开始,您可以在项目设置 > 玩家中选择严格着色器变体匹配,以确保 Unity 仅尝试加载您所需的本地和全局关键字组合的精确匹配。

如果未找到,它将使用错误着色器并在控制台中打印包含着色器、子着色器索引、实际通道和请求的关键字的错误。当您需要追踪实际需要的缺失变体时,这非常方便。与剥离一样,这仅在玩家中有效,对编辑器没有影响。
在编辑器中玩游戏时,Unity 会跟踪您场景中当前使用的着色器和变体,并允许您将其导出到 a collection 中。为此,请导航到项目设置 > 图形。在底部,您会注意到一个着色器加载部分,显示当前跟踪的活动着色器数量。
确保在此之前点击清除,以获得更准确的样本,然后进入播放模式并与场景互动,确保遇到所有需要特定着色器的游戏元素。这将增加跟踪计数器。然后,按“保存到素材资源...”按钮将所有这些保存到一个集合素材资源中。
有关更多信息,请参阅创建着色器变体集合中的Unity手册。

着色器变体集合是包含着色器及相关变体列表的素材资源。它们通常用于预定义您希望包含在构建中的变体,并预热着色器。

在某些项目中使用的一种方法是对游戏的每个关卡运行此操作,为每个关卡保存一个集合,然后通过使用IPreprocessShaders脚本剥离任何不在这些列表中的变体(在下一节中介绍)。虽然这很方便,但根据我的经验,这也相当容易出错。很难确保在一次游戏过程中遇到所有必需的变体,并且某些功能可能仅在设备上加载并在特定情况下出现,导致列表不一定准确。随着您的游戏变化和新元素添加到关卡或材料更改,集合将需要更新。因此,我主要会将此用于调试和调查目的,而不是直接将其集成到构建管道中。
有关更多信息,请参阅创建着色器变体集合中的Unity手册。
每当着色器即将被编译到您的游戏构建中时,Unity将调度一个回调。这在玩家和资产包构建中都会发生。我们可以方便地使用IPreprocessShaders.OnProcessShader和IPreprocessComputeShaders.OnProcessComputeShader(对于计算着色器)来监听这些,并添加自定义逻辑以剥离不必要的变体。通过这种方式,我们可以大大减少构建时间、构建大小以及进入构建的变体总数。
为此,创建一个实现IPreprocessShaders接口的脚本,然后在OnProcessShader中编写您的剥离逻辑。例如,这里有一个脚本将在发布构建中剥离所有包含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);
}
}
}
}回调顺序允许您定义哪个预处理脚本应该首先运行,从而让您创建多步骤剥离过程。优先级较低的脚本将首先执行。
访问 图形-着色器论坛 讨论以了解更多信息。
有关更多信息,请参阅 Unity 手册中的以下部分:
