【Unity笔记】ShaderLab与其底层原理浅谈 agile Posted on Oct 2 2021 优秀博文 > 本文由 [简悦 SimpRead](http://ksria.com/simpread/) 转码, 原文地址 [zhuanlan.zhihu.com](https://zhuanlan.zhihu.com/p/400470713) 前言 -- 继上次北京站讲了 [Unity 内存的底层原理](https://zhuanlan.zhihu.com/p/381859536)后,这次在上海站又为我们介绍了一番 ShaderLab 的一些底层流程。 视频链接: [[Unity 活动]-Unity 技术开放日 上海站录播_哔哩哔哩_bilibili](https://www.bilibili.com/video/BV1Sh411z7cN?p=5) 官方整理: [undefined](https://unity.cn/projects/openday-shaderlab) 什么是 ShaderLab? -------------- 当我们在 Unity 中创建一个 Shader 时(Surface Shader / Vertex&Fragment Shader),会生成一个 .shader 的文件,打开这个文件时可以看到其内容由一堆代码组成,如下是一个超级简陋的 Vertex&Fragment Shader: ``` Shader "Unlit/CustomUnlitShader" { SubShader { Tags { "RenderType"="Opaque" } LOD 100 Pass { HLSLPROGRAM #pragma vertex vert #pragma fragment frag struct appdata { float4 vertex : POSITION; }; struct v2f { float4 vertex : SV_POSITION; }; v2f vert (appdata v) { v2f o; o.vertex = v.vertex; return o; } float4 frag(v2f i) : SV_Target { return float4(1, 0, 0, 1); } ENDHLSL } } } ``` 可以发现里面有 Properties,SubShader,Pass 等这些 Unity 独有的关键字并且以一种嵌套的格式书写。它们看着不是 C#,也不像 Lua,C++,像是一种新的语言,没错它们就是 ShaderLab。当然了,制定一种新的语言除了要定义它的语法规范外,我们还需要制定相应的编译器才能使该语言转换成计算机能够看懂的机器语言。 因此,准确来说,**ShaderLab 是 Unity 构建的一种方便开发者做跨平台 Shading 开发的语言体系**,它主要包括如下四种: * ShaderLab Text * ShaderLab Compiler * ShaderLab Asset * ShaderLab Runtime ShaderLab Text -------------- ShaderLab Text 也就是 ShaderLab 的文本,指的其实就是我们在 .shader 文件中写的那些代码。它们用了一定的语法规则来写的,由 Unity 定义的,官方文档如下: [Unity - Manual: ShaderLab](https://docs.unity3d.com/2020.3/Documentation/Manual/SL-Reference.html) 简单来说,单个 Shader 的 ShaderLab Text 的整体框架如下: ``` Shader "<name>" { <optional: Material properties> <One or more SubShader definitions> <optional: custom editor> <optional: fallback> } ``` 可选项 Material properties 里面可以定义在 Material 上显示的数据,格式如下: ``` Properties { [optional: attribute] name("display text in Inspector", type name) = default value [optional: attribute] name("display text in Inspector", type name) = default value ...... } ``` 如果使用的是 SRP,想使用 **SRP Batcher compatibility** 特性,在 HLSL 代码中我们必须把每个 Properties 里的变量放到 **CBUFFER** 中。 接着在一个 Shader 中,可以定义一个或多个的 SubShader ,我们知道不同的设备的硬件大部分的都不同,显卡有好有坏,好的显卡我们可以渲染的精致一些,对于差的显卡则可以粗略一些。而且如今的游戏往往也都会有画质的设置,例如极简,高配等等。针对这些情况(不同的硬件,渲染管线或者运行时的设置)我们可以使用不同的 SubShader 来对应,在不同的 SubShader 里可以定义不同的 GPU 设置以及不同的 shader 效果。格式如下: ``` SubShader { <optional: LOD> <optional: tags> <optional: commands> <One or more Pass definitions> } ``` **可选项 LOD** 即为 level of detail,格式为: ``` LOD [value] ``` 当有多个不同 LOD value 的 SubShader 时,其顺序必须是从大到小的,即优先写 LOD 值更大的 SubShader。我们可以在 C# 端通过 **Shader.maximumLOD** 属性来设置某个 Shader 的最大 LOD 值,或者使用 **Shader.globalMaximumLOD** 静态属性来设置所有的 Shader 的最大 LOD 值。 例如某个 Shader 有 LOD 100 和 LOD 50 两个 SubShader,LOD 100 的要写在 LOD 50 前面。当 50<=maximumLOD<99 会使用 LOD 50 的 SubShader,当 maximumLOD>=100 会使用 LOD 100 的 SubShader,而当 0<=maximumLOD<50 则没有相对的 SubShader,导致无法渲染出来。如果我们不主动设置 maximumLOD,则其默认值为 -1,会默认使用第一个 SubShader。 **可选项 tags**,指的是一系列键值对的数据,Unity 会根据它们决定如何或何时使用 SubShader。tags 的格式如下: ``` Tags { "[name1]" = "[value1]" "[name2]" = "[value2]"} ``` 除了系统给定的 tags,例如 Queue,RenderType 等,我们也可以自定义 tags。并且在 C# 中使用 **Material.GetTag** 方法访问 SubShader 中 tags 的值。 **可选项 commands**,可用于添加 GPU 指令或是 Shader 代码,例如 Blend 用于设置透明度混合模式,ZTest 用于设置深度测试模式等等。 除了以上可选项以外,每个 SubShader 中还必须包含有一个或多个 Pass。它是 ShaderLab 中最基本的元素,它包含了设置 GPU 状态的指令,以及在 GPU 上运行的 Shader Program。若要实现一些牛逼轰轰的效果,一般会包含多个 Pass,不同的 Pass 负责不同的工作。单个 Pass 的格式如下: ``` Pass { <optional: name> <optional: tags> <optional: commands> <optional: shader code> } ``` **可选项 name** 可以给 Pass 设置一个名字,格式如下: ``` Name "<name>" ``` 该名称会显示在 Frame Debugger 中,如下图: ![](https://pic1.zhimg.com/v2-a38d367d357408ed8a5ffa0f7c96713c_r.jpg) 当我们想在某个 Shader 中引用另一个 Shader 的 Pass 时,可以使用 UsePass 的 command,填入另个 Pass 的 name 即可。此外我们也可在 C# 中通过 Material.FindPass 以及 Material.GetPassName 等方法来获取 Pass 的 name 或者在 Shader 中的下标。 **可选项 tags** 和 SubShader 中的 tags 差不多,都是设置一系列键值对数据,不过需要注意的是它们的工作方式并不一样。SubShader 的 tags 有 RenderPipeline,DisableBatching 等,而 Pass 的 tags 有 **LightMode**,PassFlags 等,我们不能把 SubShader 的 tags 放入到 Pass 中,这样是没有效果的,反之亦然。除此以外,在 C# 中访问的方式也不一样,访问 Pass 中的 tag,我们要使用 **Shader.FindPassTagValue** 方法。 **可选项 commands** 同样和 SubShader 中的 tags 差不多,不同的在于作用域。若一个 command 写在 Pass 中,那么只对当前 Pass 生效;但是若写在 SubShader 中,则对 SubShader 中的任意一个 Pass 都生效。其中 UsePass 与 GrabPass 两个 command 只能写在 SubShader 中。 **可选项 shader code**,也就是我们写 Shader code 的地方。例如 Vertex Shader 和 Fragment Shader 的相关代码就会写在这一块区域当中,并且在 Unity 中,我们通常会使用 HLSL 来写这一部分的代码。 在 ShaderLab 中,我们编写的 HLSL 代码要包含在特定的关键词中,如下: * HLSLPROGRAM 与 ENDHLSL * CGPROGRAM 与 ENDCG * HLSLINCLUDE 与 ENDHLSL * CGINCLUDE 与 ENDCG 其中带有 CG 的关键词源自于老版本的 Unity,使用它们 Unity 会自动为我们包含一些[内置的 Shader 文件](https://docs.unity3d.com/2020.3/Documentation/Manual/SL-BuiltinIncludes.html)方便我们使用其中的一些函数与变量,而使用带有 HLSL 的关键词则不包含这些。 对于同一个 Shader 文件,不同 Pass 之间的一些公共的 HLSL 代码,我们可以把它们写在带有 INCLUDE 的关键词中,并且这一部分可以写在 Pass,SubShader 或 Shader 的语法块中,这样在带有 PROGRAM 关键词的地方会自动的引用它们。 示例如下: ``` Shader "Examples/ExampleShader" { SubShader { HLSLINCLUDE // HLSL code that you want to share goes here ENDHLSL Pass { Name "ExampleFirstPassName" Tags { "LightMode" = "ExampleLightModeTagValue" } // ShaderLab commands to set the render state go here HLSLPROGRAM // This HLSL shader program automatically includes the contents of the HLSLINCLUDE block above // HLSL shader code goes here ENDHLSL } Pass { Name "ExampleSecondPassName" Tags { "LightMode" = "ExampleLightModeTagValue" } // ShaderLab commands to set the render state go here HLSLPROGRAM // This HLSL shader program automatically includes the contents of the HLSLINCLUDE block above // HLSL shader code goes here ENDHLSL } } } ``` 注:除了使用 HLSL 外,Unity 也支持使用其他的 Shader 语言,例如使用 OpenGL 的 GLSL,这一部分 code 要包含在 **GLSLPROGRAM** 与 ENDGLSL 中。再比如使用 IOS 系统用到的 Metal,这一部分 code 要包含在 **METALPROGRAM** 与 ENDMETAL 中,并且只能在 Apple 相关设备上使用该关键词。 ShaderLab Compiler ------------------ 前面简单的介绍了下 ShaderLab 的文本,提到了 Shader Program 中通常会使用 HLSL 编写,然而实际上 HLSL 并不能直接运行在 DirectX 设备上的。如同写 c++,只是写了一堆 cpp 文件的话,是无法被计算机执行的,它需要一个翻译的过程,转换成计算机可以使用的机器语言。 其次例如 HLSL 对于 OpenGL、Vulkan、Metal 等是不能直接使用的,我们要想办法把 HLSL 转换成目标平台上的语言,例如 GLSL。这个过程的工作量其实很大的。Unity 中现在能写 Shader Program 的语言有 CG、HLSL、GLSL 和 Metal。然后要输出的目标平台就非常的多了,比如说常见的手机平台有很多种不同的 API,再加上主机平台他们都有自己整套的语言规范。那么如果我们要是生翻,那么就是个乘的关系,比如 Shader Program 的 4 种语言翻译到 10 种不同语言的平台上,那么就是 40 套不同的翻译代码。并且整个代码会非常的眼花缭乱,一堆 ifelse,这样代码维护难度很大,也很难写。 为了解决上诉的问题,ShaderLab Compiler 就出现了(或者叫作 UnityShaderCompiler),它类似于一种后台提供的服务,用来**帮助我们把写的 ShaderLab Text 翻译成最终目标机器上能够认可和执行的语言。** 官方文档: [Shader compilation](https://docs.unity.cn/2021.1/Documentation/Manual/shader-compilation.html) ShaderLab Compiler 中有两个比较重要的工具,为我们解决前面提到的问题: 一个是微软官方提供的 [FXC](https://docs.microsoft.com/en-us/windows/win32/direct3dtools/fxc) 的编译器,它可以将 HLSL 编译成 DirectX shader bytecode(**DXBC**),它是是一个 Shader 的二进制中间语言,能够直接被 DirectX 设备所使用。 参考: [Compiling Shaders - Win32 apps](https://docs.microsoft.com/en-us/windows/win32/direct3dhlsl/dx-graphics-hlsl-part1)![](https://pic1.zhimg.com/v2-5bfea841337d4a6f1b8266d70c12df78_r.jpg) 另一个是 [HLSLcc](https://github.com/Unity-Technologies/HLSLcc) ,cc 的意思为交叉编译(cross compiler),它可以**将 DXBC 输出到对应的平台**上去,例如转换为 OpenGL (Core & ES) 平台的 GLSL,Metal 平台的 Metal,Vulkan 平台的 SPIR-V。 Unity 会尽量的把 Shader Program 的语言翻译到 DirectX 这个级别上,然后通过 FXC 转换成 DXBC,再用 HLSLcc 把 DXBC 向目标平台去输出。相当于一个两步编译的过程,所以它整体的难度就降低了很多,大部分的工作都是 HLSLcc 来做的。 这个编译过程也会导致一个问题,比如说一个新特性,DirectX 里面没有,翻译不过去。那怎么办呢?现在 Unity 在 2020 的版本从 DXBC 改成了 DXIL(DirectX Intermediate Language,在 D3D12 中 DXBC 进化为 DXIL),它的好处是方便进行扩展,所以 Unity 也是基于 DX 的编译器进行了一些自己的扩展,去尽快的支持一些新特性。 个人理解是:我们写的 HLSL 会首先通过 DirectX Compiler 编译成 DXBC 或 DXIL 这类中间语言,然后再从这些中间语言编译成可以在 GPU 上执行的一系列指令(Shader Model Asm)。 注:有关 HLSL,Asm,DXBC,DXIL 这一系列的关系个人暂时也还没整的很明白,要是写错的地方望大佬们更正!!! 关于跨平台的 Shader 编译,参考: [叛逆者:跨平台 shader 编译的过去、现在和未来](https://zhuanlan.zhihu.com/p/25024372) DirectX Compiler: [GitHub - microsoft/DirectXShaderCompiler: This repo hosts the source for the DirectX Shader Compiler which is based on LLVM/Clang.](https://github.com/microsoft/DirectXShaderCompiler) 对于多核的 CPU,就会有多个 Compiler,这样可以并行进行 Shader 的编译工作。在任务管理器中,我们就可以看到它的身影,如下图: ![](https://pic3.zhimg.com/v2-5118ef99cf53d0b960d53d24334f70c2_r.jpg) 当 Unity 没有编译 Shader 时,Compiler 什么也不会做并且不会占用任何 CPU 以及内存等资源。当我们每次编辑好或者导入 Shader 的时候,可以发现 Unity 是会有一个编译的过程的,会转一会会的小菊花,这个时候就说 Shader Compiler 开始工作了,如下图: ![](https://pic2.zhimg.com/v2-4378333cade89de15ca590f90108c441_r.jpg) 当我们在 Unity 中点击一个 .shader 文件时,在 Inspector 窗口可以看到有一个 **Compile and show code** 按钮,点击它会为我们 ShaderLab Compiler 处理后的代码。 ![](data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='395' height='264'></svg>) 点击右边的小三角形还可以选择要编译到的平台,如下图: ![](data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='239' height='264'></svg>) 我们来看看文章最开始的那个简陋的 Shader,点击 Compile and show code 后显示的代码是什么样的。 Direct3D11 编译后的结果: ``` Global Keywords: <none> Local Keywords: <none> -- Hardware tier variant: Tier 1 -- Vertex shader for "d3d11": Uses vertex data channel "Vertex" Shader Disassembly: vs_4_0 dcl_input v0.xyzw dcl_output_siv o0.xyzw, position 0: mov o0.xyzw, v0.xyzw 1: ret -- Hardware tier variant: Tier 1 -- Fragment shader for "d3d11": Shader Disassembly: ps_4_0 dcl_output o0.xyzw 0: mov o0.xyzw, l(1.000000,0,0,1.000000) 1: ret ``` 可以发现我们的 Shader 代码不再是 HLSL,而是 DirectX 的 Shader Model 对应的汇编语言(Asm),这些指令的介绍可参考: [Shader Model 4 Assembly - Win32 apps](https://docs.microsoft.com/en-us/windows/win32/direct3dhlsl/dx-graphics-hlsl-sm4-asm) 例如: dcl_input v0.xyzw 用来声明一个着色器输入的寄存器,后面跟的参数指的是一个顶点数据的寄存器,对应的就是 HLSL 中的 float4 vertex。 OpenGL ES3 编译后的结果: ``` Global Keywords: <none> Local Keywords: <none> -- Hardware tier variant: Tier 1 -- Vertex shader for "gles3": Shader Disassembly: #ifdef VERTEX #version 300 es in highp vec4 in_POSITION0; void main() { gl_Position = in_POSITION0; return; } #endif #ifdef FRAGMENT #version 300 es precision highp float; precision highp int; layout(location = 0) out highp vec4 SV_Target0; void main() { SV_Target0 = vec4(1.0, 0.0, 0.0, 1.0); return; } #endif ``` 利用这个功能,我们可以更好的去优化 ShadeLab Text。 ShaderLab Asset --------------- 当我们的 ShaderLab Text 通过 Compiler 翻译过后,得到的东西就叫做 ShaderLab Asset。 ShaderLab Asset 比较常见的两个地方: 一个就是由 Shader 打成的 AssetBundle。 ![](data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='299' height='128'></svg>) 另一个是我们打出来的包里面的 level 或 sharedassets 包。 ![](data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='287' height='270'></svg>) 它们的文件结构和 AssetBundle 差不多,打到这个包里的一般是 Scene 里面直接引用的一些 Shader 或者是一些 Always Included Shaders。 这里安利一个查看 AB 包的工具 [AssetStudio](https://github.com/Perfare/AssetStudio)。如下图,显示的是前面那个炒鸡简单的 Shader 打成 Android AB 包的内容,可以发现工具里面显示的代码和我们之前 Compile and show code 得到的 gles3 代码是一致的。 ![](https://pic1.zhimg.com/v2-c2d6084df1f3d272657c6afd3e7840a4_r.jpg) 除了以上两个常见的地方外,还有一个地方可能不经常遇到的,就是在 Library 文件夹里,会看到 ShaderCache 目录(Library 下还有个 ShaderCache.db 文件,它是一个错误信息的数据库,不需要去研究),如下图: ![](https://pic4.zhimg.com/v2-2f61fbf672ad6c91bba610086e521943_r.jpg) 由 ShaderLab Compiler 预处理之后的中间产物都会放到这里,至于什么是预处理,后面会提到。如果我们去看一个 Shader 的 AB 包里的代码,可以发现里面并看不见我们的 Shader Program(CGPROGRAM 或 HLSLPROGRAM 块里包含的代码)相关的代码,取而代之的是 **gpuProgramID** xxx。 我们可以通过 Unity 安装目录下 Editor\Data\Tools 里的 **WebExtract.exe** 和 **binary2text.exe** 工具来查看,先把 ab 包拖到 WebExtract 里会生成对应的二进制文件,然后将二进制文件再拖到 binary2text 会转换为可读的文本,如下: ![](data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='394' height='234'></svg>) 或者通过 AssetStudio 的 Dump 视图查看,如下: ![](https://pic4.zhimg.com/v2-bae66292b39ee766c590d1ec55bcfa3f_r.jpg) 也就是说在 ShaderLab Asset 里面用 gpuProgramID 代替了原本的一整段 Shader Program,而 ShaderCache 里面存储的正是这些 Shader Program(当然了 Unity 在存储它们时经过了一些特殊的处理),来提高 Unity 编辑器的运行速度。并且 ShaderCache 里面的这些 Shader Program 是可以通过 gpuProgramID 进行索引的,所以呈现出来的目录结构是 0 到 e 的一系列文件夹。 至于为什么 AssetStudio 的 Preview 视图能够看见 Shader Program 的代码,这是因为工具帮我们把 Shader Program 对应的 Binary Code 做好了反序列化的操作。 ![](https://pic2.zhimg.com/v2-cab3906c37308ca305c117591173c579_r.jpg) PS:GitHub 的悬浮窗里果然可以和高川老师或者他小伙伴们进行问答,尝试勾搭了一下,感觉不错,哈哈! ![](data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='416' height='588'></svg>) ShaderLab Runtime ----------------- 最后,ShaderLab 是有 Runtime 的。既然是一种有语法结构的语言,会包含很多的信息,Runtime 能不能把这些信息用起来很重要。不然的话我们写了半天的 Text,Compiler 废了半天劲打成 Asset,最后没人用的话,不就浪费了。 ShaderLab 的 Runtime 在哪看见呢?最经常看见的地方在于 Profiler 里,如下图: ![](https://pic4.zhimg.com/v2-41aa40a9f8693375b0ce6ebd7df020ab_r.jpg) 在这里,很多人问到:为什么这里的 ShaderLab 这么大,到底是哪个 Shader 大?其实可能不是哪个 Shader 大,而是 ShaderLab 整体很大。后面会讲它是由什么组成的。 ShaderLab 工作流 ------------- 接下来来了解下 ShaderLab 的工作流,也就是从大家写出来 .shader 文件到它最终运行起来,在 Unity 里经历了什么。 ### Preprocessor 在我们创建、修改或者导入 Shader 进 Unity 系统的时候,Unity 的 Shader 并不是一次性的编译到一个目标平台上的。Unity 会把原始的 ShaderLab Text 发给 ShaderLab Compiler 的预处理器(Preprocessor)做一次预处理(Preprocess)。 预处理的结果并不是我们针对某一个平台最终的那个文本结构,即预处理得到的不是前面所说的点击 Compile and show code 后显示的那部分文本。**预处理编译出来的东西叫做 Shader Compilation Info,是一个中间状态的信息集。** 这个信息集里包含了很多很多的东西,例如我们的 [Shader 变体](https://docs.unity.cn/2021.1/Documentation/Manual/SL-MultipleProgramVariants.html)(variant),通过变体这种方式一次编码实际上是可以产生大量的不同的 Shader。第一次我们去处理出变体的概念就是在 Preprocess 的时候出现的,在 Shader Compilation Info 里已经把各个变体给分开了。 当 Unity 把 Shader Compilation Info 编译出来后呢,会把相关的信息序列化,并且写到 Library/ShaderCache 里。然后这个 ShaderCache 里的信息用于我们后面的加速编译,这样就不用每次进到 Unity 都去走一遍 Preprocess 过程。当我们的 Shader 比较大,变体比较多的时候,这个 Preprocess 的过程相对还是比较慢的。 Preprocess 的过程更细化的说,它做了如下几件事情:首先是语法分析,检查大家的 shader 写的有没有问题,如果有就会报错,所以大家看见的 Shader 报错就是在这一个阶段产生的。解析完后会把每一种不同的语言 Shader Program 从 Shader Text 里切割出来,切割出来后再用对应语言(例如 HLSL)的 Preprocess Compiler 做一遍对应于这个语言的解析检查。通过这几次的检查之后,最终会得到一个完整的 Shader Compilation Info,然后写到 ShaderCache 里。 ![](https://pic3.zhimg.com/v2-e6fe9f748aa8a5b7df41dcb724d8d94a_r.jpg) 如果我们有很多的 Shader 或者经常修改 Shader,那么就会导致 ShaderCache 文件夹特别的大,有时我们可以将 ShaderCache 删掉,让 Unity 进行重新编译一下。此外有时可能出现写完一个 Shader 之后得到的效果不太对或者是有点问题,比如感觉没有进行重新编译,那么最简单的一个方法也是把 ShaderCache 给删掉,强制重新编译一次,有的时候就会解决这个问题。 在 Unity2020.1.0a15 版本 Unity 引入了一个新的 Preprocessor:[Caching Preprocessor](https://forum.unity.com/threads/new-shader-preprocessor.790328/?_ga=2.185368105.1638566822.1630401901-470599042.1625714493),它可以使 Shader 的编译更加快速,我们可以在 Project Setting 里进行全局设置,或者选中某个 Shader 进行单独设置,如下图: ![](data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='314' height='146'></svg>)![](https://pic3.zhimg.com/v2-686c03e4641456c340da3b1c21d4e2f2_r.jpg) 引入 Caching Preprocessor 还有一个好处就是,我们可以预览 Preprocess 操作后,也就是 Shader Compilation Info 的内容了。选中 Shader,然后在 Inspector 界面勾选 Preprocess only,再点击 Compile and show code 按钮即可。 ![](data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='389' height='168'></svg>) 我们在之前的 Shader Program 里加一个简单的变体声明和一点小修改,如下: ``` HLSLPROGRAM ...... #pragma multi_compile TESTA TESTB ...... float4 frag(v2f i) : SV_Target { #if TESTA return float4(1, 0, 0, 1); #else return float4(0, 1, 0, 1); #endif } ENDHLSL ``` 然后看看 Preprocess 操作后的代码: ![](https://pic3.zhimg.com/v2-4150eb5d475082076620a0c8ce1ea856_r.jpg) 可以发现,经过 Preprocess 操作,变体已经被区分开了,变成了不同的 Shader,出现很多重复的代码。此外 Preprocess 操作并不会把 Shader Program 里的代码编译成 DXBC 或者是 GLSL 这些。 如果用的 CGPROGRAM/ENDCG,那么 Preprocess 后的代码里你会看见加入了很多的 Unity 系统内置的结构体与方法。 ### Binary compile 前面提到的 Preprocess 是运行在编辑器下,Unity Editor 拿到了 Shader Compilation Info,但是它并不能用于渲染,也不能打到最终的包体里,它只是 Unity 所使用的一种中间状态。那么如何把它最终编译成可运行的版本呢? 实际上 Shader Compiler 里面包含了很多的服务,除了 Preprocess 外,还有一个叫作 Binary compile,也就是将我们 Shader Program 里面的代码输出到对应平台上去。Preprocess 后,Unity 会把 Shader Compilation Info(可以从 ShaderCache 里取,如果里面没有,就走一遍 Preprocess,重新产生 Shader Compilation Info)再送到 Shader Compiler 里,执行 Binary compile。那么什么时候会触发这个过程呢? 第一种情况是,点 Play 按钮启动 Unity 的时候,这个时候 Unity 会做一件事情叫 **Unity Editor Shader All Warmup**(在第一次导入资源的时候 Unity 也会做)。这就是为什么 2020 之前的版本大家点 Play 的时候感觉卡半天,实际上中间有个过程是把你内存里面或者说你资源里面的所有的 shader 的变体全都 Warmup 一次。但是在真机上不会这么干,Unity 实际上是两个版本,运行时和编辑期是两套完全不同的东西,两者策略是会有些差异的,我们再做一些性能分析,分析内存、CPU、GPU,不要在跑在编辑器里看。编辑期的目的是为了帮助大家以最流畅的速度去编辑,它不去考虑运行时的资源环境占用,例如 CPU 占用、内存占用等,它会认为你的电脑都足够的好,内存不会爆,CPU 不会卡,可以挥霍这些资源,尽量保证编辑体验是好的。但是在运行时,Unity 会去考虑实际的运行环境(手机或者 PC)。 第二种情况是,打包的时候,比如要打一个 Android 平台的 AssetBundle,或者说 Build 一个 Android 的 APK,这个时候也会触发。 ![](data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='403' height='110'></svg>) 也就是说,**触发这个过程的一个前提是我们的目标平台是明确的**,因为我们要知道中间的东西最终翻译成什么,是给 DirectX 用还是给 OpenGL ES、Vulkan、Metal 这些用。要知道我们写的 Shader 最终要输出到什么设备上去,所以只有当一个平台确定了后,这个过程才会发生。 ![](https://pic2.zhimg.com/v2-b0afabc678edd6ea510422017a7a0ddd_r.jpg) ### 运行时 编译完了之后就来到了运行时,这个时候要真正的在真机上把 Shader 给跑起来了(这里说的不考虑编辑器运行的情况)。真机发起的入口一般是用户的代码,这个用户代码可能是各位写的 Warmup,也可能是通过某些引用调用 UnityAPI,API 再去调用底层。我们把 Unity 的上层系统简单的抽象成 User Code,当 User Code 说 I need a shader,这个时候去加载一个 Shader,怎么加载呢? 首先 User Code 会去通知 Unity 内部的一个管理器,叫 **Persistent Manager**(持久化管理器),它是**负责所有正向序列化和反向序列化的持久化过程**。在 Unity 运行时创造的所有的实例对象,包括我们用 Instance 的各种实例对象,最终全都是找它。它会去帮我 Produce 一个新的 **Shader Class Instance**。 Unity 的 Shader 实际上从底层 c++ 那一层看也是分了两层,哪两层呢?最上面一层叫做 **Shader Wrapper**,是个套子,这个东西实际上是给引擎开发工程师做平台无关性的。因为谁也不希望写代码时还考虑到底是给谁写的,所以在 Unity 代码里面有很多的模块,它们的上面会专门有一层去把这个平台相关性给抹平。比如说当我们从 Shader Wrapper 层看这个 Shader 的时候,你是看不到这个 Shader 到底是为哪个平台写的,都是一些统一的接口。比如说 acquire,pass 等,全都是这样很 General 的接口,不会说为 Windows 写一个,为 Android 写一个,那是在下一层做的。所以说在上一层我们有一层平台无关性的 Wrapper,在下一层才是真正的 Shader,例如 Shader 的数据啊,相关的内存啊,ShaderLab 里的东西啊,是真正底下这一层在用。 上面这层 Shader Wrapper 大家看到的是什么呢,如果在 Profiler 里去看 Assets 项,有很多以你资源名字索引的一些 Shader,看到一个个你自己的 Shader,然后旁边也有一个大小,这个大小相对 ShaderLab 来说一般比较小,它实际上是指的 Shader Wrapper 的大小,并不是真正 Shader 大小,真正 Shader 用到的大小是在 ShaderLab 那一层。 ![](https://pic3.zhimg.com/v2-4516f18842e13d42fbaddc6f5f25e8ea_r.jpg) 然后我们说底下数据的那一层,我们生成了一个 Shader 的框,要往里面填能用的东西,就像大家写一个 Class,也要在构造函数里做很多的初始化,例如做赋值、读取配置文件等操作,这个类才能真正用起来。Unity 也是一样的,当我们生成一个新的 Shader Class Instance,实际上是一个空白的框,要往里填东西,而要填的东西就是前面 Compiler 生成的 ShaderLab Asset。Unity 里我们会通过一个叫反向控制的技术,一般叫 **Transfer**,就是反向序列化和正向序列化(Serialize)。它也是 Unity 的核心之一,遍布在 Unity 的方方面面,包括常用的 Redo 和 Undo,都是它在起作用。它会把这些数据反向序列化进来,扔到我们的 Instance 里面,组成一个完整的数据内存块。 ![](data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='331' height='73'></svg>) 有了 Instance 后,最后把它叫醒即可,也就是 Awake 操作,实际上名字叫 **Awake from main thread**,从主线程唤醒,这个里面会做一个类似于我们的 C# Awake 或者是 Start 里面做的工作。比如说我们的数据填充进来要做一些处理,有些工作是不能在构造函数里做的,必须在数据进来之后才能做。比如说要确定使用哪个 SubShader,如果 SubShader 的数据都没进来呢,那就没法先确定。再比如使用 SRP,我要构建 SRP Constant Buffer 的结构,如果 Shader 数据没进来,同样无法构建。所以数据进来之后我们还有个处理操作,就是 Awake from main thread。 ![](https://pic2.zhimg.com/v2-071963a7fd11f80a6bb5b095e8001561_r.jpg) 除了 Shader Class Instance 外,Unity 内所有的类在构造的时候基本都是这三步(当然了每个类的行为不太一样): 1. Produce 一个空类 2. Transfer 数据 3. Awake Warmup Variant -------------- Shader 加载进来之后呢,我们经常面临的另外一个问题就是 Warmup,此时 Unity 到底干啥了,为什么有的时候感觉 Warmup 这么卡? 参考: [Unity - Manual: Shader loading](https://docs.unity3d.com/2020.3/Documentation/Manual/shader-loading.html) 举个例子, 假如我们的 Shader Program 如下: ``` HLSLPROGRAM #pragma vertex vert #pragma fragment frag #pragma multi_compile TEST1 TEST2 #pragma multi_compile TESTA TESTB ...... float4 frag(v2f i) : SV_Target { #if TEST1 & TESTA return float4(1, 0, 0, 1); #elif TEST1 & TESTB return float4(0, 1, 0, 1); #elif TEST2 & TESTA return float4(0, 0, 1, 1); #elif TEST2 & TESTB return float4(1, 1, 1, 1); #endif } ENDHLSL ``` 那么运行时的产生的 Shader Class Instance 里面一共有四种变体组合,TEST1 TESTA、TEST1 TESTB、TEST2 TESTA、TEST2 TESTB,如下图(省略了 TEST): ![](data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='300' height='430'></svg>) 变体实际上是 Unity 带给大家的一个**语法糖**。大家都知道所有的糖都是很好吃,但是很有害的,语法糖也一样。大部分**语法糖最终都会导致你的代码体积膨胀**,比如说我们写 c++、c# 用到的模板泛型,最终都会导致你发胖。Shader Variant 也是一样,当我们用大量的变体排列组合的时候啊,实际上它会把每一种排列组合单独的变成一段代码(点击 Compile and show code 即可看到)。它们在内存中都是单独的一套完整代码,每个变体都是一个独立的个体,它们彼此之间没有任何联系,通过大家给出的 keyworld 的排列组合进行索引的。 如果此时我们用 **Shader.EnableKeyword** 的 API 去 Warmup 1A,那么使用这个 Shader 的物体就会变为红色,说明 Variant 1A Warmup 成功了。但是如果我要去 Warmup 2C,由于没有 TESTC 这个 keyword,也就没有 Variant 2C,那么又会怎么样?首先不会发生 fallback,因为 fallback 的前提是这个 Shader Class Instance 没了。其实通过代码测试一下,会发现物体变为了蓝色,也就是 Variant 2A 被 Warmup 了。 这是因为 Uinty 会有一套奇特的**打分机制**,它会根据你给出的 keyworld 和现在所有的 keyworld 进行一个打分。比如说我 Enable 了 TEST2 和 TESTC,先会拿 TEST2 到里头去找,看它在不在我有的排列组合里,如果这个排列组合里有 TEST2,那么它会得到一个比较高的分。然后再去找 TESTC,里面如果没有 TESTC 再去减分。通过这样的机制分别对拥有的变体进行打分,最后打出来分最高的那个变体,就是 Unity 要给你的。但是至于是不是你想要的,Unity 就不管了,因此上面的例子我们会得到 Variant 2A。所以有的时候会出现,有些 Shader 效果你看起来差不多,但是不太对,可能就是你的变体没有打进去,但是 Unity 为了保证不崩溃,选了一个打分最高的变体还给你。 当我们去 Warmup 一个变体的时候,实际上我们在内存的统计上是会看到一点点变化的,是什么意思呢?我们知道一个变体底下会带有一段代码,一段 Binary Code,这段 Code 在内存里是要占大小的。那么这段 Code 会一直在内存里面吗?不会,**当我们成功的 Warmup 了某个变体之后,该变体的 Code 在 CPU 里的内存就消失了,因为它已经到 GPU 那块了,到显存里去了,所以 Unity 会很聪明的帮你把它删掉**。示意图如下: ![](https://pic3.zhimg.com/v2-314fd0dffba3f511018b6384e46c2f76_r.jpg) 所以当大家观察到我们的 ShaderLab 非常非常大的时候,那么有一种可能是你打包了非常多的变体进去,但是很多其实你都没有用,都留在了 CPU 这一端。**因此对于不是 C# 控制的 keyworld 我们应该使用 shader_feature 而非 multi_compile**,这也是一个优化上的小技巧。 着色与Blinn-Phong反射模型 路径追踪(Path Tracing)与渲染方程(Render Equation)