书接上文,Shader变体管理流程-变体剔除描述了用变体剔除解决变体过多的问题,但变体还有运行时加载时间和打包引用问题需要解决。
Unity为了解决这些问题,提供了变体收集功能,功能围绕着变体收集文件ShaderVariantCollection
,创建方法为:在Project窗口右键>Create>Shader Variant Collection(2019是Create>Shader>Shader Variant Collection)
这个文件本身没有什么特殊的,就是记录变体的文件而已,每个变体为PassType与keyword的组合:
文件的作用有两个,其一是在打包时,对变体引用;其二是运行时,利用文件预热变体。
一、变体预热
1. 为什么要变体预热
举一个例子:Unity自带的设计中,附加光源是额外的变体,当场景超过一盏实时光时,会打开附加光源变体;这样可以保证,场景只有一盏实时光时,不会有额外的shader计算开销,但也带来一个问题。
假如当前场景各种物体用到了50个变体,突然多出一个实时方向光,为了使场景被这第二盏灯照亮,需要将所有物体的变体切换为有附加光源的那一个,也就是相当于要准备50个变体。假如这50个变体没有准备完怎么办?卡着呗。
这个场景是运行时游戏,附加光源的变体已经在包中,不需要重新从ShaderLab生成对应平台的底层shader,但依旧需要将底层shader送入gpu,例如glShaderSource
加载glsl源代码、vkCreateShaderModule
从二进制spirv创建VkShaderModule
对象,以及后续创建PSO等流程依旧不能节省。
这样一来,还是会造成运行时卡顿,为了解决这个问题,就需要变体预热,提前将可能用到的变体送入GPU。
2. 变体预热的方法
Unity提供了这些接口ShaderVariantCollection.WarmUp
、Shader.WarmupAllShaders
这些接口。其中Shader.WarmupAllShaders
会预热所有变体,假如对变体剔除结果非常有信心可以使用。
ShaderVariantCollection.WarmUp
会预热当前变体收集文件中所有记录的变体,提供了更精细化控制的可能,例如某些变体只会在某个小游戏场景出现,那么可以将相关变体放在一个收集文件中,只有进入这个小游戏场景加载时才预热变体。
二、变体引用
1. 为什么要变体引用
依照上文的说法,材质和变体收集文件都可以引用变体,那为何还需要变体收集文件呢?
如果是Unity直接build一个包出来,那么确实不需要变体收集文件来引用变体。
但如果在有热更需求时就不同了;全部的Shader一般会打到一个单独Bundle中,根据Bundle中其他资源对变体的引用,决定哪些变体会打入当前Bundle;对变体产生引用的材质,往往不会放到Shader所在的Bundle,而是分散到其他很多Bundle中,这样就会导致打Shader的那个Bundle找不到变体引用,从而无法将需要的变体打入Shader Bundle。
所以就需要一个变体收集文件,将需要打包的变体写入文件,用这个文件来保持变体引用,然后将文件和Shader打入同一个Bundle中,这样就能将需要的变体打入Bundle。
三、变体收集
变体收集文件没有什么特殊的,只是一个记录变体的文件而已,需要考虑的是如何收集需要的变体。
1. 基础操作
最基础的操作就是手动添加,就如下图所示,变体收集文件的面板中,点击Shader后面的+
,然后排序不需要的keyword,在下面选择需要添加的变体,然后点击Add * selected variants
。
这种方法只适合简单维护,实在不推荐这样做,显而易见的原因是这样很容易漏掉变体,而且Unity的这个工具面板,也给我一种“都别这么用”的感觉。
就提出几个简单的操作场景:如果文件中已经有了二、三十个Shader,个别Shader内收集了五、六十个变体,我想要在这么多Shader和变体中,找到我想要操作的Shader,就需要翻好久。
如果我想要添加一个keyword,与现有的变体做排列组合,只能用面板手动点击。
如果收集文件中已经有一千多个变体,这个面板就会出现明显卡顿。
总结起来就三个字:孬操作。这肯定不是技术问题,那么我只能理解为Unity告诉我们:“都给我老老实实去跑变体收集!”
2. 跑变体收集
这个是相对自动的方法,使用方法是在ProjectSetting>Graphics的最下面,先Clear掉当前的记录,然后进行游戏,尽量覆盖大多数游戏内容,之后点击Save to asset保存。
显而易见的问题是,容易漏变体,无论是给引擎还是测试来跑变体收集,总可能有覆盖不到的变体。
其次是不好更新,假如场景调了下场景以及材质,上传后需要更新文件,那只能重新跑收集,不然总不能让美术去管变体收集吧?
其三是容易受Shader质量影响。假如某个Shader开发者没注意,在Shader不需要的时候,加了这个声明:
#pragma multi_compile_fwdbase
,这个buildIn的变体声明,声明出DIRECTIONAL
、LIGHTMAP_ON
、DIRLIGHTMAP_COMBINED
、DYNAMICLIGHTMAP_ON
、SHADOWS_SCREEN
、SHADOWS_SHADOWMASK
、LIGHTMAP_SHADOW_MIXING
、LIGHTPROBE_SH
这么一大串变体,而运行游戏时,Unity会根据当前情况启用这些变体,就会导致变体收集收集到不需要的变体。
3. 定制化变体收集工具
3.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、变体:3.2 自动化的变体收集
话说回来,自动化的变体收集,就需要知道哪些变体需要被打包。按照我们之前说的,材质会引用变体,所以首先确定哪些材质会被打包;其次,确定这个材质会引用哪个、哪些变体;最后,将变体写入变体收集文件。
对于哪些材质会被打包,我能想要的有两种,其一是被打包场景所引用的材质,既BuildSetting里面那些场景;其二是项目的资源表直接或间接引用材质。
其他可能性暂时想不到,但基于拓展性需求,我抽象出收集器类,工具会执行所有收集器收集材质,如果有拓展需求,就添加收集器:
上图中就包含了两个材质收集器,分别收集场景依赖和资源表依赖材质。
对于材质会引用到哪个、哪些变体,依照变体剔除文章配图所示,材质会保留ShaderKeywords,似乎这就是材质所引用的变体。
其实不然,这里是材质经过调用
Material.EnableKeyword
后,会将keyword写入这里,哪怕Shader没有这个keyword。在上文中,我们建议对于所有在打包时,材质能确定的静态效果(是否用bumpMap、视差、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。
经过这样一轮收集,基本解决了变体打包时的引用问题。
四、工具拓展
1. 变体预热
上述解决了变体引用问题,打包大多数情况不会发生丢变体的情况,但变体预热的问题又回来了,我们只收集了材质中的ShaderKeywords,按照上面的说法,这些keyword都是shader_feature,属于静态效果的开关,但动态的效果没有进行组合。
如雾效、lightmap、多光源等效果,这些keyword是由multi_compile声明的,打包时会自动与shader_feature的组合进行再排列组合,会打入包中,不会出现丢变体的问题;但预热所解决的问题不是打包,而是运行时切换效果时,加载shader带来的卡顿问题;假如变体收集文件没有收集multi_compile的组合,ShaderVariantCollection.WarmUp
就不会预热相关变体。
所以我们希望尽可能的,将所有可能切换效果的变体,写入变体收集文件中。既然打包时会进行排列组合,那么可以将这一步骤引入变体收集。
这种功能可能会在每次重新收集变体后都要执行一遍,因此我将这一类行为抽象为批处理执行器接口,接口包含Execute方法,传入变体收集文件,然后在方法里进行相关操作。执行器是可序列化的对象,可以将数据保留,只需要变体管理者操作一次,即可在多次收集材质时复用。
排列组合执行器会完成我需要的功能:
执行器自定义面板的尝试收集声明组,会用正则匹配Shader中声明的所有multi_compile组合,然后再由人工剔除不需要的声明组。
通过运行执行器,即可将声明组与收集文件中相应Shader变体进行排列组合,这样就能将multi_compile组合也进行预热。
2. 变体剔除
自动收集免不了收集到一些不想要的Shader和变体,例如URP项目里收集到Standard
,哪怕变体剔除工具会作为打包前最后一道壁垒,我扔希望在收集就避免收集到。
我先是抽象出材质和变体的过滤器类,根据需求实现接口,这样避免收集到不想要的变体。
其次是收集到变体,再进行排列组合后,某些变体组合可能是我们不想要的,如果再写一套剔除执行器似乎和变体剔除有些重复了,但转念一想,我们有变体剔除工具,何不将两者联动下,于是专门写了一个联动执行器,调用变体剔除工具的接口提前进行变体剔除:
五、总结
我花了不少时间思考并完成了相关工具的设计,也参考了知乎上其他人的工具和方法。
项目中应用时,有些同事误以为这些工具是全自动的,放在工程里就完事,但我感觉这不大可能;项目没有对Shader进行严格的约束,Shader开发者的能力也有高有低,keyword定义各种copy、shader_feature和multi_compile定义哪个、是否定义成local、buildIn-keyword有什么作用,很多人都不明白就开始写(这是很正常的,学习是循序渐进的过程),工具自然也无法判断开发者的意图。
因此一定有一个十分了解变体管理流程的人,来管理整个项目的Shader和变体,我开发的工具是用来简化这一流程,解决上述内置变体收集功能的痛点:易漏变体、不好更新、易受Shader质量影响,以及对现有文件的增删改查问题。上述提到的操作步骤无需进行多次操作,在首次调整好参数后会记录到配置文件中,日后需要重新收集时,只需要重新收集、运行批处理执行器即可。