【Unity3D】Shader变体管理流程-变体剔除

一、什么是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变体进行管理

  上面描述了keyword组合造成的变体数量爆炸,想要将变体控制下来,需要从两方面出发,分为个人和项目。

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。
  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声明和变体数无关,是Unity2021之前为了解决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变体时,只需要将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,可见变体剔除的必要性。
  对于变体剔除工具的设计,可以参考我的个人变体剔除工具
  有时候需要注意,一些库(比如高版本的URP)也会自带变体剔除,了解项目时,先全局搜下继承IPreprocessShaders的类,防止变体在自己不知道的时候被剔掉。
  此外项目设置里也有一套变体剔除,在ProjectSetting>Graphics的Shader Stripping项下,当Modes是Custom时,只有勾选的会被打入包中。例如下图,只勾选了Baked Directional,会导致烘焙Lightmap的Shader中,如果有LIGHTMAP_ON但没有DIRLIGHTMAP_COMBINED的变体都被剔除。

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,而小部分未提前加载的变体也无伤大雅。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念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

推荐阅读更多精彩内容