每个游戏都需要一些底层支持系统以管理一些例行但重要的任务,如启动和终止引擎、存取文件系统、存取不同资产类型(网格、纹理、动画、音频),本章讨论多数引擎都会出现的底层支持系统。
当引擎启动时必须一次配置并初始化子系统,子系统之间相互依赖,这决定了子系统的初始化顺序。
游戏中的子系统,最常用的设计模式是为这些子系统定义单例类。很多游戏引擎都是基于c++语言,所以首先需要考虑c++原生的启动及终止语义能否做启动(构造子系统对象)或终止引擎子系统(析构子系统对象)之用。在程序入口main()函数执行前所有全局和静态对象被构建,在main()结束之后这些全局和静态对象被析构,不幸的是这些对象的构造和析构过程是无序的,因此无法使用。
1. 按需构建
可以使用一个c++的小技巧来解决上述问题:如果在函数体内定义静态变量,那么该变量在首次调用函数时构造。
class RenderManager
{
public:
static RenderManager& get()
{
static RenderManager renderManager; // 首次调用时实例化
return renderManager;
}
RenderManager()
{
VideoManager::get()
// ... 构造时先创建其他存在依赖的子系统
}
}
此种做法仍有问题:功能层面,该方法无法控制静态对象的析构时间,有可能在析构某一子系统静态对象时,其依赖的其他子系统已经提前被析构。设计层面:RenderManager对象在第一次使用get函数时创建,第一次调用何时发生?无人知晓。好的设计应该精确控制对象的创建时间。
2. 行之有效的简单方法
避免上述问题的一个简单办法是:将对象的启动过程从构造函数移至一个特定函数中,将对象的析构过程从析构函数也移至一个特定函数,然后重载构造析构函数,让其不做任何事。
class RenderManager{
RenderManager(){}
~RenderManager(){}
void startUp() {...}
void shutDown(){...}
}
RenderManager renderManager;
int main()
{
// .. 启动其他子系统
renderManager.startUp()
// .. 启动其他子系统
g_Game.run() // 游戏主循环
// .. 关闭其他子系统
renderManager.shutDown()
}
内存对游戏引擎的影响包含两方面:1)动态分配内存:动态内存分配效率十分低,应尽量避免;2)内存访问模式:将数据置于细小密集分布的内存中相较于将数据分散至广阔内存中,CPU对前者的操作效率会高很多。下面将针对上述两点优化内存使用。
通过C++的new/delete运算符操作内存称为动态内存分布,也成为堆分配。这一操作效率很低,原因是:1)堆分配是通用分配器,它必须能够处理任意大小的分配请求,这需要极大的管理开销;2)在很多操作系统中,new/delete操作需要将用户模式切换至内核模式,上下文环境的修改也十分耗时。因此游戏中的一个经验法则时:维持最低限度的堆分配,禁止在紧凑循环中使用堆分配。很多游戏实现了定制的分配器以解决堆分配的缺陷,下面做个介绍。
1. 基于堆栈的分配器
堆栈分配器通过new或声明一个全局字节数组分配一大块连续内存,分配器用一个顶端指针指向该大块内存的已分配内存的顶部,当分配新内存时指针继续向上移动。堆栈分配器禁止以任意次序释放内存,而必须严格按照内存分配时的顺序逆序释放。为达到这一要求,每次分配内存时返回一个标志Marker来标记当前内存的顶端位置,在后续释放该内存的下一块内存时将顶端指针与Marker直接的内存释放,如图所示。
2. 池分配器
游戏引擎有时需要分配大量同等尺寸的小内存块,如可能要分配或释放矩阵、迭代器、链表中的节点及可渲染的网格实例等,这类情况可使用池分配器。如需要分配网格实例,网格大小4*4,每个网格元素占4KB,池分配器可分配一块4*16kb的内存,内存分为16个单元,每个单元存放到一个链表当中,当需要分配一个网格实例元素时,池分配器将链表下一个单元取出并传回,实例元素将存放于该单元中,释放实例元素时,将其对应的单元插入至链表中。简单来说,池分配器将大内存块切分成很多小块并用链表将这些内存块在逻辑层面连接起来以保证内存块在物理位置上的顺序,分配和释放内存实则时链表的删除插入操作,效率很高。
3. 单帧与双缓冲内存分配器
很多时候游戏需要在主循环中使用一些临时数据,这些变量在本次主循环结束时或下次主循环结束时即刻被销毁,此时会用到单帧或双缓冲分配器来分配这些临时变量的内存。
while(true)
{
g_singleFrameAllo.clear() // 先全部清空内存
// ...
g_singleFrameAllo.allo(nbyte) // 分配内存 无需关注何时释放掉该内存
}
while(true)
{
g_doubleFrameAllo.swapBuffers() // 交换现行(上一帧)与无效(当前帧)缓冲区
g_doubleFrameAllo.clearCurrentBuffer() // 清空新的现行缓冲区
g_doubleFrameAllo.allo(nbyte) // 在新的现行缓冲区中分配内存
}
在多核游戏机上缓存非同步处理的数据,这样的分配器十分有用。如在第i帧将某任务数据写进缓存,在第i+1帧两个缓冲互换,任务数据缓冲处于非活动状态,不用担心其数据被覆盖,因此在第i+1帧结束前可放心使用这些任务数据。
动态堆分配以随机方式分配与释放内存,在多次分配与释放之后会内存自由块与使用块相间排布,我们称使用块之间的自由块为洞,当洞变得多而小的时候,这个状态称为内存碎片状态。内存碎片的问题在于:即使自由内存足够的大,分配请求仍会失败,因为对于一次分配请求,内存必须连续。
读写系统内存通常需要几千个处理器周期,而读写CPU寄存器只需要几个处理器周期,为降低系统内存的平均读写时间,现代处理器一般采用告诉的内存缓存。缓存是特殊的内存,CPU读写缓存要快于主内存。当首次读主内存时,该内存小块会载入高速缓存,这个内存块单位称为缓存线。若后来在读取内存而其数据已经在缓存中,则直接读取缓存中的数据,如果不再缓存中,则缓存命中失败,此时程序被逼停直到缓存先被更新后才能继续执行。我们无法完全避免缓存命中失败,但可以采用一些方法尽量减少命中失败。
游戏中包含不同种类的时间概念:实时、游戏时间、动画自身的时间线、函数实际执行的CPU时间周期等。下面的部分介绍了实时、动态模拟软件如何运作,并探讨这类软件中时间的常见运用方法。
图形用户界面的画面大部分的内存是静止不动的,在某一时刻只有少部分视窗会主动更新其外貌,传统上会利用一种称为矩阵失效的技术让屏幕中有改动的内容重绘。较老的游戏引擎也会采用类似的技术以尽量降低需重回的像素数目。
实时三维计算机图形以另一种方式实现,当摄像机移动时屏幕和视窗上的一切内容都不会变,而是在视窗上快速的显示一连串静止的影像。在实时渲染应用中,用一个循环体来实现这种效果。
while(!quit)
{
// 基于预设路径更新相机
updateCamera();
// 更新场景中所有动态元素的定向、位置等信息
updateSceneElement();
// 把静止的场景渲染至场景外的帧缓冲中
renderScene();
// 交换背景缓冲和前景缓冲,将最近渲染的影像显示在屏幕上
swapBuffers();
}
游戏由许多子系统构成,输入/输出设备、渲染、动画、碰撞检测、音频等。游戏子系统需要周期性的为游戏提供服务,其周期频率可能各不相同,如动画频率需要和渲染频率保持一致,如30Hz,而动力学模拟系统需要更频繁的更新,如120Hz。可通过使用游戏循环来实现子系统的更新。
有许多方法实现子系统的周期性更新,但其核心通常包含一个或多个循环。
对于window平台上的游戏,即要处理游戏的逻辑,还要处理window的消息,因此这类游戏都会有一个消息泵,基本原则是先处理window消息,在处理游戏逻辑。
while(true)
{
// 处理window消息
Msg msg;
while(PeekMessage(&msg))
{
TranslateMessage(&msg);
DispatchMessage(&msg);
}
// 开始处理游戏逻辑
RunGameLoop();
}
多数游戏引擎中的子系统都是以程序库的方式构建,程序员通过调用这些程序库来是实现相关行为,这种方式的缺陷是:很多程序库比较难用,程序员必须了解各个函数和类的具体使用方式。还有些游戏引擎以框架的方式构建,框架是半成品的应用软件,程序员需要完成框架中的空缺的自定义实现,但几乎无法修改该软件的核心控制流程,因为这些都由框架层面控制。
while(true)
{
for(each frameListener)
frameListener.frameStarted(); // 自己实现具体的frameStarted方法
renderCurrentScene();
for(each frameListener)
frameListener.frameEnded();
finializeSceneAndSwapBuffer();
}
程序员自己要实现一个frameListener以继承引擎的基类FrameListener, 并重写两个虚函数frameStarted,frameEnded。
在游戏中,事件是指游戏状态或游戏环境发生的某种变化。多数游戏引擎都会提供自己的事件系统,让各个子系统登记其关注的相关事件类型,当该类事件发生时,子系统便可以一一响应。
有些引擎使用事件系统对子系统进行周期性更新。实现的方式是:子系统在实现周期性更新时,只需要简单的加入事件类型,在事件处理器里,代码便能以任何所需要的周期进行更新,接着,该代码发送一个新事件,并设定该事件在1/60s之后生效,当事件生效后,执行关注了该事件类型的子系统,以此完成一次子系统的周期性更新。
我们可以使用CPU高分辨率计时寄存器来度量时间,这个时间在真实时间线上,此时间线的原点为游戏本次启动时间。
游戏时间线在正常情况下与真实时间线保持一直,但可通过修改游戏时钟实现游戏暂停或减慢。暂停游戏并非停止游戏的主循环,而是修改该循环体内对游戏时钟的操作方式,比如注释掉游戏时钟的更新代码,则与游戏时钟相关的功能将暂停。
播放一个动画,实则是动画在局部时间线上的一次动作,局部时间线的起点对应着动画开始播放时间点。当动画按照正常速率播放时,局部时间线可以看作在全局时间线上的简单映射,两者的长度一致,当动画的播放速率降低一倍,可以看作将局部时间线拉长一倍然后映射至全局时间线上。
高级的渲染步骤由名为管道的软件架构所实现,管道只是一连串顺序计算阶段,每个阶段对输入流进行相关处理并对输出流产生数据。管道中的每个阶段可以独立与其他阶段,因此每个阶段可以实现并行,如阶段1在处理一个数据元素,阶段2可以同步处理阶段1已经产生的相关结果。管道的吞吐量量度整体每秒产生的数据量,单个阶段的吞吐量衡量该阶段需要多长时间处理单个数据,潜伏期量度单个数据需要花费多长时间才能走完整个管道。管道由多个阶段顺序相连,因此最低吞吐量的阶段将成为整个管道的上限。对于优良设计的管道,所有阶段同步执行,没有阶段需要长期闲置等待其他阶段的数据。
管道中,最高级的阶段包括:
工具和资产调节阶段负责处理网格和材质,应用程序阶段负责处理网格实例与子网格,每个子网格关联一个材质。在几何阶段,每个子网格分解成几个顶点,顶点获大规模并行处理,在这一阶段的结尾,完全变换、着色后的顶点重新构成三角形。光栅化阶段,每个三角形分解为片段,如果片段没被裁剪丢弃,其颜色最终会写入缓冲区。
在工具阶段,三维建模式可以借助大量工具创建三维模型,这些模型可以由任何方便的表面描述方式所表达,比如四边形、三角形等各种类型的网格。然而在管道的运行时渲染前,总需要镶嵌成三角形。
工具阶段,美术人员也需要基于材质编辑器确定材质(表面属性),并为材质确定着色器、着色器所需纹理及设置着色器配置参数等。材质可由个别网格存储及管理,但如果这么做会导致大量重复性数据,因为在许多游戏中,少量材质会应用至许多物体当中。为解决这个问题,很多游戏会建立材质库以统一存储管理材质,从中为每个网格挑选合适的材质,以此让网格和材质保持松散的耦合。
资产调节阶段本身也是一个管道,其工作时导出、链接、处理多个种类的资产,生成内聚的整体。例如三维模型由几何(顶点和索引缓冲)、材质、纹理、骨骼所组成,该阶段确保三维模型所有涉及到的个别资产均可用,且都已准备好供引擎使用。
资产调节阶段也会计算高级的场景图数据结构,如为静态关卡几何建立BSF树,以加速渲染引擎判断哪些物体需要渲染。
耗时的光照计算也在该阶段完成,这种计算称为静态光照,静态光照可以计算网格顶点上的光照颜色,也可以把每像素的光照信息存于纹理中,这种纹理称为光照贴图。
在游戏开发的早起,所有渲染都在CPU上进行,后来硬件厂商开始开发图形硬件。早起的图形加速器只能处理管道中最耗时的阶段:光栅化,后来这些硬件也能负责一些几何的计算。最初,这些图形硬件只提供硬接线但可配置的的管道实现,这种管道称为固定功能管道,这项技术称为硬件变换及光照。之后该管道内的数个子阶段变成了可编程的,如工程师能编写着色器程序处理顶点及片段。
图形硬件已进化成一种专门的微处理器,称为图形处理器GPU,GPU为最大化管道吞吐量而设计,其使用了大量的并行化处理。即使GPU在完全可编程的形式下,也不能作为通用处理器使用-也不应该如此,因为GPU之所以能达到极高的处理速度,在于其仔细的控制了管道的数据流,有些管道是完全固定功能的,有些时可配置但不可编程的。内存只能在控制范围内存储,且采用了缓存存储了那些不需要重复计算的数据。
此阶段是完全可编程的,顶点着色器负责变换及着色|光照顶点,此阶段的输入是单个节点(虽然实际上会并行处理多个节点)。顶点位置和法矢量以模型空间或世界空间表达。此阶段也会进行透视投影、每顶点光照及纹理计算,顶点着色器也可以通过改变顶点位置来产生程序式动画,如模拟风吹草动和水波等。此阶段的输出时变化和光照后的顶点,其空间为齐次裁剪空间。
可选的几何着色器也是完全可编程的,该着色器用以处理以齐次裁剪空间表示的整个图元(三角形、线、点),他能剔除和修改输入的图元,又能生成新的图元,其典型应用包括:阴影体积拉伸、在网格的轮廓边拉伸毛发的鳍、几何动态镶嵌、把线段以分形细分以模拟闪电特效等。
现在的GPU容许将达致管道阶段的数据回写进内粗,数据能从那里回到管道之初做进一步处理,这一功能称为流输出。有了流输出功能,许多迷人的视觉效果可以不经CPU完成,如头发渲染,以前在实现这一效果时在CPU上进行物理模拟得到三次样条数据,之后继续在CPU上将样条镶嵌为线段。流输出可将该实现移至GPU上实现:通过顶点着色器完成物理模拟,通过几何着色器完成镶嵌过程。之后将处理后的数据输出至管道初始位置进行后续渲染。
裁剪阶段是将齐次裁剪空间以外的三角形部分裁减掉,其原理是:首先计算落在裁剪空间以外的顶点,之后求出这些顶点对应的三角形的棱与平截头体面之间的交点,这些交点将会作为裁减后新三角形的顶点。此阶段是固定功能,但提供有限度配额。如除了平截头体平面以外,还可定义其他截面。
屏幕映射时简单的平移或缩放顶点,是指从裁剪空间转换至屏幕空间,此阶段是固定且完全不可配的。
自三角形建立阶段开始,光栅化硬件迅速的将三家形转换成片段。此阶段是不可配置的。
三角形遍历将三角形分解成片段(即光栅化),每个片段对应一个像素,通常基于顶点插值获得片段属性值,以供像素着色器使用,该阶段是不可配置的。
许多显卡能够在此时间点对片段的深度进行测试,若发现片段被帧缓冲中的像素遮挡,这将丢弃该片段,避免后续像素着色器的处理,但并非所有着色器都支持在这一阶段进行深度检测,因为以前的深度检测和alpha检测都是在像素着色器之后进行的,所以这里的测试称为提前深度测试。
像素着色器是完全可编程的,该阶段可对像素进行着色(包括光照和其他处理)。像素着色器可对多个纹理采样、计算每像素光照以及任何影响片段颜色的计算。
管道的最终阶段为合并阶段或光栅运算阶段,该阶段不可编程但是可高度配置,该片段负责执行多个测试,包括:深度测试、alpha测试以及模板测试。若通过测试,就会和帧缓冲中的颜色值混合,混合方式由alpha混合函数决定,该函数时固定的,但可以通过配置其运算符及参数因子实现不同的混合方式。
多数引擎都会以某种形式提供以下的子系统:
在这些子系统当中,运行时游戏对象模型最复杂,它基本上提供了以下大部分功能:
运行时对象模型多数会采用两种架构风格:
当游戏对象模型中所有的类都继承至单个共同的基类时,此类的层次结构就表现的单一且庞大。