美文网首页自己收藏
【Unity3D】Shader变体管理流程梳理

【Unity3D】Shader变体管理流程梳理

作者: crossous | 来源:发表于2022-05-26 10:51 被阅读0次

    一、什么是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编写的新手,可能有两个问题:
    ①我不能直接传递个变量到shader里,用if实时判断吗?
    答:不可以,简单来说,由于gpu程序需要高度并行,shader中的分支判断需要将if else两个分支都走一遍,假如你的两个需求都有不短的代码,这样的开销太大且不合理。
    ②我不可以直接将shader复制一份出来改吗?
    答:不是很好,例如你现在复制一份shader出来,还需要对应脚本去找到需要替换的shader然后替换。更重要的是,当你的shader同时包含很多需要切换的效果:阴影、雾效、光照计算、附加光源、溶解、反射等等,总不能有一个需求就shader*2是吧

      当你有多组关键字,阴影是否开关,是否有雾效时,你可能会写出下面这样的关键字声明:

    #pragma multi_compile SHADOW_OFF SHADOW_ON
    #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/spirv,也可以用hlsl/glsl实时生成dxbc/spirv传递给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的spirv;而变体指数级增长,相当于这些底层的这些shader指数级增长。



      变体数太多对开发模式可能没有什么,最多是开编辑器时多喝点茶,但项目需要打包、上线就不是这样了。
      别看这些都能Shader实时用Shader编译生成,但引擎不会这么做,而是在打包时就需要知道所有可能用到的变体,将其打包出来。
      很浅显的原因是shader编译的时间也不短,Unity/UE这些引擎为了方便用户编写,主要编写的语言是hlsl,如果你的游戏是DX11/DX12,实际运行会将hlsl编译为dxbc,单个的时间不长,但达到一定数量就会有明显卡顿,如果场景出现一些附加光源,突然多出来这些变体shader需要实时生成,这个时间说不定会是几秒。
      如果你的API是OpenGL,为了获取到glsl,unity用hlslcc将hlsl变成glsl,然后再编译程序;如果API是VulKan,前面按照OpenGL一样先生成glsl,然后再用glslang生成spirv。对于UE,这个流程会有区别,详见:跨平台引擎Shader编译流程分析
      对于DX12和VK这样的现代API,新生成Shader意味着要生成PSO(管线状态对象),这又是一比超级大的开销。
      如果不提前将ShaderBuild好,你现在打包时编译Shader的时间,就是你未来用户第一次进入游戏的时间,想想这个酸爽。总之确定了一件事,在打包时,预计用到的Shader变体(dxbc/glsl/spirv)就会全都打入包中。
      变体数量对包体的影响倒是未必很大,因为AssetBundle有压缩,而你的变体之间只是略有差异,很可能200MB的shader文件,压缩后不到2MB。
      真正进入游戏中,游戏会先将Shader从AssetBundle中解压出来放到CPU先准备着,当GPU需要用到变体时,再送入GPU。重点是解压后Shader的大小就不是那么理想了,你可以用你完全没有Shader管理的游戏项目打个包,然后Unity到Window>Analysis>Profiler。

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

      未经过管理的变体可能导致ShaderLab占用内存一个多G,这显然是不可接受的。
      这是内存上的问题,此外还有运行时加载的问题。
      但现在还是上述的情景,假如场景中突然出现一盏附加光源,需要对已有的shader都开启新的变体,这些变体CPU中都存在,因为你打包时已经打入了,你省下了将hlsl生成为dxbc/glsl/spirv的时间,但是将dxbc/glsl/spirv送入gpu、生成pso的时间却是省不下的,这依旧可能会造成卡顿。
      结合上述问题,所以我们需要对Shader变体做管理。

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

      Shader变体的管理需要从两方面出发,分为个人和项目。

    1. 个人角度对Shader变体管理

      个人是指TA、引擎、图程以及其他Shader开发者,在编写Shader时就要注意变体的问题。
      首先,该用if用if,之前虽然说在GPU执行分支开销不低,但只是相对而言的,如果你的ifelse执行的是整个光照计算,那显然是不可接受的,但假如ifelse加起来没两行代码,那显然是无所谓的,要是在变体极多的时候去掉个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的数量(Unity2020及以下版本只能有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个变体,后面两个shaderfeature组分别引用到了EG和FH,然后两组组合4*2,最后打出8个变体。

    1.3 编写建议

      对于个人来说,较为通用的编写方式是,multi_compile建议用于声明可能实时切换的keyword声明组,例如阴影、全局雾效、雨、雪。因为一个物体可能在多个场景使用,材质也就会在多个场景用到,一个场景有雾,另一个场景有雨,而材质只能引用一组变体,为了能实时切换,就需要把变体也打入包中;而对于材质静态的keyword声明组就可以用shader_feature,例如这个材质是否用到了NormalMap,是否有视差计算,这个在打包时就确定好的,运行时不会动态改变,即可声明为shader_feature。

    小贴士:
      shader_feature和multi_compile后面也可以加其他条件,例如如果确定一组keyword声明只会导致vertexshader有变化,即可再后面加_vertex,例如shader_feature_vertex。
      shader_feature_local的_local声明和变体数无关,是Unity2021之前为了解决GlobalKeyword数量问题出现的解决方案,声明为local keyword不会占用global keyword数,建议是如果keyword声明组是需要材质手动勾选的参数,声明为_local,当keyword为local时,Shader.EnableKeyword无法启用当前材质的关键字。
      有些声明是Unity内置的,例如#pragma multi_compile_instancing相当于#pragma multi_compile _ INSTANCING_ON,#pragma multi_compile_fog则会声明几个雾相关的keyword。

    2. 项目角度的变体管理

      有些问题从个人开发角度是难以规避的。
      希望Shader的开发者都能从个人编写角度做好变体管理,往往是不现实的,Shader开发者水平有高有低,或许某个实习生或客户端为了快速实现效果,就从网上Copy下来一段代码,运行一下效果没问题就不管了;再或者某个美术导入了一个插件,而插件的编写者没有考虑过变体的问题等等。
      还有上面说的问题,变体是只有被用到才会被送入GPU,这样会导致卡顿,这一栏就讨论下如何解决上述问题。

    2.1 变体剔除

      Unity提供了IPreprocessShaders接口,让用户自定义剔除条件。
      自定义的类继承IPreprocessShaders后,需要实现void OnProcessShader(Shader shader, ShaderSnippetData snippet, IList<ShaderCompilerData> inputData)方法,这是一个回调函数,当打包时,所有shader变体都会送进来进行判断。
      三个参数中,第一个是Shader本体,没什么好说的。
      第二个存了底层Shader类型和Pass类型,ShaderType包括Vertex、Fragment、Geometry等;PassType存了Pass类型,例如BuildIn Shader一般有ForwardBase、ForwardAdd,SRP的SRP、SRPDefaultUnlit等。
      第三个参数是ShaderCompilerData的List,ShaderCompilerData包含了变体的集合、变体所需的api特性级别、变体的api(只要PlayerSetting里添加了平台对应的API,可以同时打出多个图形API所需的Shader)。
      这些参数包含变体的全部条件,用户可以根据项目需要自行编写剔除逻辑,当判断需要剔除一个Shader变体时,只需要将ShaderCompilerDatainputData这个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,可见变体剔除的必要性。
      个人变体剔除工具

    2.2 变体预热

      第二个问题是变体是只有被用到才会被送入GPU,这样会导致卡顿。解决方法也很简单,既然运行时按需加载会导致卡顿,那么提前一次性加载好即可。
      引擎一般提供预热功能,在Unity中是ShaderVariantCollection.WarmUpShaderWarmup.WarmupShaderFromCollectionShader.WarmupAllShaders,前两个方法会将变体收集内的全部变体提前送入GPU,省下了这一部分时间。
      更多说明可在Unity文档 着色器加载中查看。
      现在讨论下变体收集文件(ShaderVariantCollection)的使用方法。
      变体收集文件是Unity资产,可通过右键Create>Shader>Shader Variant Collection创建,或通过跑变体收集保存一个变体收集文件。
      变体收集文件包含很多Shader,每个Shader对应一个List,List中每个项是PassType-KeywordSet的组合,用户可以根据需求手动添加Shader和变体组合。
      不过第一次使用时,建议跑一遍变体收集,之后再根据需要手动添加删除。
      使用方法是在ProjectSetting>Graphics的最下面,先Clear掉当前的记录,然后进行游戏,尽量覆盖大多数游戏内容,之后点击Save to asset保存。


      我个人认为变体收集的主要作用就是用来预热变体,实际上还有个作用,就是引用变体;和材质一样,记录在变体收集内的变体会被记做引用打到包中。
      因此一些人在管理变体时喜欢将所有keyword声明为shader_feature,然后跑变体收集增加被引用到的变体,这样能被打入到包中的变体,都是会被用到的。
      但我个人不推荐这种做法,因为利用变体收集引用来管理打包会很混乱,往往跑游戏很难覆盖到所有位置,导致少收集一些变体,而一些效果不太明显,连测试都看不出来区别,可能上线后都发现不了。当Shader拥有庞大变体集合时,再增减Keyword,更新变体收集文件会导致事倍功半。
      我个人推荐的做法是,keyword完全按使用方法来声明,会动态切换的声明multi_compile,对材质静态的声明shader_feature,不可能出现的条件组合用变体剔除去掉。
      而变体收集的作用只是用来预热,因为跑变体收集后,大部分常用功能都被提前加载到GPU,而小部分未提前加载的变体也无伤大雅。

    相关文章

      网友评论

        本文标题:【Unity3D】Shader变体管理流程梳理

        本文链接:https://www.haomeiwen.com/subject/aqzltrtx.html