在 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)中,从而