从零开始手敲次世代游戏引擎(二十四)

在上一篇我们完成了一个基本的数学库。虽然很基本,但是我们从一开始就已经导入了并行计算的方式:ispc。ispc不仅支持CPU端的并行计算,也支持GPU端的并行计算。这就意味着,如果我们再进行一些编码工作,我们可以轻易地将部分数学计算在CPU和GPU之间转移。

不过我们先不进行这方面的工作,而是进入到场景管理和资源加载。因为如果我们有了这个模块,我们就可以加载场景和资源,让图形渲染模块进行渲染了。我们急切地想看到我们的第一幅通过加载外部资源渲染出来的图。

一个游戏场景是由许多的场景物体组成的。而且很多场景物体以及组件都是在场景当中重复出现的。比如一个中世纪欧洲世界观的游戏,里面很可能会反复出现各种各样的酒桶,酒店,中世纪风格的建筑物;而一个开放世界的游戏,里面会有大量的自然景物(花,草,树木,山石湖泊)出现;至于一个赛车游戏,则会有各种逼真细致的车辆。

无论是从性能角度、资源管理角度、还是从实际游戏制作的工作流角度,我们都需要尽量地将场景物体的数量进行减少;但是与此同时,为了让游戏的画面足以吸引人,我们又要让场景看上去足够的复杂,足够的真实。

达到这个目的的一个有效的手法就是进行场景的剖分与管理。通过将场景分割为场景物体,将场景物体分割为组件,将组件进行标准化归纳统一,是这个手法的关键所在。

如上面所举的例子,游戏当中可能有千千万万个酒桶,但是这些酒桶和酒桶之间的差别,有的可能是位置与大小上的差别;有的可能是外观上的区别;有的可能可以互动(比如可以打碎,可以推拉),有的不能互动。我们的场景管理必须以尽可能简单高效的方式,对这些“看起来不太相同”但是“有很多共通点”的场景物体进行管理。

当代的商业游戏引擎在这方面已经给我们作出了很好的范例。在这些引擎当中,场景整体往往是一个地图(map),其记录了该场景当中所有不同的场景物体种类的信息,以及每个场景物体种类所对应的实例的信息。而每个场景物体则可能包括到一些组件的引用,比如网格Mesh,一些材质,碰撞盒,一些动画数据,以及一些脚本与属性,或者音频、特效等其它的什么。这些可以挂载在场景物体上的构件我们称之为“组件”(Component)。每个组件包括其自身的一些特有数据(比如一张贴图,或者一段代码),还包括一些表明其状态的数据(属性)

当然每个场景物体并不是需要包括全部这些,可能只有一部分,甚至是一个“空”物体。

为了实现这样的结构,我们至少需要定义以下几种类型与结构:

  1. 场景 (Scene)
  2. 场景物体(Object)
  3. 组件(Component)
  4. 属性(Attribute)

(参考引用1)

以上是场景的静态结构。我们同时还需要考虑场景的动态结构。所谓的动态结构,就是场景当中的这些元素是如何生成,如何消灭,如何转化,如何相互影响的。

特别是,对于开放世界游戏,由于场景很大,场景物体也十分复杂,我们需要考虑如何实现这样的场景的动态加载。这个加载不仅要做到无缝,还需要做到智能,并且足够的轻量,不影响我们正常的游戏操作和画面渲染。

我们甚至还需要考虑场景的动态生成,以及玩家行动(或者是npc的行动)对于场景所造成的改变。如何允许这些改变,如何存储和加载这些改变。比如monster hunter这个游戏的地图就是随机生成的;而minecraft这个游戏的世界就是动态生成且可以被玩家任意改变的。

除了上面这些运行时的特性之外,我们还需要考虑场景的制作手法对于场景管理方面提出的要求。当代的大型游戏都有几十人到数百人的制作团队在并行地制作着内容,是否能支持这样的团队规模进行有效的并行制作,这是评价当代游戏引擎的一个很重要的点。当然,这里面更多的是Editor和工作流的部分,但是为了支持所见即所得的设计制作方式,Editor的核心就是我们的Runtime。所以我们的场景管理器需要做好支持这种复杂工作流的准备。

可见当代场景管理是十分复杂的。但是不用太担心,千里之行始于足下,无论多复杂的系统,其运用的基本原理和构件往往是很简单的。场景管理的核心无非是资源的构造与管理,主要就是资源的加载存储与组织结构。我们接下来一步一步地来看这些部分。

首先我们来看看文件I/O部分。我们场景当中的大部分资源,都是以文件的方式被生产出来并进行存放的。自顶而下地看,首先我们会有一个或者数个场景描述文件,其中可能会包括场景物体的序列化存储,也可能场景物体是以另外的方式保存在场景描述文件之外,而场景描述文件只是记述了到这些场景物体在场景当中的位置以及包含一个到场景物体存储的引用。我们还会有大量的游戏素材,比如模型、贴图、音频视频等。

在当代的高品质游戏当中,这样的文件往往多达数十万到数百万个,而且文件尺寸的跨度也很大:从几K几十K的文件到几个G的文件都是屡见不鲜。如何高效地管理并在运行时快速地访问这些文件,是我们需要考虑的。

首先在文件所占用的空间方面。由于大部分文件系统是按照一个固定的尺寸分配物理存储空间的,当文件的尺寸不是这个固定尺寸的整数倍的时候,就会出现文件存储空间的浪费。并且,如果分配的固定尺寸越大,这个浪费就越严重。比如,对于4K大小的分配尺寸来说,每个文件的平均浪费空间为2K;而对于4M大小的分配尺寸来说,平均浪费就为2M。但是如果这个固定尺寸太小,那么会大大增加文件系统头部的开销(因为文件系统头部需要记录每个固定块的状态),这不仅是会占用空间,还会使得文件检索的速度变慢,也会加剧文件的碎片化,从而使得文件读写速度减慢。(参考引用2)

文件系统一般属于操作系统的一部分,并不是由游戏引擎来指定的。但是在一些游戏专用设备上,比如PlayStation系列,会根据游戏各个部分不同的需求分别提供好几种文件系统。有的为高速读取进行优化,比如存储游戏本体的分区;有的为高速读写小文件优化,比如临时目录;有的是比较简单的顺序文件系统,可以支持一块低功耗芯片在机器处于休眠时刻的后台下载;而有的则是基于文件的overlay文件系统,用来实现打补丁,保存存档等目的。

而作为游戏引擎,虽然不能直接操控文件系统的格式,但是可以控制资源文件打包的格式。将多个资源文件合并在一个文件当中存储,可以有效地减少文件系统当中文件的数量,加快文件检索(打开)的速度;同时可以减少系统当中需要管理的文件句柄的数量,减少系统调用的次数。但是这也会使得替换其中的单个资源变得不那么容易,不利于开发阶段资源需要频繁修改的情况。而且如果不采用一些策略妥善安排资源在包当中的顺序的话,对单个资源的小编辑可能会导致整个包需要被替换,会大大加大更新补丁的尺寸。

我们在之前的文章讨论了内存管理。相对于CPU/GPU的计算能力来说,内存是比较慢的。但是,如果和文件系统相比,那么内存真是快得飞起来了。当今大多数机械硬盘的吞吐能力大致也就是在100MBps上下。即使是固态硬盘也不过在500MBps上下。这还是连续访问情况下没有考虑文件系统自身开销的数字。随机访问的话性能会大打折扣。

前面谈到过,游戏引擎的人生是毫秒级别的。以最基本的30fps计算,一帧是33毫秒,在这个时间窗口里普通机械硬盘开足马力的话也就是能读取3MB左右的数据。3MB是一个什么样的概念呢?对于1080p的彩色画面来说,1920 x 1080 x 32bpp,大概是8MB的数据量。将其压缩成JPG,平均差不多是3MB。也就是说如果我们是从硬盘直接加载事先绘制好的画面来播放的话,差不多也就是30fps就撞上了文件系统的瓶颈。

在当代的高品质游戏当中,为了确保画面的质量,2k x 2k左右的贴图是非常常见的。而对于一些环境贴图(cube map),比如天空盒,往往会达到4k x 4k甚至更高。当然,传统的游戏关卡采用预加载的方式,也就是会有一个loading画面,资源加载时间长一点没有太大关系;然而在开放世界等需要无缝加载的当代游戏当中,这就成了一个问题。一些制作比较粗糙的游戏,在切换场景或者景物明显发生大范围变化的时候会卡会掉帧,往往这就是一个主要的原因。

因为这个问题是多方面因素造成的,解决它也是需要从多个方面来进行:

– 首先是需要想办法在保持一定品质的同时尽量减少资源文件本身的体积。这主要涉及到文件的压缩算法。总的来说计算机领域的压缩算法可以分为无损压缩和有损压缩这两个大类。对于数据量相对较小但是精度要求高的数据,比如模型的顶点数据,动画的变换矩阵数据等,一般采用无损压缩;而对于数据量较大但是精度要求不高的数据,比如贴图,音频视频,则一般采用有损压缩。(参考引用3)其中,LZMA是压缩比较高的无损压缩格式,S3贴图压缩格式是当今被GPU所广泛支持的贴图格式,而AVC/AAC则是当今视频音频压缩的主流;(参考引用4)

– 其次是需要尽可能减少对文件系统的随机访问。我们知道,在当代的外部存储系统当中也有一定尺寸的高速缓存,其工作原理与cpu和内存之间的高速缓存相似,都是在收到一次读取命令的时候通过缓存相邻的数据来起到加速的作用。如果我们频繁地读取不同的文件,或者是在同一个文件的不同位置之间频繁的切换,那么就很容易造成高速缓存的cache miss,从而大大降低I/O性能;

– 再次是要尽量避免同一个资源的反复加载。也就是说对于已经加载到内存,在后面还可能会用到的资源,要尽量将其保留在内存当中;同时还需要尽量避免同一个资源的不同副本出现在内存当中;

– 最后就是要实现资源的异步流式加载和部分加载。因为资源的加载是一个相对缓慢的过程,它必然是以远低于渲染线程的频率进行执行,所以相对于渲染线程来说它是异步的。但是同时,这些加载的资源很大一部分是需要提供给渲染线程使用的。那么,在资源尚未加载完毕的时候,渲染线程该如何进行;当资源加载完毕之后,又如何将其动态地更新到渲染线程当中;对于因为尺寸问题无法一次全部加载到内存的资源,如何实现部分加载,并随着游戏的进行改变加载的区域;如何判断已经不需要的资源,何时将其从内存卸载,如何卸载。

为了寻求解决这些问题的方法,在接下来的文章里面我们将逐一讨论不同平台上文件的基本操作、常用的压缩算法及其比较、常见的资源文件格式及其解析、异步I/O、流式加载、加载顺序决策与内存管理等内容。

参考引用:

  1. Component · Decoupling Patterns · Game Programming Patterns
  2. https://en.m.wikipedia.org/wiki/File_system
  3. https://en.wikipedia.org/wiki/Lossless_compression?wprov=sfla1
  4. https://en.m.wikipedia.org/wiki/S3_Texture_Compression

本作品采用知识共享署名 4.0 国际许可协议进行许可。

发表评论

Fill in your details below or click an icon to log in:

WordPress.com 徽标

您正在使用您的 WordPress.com 账号评论。 登出 /  更改 )

Google photo

您正在使用您的 Google 账号评论。 登出 /  更改 )

Twitter picture

您正在使用您的 Twitter 账号评论。 登出 /  更改 )

Facebook photo

您正在使用您的 Facebook 账号评论。 登出 /  更改 )

Connecting to %s

%d 博主赞过: