游戏引擎支持系统(上)

每个游戏都需要一些底层支持系统,以管理一些例行却关键的任务,例如启动及终止引擎、存取文件系统、存取各种不同资产类型(网格、纹理、动画、音频等),以及为游戏团队提供调试工具。

本文(分上下篇)重点讨论多数游戏引擎中都会出现的底层支持系统,为后续探索大型的核心系统做准备。其中上篇将讨论子系统的启动和终止的顺序管理,以及各种动态内存分配器和碎片整理等内存管理问题。

1. 子系统的启动和终止

游戏引擎是复杂软件,由多个互相合作的子系统结合而成。各子系统间的相互依赖关系,隐含地定义了每个子系统所需的启动次序,例如子系统B依赖子系统A,那么先启动A,再启动B,而各子系统的终止顺序则相反。

多数游戏引擎都是采用C++为编程语言,所以应该考虑C++原生的语法如何供启动及终止子系统所用。通常,每个子系统会被设计为单例(或称为管理器manager)。最有效的简单方法是,每个单例管理器的构造和析构函数不做任何事,而是自定义各自的startUp()shutDown()方法,然后在main函数中调用控制各系统启动和终止的顺序。

还有更优雅的实现方式,例如让各管理器把自己登记在一个全局的优先队列,之后再按恰当次序逐一启动;或者让每个管理器列举其依赖的管理器,定义一个管理器间的依赖图,再计算最优的启动次序。总之,蛮力法虽然粗暴,但是简单容易实现,非常容易调试和维护,是首选的方法。

2. 内存管理

任何软件的性能,不仅受算法的选择和算法编码的效率所支配,程序如何运用内存也是重要因素。内存对性能的影响有动态内存分配、内存碎片和缓存等方面。

2.1 优化动态内存分配

通过malloc()/free()或C++的new/delete运算符动态分配内存通常是非常慢的,原因有两个:堆分配器是通用设施,可以处理任何大小的分配请求,需要大量的管理开销;多数操作系统上分配内存会在用户模式和内核模式来回切换,这些上下文切换可能会耗费很多时间。因此,游戏开发中一个常见的经验法则是:维持最低限度的堆分配,并且永不在紧凑循环中使用堆分配。 当然,任何游戏引擎都无法完全避免动态内存分配,所以会实现若干个定制分配器。定制分配器比原生分配器更高效的原因有两个:从预分配的内存中完成分配请求(顶分配内存来自new),完全避免了上下文切换;对内存的使用模式做出多种假设,会比通用的堆分配器高效得多。

2.1.1 基于堆栈的分配器

许多关卡类的游戏,载入关卡时就会为关卡分配内存,关卡载入后,就会很少甚至不会动态分配内存。在玩家完成关卡之际,关卡的数据会被卸下,所有关卡占用的内存也可被释放。这类内存分配非常适合采用堆栈分配器。这种分配器要分配一大块连续内存,通过移动一个指向堆栈顶端的指针来“模拟”内存的分配和释放。释放时按分配的相反次序,不容许释放个别的内存块,而是释放从回滚点(标记)至目前堆栈顶端之间的所有内存。

双端堆栈分配器(即一个分配器从内存块底端往上分配,另一个从内存块顶端往下分配)可以更有效地运用内存。一种非常优秀的从不会产生内存碎片问题的分配方案:所有内存分配自单个巨大内存块,以双端堆栈分配器管理,底堆栈用来载入及卸下游戏关卡,顶堆栈则用来分配临时内存块,这些临时内存会在每帧中分配及释放。堆栈分配器的实现模型见下图。

堆栈分配器的分配模式

2.1.2 池分配器

矩阵、迭代器、链表中的节点、可渲染的网格实例等会分配大量同等尺寸的小块内存。池分配器是此类分配模式的完美选择。其工作方式为:首先预分配一大块内存,大小刚好是分配元素的倍数(例如每元素4字节的4×4矩阵池的大小设为64字节的倍数),池内每个元素会加到一个存放自由元素的链表。池分配器收到分配请求时,就会把自由链表的下一个元素取出,并传回该元素;释放元素之时,只需简单地把元素插回自由链表中。这些分配和释放都是O(1)的操作。

2.1.3 含对齐功能的分配器

为了提高内存的吞吐量和效率,所有内存分配器都必须能传回字节对齐的内存块。只要在分配内存时,分配比请求所需多一点的内存,再向上调整地址至适当的对齐,最后传回调整后的地址。大多数情况下,额外分配的字节等于对齐字节。例如,若请求为16字节对齐的内存块,就可以额外分配多16字节,最坏的情况下要把地址往上移动15字节。多出的1字节可以用来存储偏移量,以便于正确释放分配的内存。

2.1.4 单帧和双缓冲分配器

几乎所有游戏都会在游戏循环中分配一些临时用数据,这些数据要么可在循环迭代结束时丢弃,要么可在下一迭代结束时丢弃。这种模式适用于以下两种分配模式:

  1. 单帧分配器:先预留一块内存,并以简单堆栈分配器管理。在每帧开始时,都把堆栈的顶端指针重置到内存块的底端地址。在该帧中,分配要求会使堆栈向上成长。优点是极其高效,分配了的内存永远不需要手动释放,因为每帧开始时分配器会自动清除所有内存。最大缺点在于,程序员必须有不错的自制能力,并意识到内存块只在目前的帧里有效,绝不能把指向单帧内存块的指针跨帧使用
  2. 双缓冲分配器:第i帧分配的内存块用于第(i+1)帧。实现方法就是建立两个相同尺寸的单帧堆栈分配器,并在每帧交替使用(见下方代码)。
class DoubleBufferedAllocater {
    U32 m_curStack;
    StackAllocator m_stack[2];
public:
    void swapBuffers() {
        m_curStack = (U32)!m_curStack;
    }

    void clearCurrentBuffer() {
        m_stack[m_curStack].clear();
    }

    void *alloc(U32 mBytes) {
        return m_stack[m_curStack].alloc(nBytes)
    }
}

DoubleBufferedAllocator g_doubleBufAllocator;
// 主游戏循环
while (true) {
    // 每帧清除单帧分配器的缓冲区
    g_singleFrameAllocator.clear();
    // 对双缓冲分配器交换现行和无效的缓冲区
    g_doubleBufAllocator.swapBuffers();
    // 清空新的现行缓冲区,保留前帧的缓冲不变
    g_doubleBufAllocator.clearCurrentBuffer();

    // ...

    // 从双缓冲分配器分配内存,不影响前帧的数据,要确保这些内存仅在本帧或次帧中使用
    void* p = g_doubleFrameAllocator.alloc(nBytes);

    // ...
}

2.2 内存碎片

当经过非常多次随机次序的分配及释放不同尺寸的内存块,堆中就会出现许多内存“孔洞”,这就是内存碎片状态。由于分配的内存必须是连续的,所以内存碎片会导致分配请求经常失败。在支持虚拟内存的操作系统上,内存碎片并非大问题。对当代的游戏机而言,虽然技术上能支持虚拟内存,但由于其导致的开销,多数游戏引擎不会使用虚拟内存。

2.2.1 用堆钱和池分配器避免内存碎片

使用堆栈分配器分配到的内存块总是连续的,可以完全避免内存碎片。池分配器虽然会产生碎片,但因为每个内存块都一样大,所以不会因缺乏足够大的连续内存块而造成分配失败

2.2.2 碎片整理及重定位

若要以随机次序分配及释放不同大小的对象,以上两种也不适用。这时可以对堆定期进行碎片整理,即把每个“洞”搬移至高位,最后所有己分配内存块都会连续地凑在堆内存空间的底端。移动内存是简单的事,但背后的副作用是移动了己分配的内存块,若有指针指向这些内存块,这些指针便会失效。其中一个解决方案就是把指向这些内存块的指针逐一更新,使移动内存块后这些指针能指到新的地址,这个过程称为指针重定位。

由于C/C++不支持搜寻所有指向某地址范围的指针,若要在游戏引擎中支持碎片整理功能,程序员必须小心手动维护所有指针,另一个选择是采用智能指针或句柄(使用索引指向句柄表内的元素,每个元素储存指针,句柄的值不变,移动内存块时要修改指针)来替代。重定位的另一难题是,使用一些第三方库,该库本身不使用智能指针或句柄,那么指向库内数据结构的指针就不能被重定位。最好的办法是,让这些库在另一个特别缓冲区里分配内存,此缓冲区位于可重定位内存范围以外。或者干脆容许一些内存块不能被重定位,若这种内存块数量少且体积小,重定位系统仍可运行得相当好。

2.2.3 分摊碎片整理整本

碎片整理要复制内存块,其过程可能很慢。然而可以把碎片整理分摊至多个帧,例如容许容许每帧进行多达N次内存块移动(N是小数目,如8或16),只要分配及释放的次数低于碎片整理的移动次数,那么堆就会经常保持接近完全整理的状态。此方法只对细小的内存块有效,使移动内存块的时间短于每帧配给的重定位时间。若要重定位非常大的内存块,有时候可以把它分拆为两个或更多的小块,而每个小块可以独立被重定位。

2.3 缓存一致性

为了降低读写主内存的平均时间,现代处理器会采用高速缓存。每当出现缓存命中失败,程序便要被逼暂停,等待缓存线自主内存更新后才能继续运行。因为数据始终要在缓存和主内存之间移动,所以无法完全避免缓存命中失败。高效计算的诀窍在于,以最优的方式安排内存中的数据及为算法编码,尽量减少缓存命中失败的次数。

2.3.1 硬件层面上的缓存

现代的CPU架构上出现了一级L1和二级L2高速缓存,其存取方向为“CPU←→L1缓存←→L2缓存←→主内存”,存取速度依次减慢,L2缓存命中失败通常比L1的成本高。有一种特别差的缓存命中失败称为load-hit-store,此问题在PowerPC架构上(如Xbox360和PS3)极为普遍。其出现过程是,CPU往某内存地址写入数据,随即又读取该地址,而此时要等待L1缓存写回数据至主内存,造成CPU的流水线停顿。具体可以参见这篇文章

此外还需要意识到大多数处理器会在物理上独立分开指令缓存和数据缓存,前者会预载即将执行的机器码,后者则用来加速内存读写数据。因此程序变慢,要考虑是指令还是数据缓存命中失败。

2.3.2 软件层面上避免缓冲命中失败

避免数据缓存命中失败的最佳办法就是,把数据编排进连续的内存块中,尺寸越小越好,并且要顺序访问这些数据。当数据是连续的(即不会经常在内存中“跳来跳去”),那么单次命中失败便会把尽可能最多的相关数据载入单个缓存线。

要避免指令缓存命中失败,需要了解C/C++链接器的一些简单规则,例如编译器和链接器按函数在cpp文件中的出现次序排列内存布局;单个函数的机器码几乎总是置于连续的内存;位于一个翻译单元内的函数总是置于连续内存中。据此可以使用以下经验法则:

  • 高效能代码的体积越小越好,体积以机器码指令数目为单位
  • 在性能关键的代码段中,避免调用函数
  • 若要调用某函数,就把该函数置于最接近调用函数的地方,最好是紧接调用函数的前后,而不要把该函数置于另一翻译单元(cpp文件)
  • 谨慎使用内联函数。内联小型函数能增进效能,然而过多的内联会增大代码体积,使性能关键代码再不能完全装进缓存。若循环内的代码不能完全装进缓存,应重新考虑算法及其代码实现

参考文献:电子工业出版社《游戏引擎架构》第5.1、5.2节

当前网速较慢或者你使用的浏览器不支持博客特定功能,请尝试刷新或换用Chrome、Firefox等现代浏览器