关于unity:图形引擎实战Unity-Shader变体管理流程

5次阅读

共计 15942 个字符,预计需要花费 40 分钟才能阅读完成。

一、什么是 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。

连贯 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 转化为 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、变体:

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)

正文完
 0