一、什么是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);#endiffloat3 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编写的老手,可能有两个问题:

1. 我不能间接传递个变量到Shader里,用if实时判断吗?
答:不能够,简略来说,因为GPU程序须要高度并行,很多状况下,Shader中的分支判断须要将if else两个分支都计算一遍,如果你的两个需要都有不短的代码,这样的开销太大且不合理。

2. 我不能够间接将Shader复制一份进去改吗?
答:不是很好,例如你当初复制一份Shader进去,还须要对应脚本去找到须要替换的Shader而后替换。更重要的是,当你的Shader同时蕴含很多须要切换的成果:暗影、雾效、光照计算、附加光源、溶解、反射等等,总不能有一个需要就Shader*2是吧。

#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源码//创立并将源码传递给GPUvertex_shader = glCreateShader(GL_VERTEX_SHADER);glShaderSource(vertex_shader, 1, vertex_shader_source, NULL);//编译glCompileShader(vertex_shader);//绑定glAttachShader(program, vertex_shader);

DX12/Vulkan的编译形式有很多,能够提前编译成二进制/两头语言的DXBC/SPIR-V,也能够用HLSL/GLSL实时生成DXBC/SPIR-V传递给GPU,例如DX12应用D3DCompileFromFile实时编译HLSL到DXBC:

ComPtr<ID3DBlob> byteCode = nullptr;//二进制DXBCD3DCompileFromFile(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的SPIR-V;而变体指数级增长,相当于这些底层的这些Shader指数级增长。

变体数太多对开发模式可能没有什么,最多是开编辑器时多喝点茶,但我的项目须要打包、上线就不是这样了。

别看这些都能Shader实时用Shader编译生成,但引擎不会这么做,而是在打包时就须要晓得所有可能用到的变体,将其打包进去。

很通俗的起因是Shader编译的工夫也不短,Unity/UE这些引擎为了不便用户编写,次要编写的语言是HLSL,如果你的游戏是DX11/DX12,理论运行会将HLSL编译为DXBC,单个的工夫不长,但达到肯定数量就会有显著卡顿,如果场景呈现一些附加光源,忽然多进去这些变体Shader须要实时生成,这个工夫说不定会是几秒。

如果你的API是OpenGL,为了获取到GLSL,Unity用HLSLcc将HLSL变成GLSL,而后再编译程序;如果API是VulKan,后面依照OpenGL一样学生成GLSL,而后再用glslang生成SPIR-V。对于UE,这个流程会有区别,详见《跨平台引擎Shader编译流程剖析》。

对于DX12和VK这样的古代API,新生成Shader意味着要生成PSO(管线状态对象),这又是一笔超级大的开销。

如果不提前将Shader Build好,你当初打包时编译Shader的工夫,就是你将来用户第一次进入游戏的工夫。总之确定了一件事,__在打包时,预计用到的Shader变体(DXBC/GLSL/SPIR-V)就会全都打入包中。

变体数量对包体的影响倒是未必很大,因为AssetBundle有压缩,而你的变体之间只是略有差别,很可能200MB的Shader文件,压缩后不到2MB。

真正进入游戏中,游戏会先将Shader从AssetBundle中解压进去放到CPU先筹备着,当GPU须要用到变体时,再送入GPU。重点是解压后Shader的大小就不是那么现实了,你能够用你齐全没有Shader治理的游戏我的项目打个包,而后Unity到Window>Analysis>Profiler。

连贯adb到手机,而后点击 内存>Take Sample AndroidPlayer>Other>Rendering>ShaderLab 查看:
<center><img src="http://uwa-ducument-img.oss-cn-beijing.aliyuncs.com/Blog/USparkle_SVMP/2.png" style="width:700px"></center>

未通过治理的变体可能导致ShaderLab占用内存一个多G,这显然是不可承受的。这是内存上的问题,此外还有运行时加载的问题。但当初还是上述的情景,如果场景中忽然呈现一盏附加光源,须要对已有的Shader都开启新的变体,这些变体都存在于内存中,因为你打包时曾经打入了,你省下了将HLSL生成为DXBC/GLSL/SPIR-V的工夫,然而将DXBC/GLSL/SPIR-V送入GPU、生成PSO的工夫却是省不下的,这仍旧可能会造成卡顿。

联合上述问题,所以咱们须要对Shader变体做治理。

二、如何对Shader变体进行治理

下面形容了Keyword组合造成的变体数量爆炸,首先咱们心愿有效变体尽量少,想要达成这个目标,须要从两方面登程,分为集体和我的项目。

1. 集体角度对Shader变体治理

集体是指TA、引擎、图程以及其余Shader开发者,在编写Shader时就要留神变体的问题。

首先,该用if用if,之前尽管说在GPU执行分支开销不低,但只是相对而言的,如果你的if else执行的是整个光照计算,那显然是不可承受的,但如果if else加起来没两行代码,那显然是无所谓的,要是在变体极多的时候去掉个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的数量(Unity 2020及以下版本只能有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个变体,前面两个shader_feature组别离援用到了EG和FH,而后两组组合4*2,最初打出8个变体。

1.3 编写倡议
对于集体来说,较为通用的编写形式是,multi_compile倡议用于申明可能实时切换的全局Keyword申明组,例如暗影、全局雾效、雨、雪。因为一个物体可能在多个场景应用,材质也就会在多个场景用到,一个场景有雾,另一个场景有雨,而材质只能援用一组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申明和变体数无关,是Unity 2021之前为了解决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<ShaderCompilerData> inputData)办法,这是一个回调函数,当打包时,所有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<ShaderCompilerData> 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的变体都被剔除。

下面用变体剔除解决变体过多的问题,但变体还有运行时加载工夫和打包援用问题须要解决。

Unity为了解决这些问题,提供了变体收集性能,性能围绕着变体收集文件ShaderVariantCollection,创立办法为:在Project窗口右键>Create>Shader Variant Collection(2019是Create>Shader>Shader Variant Collection)。

这个文件自身没有什么非凡的,就是记录变体的文件而已,每个变体为PassType与Keyword的组合:

文件的作用有两个,其一是在打包时,对变体援用;其二是运行时,利用文件预热变体。

3. 变体预热

3.1 为什么要变体预热
还是下面的例子:Unity自带的设计中,附加光源是额定的变体,当场景超过一盏实时光时,会关上附加光源变体;这样能够保障,场景只有一盏实时光时,不会有额定的Shader计算开销。

但也带来一个问题。如果以后场景各种物体用到了50个变体,忽然多出一个实时方向光,为了使场景被这第二盏灯照亮,须要将所有物体的变体切换为有附加光源的那一个,也就是相当于要筹备50个变体。如果这50个变体没有筹备完,就会造成卡顿。

这个场景是运行时游戏,附加光源的变体曾经在包中,不须要从新从ShaderLab生成对应平台的底层Shader,但仍旧须要将底层Shader送入GPU,例如glShaderSource加载GLSL源代码、vkCreateShaderModule从二进制SPIR-V创立VkShaderModule对象,以及后续创立PSO等流程仍旧不能节俭。

这样一来,还是会造成运行时卡顿,为了解决这个问题,就须要变体预热,提前将可能用到的变体送入GPU。

3.2 变体预热的办法
Unity提供了ShaderVariantCollection.WarmUp、Shader.WarmupAllShaders这些接口。其中Shader.WarmupAllShaders会预热所有变体,如果对变体剔除后果十分有信念能够应用。

ShaderVariantCollection.WarmUp会预热以后变体收集文件中所有记录的变体,提供了更精细化管制的可能,例如某些变体只会在某个小游戏场景呈现,那么能够将相干变体放在一个收集文件中,只有进入这个小游戏场景加载时才预热变体。

4. 变体援用

4.1 为什么要变体援用
按照上文的说法,材质和变体收集文件都能够援用变体,那为何还须要变体收集文件呢?

如果是Unity间接Build一个包进去,那么的确不须要变体收集文件来援用变体。

但如果在有热更需要时就不同了;全副的Shader个别会打到一个独自Bundle中,依据Bundle中其余资源对变体的援用,决定哪些变领会打入以后Bundle;对变体产生援用的材质,往往不会放到Shader所在的Bundle,而是扩散到其余很多Bundle中,这样就会导致打Shader的那个Bundle找不到变体援用,从而无奈将须要的变体打入Shader Bundle。

所以就须要一个变体收集文件,将须要打包的变体写入文件,用这个文件来放弃变体援用,而后将文件和Shader打入同一个Bundle中,这样就能将须要的变体打入Bundle。

5. 变体收集

变体收集文件是一个记录变体的文件,须要思考的是如何收集须要的变体。

5.1 根底操作
最根底的操作就是手动增加,就如下图所示,变体收集文件的面板中,点击Shader前面的“+”,而后排除不须要的Keyword,在上面抉择须要增加的变体,而后点击“Add 2 selected variants”。

这种办法只适宜简略保护,切实不举荐这样做,不言而喻的起因是这样很容易漏掉变体,而且Unity的这个工具面板,也给我一种“都别这么用”的感觉。

就提出几个简略的操作场景:如果文件中曾经有了二、三十个Shader,个别Shader内收集了五、六十个变体,我想要在这么多Shader和变体中,找到我想要操作的Shader,就须要翻良久。

如果我想要增加一个Keyword,与现有的变体做排列组合,只能用面板手动点击。

如果收集文件中曾经有一千多个变体,这个面板就会呈现显著卡顿。

总结起来就三个字:孬操作。这必定不是技术问题,那么我只能了解为Unity通知咱们:“都给我老老实实去跑变体收集!”

5.2 跑变体收集
这个是绝对主动的办法,应用办法是在ProjectSetting>Graphics的最上面,先Clear掉以后的记录,而后进行游戏,尽量笼罩大多数游戏内容,之后点击Save to asset保留。

不言而喻的问题是,容易漏变体,无论是给引擎还是测试来跑变体收集,总可能有笼罩不到的变体。

其次是不好更新,如果调了下场景以及材质,上传后须要更新文件,那只能从新跑收集,不然总不能让美术去管变体收集吧?

其三是容易受Shader品质影响。如果某个Shader开发者没留神,在Shader不须要的时候,加了这个申明:#pragma multi_compile_fwdbase,这个Built-in的变体申明,申明出DIRECTIONAL、LIGHTMAP_ON、DIRLIGHTMAP_COMBINED、DYNAMICLIGHTMAP_ON、SHADOWS_SCREEN、SHADOWS_SHADOWMASK、LIGHTMAP_SHADOW_MIXING、LIGHTPROBE_SH这么一大串变体,而运行游戏时,Unity会依据当前情况启用这些变体,就会导致变体收集到不须要的变体。

6. 定制化变体收集工具

6.1 变体收集文件的增删查改
既然Unity内置的工具不好用,那就要想方法自定义工具。

而后Unity给了当头一棒,ShaderVariantCollection接口不全,自带的接口中只蕴含:Shader数量、变体数量、增加和删除变体。至于文件中有哪些Shader和变体,接口是一律没有的。

好在Unity凋谢出了UnityCsReference,其中ShaderVariantCollection的Inspector给出了示例写法,须要用SerializedObject获取C++对象:

private ShaderVariantCollection mCollection;private Dictionary<Shader, List<SerializableShaderVariant>> mMapper = new Dictionary<Shader, List<SerializableShaderVariant>>();//将SerializedProperty转化为ShaderVariantprivate ShaderVariantCollection.ShaderVariant PropToVariantObject(Shader shader, SerializedProperty variantInfo){    PassType passType = (PassType)variantInfo.FindPropertyRelative("passType").intValue;    string keywords = variantInfo.FindPropertyRelative("keywords").stringValue;    string[] keywordSet = keywords.Split(' ');    keywordSet = (keywordSet.Length == 1 && keywordSet[0] == "") ? new string[0] : keywordSet;    ShaderVariantCollection.ShaderVariant newVariant = new ShaderVariantCollection.ShaderVariant()    {        shader = shader,        keywords = keywordSet,        passType = passType    };    return newVariant;}//将ShaderVariantCollection转化为Dictionary用来拜访private void ReadFromFile(){    mMapper.Clear();    SerializedObject serializedObject = new UnityEditor.SerializedObject(mCollection);    //serializedObject.Update();    SerializedProperty m_Shaders = serializedObject.FindProperty("m_Shaders");    for (int i = 0; i < m_Shaders.arraySize; ++i)    {        SerializedProperty pair = m_Shaders.GetArrayElementAtIndex(i);        SerializedProperty first = pair.FindPropertyRelative("first");        SerializedProperty second = pair.FindPropertyRelative("second");//ShaderInfo        Shader shader = first.objectReferenceValue as Shader;        if (shader == null)            continue;        mMapper[shader] = new List<SerializableShaderVariant>();        SerializedProperty variants = second.FindPropertyRelative("variants");        for (var vi = 0; vi < variants.arraySize; ++vi)        {            SerializedProperty variantInfo = variants.GetArrayElementAtIndex(vi);            ShaderVariantCollection.ShaderVariant variant = PropToVariantObject(shader, variantInfo);            mMapper[shader].Add(new SerializableShaderVariant(variant));        }    }}

能增删查改就带来有限的可能,在我编写的工具中,首先就给了便捷拜访性能,摈弃了Unity自带的面板,能够疾速定位Shader、Pass、变体:

6.2 自动化的变体收集
话说回来,自动化的变体收集,就要晓得哪些变体须要被打包。依照咱们之前说的,材质会援用变体,所以首先确定哪些材质会被打包;其次,确定这个材质会援用哪个、哪些变体;最初,将变体写入变体收集文件。

对于哪些材质会被打包,我能想到的有两种,其一是被打包场景所援用的材质,既BuildSetting外面那些场景;其二是我的项目的资源表间接或间接援用材质。

其余可能性临时想不到,但基于拓展性需求,我形象出收集器类,工具会执行所有收集器收集材质,如果有拓展需要,就增加收集器:

上图中就蕴含了两个材质收集器,别离收集场景依赖和资源表依赖材质。

对于材质会援用到哪个、哪些变体,按照上文变体剔除配图所示,材质会保留ShaderKeywords,仿佛这就是材质所援用的变体。

其实不然,这里是材质通过调用Material.EnableKeyword后,会将Keyword写入这里,哪怕Shader没有这个Keyword。

在上文中,咱们倡议对于所有在打包时,材质能确定的动态成果(是否用Bump Map、视差、BlendMode等),用shader_feature_local来定义;同时,材质面板的自定义代码中,开启成果的按钮,会调用Material.EnableKeyword。

但Unity形象的ShaderLab不止一个Pass,如果咱们要给暗影投射Pass申明一个Keyword组,开启成果时,面板代码会按程序往材质的ShaderKeywords外面写入一个Keyword,但失常的Pass(如UniversalForward、ForwardBase等)并没有申明这个Keyword,因而这个ShaderKeywords很显然不能代表这个材质所援用的变体,也能够阐明材质能不止援用一个变体。

如何晓得材质到底援用了多少个变体,咱们看上面的例子(伪代码):

Pass{    Tags{"LightMode" = "ShadowCaster"}    #pragma shader_feature SHADOW_BIAS_ON    #pragma shader_feature _ALPHATEST_ON}Pass{    Tags{"LightMode" = "UniversalForward"}    #pragma shader_feature _ALPHATEST_ON    #pragma shader_feature _NORMALMAP    //....}

此时,一个材质的ShaderKeywords中记录了SHADOW_BIAS_ON、_ALPHATEST_ON两个Keyword,那么材质就援用了<ShadowCaster>SHADOW_BIAS_ON _ALPHATEST_ON和<ScriptableRenderPipeline>_ALPHATEST_ON这两个变体。

这没什么问题,仿佛找到以后PassType能够蕴含的最长组合就好了,但ShaderLab中的PassType是能够反复的,此时如果有一个描边Pass:

Pass{    Tags{"LightMode" = "Outline"}    #pragma shader_feature OUTLINE_RED OUTLINE_GREEN OUTLINE_BLUE    #pragma shader_feature _ALPHATEST_ON}

这个Pass的类型也是ScriptableRenderPipeline,如果一个材质援用了SHADOW_BIAS_ON、_ALPHATEST_ON、OUTLINE_RED三个Keyword,那么实际上Shader援用了三个变体,别离是<ShadowCaster>SHADOW_BIAS_ON _ALPHATEST_ON(ShadowCasterPass)、<ScriptableRenderPipeline>_ALPHATEST_ON(UnversalForwardPass)、<ScriptableRenderPipeline>_ALPHATEST_ON OUTLINE_RED(OutlinePass),这种状况就无奈简略用Unity现有API来判断材质到底援用了多少个变体。

我以后的计划,是对ShaderKeywords中每个Keyword与其余所有Keyword进行组合,找到每个Keyword的最长非法组合都算作材质援用变体;能够缓解但无奈解决上述情况,想要解决就必须获取ShaderPass自身的Keyword申明状况,惋惜Unity没有提供相干API,只能本人写代码进行文本剖析;所以Shader倡议编写时不要在雷同PassType的不同Pass中申明雷同的Keyword。

非法的变体组合,Unity也没有提供相干接口,但结构变体对象时如果不非法,会在构造函数报错,所以我的判断函数简略粗犷,间接用try catch。

通过这样一轮收集,根本解决了变体打包时的援用问题。

7. 工具拓展

7.1 变体预热
上述解决了变体援用问题,打包大多数状况不会产生丢变体的状况,但变体预热的问题又回来了,咱们只收集了材质中的ShaderKeywords,依照下面的说法,这些Keyword都是shader_feature,属于动态成果的开关,但动静的成果没有进行组合。

如雾效、Lightmap、多光源等成果,这些Keyword是由multi_compile申明的,打包时会主动与shader_feature的组合进行再排列组合,会打入包中,不会呈现丢变体的问题;但预热所解决的问题不是打包,而是运行时切换成果时,加载Shader带来的卡顿问题;如果变体收集文件没有收集multi_compile的组合,ShaderVariantCollection.WarmUp就不会预热相干变体。

所以咱们心愿尽可能的,将所有可能切换成果的变体,写入变体收集文件中。既然打包时会进行排列组合,那么能够将这一步骤引入变体收集。

这种性能可能会在每次从新收集变体后都要执行一遍,因而我将这一类行为形象为批处理执行器接口,接口蕴含Execute办法,传入变体收集文件,而后在办法里进行相干操作。执行器是可序列化的对象,能够将数据保留,只须要变体管理者操作一次,即可在屡次收集材质时复用。

排列组合执行器会实现我须要的性能:

执行器自定义面板的尝试收集申明组,会用正则匹配Shader中申明的所有multi_compile组合,而后再由人工剔除不须要的申明组。

通过运行执行器,即可将申明组与收集文件中相应Shader变体进行排列组合,这样就能将multi_compile组合也进行预热。

7.2 变体剔除
主动收集免不了收集到一些不想要的Shader和变体,例如URP我的项目里收集到Standard,哪怕变体剔除工具会作为打包前最初一道壁垒,我扔心愿在收集就防止收集到。

我先是形象出材质和变体的过滤器类,依据需要实现接口,这样防止收集到不想要的变体。

其次是收集到变体,再进行排列组合后,某些变体组合可能是咱们不想要的,如果再写一套剔除执行器仿佛和变体剔除有些反复了,但转念一想,咱们有变体剔除工具,何不将两者联动下,于是专门写了一个联动执行器,调用变体剔除工具的接口提前进行变体剔除:

三、总结

我花了不少工夫思考并实现了相干工具的设计,也参考了其他人的工具和办法。

我的项目中利用时,有些共事误以为这些工具是全自动的,放在工程里就完事,但我感觉这不大可能;我的项目没有对Shader进行严格的束缚,Shader开发者的能力也有高有低,Keyword定义各种Copy、shader_feature和multi_compile定义哪个、是否定义成Local、built-in keyword有什么作用,很多人都不明确就开始写(这是很失常的,学习是循序渐进的过程),工具天然也无奈判断开发者的用意。

因而肯定有一个非常理解变体治理流程的人,来治理整个我的项目的Shader和变体,我开发的工具是用来简化这一流程,解决上述内置变体收集性能的痛点:易漏变体、不好更新、易受Shader品质影响,以及对现有文件的增删改查问题。上述提到的操作步骤无需进行屡次操作,在首次调整好参数后会记录到配置文件中,日后须要从新收集时,只须要从新收集、运行批处理执行器即可。


这是侑虎科技第1390篇文章,感激作者搜狐畅游引擎部供稿。欢送转发分享,未经作者受权请勿转载。如果您有任何独到的见解或者发现也欢送分割咱们,一起探讨。(QQ群:465082844)

作者主页:https://www.zhihu.com/org/sou-hu-chang-you-yin-qing-bu

再次感激搜狐畅游引擎部的分享,如果您有任何独到的见解或者发现也欢送分割咱们,一起探讨。(QQ群:465082844)