jayhoho 发表于 2023-3-23 12:39

如何高效渲染网格、材质与纹理

渲染系统对象

在计算机中,游戏世界是通过顶点及其相关信息来表示的。在空间中存在着很多顶点,顶点连成一个个三角形,三角形又形成了一个个面,这些面经过一个投影矩阵投影到屏幕上。
然后通过一个称为光栅化(rasterization)的过程,将三角形光栅化成一个个像素点。然后在每个小像素点上,我们去寻找这个像素点对应的材质和纹理,将这个像素点渲染成各种各样的颜色。
同时考虑光照、以及物体本身的花纹等信息,并渲染出最终的效果。简单来说,在这个过程中,会涉及到上百万数量级的顶点和三角形、数千万级的像素、以及10亿级的ALU和纹理运算。这就是渲染所涉及的最基础的操作。因此,绘制的最核心工作就是计算(Computation)。


实际上,绘制系统相对于游戏引擎来说,就是做这种躲在像素之后的工作。某年的SIGGRAPH上,在一个介绍游戏引擎绘制的Section中,曾经提到过一句话,叫做“the man and women behind pixel”,就是躲在像素之后的男人和女人们。因为大家看到的每一帧壮丽的游戏画面,都是现代计算机一个一个像素计算并拼接起来的。
在这个过程中,首先需要进行的计算就是投影和光栅化。我们设定一个相机位置,然后对物体进行投影(无论是正交投影还是透视投影),就可以得到屏幕空间中的三角形。在这之后,再将屏幕空间中的三角形光栅化成一个个的像素。


在投影和光栅化之后,需要进行的计算过程就是着色,也叫做绘制。下图是一段示例的着色器代码:


大家会发现,上图示例中的着色代码所涉及的运算也就只有几种。首先,着色器代码需要通过常量获取很多数值,比如屏幕的长宽(以像素为单位)。这些常数在每个像素的着色代码中都要进行访问。然后,着色器代码会进行大量的加减乘除运算。
比如我们需要计算一个Phong模型,我们需要知道法线位置、光源位置、人眼位置。通过这些信息,就可以计算出光线衰减的百分比。如果图中的小球上有很多花纹,我们就需要将花纹的纹理存储到一张2D贴图上,着色器代码再将自己所处理的像素点所对应的纹理贴图上的坐标的相应位置上的颜色值取出。通过这几种运算,即常数访问、变量访问,再加上纹理访问,我们就可以得到想要的结果。
需要注意的是,在绘制过程中,有一个大家经常会忽略的过程,它的性能消耗非常大,也十分复杂,这就是纹理采样(Texturing)。


举个例子,我们需要绘制一面砖墙。当砖墙离相机很近的时候,你会看到一个一个的像素。如果这个砖墙离相机非常远,这时候你在屏幕上看到的每一个砖墙的像素,在砖墙的纹理上是隔了很多像素的。如果我们不对纹理进行滤波(这里的情况是低频滤波)操作,当砖墙相对于相机由近及远移动时,画面就会发生抖动。这就是走样。大家在玩游戏时,会发现有一个图形设置选项,叫做反走样。
在实践中,对于每一张纹理贴图,我们会存很多层,当着色器为屏幕上的一个像素点进行纹理采样时,采样位置可能并不一定正好位于该像素点上,因此需要取四个点,并对这四个点进行插值。同时还需要在两层纹理上按照比例采样。
同学们可以思考一下,如果进行一次纹理采样,需要访问多少数据,需要进行多少次插值?
答案是:进行一次纹理采样,需要采样八个像素点的数据,并且进行七次插值运算。因此,纹理采样是绘制过程中的一个很重要的运算。
了解GPU

在实践中,游戏绘制系统是无法运行在我们假设的理想设备上的。因为这并不是一个理论证明题,即我们在理论上证明它是正确的就可以了。恰恰相反,游戏绘制系统的实现是一个实践性问题,它需要运行在各种性能的现代硬件上。
下面我们介绍GPU(Graphic Processing Unit),即日常所说的显卡。


显卡是一个非常伟大的发明创造。我们在第一节课中介绍过,现代游戏引擎之所以能够有突飞猛进的变化,就是因为随着独立显卡的出现,我们可以将这些复杂的运算用一台更高效的机器进行处理,这样可以释放出大量的CPU时间。
同时,我们可以将画面越做越精细。因此,如果同学们想成为一个游戏引擎图形程序员,那么显卡就是你最好的伙伴。此外,现代游戏引擎中的绘制系统的很多基础设计,也是人们基于对现代显卡架构的理解而构建的。
下面介绍的内容会帮助同学们建立对显卡基础架构的理解。让大家知道在架构一个渲染系统时,需要关注哪些方面。
首先,大家需要了解两个概念:
SIMD(Single Instruction Multiple Data)
SIMT(Single Instruction Multiple Threads)


大家可能对SIMD略有耳闻,因为SIMD已经被广泛用在现代CPU中,即单指令多数据的数学运算。对于一个四维向量来说,每进行一次加法操作,它的XYZW坐标会同时进行运算。所以一条指令就能够完成四个加法或者四个减法运算。同学们在阅读C++代码的过程中,如果看到SSE扩展宏,下面的代码实际上就是在调用SIMD指令。渲染过程中有很多运算都适用于SIMD运算,比如矩阵运算、坐标变换运算等。现代显卡中还有另外一个更加有趣的概念,叫做SIMT。即将一个计算核心做得很小,这样可以同时提供多个计算核心,并且可以同时在多个核心上执行同一条指令。
如果我们有100个计算核心,向这100个核心发送一条指令,就可以同时进行100次四维向量的加减。相当于将一条指令的计算效能放大了400倍。现代显卡如同一个蜂巢,其中内置了很多小型计算核心。NVIDIA的显卡中就内置了很多称为CUDA的计算核心。这就是现代显卡算力强悍的原因。
这里给大家介绍一个名词:FLOPS(floating-point operations per second)。FLOPS代表着显卡的浮点运算能力,即每秒浮点运算次数。现代显卡一般能够达到十个以上的TFLOPS,比如Xbox或者PS5。
然而,现代CPU的算力很难达到一个TFLOPS,显卡和CPU的算力差距已经超过了一个数量级。为什么显卡的算力能够遥遥领先于CPU呢?本质上是因为显卡中具有大量可以同时进行并行计算的小型计算核心,每个核心的功能简单,只可以进行简单的计算。而CPU的核心数量很少,但单个核心的计算能力很强。
因此显卡的并行计算能力十分强大。因此,我们在设计绘制算法的时候,要尽可能地利用SIMT结构的优势,尽可能使用相同的代码进行并行计算。这样一来,每个计算核心都可以分别访问自己的数据,这样可以充分发挥显卡架构的优势。这是图形程序员需要掌握的一个最重要的概念。


上图是某款现代GPU的架构图,虽然复杂,如果大家仔细观察,就会发现,图中的结构是重复的。图中展示的是大约十年前由NVIDIA发布的费米架构。在费米架构中,内置了很多核心,并且被分成了很多组。
在每个图形处理集群中,有很多流式多处理器,而在每个流式多处理器中,都装有很多的小型核心。计算机术语中称为ALU(Arithmetic Logic Units,算术逻辑单元)。在NVIDIA的显卡中称为CUDA,CUDA核心负责进行数学运算。如果向流式多处理器发送一条指令,这些CUDA核心就可以同时执行同一条指令。
同时,会有专门的硬件处理各种耗时的纹理采样工作,以及一些比较复杂的数学运算。比如正弦、余弦、指数、对数等超越函数运算。因为超越函数的运算速度比较慢,所以显卡中有一些专门的SFU(Special Function Unit,特殊功能单元)负责处理这些运算。最新的Ampere架构中,还有一个Tensor Core,这就是用于人工智能处理核心。
同时还有一个RT Core,这是用来加速光线追踪BVH算法的硬件逻辑电路。这就是现代GPU的架构。也就是说,在GPU上的运算都会被分配到每个流式多处理器上进行处理。而流式多处理器中的几十个核心不仅可以进行并行处理,相互之间还可以交换数据,从而进行协作。因此费米架构中的流式多处理器相对于之前的架构增加了共享内存(Shared Memory)。如果同学们有过并行化编程的经验,就会知道,如果CPU之间还可以交换数据,那么就可以实现一些非常酷炫的运算。以上大致说明了SIMT的概念。


下面介绍一下数据在计算机中流动的成本。从计算机诞生时,我们一直使用的都是冯洛伊曼架构,即将计算和数据分开。这样的架构会让硬件设计变得非常简单。现在也有一些很前沿的研究在研究如何突破这个架构。冯洛伊曼架构的最大问题是,每一次计算,都需要去获取数据。后来人们发现,获取数据的操作速度非常慢,而且数据在不同的计算单元中搬来搬去也是非常之慢。以我们本节课程开始时提到的南桥和北桥芯片来说,北桥芯片连接着CPU和显卡,CPU用到的数据放在主内存中。
如果想将CPU准备好的数据上传到显卡的显存中的话,这个上传速度是非常慢的。还有一个问题,如果CPU已经准备好数据交给显卡去计算,但需要等显卡计算完毕之后,再将结果从显卡回读到CPU,CPU再基于显卡的计算结果进行一些判断,然后再告诉GPU如何进行绘制。这称之为数据的“Back Force”。
这一过程存在一个非常严重的问题,在现在引擎架构中,绘制和逻辑通常是不同步的。如果有一步绘制运算需要等待数据的“Back Force”,则可能会导致半帧到一帧的延迟(Latency)。游戏中的逻辑和画面不同步的问题,有时就是因为这个原因。因此,在游戏引擎的绘制系统架构中有一个原则,就是尽可能将数据单向传输。即CPU将数据单向发送到显卡,并且尽可能不要从显卡中回读数据。这也是现代计算机结构对渲染系统设计的一个限制。
下面介绍一下缓存(Cache)的概念。


缓存对于现代计算的性能影响是非常大的,可能远远超过大家的想象。举例来说,在现代CPU上,如果进行一次数学运算,可能一个时钟周期就做完了。但如果这个时候,比如CPU正在进行A+B的运算,而A的数值CPU需要从内存中获取。这时,如果CPU发现A不在自己的缓存中,而需要去内存中读取A的值,实际上它需要等待100多个时钟周期才能从内存中得到A的值。在等待的这段时间里,CPU理论上可以进行几十次到上百次数学运算。这也是上节课中提到过的数据一定要放在一起的原因,其实就是为了缓存去做这样的准备。因为数据连贯性对于缓存来说非常重要。如果有些数据过大,那么也会导致缓存很难被利用好。
在每次计算时,如果所需要的数据刚好都位于缓存中,则叫做缓存命中。如果所需要的数据不在缓存中,则叫做缓存未命中。这时,CPU就需要等待很多时钟周期,才能获取到数据。
所以大家在从事计算机图形学和游戏引擎的相关开发时,如果纹理采样等计算没有设计好,使得计算过程中经常发生缓存未命中事件,计算效率就会直线下降。
如果大家以后有机会进入到图形程序员相关的岗位,将会学会很多行话。比如“ALU Bounds”,这个说法是指程序的数学计算太多,而其他的操作(比如纹理采样等)都能够及时完成,但需要等待数学运算的结果。
还有填充率限制(Fill Rate Bounds),这个说法是指所有的数学运算都完成了,但是写入缓存的速度太慢,结果导致数据传输发生了堵塞。大家会发现,现代计算机就是一个流水线,只要有一个环节没有平衡好,就会发生卡顿,而其他环节优化得再快,整个流水线也会被卡住。所以“Bounds”这个词是一个最常用的行话。以后大家进入到游戏开发行业,会熟悉更多的行话和黑话。
现在,大家应该可以理解,对于现代的游戏,特别是非常复杂的顶级游戏来说,为什么开发人员在进行渲染系统的设计时,需要特别关注对GPU的使用和利用。
硬件的架构也一直在不断演进。大约十年前,从DirectX 11时代开始,GPU就可以完成更高级的曲面细分,以及实现更加灵活的Shader,包括更通用的计算Shader等功能。实时今日,GPU也可以支持更加容易处理的Mesh Shader。而主机的架构又有所不同,因为主机使用的是一种叫做UMA的共享内存架构。因此,主机上使用的游戏引擎的架构又会不同于PC上所使用的架构。还有一个平台就是手机游戏平台。
手机游戏运行在移动端,移动端最关注的指标是功耗,因为移动端芯片的处理能力有限,而数据访问对于移动端来说是一个相当昂贵的操作,因此人们开发出了“Tile-Based Rendering”(分块渲染)技术。大家所看到的手机上呈现的游戏画面(比如1080P或者4K分辨率的画面),其实是分块渲染出来的。
换言之,所有的引擎架构都是和硬件架构息息相关的。所以同学们在学习渲染之前,需要先了解显卡的工作原理。一旦理解了显卡的工作原理,就会对各种渲染引擎的算法产生更加深入的理解。这部分同学们听不懂没有关系,建立一个基础概念即可。
如果大家后续不会从事引擎开发,但是会从事游戏开发的相关工作,比如游戏美术等。我个人认为,掌握这些概念还是有用的。
因为当你去设计你的游戏玩法的时候,你会知道,硬件上会有哪些限制,所以我们不能将场景做得无限复杂,而只能更专注于游戏的玩法体验。
可渲染物体

在上一节课中,我们介绍过,游戏世界中的大部分物体都叫做“Game Object”(游戏对象)。所有这些游戏对象就构建出了这个游戏世界。但我们对游戏中的每个物体有很多描述,比如说它是一辆车,它是一架飞机,有的物体还有血量,物体可以具有各种各样的行为。这里所描述的所有信息,只是逻辑上的描述。这些逻辑描述是无法进行绘制的。
因此,大家需要区分一个概念,即一个逻辑上所表达的游戏对象,和游戏中可以绘制的物体是不同的。在上节课中,我们介绍组件时,曾经还提到过一个组件,叫做“Mesh Component”。这个名词在不同的引擎中有很多的变化,有的引擎叫做“Mesh Component”,有的引擎叫做”Skinned Mesh Component”。而对于“Skinned Mesh Component”来说,引擎会假设这个网格是有骨骼的,可以进行变形。
比如我们制作了一个角色,这个角色就可以走来走去。这些概念的底层理念是大同小异的。我们会在这个组件中存放一个叫做“Renderable”的成员,即可绘制的对象。我们获取到这个Renderable对象,就可以将其绘制出来。这就是绘制系统的核心数据对象。
下面介绍如何生成一个Renderable对象。假设我们想制作一款叫做《超越2042》的现代战争游戏。由艺术家制作了一个士兵角色。我们会发现,这个角色会具有很多网格。这就是这个角色的几何形体,比如角色的头盔、枪支。每个网格上又有各种各样的材质,比如布料、金属、皮肤等等。这些材质上还有很多的花纹,所以会呈现出各种纹理。还有法线(Normal)等属性,这些属性更加细节,无法使用网格来表达。这些就是可以绘制的属性,这就是Renderable对象最简单的构建块(Building Block)。Renderable对象在现代游戏引擎中比我们描述的更加复杂,我们描述的只是最基础的概念。
首先,我们介绍网格在游戏引擎中如何表示。当然,如果大家不从事引擎相关的开发工作,则无需关注这些数据。想象一下,我们使用导入器从3DS Max中导入一个模型,我们就可以在Unity或者Unreal引擎中看到这个模型。而对于底层如何实现模型的显示,我们却无法得知。首先,我们定义一个网格图元(Mesh Primitive)。我们在模型文件中保存了很多的顶点,每个顶点上有很多数据,比如顶点位置、顶点处的法线朝向、顶点的UV坐标,以及其他各种各样的属性。每三个顶点就可以组成一个三角形,我们将这些三角形组合在一起,就形成了模型的外观。当然,这种数据存储方式是一种很笨拙的方式。


如果大家有过基础的OpenGL和DirectX开发经验,就会知道,我们可以使用索引数据(Index Data)和顶点数据(Vertex Data)来定义三角形的信息。即将所有的顶点放在一个数组中,三角形不会再将顶点数据存储一遍,而只存储了三个顶点的索引位置信息。如果我们打开一个真实的模型文件,就会发现,文件中的很多顶点是被很多个三角形所共用的。在大部分模型文件中,顶点的数量只有三角形数量的一半,而一个三角形又有三个顶点。因此,如果使用上述的索引方法,理论上的存储量可以节约六倍以上。因为顶点数据需要存储的信息很多,比如顶点的空间位置(三个浮点数)、顶点处的法线信息、以及UV等数据。这是在一般实战中所使用的方法。
这里再介绍一些简单的概念。如果将顶点按照一定的顺序存放,就可以不需要索引数据。比如三角形带(Triangle Strip),三角形带类似于一笔画问题。假设有一个复杂的网络,需要一笔将网络的所有边全部勾勒出来。在勾勒过程中,画笔经过的所有顶点按照访问顺序形成了一个数组,数组中的每三个连续顶点都能够形成一个三角形,并且和模型三角形带所表示的形状相符。这样就不需要单独存储这个三角形带的索引信息,并且也能够表达一个网格。这种表达方式还有一个附加的好处,如果我们在绘制三角形时,对每个顶点数据都按照三角形带所形成的顶点顺序进行访问,这种访问方式对于缓存是十分友好的。在早期的游戏引擎中,开发人员会尽可能地想办法将一些模型变为一系列的三角形带。随着计算机硬件的发展,现在已经不大使用这种方式。
下面解释一下每个顶点都要存储一个法向量的原因。一般来说,我们每个计算出每个三角形的朝向,然后使用邻近的几个三角形的法向量进行平均,就可以计算出来顶点的法向量的朝向。这在大部分情况下都是对的。但如果表面是一个硬表面(比如立方体),即存在一条折线的时候,就会出现位于不同表面的两个顶点的位置重合的情况。这两个顶点的法向完全不一样。因此大家在写游戏引擎的绘制系统的时候,在定义你的顶点数据时,一定要为每个顶点单独定义它的法向方向。
另外一个数据就是材质。我们已经实现了物体的形状,下面的问题就是物体看起来是像石头呢?还是像金属呢?还是像布呢?还是像漂亮的塑料呢?材质系统来自于我们真实的生活,你会发现早期的材质系统表达非常接近于我们在物理世界中对物体的感知。从最早的Phong模型开始,大家就会说塑料的反射应该是什么样子的,金属的反射应该是什么样子,非金属应该是什么样子的。这里需要提醒大家的是,在后面我们讲到物理的时候,其实还有另外一种材质,叫做物理材质(Physics Material)。它和我们现在所说的材质很容易混淆。我们在绘制系统中定义的材质表达的是物体的视觉属性。而物理材质更多表达的是物体的物理属性,比如摩擦系数、反弹系数等。因此我们会单独定义物理材质。


在计算机图形学领域,材质系统也经过了多年的发展和演变。从最经典的Phong模型,到我们下一节课中将要介绍的基于物理的材质,还有一些实现特殊效果的材质,比如半透明的次表面散射材质等。多年以来,人们已经积累了一大批非常优秀的材质模型。


有了这些材质模型之后,接下来我们需要的就是纹理。在表达一种材质的时候,纹理扮演了非常重要的角色。在计算机图形学中,以及游戏引擎中,人眼对于材质类型的感知(即看起来像金属还是像生锈的非金属表面)并不是由材质的参数决定的,很多时候是由它的纹理所决定的。如下图所示:


上图中大家看到这个生锈的铁球,对于光滑的金属表面和生锈的非金属表面的视觉表现的区分,实际上是通过粗糙度(Roughness)这类的纹理来区分的。所以纹理也是材质非常重要的一种表达方式。
最后,有了材质的表达,有了纹理,有了网格,我们还是无法绘制出我们想要的效果。因为我们需要着色器(Shader)对具体的效果进行计算。着色器在游戏引擎中是一个特殊的存在。游戏引擎一般会严格区分数据和代码。比如我们制作的各种资产(Asset),或者我们制作的各种各样的模型。而源代码(Source Code)则是程序员编写的代码。一般来说,艺术家、设计师主要处理数据,程序员则处理源码。Shader的神奇之处在于,Shader是一段代码,因为我们需要编写大量的代码来表达材质,但是在游戏引擎中又会被当成数据来处理。绘制一个物体的流程大致如下:首先告诉显卡需要绘制的具体物体,然后传入物体的纹理,这时还需要传入一小段代码,我们一般称为一个Block(一个二进制的数据块),这就是我们编译好的一段Shader代码。显卡会使用这段Shader代码,将这些元素融合到一起,进行一些计算,绘制出我们想要的效果。


大家可能听说过一个概念,叫做Shader Graph。当艺术家想表达各种各样的材质时,会像搭积木一样,将各种元素按照自己的方法进行组合。组合完之后,引擎就会生成一段Shader代码,而这段Shader代码又会被编译成一个Block,和网格存储在一起。各种各样的网格和Shader代码组合在一起,就形成了多彩的游戏世界。因此,着色器代码也是一种关键的可渲染数据。
有了上述数据,我们就可以在引擎中绘制物体了。大家可以回顾一下GAMES101课程,课程中详细介绍了绘制一个物体的具体流程。
接下来,按照我们前面讲述的内容,先将顶点缓冲区数据和索引缓冲区数据提交给显卡,然后将材质参数提交给显卡,再将纹理提交给显卡,再提交一小段Shader代码,让显卡对每一个顶点和每个像素执行着色工作。显卡运行完毕,我们就将这个物体真正地绘制出来了。这时,我们就完成了从一个抽象的逻辑游戏对象到一个可绘制物体的转变。然而,这样绘制所得到的结果是不对的。一般来说,一个物体会拥有不止一种材质。如果按照上面的处理流程,我们将只能对一个物体应用一种材质。因此,我们要对一个物体所拥有的各种不同的材质进行不同的处理。


这里我们引入一个非常重要的概念,叫做子网格(Submesh)。在现代游戏引擎中,对于每个游戏对象上的网格,我们会根据所应用材质的不同,把其切分成很多子网格。然后对于每个子网格,分别应用各自的的材质、纹理和着色器代码。一般情况下,我们会将网格的顶点和三角形全部存放在一个大的缓冲区中,所以对于每个子网格,只需要存储偏移值(Offset)。换言之,只需要存储索引缓冲区中的起始位置和结束位置的偏移值即可,因为每个子网格只使用了大缓冲区中的一小段数据。这样就可以对每个子网格,亦即缓冲区中某个起始位置到结束为止所形成的所有三角形,单独应用材质、着色器和纹理进行绘制。子网格是现代游戏引擎中经常用到的一个概念,如果大家打开虚幻引擎,或者其他引擎,都会看到类似的结构。有些引擎中可能不将其称之为子网格,但基本原理是一样的。
现在,根据我们前面掌握的知识,我们再来设计游戏引擎。我们会发现,当我们绘制很多物体时,如果为每个游戏对象都存储一套网格、子网格、材质、着色器和纹理数据,这个数据量会非常之大。如果大家仔细观察,就会发现这些数据中的很多网格、贴图和着色器都是一样的。所以为了节约空间,在现代游戏引擎中,通用的做法是建立一个池(Pool)。将所有的网格放到一起,形成一个网格池;将所有的纹理放在一起,也形成一个纹理池。因为有些纹理也会被其他对象使用。尤其是着色器,特别是当大家都使用了PBR材质时,着色器都是相同的。假设有1000个这样的对象,如果使用了着色器池,则只需要存储一份着色器代码。这样的话,当绘制一个场景时中的各种角色、以及各种小兵时,我们会发现这些角色和小兵只是通过一个引用指向了各自所需要数据,比如网格、材质等。这是一个非常经典的游戏引擎的架构。


大家在架构自己的游戏引擎时,需要记住两点。首先,通过子网格将每个对象或者物体按照材质进行切分。切分完成之后,将相同的材质全部归类到一起,相同的纹理也归类到一起,用一个池进行管理。然后,将相同的网格也放到一起,也使用一个池进行管理。当绘制物体时,到相应的池中寻找对应的数据即可。这种做法占用的存储空间最小。


这时就引入了游戏引擎架构中一个很经典的概念,叫做Instancing(实例化)。我们刚刚介绍的那些数据,都是实例的定义,即我们定义了一个小兵,它的Renderable成员应该是什么。然而,当我们在屏幕上绘制了几千个小兵的时候,每一个小兵只是这个数据定义的一个实例。这个概念在游戏引擎的设计中是贯彻始终的,不仅在绘制部分,在游戏逻辑、游戏的场景物体的管理等模块,都有“Object Definition,Object Instance”这个概念。所以同学们一定要将“Instance”这个词牢记于心,当你进行引擎开发时,一定要区分清楚,哪些数据是定义,哪些数据是实例。一般来说,在创建了实例之后,还可以再为每个实例增加一点变化。


现在,大家基本上已经知道如何构建一个可以绘制的、有很多物体的游戏世界。我们继续讲解GPU的一些特点。GPU有个特点,就是改变参数特别影响GPU的高速运行,比如改变贴图、着色器代码等。对于前面介绍的流式多处理器来说,每次改变参数,所有32个小核都会停下来,等待参数修改完成,然后再继续运转。


现在我们考虑对上述过程的优化。我们会发现,对于一个游戏场景来说,有很多的物体使用的都是同一个材质,具有相同的参数,相同的纹理。于是,我们可以将整个场景的物体按照材质进行排序,将具有相同材质的网格分组到一起。然后设置一次材质,绘制这一组拥有相同材质的子网格。直观上看,这样做的计算量和每次单独渲染每个子网格是一样的。然而,这种做法的运行速度确实会变快。对于现代的底层绘制API来说,比如DirectX 12和Vulkan,会将对GPU的状态设置专门抽象成一个“Render State Object”。具体的API会有所不同,但是基础逻辑都是类似的。即预先设置好显卡的状态,尽量不要变动,然后进行一大堆运算。所以在绘制时,可以用材质进行排序,将同样子网格归集在一起。
除此之外,我们还会发现,现代游戏场景中的很多物体其实是一模一样的。如果我们依次绘制这些物体,并依次设置顶点缓冲和索引缓冲,也是很浪费的。如果我们使用的是现代的绘制API,可以在一个Drawcall中设置一次顶点缓冲和索引缓冲、以及所绘制的一堆位移数据。
即将一列数据送入显卡之后,通过一次绘制调用(Drawcall),就可以将成百上千个物体全部创建出来。这就是“GPU Based Batch Rendering”的思想。
我们不会深入介绍这种做法,但同学们要建立这样的概念,即在现代的游戏引擎架构中,我们会尽可能的将绘制工作交给GPU来执行,而不是使用CPU来执行。这种做法对于绘制大量相同的物体特别有用,比如绘制大量的树木、草丛等,这些物体看起来都差不多。如果需要一次性绘制几百米开外,甚至上千米这类物体的话,这种做法非常有用。
大家理解了如何将一个对象按材质切分成子网格之后,就可以进行很多有意思的优化,但最基础的概念还是网格、子网格。
可见性剪裁

有了上述概念之后,我们就可以开始绘制一个小型的游戏场景了。然而,这样的绘制并不高效。因为我们会绘制很多在视锥体中看不到的物体。视锥体实际上是锥形的,只有位于视锥体中的物体才会被显示到屏幕上。比如在我们前面提到的《超越2042》的现代战争游戏中,当相机开始移动时,我们会发现,整个场景中的大部分内容都无法出现在视野中。这些物体、对象、粒子效果、地形等,我们都不需要进行绘制。因此,可见性裁剪(Visibility Culling)是游戏绘制系统的一个最基础的底层系统。


在上一节课中,我们讲过,每个物体都有一个包围盒。对于包围盒来说,当我们给定一个四棱锥形的视锥体时,我们可以通过一些简单的数学运算,判断物体的包围盒是否位于视锥中。这就是可见性裁剪的基础思想。
下面我们简单介绍一下包围盒。在游戏引擎架构中,包围盒是一个非常重要的概念,不仅被用于绘制系统,还被用于AI、逻辑、物理等模块中。包围盒有很多种。最简单的包围盒是球形的,即使用一个最紧密的球体将物体包围,这种包围盒称为包围球(Bounding Sphere)。还有一种更常用的包围盒,叫做轴对齐包围盒(AABB,Axis-Aligned-Bounding-Box),即和我们在游戏世界中定义的XYZ坐标轴平行的包围盒。因此,我们只需要存储两个顶点,就可以将轴对齐包围盒构建出来。而且,除了包围球之外,轴对齐包围盒的计算效率也是最高的。如果让包围盒的XYZ边和所包围物体的局部坐标系的XYZ边平行,那么这种包围盒就叫做定向包围盒(OBB,Oriented-Bounding-Box)。还有一种包围盒,叫做凸包(Convex Hull)。在很多物理运算中,凸包是一个特别常用的概念。


无论是可见性裁剪,还是游戏引擎中的其他运算,对于很多物体来说,我们都可以使用一个近似的包围盒来表示这个物体,以代表该物体大致所占用的空间。因为一个物体的形状非常复杂,比如对于一个角色来说,角色可能有几万面,我们不可能对这几万个面一一进行计算。当我们判断子弹是否击中了角色,或者在视锥体中是否能够看到这个角色时,我们都可以使用包围盒来判断。因此,包围盒是很多计算的基础。
有了包围盒之后,我们依次进行相交计算,就可以进行裁剪。显然,这样做的效率并不高,因为存在着很多无效判定。回顾一下,在上节课中,我们介绍了空间划分技术。比如经典的四叉树划分,还有我们提到过的层次包围盒(BVH,Bounding Volume Hierarchy)。BVH就是将包围盒一层一层地沿着树形结构向上合并,这样做的好处是,当进行裁剪运算时,可以从上到下一层层进行计算和查询。具体来说,当有多个物体的包围盒被合并到一个节点中时,如果这个最大的包围盒(即合并了子节点上所有物体的包围盒)和视锥体不相交,就代表在相机视野中无法看到这些物体,因此也就无需绘制这些物体。反之,如果能够看到这个包围盒,就可以进行更精细的划分,即在BVH中沿该节点依次向下迭代计算和查询,直到叶节点。这时,我们就可以具体得知,哪些物体可见,哪些物体不可见。显然,这种结构的计算复杂度要比依次计算的复杂度低得多。


现代游戏引擎中经常用到BVH,因为这个算法非常简单。然而,BVH并不是最高效的算法,但由于BVH在构建树形结构时速度上的优势,BVH仍然被广泛使用。在现代游戏中,场景中运动的物体很多,比如我们之前提到的实时战略游戏,场景中会有很多小兵跑来跑去。这时我们需要解决一个很复杂的问题。即在BVH的树形结构构建好之后,当BVH中的节点发生变化时,重建BVH的成本要尽可能的低。对于别的算法来说,即便每次进行裁剪所花费的时间更少,但每次构建或者重建BVH都需要花费很长的时间。而BVH在构建树形结构的时间上具有很大的优势,这也是现代游戏中广泛使用BVH的原因,特别是针对具有大量动态元素的场景时。


有很多算法都可以进行可见性裁剪。这里我们介绍一个非常有意思的算法,也代表了一种思路。这就是潜在可见集(PVS,Potential Visibility Set),这个思想是游戏引擎行业之父John Carmark发明的。当年,John Carmark在制作早期的FPS游戏时,当时的硬件性能很低。他发现,当玩家处于一个房间中时,游戏引擎仍然会绘制很多看不到的场景,于是就设计了一种方法针对这种情况进行优化。方法很简单,先使用BSP树,将空间划分成一个个的格子,每个格子之间通过一个入口(Portal)连接。


想象一下,在建筑物中,房间都是通过门和窗连接在一起的,当玩家处于一个房间中时,通过每个入口所看到的其他房间是不一样的。PVS的想法非常淳朴,即计算在每个房间中,通过该房间的门窗所能看到的其他房间,并且只渲染所能看到的房间。这个想法非常简单、直接,并且符合人类的直觉,而且执行效率非常高。虽然PVS的原理非常简单,在实践中,对于PVS的计算、包括对空间划分(Partition)的算法,仍然相当复杂。大家可以自己实现一下PVS的算法,这有助于锻炼自己的数学和编程能力。



在现代游戏中,真正使用PVS算法进行裁剪的游戏已经越来越少。但是PVS算法的思想非常有用。举个例子,对于很多主机上的3A大作来说,虽然玩家感觉自己处于开放世界中,但底层的区域划分仍然是线性的,玩家仍然行走在设计师预先设计好的分块(Chunks)中。假设将玩家能够经过的世界划分成一个个区域(Zone),这里的区域就类似于PVS算法中的小房间,每个区域之间会设计一个峡谷、关口、或者门进行区分,每个区域中能够看到的其他区域也是不同的。


PVS算法除了可以用来进行可见性裁剪之外,还可以用于资源加载。大家在体验闯关游戏,当通关BOSS时,就会通过一道门来到后续场景。这时大家会发现,有的引擎可能会在这时花费一定时间进行加载。这是因为玩家更换了一个区域,在新的区域中,需要加载新的可见区域。虽然PVS算法在用于可见性裁剪方面已经不如之前流行,但是这个思想非常有用。PVS算法可以帮助我们进行各种资源的调度,希望大家能够掌握这种思路。
上面介绍的这两个算法都是非常经典的算法。然而,游戏引擎的渲染系统是一个高度实践性的工程,随着现代硬件性能突飞猛进的变化,越来越多裁剪都已经不再使用上述方法来完成。GPU自身就可以完成这项工作。比如GPU提供的遮挡查询(Occlusion Query)功能。即将很多物体的数据传入显卡,显卡会反馈回一个比特位数组,每个比特位依次记录了各个物体的可见性。显卡的并行计算能力十分强大,所以计算起来非常迅速。包括视锥体裁剪,我们也可以直接将包围盒数据传递给显卡,由显卡来完成计算。这样计算的速度也不会太慢。当然,我们也可以在显卡上构建一个层次化的数据结构。对于现代计算机来说,这样做的难度并不大。
我们花费了一定的篇幅来介绍基于GPU的裁剪,我们的目的是希望大家记住,如果大家真的走上了工作岗位,真正开始从事游戏开发的时候,千万不要用老的算法去限制你的想法,一定要拥抱硬件的最新变化。换言之,只要能够使用硬件功能完成的工作,一定使用硬件来完成。
下面我们介绍另外一个概念,Hi-z,也叫Early-z。即在逐个绘制像素时,有的像素会被别的像素遮挡,这时,就不必绘制这个像素。最简单方法是先将场景绘制一遍,但不对像素进行着色,而只计算每个像素的深度。如下图中的黑白色的图:


图中,白色位置距离相机较近,黑色位置距离相机较远。需要注意的是,GPU中会将深度数据反转存储。这时,如果在绘制一个像素时,发现该像素位于我们之前计算的像素的后方,就可以跳过该像素的绘制,甚至可以跳过整个物体的绘制。
这就是非常朴素的Early-z的思想,现在也有一些更复杂的方法,比如基于层次结构(Hierarchy)的方法进行深度的处理。但它们的整体思想是大同小异的。这类方法都是利用GPU高速的并行化能力,以尽可能廉价的成本,形成一组遮挡物的深度图,然后将可以裁剪掉的物体尽量裁剪掉。这种做法对于复杂的场景十分有用。
比如在游戏中,当玩家进入一个房间的时候,整个游戏世界的99%以上的部分都不需要显示,游戏画面只需要关注房间中的物体和角色即可。因此,当我们在进行游戏引擎设计的时候,需要特别注意这些变化。这就是裁剪的核心思想。
纹理压缩

下面我们介绍另外一个很关键的概念——纹理压缩。渲染的一切基础都是可渲染物体,在可渲染物体中,有一个很重要的组件,叫做纹理。对于照片来说,我们一般会将其存成JPEG或者PNG格式,或者其他一些流行的图片格式。


而在游戏引擎中,我们一般会将纹理压缩存储。大家知道,计算机会将照片进行压缩存储,对于未经压缩的BMP格式和经过压缩的图片格式来说,同一张图片所占用的空间可能会相差十倍以上。而在游戏引擎的绘制系统中,我们无法使用一些流行的、非常优秀的算法对图片进行压缩,因为经过这些算法压缩后的图片无法进行随机访问。举例来说,对于JPEG格式的文件,如果给定一个UV坐标,系统无法快速从JPEG格式的文件中获取到相应坐标的信息,而且这个计算成本非常高。在游戏引擎中,我们一般采用基于块(Block Based)的压缩方法。我们将图片切成一个个小方块,最经典的就是4×4的小方块,然后进行压缩。


这里介绍一个非常经典的算法。对于DXT类型的纹理,在一个4×4的色块中,可以找到最亮的点和最暗的点,即颜色最鲜艳和颜色最暗的点,然后将该方块中的其他点都视为这两个点之间的插值。因为对于很多图片来说,相邻的像素之间都有一定的关联度(Coherence)。所以我们可以存储一个最大值和一个最小值,然后为每个像素存储一个距离最大值和最小值的比例关系,这样就可以近似地表达整个色块中的每个像素的颜色值。在计算机图形学领域中,纹理压缩(Texture Compression)都是基于这个思想,称为块压缩(Block Comppression)。在DirectX中,最经典的就是DXT系列的压缩算法。块压缩系列压缩算法的最新版本已经演进到了BC7。DXT系列压缩算法的优势在于,当我们生成了一个纹理后,就可以在CPU上对纹理进行实时压缩。因为无论是压缩还是解压缩,这一系列算法的效率都非常高。
另外一类压缩算法就是手机上使用的压缩算法,使用较多的就是ASTC算法。ASTC压缩的分块就不再是严格的4×4了,它可以使用任意的形状,而且ASTC的压缩效果是最好的,解压缩的效率也不低。然而,ASTC算法压缩时的性能消耗较大,因此无法在运行中进行压缩。总而言之,对于计算机的渲染系统来说,纹理压缩的基本逻辑都是按照这个思想来进行压缩的。
我们之所以介绍纹理压缩的相关内容,是因为当大家构建游戏引擎的时候,对纹理的压缩和管理是一个非常重要的模块。而且,我们加载到显卡中的图片基本上也是压缩过的数据格式。
建模工具

下面我们介绍如何制作和生成可渲染物体。我们会在工具链部分中详细介绍。在此,作为前导知识,我们对一些建模工具做一下简单的介绍。


构建3D模型最经典的工具是3DS Max和Maya,近几年来,Blender也变得越来越流行。我个人也非常看好Blender,因为它的功能越来越强大。大家可以在这些软件中构建我们需要的各种各样的模型。在十几年前,流行的做法是在这些软件中卡住一个个关键点,由粗到细地构建模型。近年来,随着ZBrush的出现,传统的制作流程受到了巨大的冲击。ZBrush是一个雕刻性的工具。在真实的世界中,当雕塑家通过雕刻塑造一个形体时,会不断地对雕刻材料进行切削,从而形成想要的形状。而在计算机中,这种雕刻行为可以更加自由,因为我们不仅可以进行切削,还可以进行放大和拉伸。这给了艺术家更大的自由度。基于ZBrush通过雕刻生成素材的工具也越来越受欢迎。还有一种生成素材的方式是3D扫描。手机上有一些APP,我们使用这些APP围绕物体拍摄几张照片,这些APP就可以将这个物体的3D模型建立起来。这也是得益于现在的深度学习、以及图像配准(Registration)算法的提升。在3A游戏行业中,基于实体扫描的模型也越来越多。比如下图中展示的一个非常复杂的古代武士盔甲,我们通过一圈扫描,就能够形成非常高精度的、能够达到十几亿面级别的精细网格,这个精度远远超过了手工构建网格的精度。
最后一个方法就是现在快速发展的程序性生成算法。这类算法可以通过一些规则自动生成网格。Houdini就是这样的一个工具,它能够生成漂亮的地形网格。现在有些前沿的人工智能算法可以生成很多我们需要的网格细节。这也是游戏引擎工业未来一个很重要的发展方向。因为这类方法能够将艺术家们从繁琐的细节工作中解放出来,使艺术家能够真正专注在创意上,而人工智能可以自动将细节部分补足。


这四种方法各有利弊。有的方法更有弹性,而有的方法更难以进行调整。比如对于3D扫描来说,大家会觉得这种方法特别方便,因为我们将一个物体扫描一下就得到了这个物体的3D网格。但这种方式有一个很大的难题,就是我们首先得拥有想要扫描的物体。以前有个很著名大型游戏厂商,他们为了做一个游戏,会先在网上购买所需要的物体,然后将这些物体扫描进电脑,这样才能进入游戏资产库。不同的工艺制作方法会产生不同的数据,而这些数据就是我们计算机绘制所需要的输入。


渲染管线

下面是本节课程的最后一部分,我们会介绍现代游戏引擎的管线在往哪个方向发展。前面我们所介绍的内容,比如网格、子网格、纹理、着色器等,虽然有点复杂,但同学们应该可以理解。然而,现代游戏的工艺变化非常之大。大家如果仔细观察近几年发布的新游戏,就会发现,这些游戏的细节越来越多,而且地图越来越大,这就是开放世界。对于传统的射击游戏和开放世界的射击游戏来说,在每一帧画面中,你所能看到的数据量可以相差十倍以上。随着我们前面介绍的雕刻工具(ZBrush等)的普及,包括3D扫描的普及,我们得到的模型的数据量也越来越大。举个例子,对于一个很简单的雕像来说,如果我们推进相机,我们会发现它有无数的细节。如果大家喜欢玩3A游戏,那么就会很在意场景的精度。精度问题非常重要,而精度问题恰恰又对现代引擎的基础架构产生了巨大的冲击。希望同学们建立这样一个概念,即对于游戏引擎的绘制系统来说,它并不是一个静态的系统,它的技术一直在进步。这其中有一个很重要的发展方向,我们叫做“Cluster-Based Pipeline”。这是一条新的模型表达的主管线。他的基本思想来自于2015年育碧软件制作的《刺客信条·大革命》。在这款游戏中,有很多非常华丽建筑物,每个建筑物都有很多的细节。经过五六年的发展,同时随着硬件的发展,有越来越多的引擎在往这类管线上转移。



受到篇幅限制,我们无法详细展开介绍这类管线的核心思想,我们可以介绍一些最基础的概念。当我们面对一个非常精细的模型时,我们可以将其分成一个个小的分块,可以称之为Meshlet或者Cluster。而每一个Meshlet都是固定的,比如32个或者64个三角形大小。下图中的龙模型大约有几十万个面片,我们可以将其分成很多小型的Meshlet,每个Meshlet有64个面片。


之所以这样做,是因为对于现代计算机来说,显卡已经能够基于数据高效地创建很多几何细节,而并不需要像传统的管线那样,需要预先将顶点缓冲区和索引缓冲区构建好,再将网格数据传入显卡。现代显卡可以凭空计算出很多几何信息,而且如果我们输入一个三角形,现代显卡可以借此生成无数个三角形。因此,当我们将每个Meshlet的大小固定之后,在显卡上的计算都是极其高效且一致的。请大家回顾一下前文提到的GPU硬件架构和流式多处理器的概念,每个流式多处理器中都有很多小型的计算核心。所以,上图中的龙模型虽然复杂,但它的很多计算都是完全一致的。这就是Cluster-Based Pipeline的核心思想。



大家刚开始接触图形学时,会了解到经典的渲染管线。即构建好模型、材质之后,通过顶点着色器和像素着色器进行渲染。最早的渲染管线甚至都没有顶点着色器和像素着色器。从十几年前开始,显卡厂商开始尝试在显卡中提供创建精细的几何结构的功能。并引入了壳着色器(Hull Shader)、域着色器(Domain Shader)和几何着色器(Geometry Shader)。这些着色器可以将输入的三角形进行无限细化,生成我们想要的细节。随着硬件的发展,又演变出了“Mesh Shader”和“Amplifying Shader”。在Vulkan和DirectX中,这些Shader的名称有所不同。
这类Shader的核心想法是,我们可以使用一个算法,基于数据凭空生成很多几何细节,而且可以根据距离相机的远近,选择所生成的几何细节的精度。对于显卡来说,最高效的数据组织方式就是大小一致的一个个小分块。基于Cluster或者Meshlet的管线对于程序员的要求要比以前高很多。因为我们要进行大量的处理和运算,而且具体实现代码也不易理解。然而,这种方法的优势在于,它可以产生无数的细节,并且可以让艺术家自由发挥。这是一个非常值得大家关注的引擎的一个前沿的发展方向。
举个例子,如下图所示的怪物:


这个怪物有很多面片。如果我们不将其分成Meshlet,我们会发现,当相机在移动的时候,这个怪物会被整个裁剪掉,因为我们只能够按照物体的粒度进行裁剪。但现在我们可以将其手部的一部分裁剪掉,因为每个Meshlet都有自己的包围区域,我们可以在GPU上实时计算出裁剪区域。即在当前的相机位置的情况下,哪些部位可以不需要绘制,这样做的效率也很高。
这里我们需要提及虚幻引擎的Nanite,它实现了像素级的网格密度。可以认为,Nanite是将Meshlet的思想又往前深入了一步,做的更加工业化、更加成熟。这也是现代引擎的渲染管线发展的一个重要方向。



在这一讲中,我们介绍了最基础的渲染引擎架构的思想,包括显卡硬件的基础知识。如果大家真正进入游戏引擎开发行业,我们希望同学们能够关注一些最前沿的技术发展趋势。那么我们如何理解渲染系统?在渲染主题的第一节课中,希望同学们能够建立几个基础概念。
第一,游戏引擎的绘制系统是一个工程科学,并且深度依赖于你对现代图形硬件的理解。因此如果你想成为一个图形工程师,你必须要理解显卡的架构,知道显卡的性能卡点在哪里,了解各种性能限制。
第二,在游戏中,我们要解决的核心问题就是网格模型、材质等数据之间的关系。最经典Mesh和Submesh就是一个非常好的解决方案。但是最前沿的技术会有所不同。
第三,希望同学们建立一个概念,即在进行绘制的时候,尽可能的通过一些运算减少绘制工作,这样能够达到最佳性能。因此可见性是一个非常重要的概念。这里和大家分享一位业内大神说过的一句话,曾经有人问这位大神,你认为优化的最高境界是什么呢?大神回答“do nothing”。请注意,这里的“do nothing”并不是什么工作都不做的意思,而是需要通过算法让计算机尽可能少做事情,这时的效率一定是最高的,这就是“do nothing”的精髓。因此,在我们进行优化工作时,需要尽可能地让计算机“do nothing”。
最后,也是非常重要的一个趋势,即越来越多的绘制运算,包括一些复杂的处理,都已经从CPU转移到GPU,以利用现代GPU的高速处理能力。这就是GPU驱动(GPU-Driven)的思想,即将很多在CPU上进行的一些复杂运算(比如动画系统等)全部转移到显卡。这也是利用GPU帮助CPU分担负载的一个重要的方式。
当大家在理解绘制系统时,记住以上四点,基本上就可以了解绘制技术的发展方向。
页: [1]
查看完整版本: 如何高效渲染网格、材质与纹理