一、什么是Shader变体管理
想要回答这个问题,要看看什么是Shader变体。
1. 变体
我们用ShaderLab编写Unity中的Shader,当我们需要让Shader同时满足多个需求,例如说,这个是否支持阴影,此时就需要加keyword(关键字),例如在代码中#pragma multi_compile SHADOW_ON SHADOW_OFF
,对逻辑上有差异的地方用#ifdef SHADOW_ON
或#if defined(SHADOW_ON)
区分(#if defined()
的好处是可以有多个条件,用与、或逻辑运算连接起来):
Light mainLight = GetMainLight();
float shadowAtten = 1;
#ifdef SHADOW_ON
shadowAtten = CalculateShadow(shadowCoord);
#endif
float3 color = albedo * max(0, dot(mainLight.direction, normalWS)) * shadowAtten;
然后对需要的材质进行material.EnableKeyword("SHADOW_ON")
和material.DisableKeyword("SHADOW_ON")
开关关键字,或者用Shader.EnableKeyword("SHADOW_ON")
对全场景包含这一keyword的物体进行设置
上述情况是开关的设置,还有设置配置的情况,例如说我希望高配光照计算用PBR基于物理的光照计算方式,而低配用Blinn-Phong,其他计算例如阴影、雾效完全一致,也可以将光照计算用变体的方式分隔。
如果是shader编写的新手,可能有两个问题:
①我不能直接传递个变量到shader里,用if实时判断吗?
答:不可以,简单来说,由于gpu程序需要高度并行,shader中的分支判断需要将if else两个分支都走一遍,假如你的两个需求都有不短的代码,这样的开销太大且不合理。
②我不可以直接将shader复制一份出来改吗?
答:不是很好,例如你现在复制一份shader出来,还需要对应脚本去找到需要替换的shader然后替换。更重要的是,当你的shader同时包含很多需要切换的效果:阴影、雾效、光照计算、附加光源、溶解、反射等等,总不能有一个需求就shader*2是吧
当你有多组关键字,阴影是否开关,是否有雾效时,你可能会写出下面这样的关键字声明:
#pragma multi_compile SHADOW_OFF SHADOW_ON
#pragma multi_compile FOG_OFF FOG_ON
#pragma multi_compile ADDLIGHT_OFF ADDLIGHT_ON
#pragma multi_compile REFLECT_OFF REFLECT_ON
//something keyword ...
这种写法属于比较死亡的写法,别在意,后面自然会说出各种写法中不好的地方并提出回避建议。
而对于当前材质,就会利用上述的关键字进行排列组合,例如一个“不希望接受阴影,希望有雾,需要附加光源,不带反射”,得到的Keyword组合就是:SHADOW_OFF FOG_ON ADDLIGHT_ON REFLECT_OFF
,这个Keyword组合就是一个变体。对于上面这个例子,可以得到2的4次方16个变体。
我们知道了什么是变体,再来回答为什么要变体管理。
可以发现上述例子中,每多一条都会乘2,实际上一列keyword声明可以不止两个,声明三个、甚至更多也是可能的。
但不管怎么说,随着
#pragma multi_compile
的增加,变体数量会指数增长。这样会带来什么问题呢?
这时候需要了解下shader到底是什么。
2. Shader
我就当大家都知道,ShaderLab其实不是很底层的东西,它封装了图形API的Shader,以及一堆渲染命令。对于图形API,Shader是gpu的程序,不同API上传shader略有区别,例如OpenGL:
GLuint vertex_shader;
GLchar * vertex_shader_source[];//glsl源码
//创建并将源码传递给GPU
vertex_shader = glCreateShader(GL_VERTEX_SHADER);
glShaderSource(vertex_shader, 1, vertex_shader_source, NULL);
//编译
glCompileShader(vertex_shader);
//绑定
glAttachShader(program, vertex_shader);
DX12/Vulkan的编译方式有很多,可以提前编译成二进制/中间语言的dxbc/spirv,也可以用hlsl/glsl实时生成dxbc/spirv传递给GPU,例如DX12使用D3DCompileFromFile
实时编译hlsl到dxbc:
ComPtr byteCode = nullptr;//二进制dxbc
D3DCompileFromFile(filename.c_str(), defines, D3D_COMPILE_STANDARD_FILE_INCLUDE,
entrypoint.c_str(), target.c_str(), compileFlags, 0, &byteCode, nullptr);
对于现在我们来说主要关注前两个参数,第一个是读取的文件名没什么好说的,第二个是D3D_SHADER_MACRO
的数组:
typedef struct _D3D_SHADER_MACRO
{
LPCSTR Name;
LPCSTR Definition;
} D3D_SHADER_MACRO;
实际上传入类似这样:
const D3D_SHADER_MACRO defines[] =
{
"FOG", "1",
"ALPHA_TEST", "1",
NULL, NULL
};
这个就是变体的底层所在,也就是说,每有一个变体,都会构造这么一个defines,然后调用编译程序编译shader为dxbc。
我们在引擎层面说的变体,就是这些底层的Shader,是OpenGL的glsl、DirectX的dxbc/dxil、Vulkan的spirv;而变体指数级增长,相当于这些底层的这些shader指数级增长。
变体数太多对开发模式可能没有什么,最多是开编辑器时多喝点茶,但项目需要打包、上线就不是这样了。
别看这些都能Shader实时用Shader编译生成,但引擎不会这么做,而是在打包时就需要知道所有可能用到的变体,将其打包出来。
很浅显的原因是shader编译的时间也不短,Unity/UE这些引擎为了方便用户编写,主要编写的语言是hlsl,如果你的游戏是DX11/DX12,实际运行会将hlsl编译为dxbc,单个的时间不长,但达到一定数量就会有明显卡顿,如果场景出现一些附加光源,突然多出来这些变体shader需要实时生成,这个时间说不定会是几秒。
如果你的API是OpenGL,为了获取到glsl,unity用hlslcc将hlsl变成glsl,然后再编译程序;如果API是VulKan,前面按照OpenGL一样先生成glsl,然后再用glslang生成spirv。对于UE,这个流程会有区别,详见:跨平台引擎Shader编译流程分析。
对于DX12和VK这样的现代API,新生成Shader意味着要生成PSO(管线状态对象),这又是一比超级大的开销。
如果不提前将ShaderBuild好,你现在打包时编译Shader的时间,就是你未来用户第一次进入游戏的时间,想想这个酸爽。总之确定了一件事,在打包时,预计用到的Shader变体(dxbc/glsl/spirv)就会全都打入包中。
变体数量对包体的影响倒是未必很大,因为AssetBundle有压缩,而你的变体之间只是略有差异,很可能200MB的shader文件,压缩后不到2MB。
真正进入游戏中,游戏会先将Shader从AssetBundle中解压出来放到CPU先准备着,当GPU需要用到变体时,再送入GPU。重点是解压后Shader的大小就不是那么理想了,你可以用你完全没有Shader管理的游戏项目打个包,然后Unity到Window>Analysis>Profiler。
连接adb到手机,然后点击内存>Take Sample AndroidPlayer>Other>Rendering>ShaderLab查看:
未经过管理的变体可能导致ShaderLab占用内存一个多G,这显然是不可接受的。
这是内存上的问题,此外还有运行时加载的问题。
但现在还是上述的情景,假如场景中突然出现一盏附加光源,需要对已有的shader都开启新的变体,这些变体CPU中都存在,因为你打包时已经打入了,你省下了将hlsl生成为dxbc/glsl/spirv的时间,但是将dxbc/glsl/spirv送入gpu、生成pso的时间却是省不下的,这依旧可能会造成卡顿。
结合上述问题,所以我们需要对Shader变体做管理。
二、如何对Shader变体进行管理
上面描述了keyword组合造成的变体数量爆炸,想要将变体控制下来,需要从两方面出发,分为个人和项目。
1. 个人角度对Shader变体管理
个人是指TA、引擎、图程以及其他Shader开发者,在编写Shader时就要注意变体的问题。
首先,该用if用if,之前虽然说在GPU执行分支开销不低,但只是相对而言的,如果你的ifelse执行的是整个光照计算,那显然是不可接受的,但假如ifelse加起来没两行代码,那显然是无所谓的,要是在变体极多的时候去掉个keyword,变体数直接砍半,对项目的好处是极大的,这需要开发者自己权衡。
其次,之前的例子都用的是multi_compile,但实际上不一定需要multi_compile,某些情况下用shader_feature是可以的。
1.1 multi_compile和shader_feature的区别
用multi_compile声明的keyword是全排列组合,例如:
#pragma multi_compile A B
#pragma multi_compile C D E
组合出来就是AC AD AE BC BD BE6个,如果再来一个#pragma multi_compile F G
显然会直接翻倍为12个。
shader_feature则不同,它打包时,找到打包资源对变体的引用,最普通能对变体引用的资源是Material(例如场景用了一个MeshRenderer,MeshRenderer用了这个材质,材质用了这个Shader的一个变体)。
在Inspector窗口右上角将Normal换成Debug模式,可以看到材质引用的Keyword组合:
假如将上述multi_compile替换为shader_feature:
#pragma shader_feature A B
#pragma shader_feature C D E
我打包只打一个材质,这个材质用到了变体组合AC,那么打包时只会将AC打出来。
如果我的材质引用的是AE,那么会打出AC和AE,因为C是第二个keyword声明组的默认keyword,当你的材质用了这个Shader,却没有发现没有引用这一声明组的任何一个keyword(比如上面CDE都没引用),就会退化成第一个默认keyword(上面的例子是C)。
所以一般声明keyword组如果包含默认keyword、关闭keyword不会声明XXX_OFF,而是声明成
#pragma multi_compile _ C D
,这样如果材质引用AD,则会打出 A和AD,不会减少变体数量,但可以减少Global Keyword的数量(Unity2020及以下版本只能有384个Global Keyword,2021之上有42亿个。详见Shader Keywords)
1.2 打包规则
打包时会将multi_compile和shader_feature分为两堆,分别计算组合数,然后两者再组合,例如:
#pragma multi_compile A B
#pragma multi_compile C D
#pragma shader_feature E F
#pragma shader_feature G H
当你只打两个材质,引用的变体分别是ADEG和ACFH,前两个multi_compile组直接组合成4个变体,后面两个shaderfeature组分别引用到了EG和FH,然后两组组合4*2,最后打出8个变体。
1.3 编写建议
对于个人来说,较为通用的编写方式是,multi_compile建议用于声明可能实时切换的keyword声明组,例如阴影、全局雾效、雨、雪。因为一个物体可能在多个场景使用,材质也就会在多个场景用到,一个场景有雾,另一个场景有雨,而材质只能引用一组变体,为了能实时切换,就需要把变体也打入包中;而对于材质静态的keyword声明组就可以用shader_feature,例如这个材质是否用到了NormalMap,是否有视差计算,这个在打包时就确定好的,运行时不会动态改变,即可声明为shader_feature。
multi_compile_local适合解决打包时不确定变体,需要在运行时动态切换单个材质变体的需求,例如某些建筑、角色需要运行时溶解;溶解只针对当前角色的材质而不是全局的,需要Material.EnableKeyword,所以用local;并且需要溶解的材质被打入包中,所以需要multi_compile,组合起来就是multi_compile_local。
小贴士:
shader_feature和multi_compile后面也可以加其他条件,例如如果确定一组keyword声明只会导致vertexshader有变化,即可再后面加_vertex,例如shader_feature_vertex。
shader_feature_local的_local声明和变体数无关,是Unity2021之前为了解决GlobalKeyword数量问题出现的解决方案,声明为local keyword不会占用global keyword数,建议是如果keyword声明组是需要材质手动勾选的参数,声明为_local;当keyword为local时,Shader.EnableKeyword或CommandBuffer.EnableKeyword这种全局开启keyword方式,无法启用当前材质的关键字,只能由材质开启。
有些声明是Unity内置的,例如#pragma multi_compile_instancing相当于#pragma multi_compile _ INSTANCING_ON,#pragma multi_compile_fog则会声明几个雾相关的keyword。
2. 项目角度的变体管理
有些问题从个人开发角度是难以规避的。
希望Shader的开发者都能从个人编写角度做好变体管理,往往是不现实的,Shader开发者水平有高有低,或许某个实习生或客户端为了快速实现效果,就从网上Copy下来一段代码,运行一下效果没问题就不管了;再或者某个美术导入了一个插件,而插件的编写者没有考虑过变体的问题等等。
2.1 变体剔除
Unity提供了IPreprocessShaders
接口,让用户自定义剔除条件。
自定义的类继承IPreprocessShaders
后,需要实现void OnProcessShader(Shader shader, ShaderSnippetData snippet, IList
方法,这是一个回调函数,当打包时,所有shader变体都会送进来进行判断。
三个参数中,第一个是UnityShader对象本体,没什么好说的。
第二个存了底层Shader类型和Pass类型,ShaderType包括Vertex、Fragment、Geometry等;PassType存了Pass类型,例如BuildIn Shader一般有ForwardBase、ForwardAdd,SRP的SRP、SRPDefaultUnlit等。
第三个参数是ShaderCompilerData
的List,ShaderCompilerData
包含了当前变体包含哪些keyword、变体所需的api特性级别、变体的api(只要PlayerSetting里添加了平台对应的API,可以同时打出多个图形API所需的Shader),可以将一个ShaderCompilerData
视作一个变体。
这些参数包含变体的全部条件,用户可以根据项目需要自行编写剔除逻辑,当判断需要剔除一个Shader变体时,只需要将ShaderCompilerData
从inputData
这个list中删除即可。
下面是一个简单实例,如果我们想剔除所有包含INSTANCING_ON keyword的变体时应该如何编写:
class StripInstancingOnKeyword : IPreprocessShaders
{
public void OnProcessShader(Shader shader, ShaderSnippetData snippet, IList inputData)
{
for (int i = inputData.Count - 1; i >= 0; i--)
{
ShaderCompilerData input = inputData[i];
//Global And Local Keyword
if (input.shaderKeywordSet.IsEnabled(new ShaderKeyword("INSTANCING_ON")) || input.shaderKeywordSet.IsEnabled(new ShaderKeyword(shader, "INSTANCING_ON")))
{
inputData.RemoveAt(i);
}
}
}
}
一般情况下,项目会编写一个配置文件,里面记录各种需要剔除的变体条件,比如URP项目不需要BuildIn下的ForwardBasePass、DeferredPass,可以直接将这些Pass剔除掉,防止项目中有BuildIn下残留的变体。
有些shader抄案例时,附带了#pragma multi_compile_fog
等Unity自动生成的关键字,而实际上Shader可能用不到,可以通过项目整体剔除来抵消项目人员犯错。
还可以根据项目需求编写条件,比如说项目中角色Shader带有高配和低配关键字,用于区分着色计算,高配用于展示,低配用于战斗,能确定战斗效果(例如溶解、石化)变体不可能出现高配变体上,因此可以判断当同时出现高模Keyword和战斗效果Keyword时剔除变体。
在我们项目中,通过变体剔除,能将占用上GB内存的ShaderLab降低到20多MB,可见变体剔除的必要性。
对于变体剔除工具的设计,可以参考我的个人变体剔除工具。
有时候需要注意,一些库(比如高版本的URP)也会自带变体剔除,了解项目时,先全局搜下继承IPreprocessShaders
的类,防止变体在自己不知道的时候被剔掉。
此外项目设置里也有一套变体剔除,在ProjectSetting>Graphics的Shader Stripping项下,当Modes是Custom时,只有勾选的会被打入包中。例如下图,只勾选了Baked Directional,会导致烘焙Lightmap的Shader中,如果有LIGHTMAP_ON
但没有DIRLIGHTMAP_COMBINED
的变体都被剔除。
2.2 变体预热、
这一部分我修改并留到下一部分讲,下面的一些看法有一定错误。
第二个问题是变体是只有被用到才会被送入GPU,这样会导致卡顿。解决方法也很简单,既然运行时按需加载会导致卡顿,那么提前一次性加载好即可。
引擎一般提供预热功能,在Unity中是ShaderVariantCollection.WarmUp
、ShaderWarmup.WarmupShaderFromCollection
、Shader.WarmupAllShaders
,前两个方法会将变体收集内的全部变体提前送入GPU,省下了这一部分时间。
更多说明可在Unity文档 着色器加载中查看。
现在讨论下变体收集文件(ShaderVariantCollection)的使用方法。
变体收集文件是Unity资产,可通过右键Create>Shader>Shader Variant Collection创建,或通过跑变体收集保存一个变体收集文件。
变体收集文件包含很多Shader,每个Shader对应一个List,List中每个项是PassType-KeywordSet的组合,用户可以根据需求手动添加Shader和变体组合。
不过第一次使用时,建议跑一遍变体收集,之后再根据需要手动添加删除。
使用方法是在ProjectSetting>Graphics的最下面,先Clear掉当前的记录,然后进行游戏,尽量覆盖大多数游戏内容,之后点击Save to asset
保存。
我个人认为变体收集的主要作用就是用来预热变体,实际上还有个作用,就是引用变体;和材质一样,记录在变体收集内的变体会被记做引用打到包中。
因此一些人在管理变体时喜欢将所有keyword声明为shader_feature,然后跑变体收集增加被引用到的变体,这样能被打入到包中的变体,都是会被用到的。
但我个人不推荐这种做法,因为利用变体收集引用来管理打包会很混乱,往往跑游戏很难覆盖到所有位置,导致少收集一些变体,而一些效果不太明显,连测试都看不出来区别,可能上线后都发现不了。当Shader拥有庞大变体集合时,再增减Keyword,更新变体收集文件会导致事倍功半。
我个人推荐的做法是,keyword完全按使用方法来声明,会动态切换的声明multi_compile,对材质静态的声明shader_feature,不可能出现的条件组合用变体剔除去掉。
而变体收集的作用只是用来预热,因为跑变体收集后,大部分常用功能都被提前加载到GPU,而小部分未提前加载的变体也无伤大雅。