【Unity3D】Shader变体管理流程2-变体收集

  书接上文,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.WarmUpShader.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的变体声明,声明出DIRECTIONALLIGHTMAP_ONDIRLIGHTMAP_COMBINEDDYNAMICLIGHTMAP_ONSHADOWS_SCREENSHADOWS_SHADOWMASKLIGHTMAP_SHADOW_MIXINGLIGHTPROBE_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_ONOUTLINE_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质量影响,以及对现有文件的增删改查问题。上述提到的操作步骤无需进行多次操作,在首次调整好参数后会记录到配置文件中,日后需要重新收集时,只需要重新收集、运行批处理执行器即可。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,835评论 4 364
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,598评论 1 295
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,569评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,159评论 0 213
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,533评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,710评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,923评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,674评论 0 203
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,421评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,622评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,115评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,428评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,114评论 3 238
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,097评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,875评论 0 197
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,753评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,649评论 2 271

推荐阅读更多精彩内容