游戏引擎架构-学习笔记
5 游戏支持系统5.1 子系统的启动和终止5.1.1 c++的静态初始化次序5.2 内存管理5.2.1 优化动态内存分布5.2.2 内存碎片5.2.3 缓存一致性7 游戏循环及实时模拟7.1 渲染循环7.2 游戏循环7.2.1 游戏循环的架构风格1. 视窗消息泵2. 回调驱动框架3. 基于事件的更新7.3 抽象时间线7.3.1 真实时间线7....
5 游戏支持系统
每个游戏都需要一些底层支持系统以管理一些例行但重要的任务,如启动和终止引擎、存取文件系统、存取不同资产类型(网格、纹理、动画、音频),本章讨论多数引擎都会出现的底层支持系统。
5.1 子系统的启动和终止
当引擎启动时必须一次配置并初始化子系统,子系统之间相互依赖,这决定了子系统的初始化顺序。
5.1.1 c++的静态初始化次序
游戏中的子系统,最常用的设计模式是为这些子系统定义单例类。很多游戏引擎都是基于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()
}
5.2 内存管理
内存对游戏引擎的影响包含两方面:1)动态分配内存:动态内存分配效率十分低,应尽量避免;2)内存访问模式:将数据置于细小密集分布的内存中相较于将数据分散至广阔内存中,CPU对前者的操作效率会高很多。下面将针对上述两点优化内存使用。
5.2.1 优化动态内存分布
通过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帧结束前可放心使用这些任务数据。
5.2.2 内存碎片
动态堆分配以随机方式分配与释放内存,在多次分配与释放之后会内存自由块与使用块相间排布,我们称使用块之间的自由块为洞,当洞变得多而小的时候,这个状态称为内存碎片状态。内存碎片的问题在于:即使自由内存足够的大,分配请求仍会失败,因为对于一次分配请求,内存必须连续。
- 以堆栈或池分配器避免内存碎片
堆栈分配器能完全避免内存碎片,因为它以连续方式分配内存,并严格按照逆序方式释放内存;池分配器也能完全避免内存碎片,虽然池分配器也会产生自由块与使用块相间分布的状态,但它的内存块大小完全一致,且是提前基于分配需求划定的,因此它能够相应后续的内存分配请求。 - 碎片整理与重定向
若需要分配大小不同的内存(无法使用池分配器)且必须按照随机次序进行(无法使用堆栈分配器),对付这种情况,我们可以使用碎片整理。碎片整理将洞(自由内存区)冒泡至内存高地址区,相对应的,使用区被移至内存低地址区域,从而使得自由区连续。这个操作不难,但困难的是:使用区的地址发生了变化,那么指向这些使用区的指针也将失效。因此我们必须采用某些手段维护这些指针,使其在内存地址发生变化后指向新的内存,这一过程称为重定位。但是在C++中无法搜索指向某块内存的指针,即在移动已使用内存时我们无法得知那些指针指向了该内存,因此这使得指针的更新很难进行(只能通过程序员自己实现方法手动维护)。一种更好的方式是舍弃使用普通指针而使用智能指针或句柄。
- 智能指针:智能指针使用起来与普通指针几乎完全一样,它实则是一个细小的类,类包含一个普通指针。智能指针之所以能够解决重定向,是因为我们可以在这个类里新增代码:当智能指针指向一个内存时,将该智能指针存储到一个全局链表里,当移动某块内存时在全局链表里搜索并更新相关的智能指针里的普通指针信息。
- 句柄:句柄通常实现为句柄表的索引,句柄指向的表内元素存放着某块内存的地址。当内存发生移动时自动遍历句柄表并更新相关内存地址,这样所有使用该句柄的程序总能够得到正确的内存位置。
- 分摊碎片整理成本
碎片整理涉及到内存复制,其操作可能十分耗时,如果放在一帧内处理很有可能对游戏帧率产生影响。我们可以将碎片整理操作放在多帧里完成。假设一帧执行时间为1/30秒,则1秒包含30帧,我们将整理操作分摊到这30帧里完成。只要分配及释放次数低于碎片整理移动次数,堆内存块就能基本保持完全整理状态。
5.2.3 缓存一致性
读写系统内存通常需要几千个处理器周期,而读写CPU寄存器只需要几个处理器周期,为降低系统内存的平均读写时间,现代处理器一般采用告诉的内存缓存。缓存是特殊的内存,CPU读写缓存要快于主内存。当首次读主内存时,该内存小块会载入高速缓存,这个内存块单位称为缓存线。若后来在读取内存而其数据已经在缓存中,则直接读取缓存中的数据,如果不再缓存中,则缓存命中失败,此时程序被逼停直到缓存先被更新后才能继续执行。我们无法完全避免缓存命中失败,但可以采用一些方法尽量减少命中失败。
- 避免缓存命中失败:避免缓存命中失败最佳的方式是把数据编排进连续的内存块中,尺寸越小越好,且顺序访问这些数据。这种做法之所以有效,是因为当发生命中失败时,会尽可能多的将相关的数据载入至缓存线中,如果数据量小,很有可能载入至单个缓存线中,当顺序存取数据时(不用在内存直接跳来跳去),就可以最大程度的减少缓存命中失败。
7 游戏循环及实时模拟
游戏中包含不同种类的时间概念:实时、游戏时间、动画自身的时间线、函数实际执行的CPU时间周期等。下面的部分介绍了实时、动态模拟软件如何运作,并探讨这类软件中时间的常见运用方法。
7.1 渲染循环
图形用户界面的画面大部分的内存是静止不动的,在某一时刻只有少部分视窗会主动更新其外貌,传统上会利用一种称为矩阵失效的技术让屏幕中有改动的内容重绘。较老的游戏引擎也会采用类似的技术以尽量降低需重回的像素数目。
实时三维计算机图形以另一种方式实现,当摄像机移动时屏幕和视窗上的一切内容都不会变,而是在视窗上快速的显示一连串静止的影像。在实时渲染应用中,用一个循环体来实现这种效果。
while(!quit)
{
// 基于预设路径更新相机
updateCamera();
// 更新场景中所有动态元素的定向、位置等信息
updateSceneElement();
// 把静止的场景渲染至场景外的帧缓冲中
renderScene();
// 交换背景缓冲和前景缓冲,将最近渲染的影像显示在屏幕上
swapBuffers();
}
7.2 游戏循环
游戏由许多子系统构成,输入/输出设备、渲染、动画、碰撞检测、音频等。游戏子系统需要周期性的为游戏提供服务,其周期频率可能各不相同,如动画频率需要和渲染频率保持一致,如30Hz,而动力学模拟系统需要更频繁的更新,如120Hz。可通过使用游戏循环来实现子系统的更新。
7.2.1 游戏循环的架构风格
有许多方法实现子系统的周期性更新,但其核心通常包含一个或多个循环。
1. 视窗消息泵
对于window平台上的游戏,即要处理游戏的逻辑,还要处理window的消息,因此这类游戏都会有一个消息泵,基本原则是先处理window消息,在处理游戏逻辑。
while(true)
{
// 处理window消息
Msg msg;
while(PeekMessage(&msg))
{
TranslateMessage(&msg);
DispatchMessage(&msg);
}
// 开始处理游戏逻辑
RunGameLoop();
}
2. 回调驱动框架
多数游戏引擎中的子系统都是以程序库的方式构建,程序员通过调用这些程序库来是实现相关行为,这种方式的缺陷是:很多程序库比较难用,程序员必须了解各个函数和类的具体使用方式。还有些游戏引擎以框架的方式构建,框架是半成品的应用软件,程序员需要完成框架中的空缺的自定义实现,但几乎无法修改该软件的核心控制流程,因为这些都由框架层面控制。
while(true)
{
for(each frameListener)
frameListener.frameStarted(); // 自己实现具体的frameStarted方法
renderCurrentScene();
for(each frameListener)
frameListener.frameEnded();
finializeSceneAndSwapBuffer();
}
程序员自己要实现一个frameListener以继承引擎的基类FrameListener, 并重写两个虚函数frameStarted,frameEnded。
3. 基于事件的更新
在游戏中,事件是指游戏状态或游戏环境发生的某种变化。多数游戏引擎都会提供自己的事件系统,让各个子系统登记其关注的相关事件类型,当该类事件发生时,子系统便可以一一响应。
有些引擎使用事件系统对子系统进行周期性更新。实现的方式是:子系统在实现周期性更新时,只需要简单的加入事件类型,在事件处理器里,代码便能以任何所需要的周期进行更新,接着,该代码发送一个新事件,并设定该事件在1/60s之后生效,当事件生效后,执行关注了该事件类型的子系统,以此完成一次子系统的周期性更新。
7.3 抽象时间线
7.3.1 真实时间线
我们可以使用CPU高分辨率计时寄存器来度量时间,这个时间在真实时间线上,此时间线的原点为游戏本次启动时间。
7.3.2 游戏时间线
游戏时间线在正常情况下与真实时间线保持一直,但可通过修改游戏时钟实现游戏暂停或减慢。暂停游戏并非停止游戏的主循环,而是修改该循环体内对游戏时钟的操作方式,比如注释掉游戏时钟的更新代码,则与游戏时钟相关的功能将暂停。
7.3.3 局部和全局时间线
播放一个动画,实则是动画在局部时间线上的一次动作,局部时间线的起点对应着动画开始播放时间点。当动画按照正常速率播放时,局部时间线可以看作在全局时间线上的简单映射,两者的长度一致,当动画的播放速率降低一倍,可以看作将局部时间线拉长一倍然后映射至全局时间线上。
10 渲染引擎
10.2 渲染管道
高级的渲染步骤由名为管道的软件架构所实现,管道只是一连串顺序计算阶段,每个阶段对输入流进行相关处理并对输出流产生数据。管道中的每个阶段可以独立与其他阶段,因此每个阶段可以实现并行,如阶段1在处理一个数据元素,阶段2可以同步处理阶段1已经产生的相关结果。管道的吞吐量量度整体每秒产生的数据量,单个阶段的吞吐量衡量该阶段需要多长时间处理单个数据,潜伏期量度单个数据需要花费多长时间才能走完整个管道。管道由多个阶段顺序相连,因此最低吞吐量的阶段将成为整个管道的上限。对于优良设计的管道,所有阶段同步执行,没有阶段需要长期闲置等待其他阶段的数据。
10.2.1 渲染管道概观
管道中,最高级的阶段包括:
- 工具阶段(脱机):定义几何与表面特性(材质);
- 资产调节阶段(脱机):资产调节管道处理几何与材质数据,生成引擎能处理的格式;
- 应用程序阶段(CPU):识别出潜在可是的网格实例,并把它们及其材质递交至图形硬件以供渲染;
- 几何阶段(GPU):对顶点实施变换、照明,接着投影至齐次裁剪空间。可选用几何着色器处理三角形,并用平截头体裁剪三角形;
- 光栅化处理(GPU):将三角形转换为片段,并对片段着色。片段经过多种测试(深度测试、alpha测试、模板测试等)后,最终与帧缓冲混合。
1 渲染管道如何变换数据
工具和资产调节阶段负责处理网格和材质,应用程序阶段负责处理网格实例与子网格,每个子网格关联一个材质。在几何阶段,每个子网格分解成几个顶点,顶点获大规模并行处理,在这一阶段的结尾,完全变换、着色后的顶点重新构成三角形。光栅化阶段,每个三角形分解为片段,如果片段没被裁剪丢弃,其颜色最终会写入缓冲区。
10.2.2 工具阶段
在工具阶段,三维建模式可以借助大量工具创建三维模型,这些模型可以由任何方便的表面描述方式所表达,比如四边形、三角形等各种类型的网格。然而在管道的运行时渲染前,总需要镶嵌成三角形。
工具阶段,美术人员也需要基于材质编辑器确定材质(表面属性),并为材质确定着色器、着色器所需纹理及设置着色器配置参数等。材质可由个别网格存储及管理,但如果这么做会导致大量重复性数据,因为在许多游戏中,少量材质会应用至许多物体当中。为解决这个问题,很多游戏会建立材质库以统一存储管理材质,从中为每个网格挑选合适的材质,以此让网格和材质保持松散的耦合。
10.2.3 资产调节阶段
资产调节阶段本身也是一个管道,其工作时导出、链接、处理多个种类的资产,生成内聚的整体。例如三维模型由几何(顶点和索引缓冲)、材质、纹理、骨骼所组成,该阶段确保三维模型所有涉及到的个别资产均可用,且都已准备好供引擎使用。
资产调节阶段也会计算高级的场景图数据结构,如为静态关卡几何建立BSF树,以加速渲染引擎判断哪些物体需要渲染。
耗时的光照计算也在该阶段完成,这种计算称为静态光照,静态光照可以计算网格顶点上的光照颜色,也可以把每像素的光照信息存于纹理中,这种纹理称为光照贴图。
10.2.4 GPU简史
在游戏开发的早起,所有渲染都在CPU上进行,后来硬件厂商开始开发图形硬件。早起的图形加速器只能处理管道中最耗时的阶段:光栅化,后来这些硬件也能负责一些几何的计算。最初,这些图形硬件只提供硬接线但可配置的的管道实现,这种管道称为固定功能管道,这项技术称为硬件变换及光照。之后该管道内的数个子阶段变成了可编程的,如工程师能编写着色器程序处理顶点及片段。
图形硬件已进化成一种专门的微处理器,称为图形处理器GPU,GPU为最大化管道吞吐量而设计,其使用了大量的并行化处理。即使GPU在完全可编程的形式下,也不能作为通用处理器使用-也不应该如此,因为GPU之所以能达到极高的处理速度,在于其仔细的控制了管道的数据流,有些管道是完全固定功能的,有些时可配置但不可编程的。内存只能在控制范围内存储,且采用了缓存存储了那些不需要重复计算的数据。
10.2.5 GPU管道
几乎所有的GPU都会将管道拆分成下图所述的各个子阶段。
1. 顶点着色器
此阶段是完全可编程的,顶点着色器负责变换及着色|光照顶点,此阶段的输入是单个节点(虽然实际上会并行处理多个节点)。顶点位置和法矢量以模型空间或世界空间表达。此阶段也会进行透视投影、每顶点光照及纹理计算,顶点着色器也可以通过改变顶点位置来产生程序式动画,如模拟风吹草动和水波等。此阶段的输出时变化和光照后的顶点,其空间为齐次裁剪空间。
2. 几何着色器
可选的几何着色器也是完全可编程的,该着色器用以处理以齐次裁剪空间表示的整个图元(三角形、线、点),他能剔除和修改输入的图元,又能生成新的图元,其典型应用包括:阴影体积拉伸、在网格的轮廓边拉伸毛发的鳍、几何动态镶嵌、把线段以分形细分以模拟闪电特效等。
3. 流输出
现在的GPU容许将达致管道阶段的数据回写进内粗,数据能从那里回到管道之初做进一步处理,这一功能称为流输出。有了流输出功能,许多迷人的视觉效果可以不经CPU完成,如头发渲染,以前在实现这一效果时在CPU上进行物理模拟得到三次样条数据,之后继续在CPU上将样条镶嵌为线段。流输出可将该实现移至GPU上实现:通过顶点着色器完成物理模拟,通过几何着色器完成镶嵌过程。之后将处理后的数据输出至管道初始位置进行后续渲染。
4. 裁剪
裁剪阶段是将齐次裁剪空间以外的三角形部分裁减掉,其原理是:首先计算落在裁剪空间以外的顶点,之后求出这些顶点对应的三角形的棱与平截头体面之间的交点,这些交点将会作为裁减后新三角形的顶点。此阶段是固定功能,但提供有限度配额。如除了平截头体平面以外,还可定义其他截面。
5. 屏幕映射
屏幕映射时简单的平移或缩放顶点,是指从裁剪空间转换至屏幕空间,此阶段是固定且完全不可配的。
6. 三角形建立
自三角形建立阶段开始,光栅化硬件迅速的将三家形转换成片段。此阶段是不可配置的。
7. 三角形遍历
三角形遍历将三角形分解成片段(即光栅化),每个片段对应一个像素,通常基于顶点插值获得片段属性值,以供像素着色器使用,该阶段是不可配置的。
8. 提前深度测试
许多显卡能够在此时间点对片段的深度进行测试,若发现片段被帧缓冲中的像素遮挡,这将丢弃该片段,避免后续像素着色器的处理,但并非所有着色器都支持在这一阶段进行深度检测,因为以前的深度检测和alpha检测都是在像素着色器之后进行的,所以这里的测试称为提前深度测试。
9. 像素着色器
像素着色器是完全可编程的,该阶段可对像素进行着色(包括光照和其他处理)。像素着色器可对多个纹理采样、计算每像素光照以及任何影响片段颜色的计算。
10. 合并或光栅运算阶段
管道的最终阶段为合并阶段或光栅运算阶段,该阶段不可编程但是可高度配置,该片段负责执行多个测试,包括:深度测试、alpha测试以及模板测试。若通过测试,就会和帧缓冲中的颜色值混合,混合方式由alpha混合函数决定,该函数时固定的,但可以通过配置其运算符及参数因子实现不同的混合方式。
14 运行时游戏性基础系统
14.1 游戏性基础系统的组件
多数引擎都会以某种形式提供以下的子系统:
- 运行时游戏对象模型:抽象游戏对象模型的实现,供游戏设计师在世界编辑器中使用;
- 关卡管理及串流:此系统负责载入及释放游戏用到的数据内容,许多引擎会在游戏运行时把数据串流至内存中;
- 更新实时对象模型:负责实时更新游戏中的对象;
- 消息及事件处理:大多数游戏对象需要和其他对象通信,对象间的消息很多时候是用来发出游戏中各种状态改变的信号的,此时称这种消息为事件。
- 脚本:很多时候用C\C++等语言编写较为上层的游戏逻辑会过于累赘,此时通常需要使用脚本;
- 目标及游戏流程管理:此子系统管理玩家的目标及游戏的整体流程。
在这些子系统当中,运行时游戏对象模型最复杂,它基本上提供了以下大部分功能:
- 动态的产生与销毁游戏对象:许多引擎会提供一个系统,为动态产生的游戏对象管理内存及相关资源;
- 联系底层引擎系统:多数游戏对象在视觉上以可渲染的三角形网格表示,有些对象有粒子效果,有些有声音,有些有动画,因此需要确保游戏对象能够访问所需要的底层的引擎系统;
- 实时模拟对象行为:游戏引擎需要随时间动态更新游戏对象的各种行为及状态,对象可能需要以某种特定次序进行更新;
- 游戏对象查询:游戏性基础系统必须提供一些方法去搜索游戏世界中的对象;
- 游戏对象引用:当找到了所需的对象,我们需要以某种形式保留对象的引用。
- 有限状态机:许多游戏对象类型的最佳建模方式是使用有限状态机,有些游戏引擎可以令游戏对象处于多个状态之一,而每个状态下尤其属性及行为特征。
14.2 各种运行时对象模型架构
运行时对象模型多数会采用两种架构风格:
- 以对象为中心:游戏对象在运行时以单个类实例或数个相连的实例组成,每个对象所包含的属性及行为都会封装在那些对象实例的类中,而游戏世界则是众多游戏对象的集合;
- 以属性为中心:游戏对象仅以唯一标识符表示,每个对象的属性分布于多个数据表,每个属性类型对应一张表,表以对象的唯一标识符为键,而游戏对象的行为,有对象拥有的属性决定,比如对象定义了血量属性,则该对象能够被攻击、扣血及死亡等,若对象含有网格实例属性,则该对象能被渲染三角网格实例。
14.2.1 以对象为中心的各种架构
1. 单一庞大的类层次结构
当游戏对象模型中所有的类都继承至单个共同的基类时,此类的层次结构就表现的单一且庞大。
2. 深宽层次结构
15 游戏中常用的机制实现方法
15.1 场景数据的下发
更多推荐
所有评论(0)