fwalker 发表于 2021-12-7 12:47

【Unity笔记】ShaderLab与其底层原理浅谈

前言

继上次北京站讲了Unity内存的底层原理后,这次在上海站又为我们介绍了一番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定义的,官方文档如下:
简单来说,单个Shader的ShaderLab Text的整体框架如下:
Shader "<name>"
{
    <optional: Material properties>
    <One or more SubShader definitions>
    <optional: custom editor>
    <optional: fallback>
}可选项 Material properties 里面可以定义在Material上显示的数据,格式如下:
Properties
{
    name("display text in Inspector", type name) = default value
    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 当有多个不同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 { "" = "" "" = ""}除了系统给定的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中,如下图:



Frame Debugger

当我们想在某个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文件方便我们使用其中的一些函数与变量,而使用带有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翻译成最终目标机器上能够认可和执行的语言。
官方文档:
ShaderLab Compiler中有两个比较重要的工具,为我们解决前面提到的问题:
一个是微软官方提供的 FXC 的编译器,它可以将HLSL编译成 DirectX shader bytecode(DXBC),它是是一个Shader的二进制中间语言,能够直接被DirectX设备所使用。
参考:


另一个是 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编译,参考:
DirectX Compiler:

对于多核的CPU,就会有多个Compiler,这样可以并行进行Shader的编译工作。在任务管理器中,我们就可以看到它的身影,如下图:



未工作状态

当Unity没有编译Shader时,Compiler什么也不会做并且不会占用任何CPU以及内存等资源。当我们每次编辑好或者导入Shader的时候,可以发现Unity是会有一个编译的过程的,会转一会会的小菊花,这个时候就说Shader Compiler开始工作了,如下图:



导入Shader,Compiler开始工作

当我们在Unity中点击一个 .shader 文件时,在Inspector窗口可以看到有一个 Compile and show code 按钮,点击它会为我们ShaderLab Compiler处理后的代码。


点击右边的小三角形还可以选择要编译到的平台,如下图:


我们来看看文章最开始的那个简陋的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),这些指令的介绍可参考:
例如: 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。



AssetBundle

另一个是我们打出来的包里面的level或sharedassets包。



Build

它们的文件结构和AssetBundle差不多,打到这个包里的一般是Scene里面直接引用的一些Shader或者是一些Always Included Shaders。
这里安利一个查看AB包的工具 AssetStudio。如下图,显示的是前面那个炒鸡简单的Shader打成Android AB包的内容,可以发现工具里面显示的代码和我们之前Compile and show code得到的gles3代码是一致的。



AssetStudio

除了以上两个常见的地方外,还有一个地方可能不经常遇到的,就是在Library文件夹里,会看到ShaderCache目录(Library下还有个ShaderCache.db文件,它是一个错误信息的数据库,不需要去研究),如下图:



ShaderCache

由ShaderLab Compiler预处理之后的中间产物都会放到这里,至于什么是预处理,后面会提到。如果我们去看一个Shader的AB包里的代码,可以发现里面并看不见我们的Shader Program(CGPROGRAM或HLSLPROGRAM块里包含的代码)相关的代码,取而代之的是gpuProgramID xxx。
我们可以通过Unity安装目录下Editor\Data\Tools里的 WebExtract.exe 和 binary2text.exe 工具来查看,先把ab包拖到WebExtract里会生成对应的二进制文件,然后将二进制文件再拖到binary2text会转换为可读的文本,如下:


或者通过AssetStudio的Dump视图查看,如下:


也就是说在ShaderLab Asset里面用gpuProgramID代替了原本的一整段Shader Program,而ShaderCache里面存储的正是这些Shader Program(当然了Unity在存储它们时经过了一些特殊的处理),来提高Unity编辑器的运行速度。并且ShaderCache里面的这些Shader Program是可以通过gpuProgramID进行索引的,所以呈现出来的目录结构是0到e的一系列文件夹。
至于为什么AssetStudio的Preview视图能够看见Shader Program的代码,这是因为工具帮我们把Shader Program对应的Binary Code做好了反序列化的操作。



Binary Code

PS:GitHub的悬浮窗里果然可以和高川老师或者他小伙伴们进行问答,尝试勾搭了一下,感觉不错,哈哈!



ShaderLab Runtime

最后,ShaderLab是有Runtime的。既然是一种有语法结构的语言,会包含很多的信息,Runtime能不能把这些信息用起来很重要。不然的话我们写了半天的Text,Compiler废了半天劲打成Asset,最后没人用的话,不就浪费了。
ShaderLab的Runtime在哪看见呢?最经常看见的地方在于Profiler里,如下图:


在这里,很多人问到:为什么这里的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变体(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里。



Preprocess流程

如果我们有很多的Shader或者经常修改Shader,那么就会导致ShaderCache文件夹特别的大,有时我们可以将ShaderCache删掉,让Unity进行重新编译一下。此外有时可能出现写完一个Shader之后得到的效果不太对或者是有点问题,比如感觉没有进行重新编译,那么最简单的一个方法也是把ShaderCache给删掉,强制重新编译一次,有的时候就会解决这个问题。
在Unity2020.1.0a15版本Unity引入了一个新的Preprocessor:Caching Preprocessor,它可以使Shader的编译更加快速,我们可以在Project Setting里进行全局设置,或者选中某个Shader进行单独设置,如下图:



Project Setting



Per Shader

引入Caching Preprocessor还有一个好处就是,我们可以预览Preprocess操作后,也就是Shader Compilation Info的内容了。选中Shader,然后在Inspector界面勾选Preprocess only,再点击Compile and show code按钮即可。



Preprocess only

我们在之前的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操作后的代码:



Preprocess Only

可以发现,经过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,这个时候也会触发。



Build APK

也就是说,触发这个过程的一个前提是我们的目标平台是明确的,因为我们要知道中间的东西最终翻译成什么,是给DirectX用还是给OpenGL ES、Vulkan、Metal这些用。要知道我们写的Shader最终要输出到什么设备上去,所以只有当一个平台确定了后,这个过程才会发生。


运行时

编译完了之后就来到了运行时,这个时候要真正的在真机上把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那一层。



Editor下的Profiler里的Shader Wrapper

然后我们说底下数据的那一层,我们生成了一个Shader的框,要往里面填能用的东西,就像大家写一个Class,也要在构造函数里做很多的初始化,例如做赋值、读取配置文件等操作,这个类才能真正用起来。Unity也是一样的,当我们生成一个新的Shader Class Instance,实际上是一个空白的框,要往里填东西,而要填的东西就是前面Compiler生成的ShaderLab Asset。Unity里我们会通过一个叫反向控制的技术,一般叫Transfer,就是反向序列化和正向序列化(Serialize)。它也是Unity的核心之一,遍布在Unity的方方面面,包括常用的Redo和Undo,都是它在起作用。它会把这些数据反向序列化进来,扔到我们的Instance里面,组成一个完整的数据内存块。



Redo和Undo

有了Instance后,最后把它叫醒即可,也就是Awake操作,实际上名字叫Awake from main thread,从主线程唤醒,这个里面会做一个类似于我们的C# Awake或者是Start里面做的工作。比如说我们的数据填充进来要做一些处理,有些工作是不能在构造函数里做的,必须在数据进来之后才能做。比如说要确定使用哪个SubShader,如果SubShader的数据都没进来呢,那就没法先确定。再比如使用SRP,我要构建SRP Constant Buffer的结构,如果Shader数据没进来,同样无法构建。所以数据进来之后我们还有个处理操作,就是Awake from main thread。



Runtime流程图

除了Shader Class Instance外,Unity内所有的类在构造的时候基本都是这三步(当然了每个类的行为不太一样):

[*]Produce一个空类
[*]Transfer数据
[*]Awake

Warmup Variant

Shader加载进来之后呢,我们经常面临的另外一个问题就是Warmup,此时Unity到底干啥了,为什么有的时候感觉Warmup这么卡?
参考:
举个例子, 假如我们的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):


变体实际上是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会很聪明的帮你把它删掉。示意图如下:


所以当大家观察到我们的ShaderLab非常非常大的时候,那么有一种可能是你打包了非常多的变体进去,但是很多其实你都没有用,都留在了CPU这一端。因此对于不是C#控制的keyworld我们应该使用shader_feature而非multi_compile,这也是一个优化上的小技巧。

johnsoncodehk 发表于 2021-12-7 12:49

客观的说 这篇比官方公众号的上面的那篇 强太多了 怒赞

七彩极 发表于 2021-12-7 12:59

如果你没有源码,我很难想象你怎么知道这些细节

闲鱼技术01 发表于 2021-12-7 12:59

这不是大佬为我们分享了嘛
有、厉害

Mecanim 发表于 2021-12-7 13:07

你看看这个:GitHub - BobLChen/ShaderLab at 1.0.0

我在几年前就把它的实现原理猜的一干二净。后面拿到了源码之后发现跟它的实现几乎一致。所以看这个等于看它的源码。
页: [1]
查看完整版本: 【Unity笔记】ShaderLab与其底层原理浅谈