在 Unity 技术开放日-上海站活动中,Unity 专家为大家揭秘了 Unity 的黑盒世界,深入浅出地讲解了“ShaderLab”的底层原理

本文节选了部分精彩内容,完整内容已上传至 Unity 社区中的技术专栏。滑至文末,点击“阅读原文”,即可跳转至技术专栏学习:

https://unity.cn/projects/openday-shaderlab

大家下午好,我今天为大家带来的是揭秘 Unity 的黑盒世界,看到这个题目是不是有人会觉得比较熟悉,有没有人在 B 站上看过北京站直播或者是录像的同学?上一次在北京讲的是“Memory”,今天换另外一个话题,我们来聊聊“ShaderLab”。

如果有同学想去了解一下 Unity 内存方面的底层逻辑,欢迎看我们北京站的录播:

https://www.bilibili.com/video/BV1r44y1z7X3?p=2

我们先来提出一个简单的问题,什么是 ShaderLab?

ShaderLab 是由 Unity发明或者说是由 Unity 首创的一种语言体系,用来帮助大家做跨平台 Shading 开发。它里面除了语言规则之外,还有很多其他的东西。我们一个个来看。

我们先来看看ShaderLab Text,就是它的文本。

我们大家知道,无论是写代码,还是写 shading,实际上我们写的都是一堆文本。虽然在 IDE 里看起来“花里胡哨”的,但是对计算机来讲就是文本。大家能看到,这就是我们非常熟悉的 Unity 的 Shader,这个东西叫 ShaderLab 文本,是根据 Unity 定义的语法规则来写的。

我们可以看到,在基础文本里面会包含一些块,比如说 Shader 的名字,在 Subshader 里面也有它自己的属性,比如 Tag、LOD 等等。在 Subshader 下面还有一个个 Pass,在 Pass 里面我们还可以定义不同的 pragma、Vertex、Fragment,每个都对应不同的代码块,一般就是这样的。

这是 ShaderLab 第一个组成的部分,叫 ShaderLab 文本。

光有文本是不行的,就如同你写 C++,如果只是写了一堆 CPP 文件,依然是无法被计算机认可并执行的。中间需要有翻译的过程,就是Shader Compiler的过程。如果大家在 Windows 或者是 Mac 上有留意过的话,就会发现,打开 Unity 之后再打开任务管理器,或者在 Mac 上打开 active monitor,你都会看到一个叫 Unity shader Compiler 的东西。如果是早期的 Unity 版本,你看到的这个东西叫做 cgbatch。

这个东西是干什么的呢?实际上它类似一种服务,是 Unity 在后台提供的一种服务,用来帮助我们去把写好的 ShaderLab 语言翻译为目标机器能够认可并执行的语言(一会儿讲大概是怎么翻译的)。

这是第二个组成模块。

第三个是我们的一个体系,叫ShaderLab Asset。你写的 ShaderLab 大部分是不会进入到最终的运行环境中去的,需要经过二次加工。ShaderLab 里面有很多东西都是不能直接使用的,需要进行翻译。而加工之后的东西,我们叫它 Asset,这个 Asset 比较常见的是这两个地方。

第一个地方,我们打 Assetbundle,这是大家最常见的,我们把 shader 打成一个包,放到 bundle 里。

还有一个就是在我们打出来包的 Resources 下面,会有 level 或者是 SharedAssets 这样的包,其实和 Assetbundle 的文件结构是很类似的,比如说场景里面直接引用的一些东西,大家既爱又恨的 Always include Shader 也在这里面。

这是两个 Asset 比较常见的地方。

但是有一个地方,大家不经常会用到。当我们在 library 文件夹里打开一个工程,就会看到一个叫 ShaderCache 的文件。我们在做预处理之后的中间产物,都会放到 ShaderCache 里面,如果大家了解过之前我讲过的 Asset 相关的课程,我们在 B 站上也有,中文课堂里也有,大家可以去看。

我们都知道 Unity 特别喜欢 GUID 做索引的,这个看起来和我们的 library 里面的 data 的东西很熟悉,也是一堆文件夹,以数字或字母开头的,这个也是 ID 但不是 GUID。如果大家曾经拆过 Unity AssetBundle,你会看到你写的代码。

你会经常看到整个从 CGPROGRAM 到 ENDCG,在你的 Asset 里看不到了,变成了一个名字叫 GPU program IDXXX,用一个 ID 索引了整个这一段。在里面我们可以很简单地认为里面存的就是大家写的 CG program 中间的东西,当然不是直接存进去的,是经过了一系列的加工。这是我们存储几个 shaderAsset 的地方。

最后还有一个东西,大家会经常遇到,但是往往被忽略掉,ShaderLab 是有Runtime的。既然是一种有语法结构的语言,会包含很多的信息,Runtime 能不能把这些信息用起来很重要。不然的话写了半天,用了半天,打上 Asset 没人用,就浪费了。

这个东西经常在哪儿见到呢?我截了一个大家最经常看到的地方,在 Memory 里面,如果去 take 一个 sample,现在 2017 之后的版本,你们会在 ShaderLab 里面看到它。

这也是大家问的最多的地方,为什么我 ShaderLab 这么大,到底是哪个 Shader 大?其实不是 Shader 大,可能是 ShaderLab 整体很大,我一会儿讲一下这个东西大致由什么都是组成的。

所以整个 Unity 的 ShaderLab 大致分为四块,是由 ShaderLab Text、shaderLab Compiler、shaderLab Asset、shaderLab Runtime 组成的。

我们了解了 ShaderLab 之后,再简单地看一下 ShaderLab 的工作流。

首先,我们去做 Shader 的时候,第一步就是去写 ShaderLab 的 Text,写完了之后干什么呢?写完之后你会发现,当你回到 Unity 的时候,Unity 开始有一个编译的过程,如果你第一次导入了很多 Shader,这个时候就是我们的 ShaderCompiler 开始工作了。

Unity 的 Shader 不是一次性编译到一个平台上的。

刚刚提到了一个叫ShaderCache的东西,它是在什么时候产生的呢?是在 shader 被 import 进 Unity 系统的时候,Unity 会把原始的 shader 文本发给 shaderCompiler 去做一次预处理,预处理的结果并不是针对某一个平台最终的文本结构,这个时候编译出来的东西叫 shader compilation info,是一个中间状态的一个信息集,这个信息集里面包含了很多重要的东西。以下是其中比较重要的几点:

第一,你的变体。我们知道 Unity 引入了 multi compile 和 shader feature 之后,通过一次编码就可以产生大量不同的 Shader。第一次我们去处理出变体的概念,是在我们做 Preprocess 的时候出现的。经过 Preprocess 第一次的处理,在 shader compilation info 里面,就已经把各个变体分开了。

当我们去把 shader compilation info 编译出来之后,会把相关的信息序列化,并且写到我们 ShaderCache 里面,这就是刚才大家看到的 ShaderCache。这个 ShaderCache 的信息用于我们后面的一些加速编译,不需要每次进入 Unity 都重新走一遍过程。当你的 shader 比较大,变体比较多的时候,Preprocess 相对比较慢的。Preprocess 的过程,如果我们更细化地说,做了几件事情。

第一个是做语法分析,我不知道各位有没有写过语法编译器,比如说我们解析语法数,生成词法解析器和语法解析器。我们先去做了一次语法解析和司法解析,当然在这个过程中就会去检查大家的 shader 写得有没有问题,如果有报错,就在这个阶段产生了。

当我们解析完了之后,会把每一种不同的语言,从 shader 的文本里面切割出来,比如说我们刚才说的 CG program,后面还会提到 HLSL program,会把它对应的部分切割出来。切割出来之后,再用对应的语言的 Preprocess compile 去做一遍对应这个语言的解析检查。通过这几次检查之后,最终我们会得到完整的 compilation info,再把它写到 ShaderCache 里面。

如果大家在去做一个 Shader 的时候,发现我写完这个 Shader 好像不太对,或者有点问题,你感觉没有进行重新编译,最简单的方法就是把 ShaderCache 删掉,强行导入一次,重新编译一次,有时候问题就迎刃而解了。

我们把它编出来,放到 ShaderCache 里之后,这个时候只是 Unity editor 拿到了 Compile 这个东西,但并不能用于渲染,也不能打到最终的包里。它只是 Unity 所使用的中间状态,如果是编译的话,就类似于IR的东西。

我们如何把它最终编译成可运行的版本呢?

我可以从 shader Compilation info,或者是 ShaderCache 里面取。这取决于你有没有,如果有的话,就会从 shadercache 里取;如果没有的话,就会走一遍 Prepocess 的过程,再重新产生 shader Compilation info。

拿到之后,我们会把这个东西再送到 shader Compiler 里面,再做一些其他的事情。这个 shader Compiler 里面包含了很多不同的服务,刚才是 Preprocess,这次我们要做的就是Binary Compile。这个事情会在以下几种情况下发生。

第一,我们现在启动了 Unity。我们把资源都导入了,点击 play。点的时候,Unity 会做一件叫 Unity Editor warmup all shader 的事情(当然在第一次导入的时候,Unity 也会做)。这就是为什么 2020 年之前的版本,大家在点开始的时候,会经常感觉到卡半天。实际上,“卡”的过程会把你内存里面,或者是资源里面所有 shader 的变体都 warmup。但是真机上不会卡。

大家在去做一些性能检查,包括去研究原理的时候你会惊讶地发现,Unity 实际上是两个版本,运行时和编辑期是两套完全不同的东西。所以我们在做性能分析,或者是内存、CPU、GPU 分析的时候,不要在编辑器里面做。编辑器的设计目的是为了帮助大家以最流畅的速度去编辑,所以有很多的东西,不会去考虑运行时资源环境的占用,比如说 CPU 或者是内存的占用。Unity 会默认认为你的电脑非常棒,内存不会爆,CPU 不会卡,所以它可以尽情地挥霍这些资源,尽量保证大家整体的编辑体验是好的。但是在运行时,Unity 会考虑实际的运行环境。比如说我们手机和 PC 上的策略会有一些差异。

在这个地方我们进行 Binary Compile,这是第一个。

第二种情况,是真正开始打包了,比如说我要给安卓打一个 AssetBundle,或者发一个安卓的 APK,这时候也会触发这个过程。总之触发这个过程的必要前提是我的目标平台是明确的,我知道要把中间的东西最终要翻译成什么。BinaryCompile 的过程其实是一个非常神奇的过程,Unity 实际上也不是直接把大家写的,比如说 CG 就直接翻译到目标平台上,这个工作量其实是很大的。

我们的目标平台就非常多了,比如说大家常见的手机平台上有很多的 API,加上我们的主机平台,他们都有自己整套的语言规范。

大家可以脑补一下如果我们要是生翻会怎样。这是一个乘的关系,左边 4 个,假如说右边是 10 个不同的平台,那就是 40 个,要写 40 套不同的代码,代码的路径就非常的缭乱。其一代码维护难度很大,其二是也很难写。

Unity 使用了第三方技术,叫 HLSLCC,CC 的意思叫交叉编译器,大家可以搜到。这个东西 Unity 做了一些优化和改变,和 Unity 使用的版本不是完全一样的(大家不要把网上的内容改一下直接替进来,这样行不通)。

Unity 实际上做了这样的工作:Unity 会先把前端的一些语言,尽量地翻译到 DX 那个级别上去,通过 DX 的编辑器上编一编,编完了之后,后端再走到 HLSLCC,再向目标平台去输出,相当于是一个两步编译的过程。所以整体的难度降低了很多,大部分的工作是 HLSLCC 来做的。

这个编译过程也会导致一个问题,比如说 DX 里面没有,你翻译不过去,中间要经过一步,这个过路费你要交,但是过路的时候没有这个东西。因此 Unity 在 2020 以后的版本,最早的时候是用的 DXBC,现在是 DXRL,Unity 也是基础于 DX 的编译器进行了自己的扩展,尽快地去支持新特性。

这是在编译的过程中会发生的一些事情。

欢迎点击“阅读原文”跳转社区专栏学习完整版,全面掌握“ShaderLab”的底层逻辑:

https://unity.cn/projects/openday-shaderlab

Unity 官方微信

第一时间了解Unity引擎动向,学习最新开发技巧