一、什么是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编写的新手,或许有两个问题:

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源码
//创立并将源码传递给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/SPIR-V,也能够用HLSL/GLSL实时生成DXBC/SPIR-V传递给GPU,例如DX12运用D3DCompileFromFile实时编译HLSL到DXBC:

ComPtr<ID3DBlob> 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的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。

图形引擎实战:Unity Shader变体管理流程

衔接adb到手机,然后点击 内存>Take Sample AndroidPlayer>Other>Rendering>ShaderLab 查看:

图形引擎实战:Unity Shader变体管理流程

未经过办理的变体或许导致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组合:

图形引擎实战:Unity Shader变体管理流程

假设将上述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,IListinputData)方法,这是一个回调函数,当打包时,一切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 Shader变体管理流程

上面用变体除掉处理变体过多的问题,但变体还有运转时加载时刻和打包引证问题需求处理。

Unity为了处理这些问题,供给了变体搜集功用,功用围绕着变体搜集文件ShaderVariantCollection,创立方法为:在Project窗口右键>Create>Shader Variant Collection(2019是Create>Shader>Shader Variant Collection)。

图形引擎实战:Unity Shader变体管理流程

这个文件自身没有什么特别的,便是记载变体的文件罢了,每个变体为PassType与Keyword的组合:

图形引擎实战:Unity Shader变体管理流程

文件的效果有两个,其一是在打包时,对变体引证;其二是运转时,运用文件预热变体。

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。

图形引擎实战:Unity Shader变体管理流程

所以就需求一个变体搜集文件,将需求打包的变体写入文件,用这个文件来保持变体引证,然后将文件和Shader打入同一个Bundle中,这样就能将需求的变体打入Bundle。

5. 变体搜集

变体搜集文件是一个记载变体的文件,需求考虑的是怎么搜集需求的变体。

5.1 根底操作
最根底的操作便是手动增加,就如下图所示,变体搜集文件的面板中,点击Shader后边的“+”,然后扫除不需求的Keyword,在下面挑选需求增加的变体,然后点击“Add 2 selected variants”。

图形引擎实战:Unity Shader变体管理流程

这种方法只合适简略保护,真实不推荐这样做,清楚明了的原因是这样很简略漏掉变体,而且Unity的这个东西面板,也给我一种“都别这么用”的感觉。

就提出几个简略的操作场景:假设文件中已经有了二、三十个Shader,单个Shader内搜集了五、六十个变体,我想要在这么多Shader和变体中,找到我想要操作的Shader,就需求翻好久。

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

假设搜集文件中已经有一千多个变体,这个面板就会呈现明显卡顿。

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

5.2 跑变体搜集
这个是相对主动的方法,运用方法是在ProjectSetting>Graphics的最下面,先Clear掉当时的记载,然后进行游戏,尽量掩盖大多数游戏内容,之后点击Save to asset保存。

图形引擎实战:Unity Shader变体管理流程

清楚明了的问题是,简略漏变体,无论是给引擎仍是测验来跑变体搜集,总或许有掩盖不到的变体。

其次是欠好更新,假设调了下场景以及原料,上传后需求更新文件,那只能重新跑搜集,不然总不能让美术去管变体搜集吧?

其三是简略受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转化为ShaderVariant
private 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、变体:

图形引擎实战:Unity Shader变体管理流程

6.2 主动化的变体搜集
话说回来,主动化的变体搜集,就要知道哪些变体需求被打包。依照咱们之前说的,原料会引证变体,所以首要确认哪些原料会被打包;其次,确认这个原料会引证哪个、哪些变体;最终,将变体写入变体搜集文件。

关于哪些原料会被打包,我能想到的有两种,其一是被打包场景所引证的原料,既BuildSetting里边那些场景;其二是项意图资源表直接或间接引证原料。

其他或许性暂时想不到,但依据拓展性需求,我抽象出搜集器类,东西会履行一切搜集器搜集原料,假设有拓展需求,就增加搜集器:

图形引擎实战:Unity Shader变体管理流程

上图中就包括了两个原料搜集器,别离搜集场景依赖和资源表依赖原料。

关于原料会引证到哪个、哪些变体,依照上文变体除掉配图所示,原料会保存ShaderKeywords,好像这便是原料所引证的变体。

图形引擎实战:Unity Shader变体管理流程

其实不然,这儿是原料经过调用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,那么原料就引证了SHADOW_BIAS_ON _ALPHATEST_ON和_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引证了三个变体,别离是SHADOW_BIAS_ON _ALPHATEST_ON(ShadowCasterPass)、_ALPHATEST_ON(UnversalForwardPass)、_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方法,传入变体搜集文件,然后在方法里进行相关操作。履行器是可序列化的目标,能够将数据保存,只需求变体办理者操作一次,即可在多次搜集原料时复用。

排列组合履行器会完结我需求的功用:

图形引擎实战:Unity Shader变体管理流程

履行器自界说面板的尝试搜集声明组,会用正则匹配Shader中声明的一切multi_compile组合,然后再由人工除掉不需求的声明组。

经过运转履行器,即可将声明组与搜集文件中相应Shader变体进行排列组合,这样就能将multi_compile组合也进行预热。

7.2 变体除掉
主动搜集免不了搜集到一些不想要的Shader和变体,例如URP项目里搜集到Standard,哪怕变体除掉东西会作为打包前最终一道壁垒,我扔期望在搜集就防止搜集到。

我先是抽象出原料和变体的过滤器类,依据需求实现接口,这样防止搜集到不想要的变体。

图形引擎实战:Unity Shader变体管理流程

其次是搜集到变体,再进行排列组合后,某些变体组合或许是咱们不想要的,假设再写一套除掉履行器好像和变体除掉有些重复了,但转念一想,咱们有变体除掉东西,何不将两者联动下,所以专门写了一个联动履行器,调用变体除掉东西的接口提早进行变体除掉:

图形引擎实战:Unity Shader变体管理流程

三、总结

我花了不少时刻考虑并完结了相关东西的规划,也参阅了其他人的东西和方法。

项目中应用时,有些搭档误以为这些东西是全主动的,放在工程里就完事,但我感觉这不大或许;项目没有对Shader进行严格的束缚,Shader开发者的能力也有高有低,Keyword界说各种Copy、shader_feature和multi_compile界说哪个、是否界说成Local、built-in keyword有什么效果,许多人都不理解就开端写(这是很正常的,学习是循序渐进的进程),东西天然也无法判别开发者的意图。

因而一定有一个非常了解变体办理流程的人,来办理整个项意图Shader和变体,我开发的东西是用来简化这一流程,处理上述内置变体搜集功用的痛点:易漏变体、欠好更新、易受Shader质量影响,以及对现有文件的增删改查问题。上述提到的操作过程无需进行多次操作,在初次调整好参数后会记载到装备文件中,日后需求重新搜集时,只需求重新搜集、运转批处理履行器即可。


这是侑虎科技第1390篇文章,感谢作者搜狐畅游引擎部供稿。欢迎转发共享,未经作者授权请勿转载。假设您有任何独到的见地或许发现也欢迎联络咱们,一同讨论。(QQ群:465082844)

作者主页:www.zhihu.com/org/sou-hu-…

再次感谢搜狐畅游引擎部的共享,假设您有任何独到的见地或许发现也欢迎联络咱们,一同讨论。(QQ群:465082844)