社区分享 | Unity Shader 变体指南

文摘   科技   2024-11-01 20:00   上海  

这篇文章转载自 Unity 社区大佬 ForgemasterGua。全方位解读 Unity Shader 变体的由来、编译流程、剔除流程、加载流程等。ForgemasterGua 在 Unity 中国开发者社区持续更新技术内容中,点击阅读原文,前往 ForgemasterGua 的社区主页,阅读更多干货文章。

Shader 变体的由来


首先引用官方对 Shader 的定义,下图:

如图中所示,Unity 的 shader 不仅仅包含 GPU 上执行的着色器代码,还包含了包括渲染状态、属性、Pass、变体等定义。而 shader 变体是本文讨论的主角,它是 Unity 在编译阶段,根据不同的图形设置、平台、keyword 等生成的着色器代码。说简单点就是同一份着色器代码的不同分身,每个分身有不同的功能。 

以下为官方对 shader 变体定义的说明:

https://docs.unity.cn/2022.3/Documentation/Manual/SL-MultipleProgramVariants.html
(dynamic_branch 由于不会产生 shader 变体,所以不在本文的讨论之内)。
那为什么 Unity 要搞出 Shader 变体这么个玩意呢?因为 GPU 非常擅长并行化可预测的代码,并且始终遵循相同的路径,从而提高并发量。如果编译的着色器程序中存在条件语句,则 GPU 将需要花费资源来执行预测任务、等待其他路径完成等,从而导致效率低下。所以为了解决 shader 不同效果的单独计算,Unity 提供了 shader 变体这一方式,也就是在编译期间根据判断(宏)来为 shader 编译不同的分身。下图为 Unity Shader 编译流程(橙色方框为 Unity 提供的可编程变体剔除方法,允许用户自定义剔除规则):

虽然 shader 变体解决了 shader 并行计算的问题,节省了性能开销,但是也带来了额外的问题。比如随着项目的 shader 越来越多,变体越来越多,导致内存直线增长,顺带着构建过程越来越久,也就是我们常说的变体爆炸。我们通过下图来直观的感受一下变体爆炸的威力:

你没有看错,如果不进行任何的变体收集和剔除,单单一个 urp 自带的 lit shader,变体数量就能达到 355W 个!如果开启选择 skip shader features 呢?如下图,竟也还有 6w 多个变体。

我们接着测试,把 Graphics 下 Shader Stripping 的剔除条件都关闭,再来看一下 lit shader 的变体数量,只剩 593 个了。

所以说,生成的变体数量的增加,具体取决于各种因素,包括定义的关键字和属性、质量设置、图形层、启用的图形 API、后处理效果、渲染管道、照明和雾模式以及是否启用 XR 等。而这些条件,归根结底都是 shader_feature 和 multi_compile 关键字的不同组合。

关键字 Keyword

既然 Unity 是通过 shader_feature 和 multi_compile 关键字来管理变体的,那么我们如何查看呢?在 Editor 下,我们可以通过直接在 Inspector 面板里查看 Shader 的 Keywords。
关键字分为 Overridable 和 Not Overridable。具有全局范围的局部关键字(在实际着色器文件中定义的关键字)可以被具有匹配名称的全局着色器关键字覆盖。相反,如果它们是在本地范围内定义的(通过使用 multi_compile_local 或 shader_feature_local),则无法覆盖它们,并且将显示在下面的 Not overridable (不可覆盖) 部分中。全局着色器关键字由 Unity 引擎提供,并且它们是可重写的。由于可以在构建过程中的任何时候添加它们,因此并非所有全局关键字都可能显示在此列表中。
我们还可以在关键字后面加上更多限制关键词,来做更精细的控制。
这里要注意一下,在 OpenGL、OpenGL ES、Vulkan 这些后缀会被忽略。

Shader 变体编译流程

既然我们知道了 Shader 变体怎么来的,还要知道 shader 变体是怎么用的。在我们实际构建游戏之前,也就是开发阶段是不会编译 shader 变体的。我们可以通过 Compile and show code 来查看具体平台的 shader 变体,这可以帮助我们提前检查问题。另外我们还可以把生成的代码粘贴到 GPU 的性能分析工具中(比如 PVRShaderEditor、Mali Offline Compiler),来进一步优化 shader 性能。
在构建游戏时,Unity 将根据其功能、引擎设置和其他因素的所有可能排列来确定每个着色器的变体组合。然后,这些组合将传递给预处理器以进行多次剥离。这可以使用 IPreprocessShaders 回调进行扩展,以创建自定义逻辑以从构建中剔除变体。
如果是包含在 Always-included shaders ,(在 Project Settings > Graphics 下)的着色器将包含其所有变体。因此,最好仅在绝对必要时才使用它,因为它很容易导致生成大量变体。
需要注意的是,构建过程中将经历一个称为重复数据删除的过程,会识别同一 Pass 中的相同变体,并确保它们指向相同的字节码。这将减小磁盘大小,但相同的变体仍会对构建时间、加载时间和运行时内存使用产生负面影响。
下图为构建游戏时提示的变体构建进度:

构建完成后,我们可以在 Editor.log 文件中搜索 shader 变体的打包信息,可以直接在 log 中搜索 "Compiling shader"。

如果项目设置支持多个图形 API,我们还可以在每个 shader 编译的最后信息里面看到最终的压缩内存大小:
如果项目使用的是 URP 渲染管线,还可以在 URP Global Settings 下,设置 Shader Variant Log Level。
如果选择了下面的 Export Shader Variants 选项,那么在构建后还会生成一个 JSON 文件,其中会包含所有的 Shader 变体的编译结果,位置在 Temp/shader-stripping.json,这样如果我们在游戏中发现某个 shader 变体没有找到,就可以在这个 json 里面确认一下是否被意外剔除了。

上述只是记录了打包的 shader 变体,为了了解在运行时实际为 GPU 编译了哪些着色器,可以在 Project Settings > Graphics 下启用 Log Shader Compilation 选项。

这将导致游戏在运行时会编译着色器变体显示在玩家日志中。它仅适用于开发版本和 Debug 模式,如工具提示中所述。如下图所示,在 android studio 中搜索 Compiled shader 可以看到进入场景时,编译的 shader 变体都有哪些:

最后,您可以使用 Memory Profiler 包在游戏运行时拍摄游戏的快照,然后大致了解内存中当前加载的着色器及其大小。按大小排序通常可以很好地指示哪些着色器引入的变体最多,并且值得优化。

现在 2021、2022、2023 的 LTS 版本另外还支持 Shader 变体的动态压缩,更灵活地控制 shader 内存。构建期间的数据块大小(以 MB 为单位)和在运行时为每个着色器同时保持解压缩的最大数据块数。这两个设置都可以全局配置,并针对每个平台进行覆盖。默认值为每个数据块 16 MB,数据块数量不受限制。如果是 0 个块则视为无限制。


Shader 变体剔除流程

利用引擎剔除

Unity 构建过程中,将删除与游戏未使用的图形功能相关的着色器变体。如果您使用的是 Built-in Render Pipeline 或 URP,则该过程会略有变化。要定义这些参数,请转到 Project Settings > Graphics。在这里,在使用内置渲染管线时,您可以选择游戏支持的光照贴图和雾模式。

如果将它们设为 Automatic,Unity 就可以根据构建中包含的场景来确定要剥离的变体。

如果您不确定自己正在使用哪些功能,还可以使用 Import from Current Scene 按钮让 Unity 找出您需要的功能。当然,仅当所有场景都使用相同的设置时,这才有用,因此请确保在使用此选项时选择代表性场景。

如果您使用的是 URP,则其中一些选项将被隐藏。相反,您可以直接在 Pipeline Settings (工作流设置) 资源中定义游戏所需的功能。例如,禁用 Terrain Holes 将导致所有 Terrain Holes Shader 变体被剥离,从而缩短构建时间。

URP 对要包含在游戏中的功能提供了更精细的控制,从而有可能产生更优化的版本和更少的未使用变体。

基于图形层的剥离

注意:这仅在使用 Built-in Render Pipeline 时相关。使用可编程渲染管道(如 URP)时,将忽略这些设置。

图形层用于根据运行游戏的硬件应用不同的图形设置(不要与 Quality Settings 混淆)。游戏启动时,Unity 将根据硬件功能、图形 API 和其他因素确定您的设备图形层。

可以在 Project Settings > Graphics > Tier Settings 中设置它们。

基于这些,Unity 会将以下三个关键字添加到所有着色器中:

UNITY_HARDWARE_TIER1

UNITY_HARDWARE_TIER2

UNITY_HARDWARE_TIER3

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

如前所述,Unity 将尝试删除相同的重复变体,因此,例如,如果三个层中的两个具有相同的设置,这将导致磁盘大小减小,即使仍会生成所有变体。您可以选择强制 Unity 为给定的着色器和图形渲染器 API 生成层变体,使用如下所示hardware_tier_variants:

// Direct3D 11/12#pragma hardware_tier_variants d3d11

基于图形 API 的剥离,Unity 会为构建中包含的每个图形 API 编译一组着色器变体,因此在某些情况下,手动选择 API 并排除不需要的 API 是有益的。为此,请转到 Project Settings > Player。默认情况下,Auto Graphics API 处于选中状态,Unity 将包含一组内置图形 API,并在运行时根据设备功能选择一个。例如,在 Android 上,Unity 将首先尝试使用 Vulkan,如果设备不支持它,则引擎将回退到 GLES3.2、GLES3.1 或 GLES3.0(尽管这些 GLES 版本的变体是相同的)。相反,请为相关平台禁用 Auto Graphics API,并手动选择要包含的 API。然后,Unity 将优先考虑列表中的第一个 ID。

缺点是您可能会限制支持您的游戏的设备数量,因此请确保您知道在更改此设置时要做什么并在各种设备上进行测试。
*修改着色器变体匹配
通常,在运行时,如果完全匹配不可用或已从播放器构建中剥离,Unity 会尝试加载最接近请求的关键字集的变体。虽然这很方便,但它也隐藏了着色器关键字设置的潜在问题。
从 Unity 2022.3 开始,您可以在 Project Settings > Player 中选择 Strict Shader Variant Matching,以确保 Unity 仅尝试加载所需本地和全局关键字组合的精确匹配。
如果未找到,它将使用 Error Shader 并在控制台中打印包含着色器、子着色器索引、实际通道和请求的关键字的错误。当您需要追踪实际需要的缺失变体时,这非常方便。与通常的剥离一样,这仅在 Player 中有效,在 Editor 中不起作用。

脚本自定义剔除

每当着色器即将编译到您的游戏版本中时,Unity 都会调度一个回调。这种情况在 Player 和 Asset Bundle 构建中都会发生。我们可以使用 IPreprocessShaders.OnProcessShader 和IPreprocessComputeShaders.OnProcessComputeShaders 方便地监听这些内容,并添加自定义逻辑来去除不必要的变体。这样,我们可以大大减少构建时间、构建大小和进入您的构建的变体总数。为此,需要创建一个实现 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); } } }}
回调顺序允许您定义应首先运行哪个预处理脚本,从而允许您创建多步骤剥离过程。优先级较低的脚本将首先执行。

Shader 变体加载流程

当一个游戏对象的材质需要使用特定的着色器时,Unity 会确定所需的着色器变体。每个材质可能需要一个具体的变体,这取决于材质的属性和所启用的特性。着色器变体是由关键字(keywords)决定的。关键字可以开启或关闭特定的着色器代码块,生成不同的变体。每种关键字组合代表一个不同的变体。

Unity 会从项目资源中加载所需的着色器。如果着色器已经在资源中(例如,在 AssetBundle 或 Resources 文件夹中),Unity 会从这些资源中加载着色器。如果之前已经编译了该着色器变体(例如,通过 Shader Variant Collection),Unity 会直接使用预编译的代码。预编译可以大大减少运行时编译的开销。
Unity 会检查所需的着色器变体是否已经存在。如果该变体已经存在于内存中,Unity 会直接使用它。
如果所需的着色器变体未预编译或尚不存在,Unity 会在运行时动态编译该变体。编译过程包括:
1>解析着色器代码:读取 HLSL 代码和着色器的配置。
2>应用关键字和设置:根据材质的设置和启用的关键字,应用相应的变体配置。
3>生成 GPU 代码:将处理后的 HLSL 代码转换为 GPU 能够理解和执行的代码。
动态编译可能导致性能开销,因此减少动态编译的变体数量对优化性能很重要。一旦着色器变体编译完成,Unity 会将编译后的变体应用于材质。材质会使用加载的着色器进行渲染。

Shader Variant Collection

SVC 是 Unity 提供的 shader 变体收集工具,使用 SVC 可以帮助我们灵活控制 shader 变体。


Shader Variant Collection 提供了优化着色器管理和性能的有效手段。它通过预编译、优化内存使用、提升构建和加载速度、提供更好的调试工具、增强控制和灵活性、减少冗余工作,以及改进整体性能,帮助开发者实现更高效、更流畅的游戏体验。

既然好处这么多,一旦项目中 Shader 体量过大,那么就很有必要来通过 SVC 控制项目当中的变体了,具体要怎么用呢?有 2 种办法(更推荐第 2 种):

方法一

在编辑器中运行时,Unity 会记录场景中当前使用的着色器和变体,并允许将其导出到 SVC 中。可以 Project Settings>Graphics 。在底部,你会看到 "着色器加载 "部分,显示当前有多少个着色器处于活动状态。

确保事先点击 "清除 "以获得更准确的样本,然后进入 "播放 "模式并进入场景,确保遇到需要特定着色器的所有游戏元素。这将增加跟踪计数器。然后,按下 "保存到资产... "按钮,将所有这些保存到 Collections 资产中。

虽然这样做很方便,但根据我的经验,这样做也很容易出错。很难确保在一次游戏中遇到所有需要的变体,而且有些功能可能只能在特定情况下在设备上加载,因此列表并不一定准确。当游戏发生变化,关卡中添加了新元素或材质发生变化时,就需要更新这些 Collections。

方法二


编写自动化收集工具,扫描参与打包的所有材质(主要是 Assetsbundle),并收集场景渲染器上的光照参数来综合获取变体。这里提供一个思路,即通过创建一个临时场景,增加符合项目的光源把收集到的所有材质渲染一帧,来间接的收集所有 shader 变体,再把收集到的 shader 变体保存到 SVC 上。
开源框架 Yooaseet 里有对 SVC 自动收集的代码工具,如果有兴趣可以看下其中具体实现用来参考:

https://github.com/tuyoogame/YooAsset

Unity 中文社区持续征集内容投稿,欢迎与 Unity 官方分享你的技术笔记、项目 demo、行业经验、有趣案例,加入社区建设,繁荣内容生态,带领百万 Unity 中文开发者一同学习。

投稿方式:
方式一:在 Unity 中文社区首页(https://developer.unity.cn/)创建个人账号,点击【写文章】,发表文章;

方式二:联系邮箱 learn-cn@unity.cn投稿技术内容


长按关注
Unity 官方开发者服务平台
第一时间了解 Unity 社区动向,学习开发技巧

 点击“阅读原文”,访问博主更多技术分享 


Unity官方开发者服务平台
Unity引擎官方开发者服务平台,分享技术干货、学习课程、产品信息、前沿案例、活动资讯、直播信息等内容。
 最新文章