Unity Shader变体优化与故障排除技巧

在 Unity 中编写着色器时,我们可以方便地在一个源文件中包含多个特性、通道和分支逻辑。在构建时,着色器源文件会被编译成着色器程序,这些程序包含一个或多个变体。变体是该着色器在满足一组条件后生成的版本,这通常会导致线性执行路径,而没有静态分支条件。

我们使用变体而不是将所有分支路径保留在一个着色器中的原因是,GPU 在并行处理可预测的代码时表现出色,且始终遵循相同路径的,从而实现更高的吞吐量。如果编译后的着色器程序中存在条件分支,GPU 将需要花费资源进行预测任务,等待其他路径完成,等等,从而导致效率低下。

虽然这相比动态分支显著提升了 GPU 性能,但也有一些缺点。随着变体数量的增加,构建时间会变得更长,有时每次构建甚至会增加多个小时。游戏启动时间也会更长,因为需要更多时间来加载和预热着色器。最后,如果变体管理不当,着色器在运行时可能会占用大量内存,有时甚至超过 1GB。

生成的变体数量取决于多种因素,包括定义的关键字和属性、质量设置、图形层级、启用的图形 API、后处理效果、活动的渲染管线、光照和雾效模式,以及是否启用了 XR 等。生成大量变体的着色器通常被称为超级着色器(uber shader)。在运行时,Unity 会加载与所需设置和关键字匹配的变体,这部分内容我们稍后会详细介绍。

如果考虑到我们经常看到有超过 100 个关键字的着色器,这将导致不可控的变体数量,也就是我们常说的着色器变体爆炸,这对我们的影响尤其大。在应用任何过滤之前,着色器的初始变体空间达到数百万的情况并不罕见。

为了解决这个问题,Unity 会尝试通过一些过滤步骤来减少生成的变体数量。例如,如果未启用 XR,那么所需的相关变体通常会被剥离。接下来,Unity 会考虑你在场景中实际使用的功能,如光照模式、雾效等。这些特别难以察觉,因为开发人员和艺术家可能会引入看似安全的更改,但实际上会显著增加着色器变体的数量,除非你在部署管道中设置一些保障措施,否则没有明显的方法可以检测到。

虽然这很有帮助,但这一过程并不完美,我们可以做很多事情,在不影响游戏视觉质量的情况下尽可能多地剥离变体。

在此,我想分享一些关于如何处理变体、理解它们的来源以及一些有效减少变体的实用技巧。这将显著缩短项目的构建时间并减少内存占用。

理解关键字对变体的影响

除其他因素外,着色器变体是根据着色器(Shader)中使用的 shader_feature 和 multi_compile 关键字的所有可能组合生成的。标记为 multi_compile 的关键字始终会包含在构建中,而标记为shader_feature 的关键字只有在被项目中的任何材质引用时才会包含进去。因此,应尽可能使用 shader_feature。

要查看着色器中定义的关键字,可以选择它并查看检视器(Inspector)。

*Shader Inspector 视图中的关键字

如你所见,关键字分为 Overridable(可覆盖)和 Not Overridable(不可覆盖)。具有全局作用域的本地关键字(即在实际着色器文件中定义的关键字)可以被具有相同名称的全局着色器关键字覆盖。如果它们是在局部作用域下定义的(通过使用 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

请注意,这些带有宏的表达式不能依赖于与构建目标无关的其他关键字或功能。

关键字也可以限定在特定的通道(pass)中,从而减少可能的组合数量。为此,你可以在指令后添加以下任意一个后缀:

  • 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 性能下降。因此,在使用时建议进行适当的性能分析。

检查生成的着色器代码

通常情况下,着色器变体直到实际构建游戏时才会被编译。但你可以使用这个选项来检查针对特定构建平台或图形 API 生成的着色器变体。这样可以提前检查是否有错误。此外,你还可以将生成的代码粘贴到 GPU 着色器性能分析工具(如 PVRShaderEditor)中,以进一步的优化。

*Shader Inspector 视图中的关键字

在底部有一个条目,说明根据当前打开场景中的材质,在未应用任何脚本剥离的情况下,包含了多少变体。如果点击"显示 "按钮,就会显示一个临时文件,其中包含一些额外的调试信息,说明在不同平台上使用或剥离了哪些关键字,包括顶点阶段变体的数量。

上面的“Preprocess only(仅预处理)”复选框让你能够在编译后的着色器代码和预处理后的着色器源代码之间切换,以便更为轻松、快速地进行调试。

如果你正在使用 Built-in 内置渲染管线并且使用表面着色器(surface shader),你可以选择检查 Unity 在构建时将用来替换你简化的着色器源代码的生成代码。然后,你可以选择性地用生成的代码替换你的着色器源代码,如果你想要修改输出的话。

*表面着色器的显示生成代码(Show generated code)选项

确定在构建时生成哪些变体

在构建游戏时,Unity 会根据每个着色器的功能、引擎设置和其他因素的所有可能排列组合,确定每个着色器的变体空间。然后将这些组合传递给预处理器,进行多次剥离。可以使用 IPreprocessShaders 回调对其进行扩展,以创建自定义逻辑,从构建中剥离更多变体,如下所述。

在“Project Settings(项目设置)> Graphics(图形)”下的“始终包含的着色器(Always-included shaders)”列表中包含的着色器,其所有变体都会包含在构建中。因此,最好只在绝对必要的情况下使用这个选项,因为它很容易导致生成大量的着色器变体。

最后,构建管线会经过一个称为去重(deduplication)的过程,识别在同一通道(Pass)中的相同变体,并确保它们指向相同的字节码。这样可以减少磁盘上的空间占用,但相同的变体仍然会对构建时间、加载时间和运行时内存使用产生负面影响,因此它不能替代正确的变体剥离。

在成功构建后,我们可以查看 Editor.log 文件,收集一些关于构建中包含了哪些着色器变体的有用信息。要做到这一点,可以在日志文件中搜索 “Compiling shader” 和着色器名称。这是一个示例,看起来是这样的:

 
 

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 着色器的日志、是否生成所有着色器的日志,或者禁用日志。要进行这些设置,请在项目设置中选择“图形(Graphics) > URP 全局设置 (URP Global Settings)”下的日志级别 (Log Level)。

*在 URP 全局设置(URP Global Settings)中设置日志级别(Log Level)

此外,如果你选择下面的“导出着色器变体 (Export Shader Variants)”选项,构建完成后将生成一个包含着色器变体编译报告的 JSON 文件。这在 Unity 2022.2 或更新版本中可用。

确定运行时使用哪些变体

要了解在运行时实际为 GPU 编译了哪些着色器,可以启用“记录着色器编译(Log Shader Compilation)”选项,位于项目设置(Project Settings)> 图形(Graphics)下。

*启用图形项目设置(Graphics Project Settings)中的记录着色器编译(Log Shader Compilation)选项

只要在游戏过程中编译了着色器,就会在玩家日志中打印出来。如工具提示所述,它仅适用于开发构建和 Debug 模式。

格式如下:

 
 

Compiled Shader: Folder/ShaderName, pass: PASS_NAME, stage: STAGE_NAME, keywords ACTIVE_KEYWORD_1 ACTIVE_KEYWORD_2

请注意,某些平台(如 Android)会缓存已编译的着色器。因此,在测试之前,你可能需要卸载并重新安装游戏,以捕获所有已编译的着色器。

最后,你可以使用内存分析器(Memory Profiler)包在游戏运行时捕获快照,然后概览当前加载到内存中的着色器及其大小。按大小排序通常可以很好地说明哪些着色器生成的变体最多,值得优化。

*内存分析器(Memory Profiler)中的着色器概览

基于图形设置的剥离

作为剥离过程的一部分,Unity 会移除与游戏未使用的图形功能相关的着色器变体。如果使用的是内置渲染管线(Built-in Render Pipeline)或通用渲染管线(URP),这个过程会有所不同。

要定义这些设置,可以进入项目设置(Project Settings)> 图形(Graphics)。在这里,使用内置渲染管线时,可以选择游戏支持的光照贴图(Lightmap)和雾效(Fog)模式。

*图形着色器剥离设置

将它们设置为“自动(Automatic)”让 Unity 根据构建中包含的场景确定需要剥离的着色器变体。

如果不确定正在使用哪些功能,也可以使用“从当前场景导入(Import from Current Scene)”按钮让 Unity 来确定需要哪些功能。当然,这只有在所有场景都使用相同设置的情况下才有帮助,因此在使用此选项时,请确保选择一个具有代表性的场景。

如果使用的是 URP,其中的一些选项将被隐藏。这时可以直接在管线设置(Pipeline Settings)配置文件中定义游戏所需的功能。

例如,禁用 Terrain Holes 会导致所有 Terrain Holes 着色器变体被剥离,虽然这样也会减少构建时间。

URP 提供了更精细的控制,可以选择在游戏中包含哪些功能,从而减少未使用的变体,实现更优化的构建。

根据图形层级进行剥离

注意:这仅适用于使用内置渲染管线的情况。当使用可编程渲染管线(如URP)时,这些设置将被忽略。

图形层级(Graphics tiers )用于根据游戏运行的硬件应用不同的图形设置(与质量设置( Quality Settings )不同)。当游戏启动时,Unity 会根据硬件能力、图形API 和其他因素确定设备的图形层级。

可以在项目设置(Project Settings)> 图形(Graphics)>层级设置(Tier Settings)中进行设置。

*图形层级设置

在此基础上,Unity 会为所有着色器添加了以下三个关键字:

UNITY_HARDWARE_TIER1

UNITY_HARDWARE_TIER2

UNITY_HARDWARE_TIER3

然后,它会为定义的每个图形层级生成着色器变体。如果不使用图形层级并希望避免使用相关的变体,则需要确保所有图形层级的设置完全相同,这样 Unity 就会跳过这些变体。

如前所述,Unity 会尝试删除重复的变体,因此,如果三个层级中有两个层级的设置相同,虽然仍会生成所有变体,但导致磁盘大小减小。你可以选择性地强制Unity 为特定的着色器和图形渲染 API 生成层级变体,例如使用以下方式指定:

 
 

// Direct3D 11/12 #pragma hardware_tier_variants d3d11

根据图形 API 进行剥离

Unity 会为构建中包含的每个图形 API 编译一套着色器变体,因此在某些情况下,手动选择并排除不需要的 API 是更好的选择。

要进行这样的设置,请前往 Project Settings > Player。默认情况下,“自动图形 API(Auto Graphics API)”会被选中,Unity 会包含一组内置的图形 API,并根据设备的能力在运行时选择其中一个。例如,在 Android 上,Unity首先尝试使用 Vulkan,如果设备不支持,则引擎会回退到 GLES3.2、GLES3.1 或 GLES3.0(尽管这些GLES 版本上的变体是相同的)。

否则为相关平台禁用“自动图形 API(Auto Graphics API)”,并手动选择要包含的 API。Unity会优先使用列表中排在第一位的 API。

*禁用“自动图形 API(Auto Graphics API)”以选择要优先使用的 API

缺点是可能会限制支持游戏的设备数量,因此在更改这个设置时,确保你知道自己在做什么,并在各种设备上进行测试。

严格的着色器变体匹配

通常在运行时,如果没有找到完全匹配的变体或者在运行版构建中已经被剥离,Unity 会尝试加载最接近所请求的关键字集合的变体。虽然这很方便,但这种做法也可能掩盖着色器关键字设置中的潜在问题。

从 Unity 2022.3 开始,你可以在 Project Settings > Player 中选择“严格的着色器变体匹配(Strict Shader Variant Matching)”选项,以确保 Unity 只尝试加载与所需本地和全局关键字组合完全匹配的变体。

*在项目设置(Project Settings)中启用“严格的着色器变体匹配(Strict Shader Variant Matching)”

如果没有找到匹配项,Unity 将使用 Error Shader 并在控制台中打印错误信息,包括着色器、子着色器索引、实际通道和请求的关键字。当需要追踪实际上需要但缺失的变体时,这非常方便。和其他剥离操作一样,这仅在 Player 中生效,对 Editor 没有影响。

将使用的变体导出到着色器变体集合(Shader Variants Collection)中

在编辑器中运行游戏时,Unity 会跟踪当前场景中使用的着色器和变体,并允许你将它们导出到一个集合中。要进行此操作,请导航到项目设置(Project Settings)> 图形(Graphics)。在底部,你会看到一个着色器加载部分,显示当前正在被使用的着色器数量。

集合链接:

https://docs.unity3d.com/Manual/shader-variant-collections.html

确保事先点击“清除(Clear)”按钮以获得更准确的样本,然后进入运行(Play)模式并与场景进行交互,确保所有需要特定着色器的游戏元素都能正常工作。这将增加被跟踪的计数器。然后,点击“保存到资产...(Save to asset…)”按钮,将所有这些保存到一个集合资产中。

*Save to asset 按钮

着色器变体集合(Shader Variants Collections)是包含着色器及其相关变体列表的资源。它们通常用于预定义要在构建中包含的变体以及预热着色器。

*将着色器添加到着色器变体集合(Shader Variants Collection)中

在一些项目中使用的一种方法是为游戏的每个关卡运行此操作,为每个关卡保存一个集合,然后通过使用 IPreprocessShaders 脚本(在下一节中介绍)剥离任何在这些列表中不存在的变体。尽管这种方法很方便,在我看来也容易出现错误。因为很难确保在单次游戏过程中用到所有需要的变体,有些功能可能仅在设备上加载并在特定情况下出现,导致生成的列表并不一定准确。随着游戏内容的变化和新元素添加到关卡或材质的变更,集合需要不断更新。因此,我建议主要将其用于调试和研究目的,而不是直接集成到构建流程中。

可编程着色器变体剥离

每当着色器即将编译到游戏构建中时,Unity 都会发出一个回调。运行版(Player)和 Asset Bundles 构建时都会发生这种情况。我们可以使用 IPreprocessShaders.OnProcessShader 和 IPreprocessComputeShaders.OnProcessComputeShader(用于计算着色器 compute shader)方便地监听这些回调,并添加自定义逻辑以剥离不必要的变体。这样,我们就能大大缩短构建时间、缩小构建规模,并减少进入构建的变体总数。

为此,请创建一个实现 IPreprocessShaders 接口的脚本,然后在 OnProcessShaders 中编写剥离逻辑。例如,下面的脚本将剥离发布版构建中所有包含 DEBUG 着色器关键字的变体:

*剥离所有包含 DEBUG 着色器关键字变体的脚本

回调顺序让你能够定义预处理脚本的执行顺序,从而创建多步剥离过程。优先级较低的脚本将首先执行。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值