Unity 社区
7
2

Unity Shader 变体指南

Unity Solution Official
阅读 3589
2024年10月21日
Unity Shader 变体指南

1. 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 关键字的不同组合。

2.关键字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这些后缀会被忽略。

3.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 个块则视为 无限制。

4.Shader变体剔除流程

4.1 利用引擎剔除

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 中不起作用。

4.2 脚本自定义剔除

每当着色器即将编译到您的游戏版本中时,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); } } } }
回调顺序允许您定义应首先运行哪个预处理脚本,从而允许您创建多步骤剥离过程。优先级较低的脚本将首先执行。

5.Shader变体加载流程

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

6. Shader Variant Collection

SVC是Unity提供的shader变体收集工具,使用SVC可以帮助我们灵活的控制shader变体。
Shader Variant Collection 提供了优化着色器管理和性能的有效手段。它通过预编译、优化内存使用、提升构建和加载速度、提供更好的调试工具、增强控制和灵活性、减少冗余工作,以及改进整体性能,帮助开发者实现更高效、更流畅的游戏体验。
既然好处这么多,一旦项目中Shader体量过大,那么就很有必要来通过SVC控制项目当中的变体了,具体要怎么用呢?有2种办法(更推荐第2种):
1.
在编辑器中运行时,Unity 会记录场景中当前使用的着色器和变体,并允许将其导出到 SVC 中。可以Project Settings>Graphics 。在底部,你会看到 "着色器加载 "部分,显示当前有多少个着色器处于活动状态。
确保事先点击 "清除 "以获得更准确的样本,然后进入 "播放 "模式并进入场景,确保遇到需要特定着色器的所有游戏元素。这将增加跟踪计数器。然后,按下 "保存到资产... "按钮,将所有这些保存到 Collections 资产中。
虽然这样做很方便,但根据我的经验,这样做也很容易出错。很难确保在一次游戏中遇到所有需要的变体,而且有些功能可能只能在特定情况下在设备上加载,因此列表并不一定准确。当游戏发生变化,关卡中添加了新元素或材质发生变化时,就需要更新这些 Collections。
2.
编写自动化收集工具,扫描参与打包的所有材质(主要是Assetsbundle),并收集场景渲染器上的光照参数来综合获取变体。这里提供一个思路,即通过创建一个临时场景,增加符合项目的光源把收集到的所有材质渲染一帧,来间接的收集所有shader变体,再把收集到的shader变体保存到SVC上。
开源框架Yooaseet里有对SVC自动收集的代码工具,如果有兴趣可以看下其中具体实现用来参考。https://github.com/tuyoogame/YooAsset
2条评论

AI

全新AI功能上线

1. 基于Unity微调:专为Unity优化,提供精准高效的支持。

2. 深度集成:内置于团结引擎,随时查阅与学习。

3. 多功能支持:全面解决技术问题与学习需求。

AI