如何高效渲染小型游戏场景?

本期为GAMES104《现代游戏引擎:从入门到实践》视频公开课文字实录第12期。本课程由GAMES(图形学与混合现实研讨会)发起,游戏引擎技术专家王希携手游戏引擎一线开发者共同研发。

课程共计22个课时,将介绍现代游戏引擎所涉及的系统架构,技术点,引擎系统相关的知识。为配合学习实践,课程组在 GitHub 上开源了小引擎Piccolo,上线1个月即获得了2900+star, 累计下载量已超过20000+。

01 「可渲染物体」

在上一节课中,我们介绍过,游戏世界中的大部分物体都叫做“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来执行。这种做法对于绘制大量相同的物体特别有用,比如绘制大量的树木、草丛等,这些物体看起来都差不多。如果需要一次性绘制几百米开外,甚至上千米这类物体的话,这种做法非常有用。

大家理解了如何将一个对象按材质切分成子网格之后,就可以进行很多有意思的优化,但最基础的概念还是网格、子网格。

02「可见性裁剪」

有了上述概念之后,我们就可以开始绘制一个小型的游戏场景了。然而,这样的绘制并不高效。因为我们会绘制很多在视锥体中看不到的物体。视锥体实际上是锥形的,只有位于视锥体中的物体才会被显示到屏幕上。比如在我们前面提到的《超越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%以上的部分都不需要显示,游戏画面只需要关注房间中的物体和角色即可。因此,当我们在进行游戏引擎设计的时候,需要特别注意这些变化。这就是裁剪的核心思想。

更多关于渲染的内容,请关注我们的下节课。

本文编辑:Piccolo 社区编委会 彭渊

如对本节课有任何问题,欢迎加入我们的社群或给我们发送邮件:

[email protected]

关于我们

Piccolo游戏引擎社区

Piccolo社区是中国开源游戏引擎社区,由游戏引擎行业大佬、共创官、学习者共同建立。你可以在我们的社区里交流技术、互助问答、参加活动,你也可以参与Piccolo 的共建,如撰写贡献代码、撰写技术文章、参与技术挑战等。

Piccolo游戏引擎

由中国游戏引擎社区Piccolo开源的一款Mini游戏引擎。采用世界-关卡-游戏对象-组件的简洁架构,便于理解游戏引擎架构思想,它不仅能有效的帮助开发者学习游戏引擎架构知识,也能帮助一线开发者实验引擎算法与第三方库、辅助个人项目快速启动。截止目前,Github点赞已突破3600+,累计下载量已超过20000+

Piccolo GitHub地址:https://github.com/BoomingTech/Piccolo/discussions

猜你喜欢

转载自blog.csdn.net/m0_74737520/article/details/129398249