游戏循环的实现方式

游戏软件本质上是由一个大循环构成的。本文从最简单的渲染循环开始,讨论各种游戏循环的架构风格,接着针对现代多处理器硬件,简要说明一些让游戏引擎利用多核硬件的常见方法。网络游戏的游戏循环比较特殊,最后也会介绍两种最常见的多人游戏循环架构。

1. 基本游戏循环

1.1 渲染循环

相比于Windows的GUI采用矩形失效技术仅让屏幕有改动的部位重绘,现代3D游戏采用和电影相同的方式产生运动的错觉和互动性——对观众快速连续地显示一连串静止映像,即渲染循环,其最简单的结构如下:

while (!quit) {
    // 基于输入或预设的路径更新摄像机变换
    updateCamera();
    // 更新场景中所有动态元素的位置、定向及其他相关的视觉状态
    updateSceneElements();
    // 把静止的场景渲染至屏幕外的帧缓冲(称为“背景缓冲”)
    renderScene();
    // 交换背景缓冲和前景缓冲,令最近渲染的影像显示于屏幕之上
    // (或是在视窗模式下,把背景缓冲复制至前景缓冲)
    swapBuffers();
}

1.2 游戏循环的架构风格

在游戏运行时,多数游戏引擎子系统都需要周期性地提供服务,而它们所需的服务频率各有不同。动画子系统通常需要30Hz或60Hz的更新率,和渲染子系统同步。动力学模拟可能需要更频繁地更新(如120Hz)。像人工智能这种更高级的系统,可能只需要每秒1-2次更新,而且完全不需要和渲染循环同步。

最简单的游戏循环,是采用单一循环更新所有子系统,即在一个无限循环中计算逻辑并渲染画面。此外还有其他常见的架构风格,核心由若干个简单循环组成,再加上不同的修饰。

1.2.1 视窗消息泵

Windows平台下,游戏除了要服务引擎本身的子系统,还要处理来自操作系统的消息。因此需要一段成为消息泵的代码来处理,基本原理是先处理来自Windows的消息,无消息时才执行引擎的任务。这种方法的副作用是设置了处理Windows消息为先,渲染和模拟游戏为后的优先次序,导致当玩家在桌面上改变游戏的视窗大小或移动视窗时,游戏就会愣住不动。典型消息泵代码如下:

while (true) {
    // 处理所有Windows消息
    MSG msg;
    while (PeekMessage(&msg, NULL, 0, 0) > 0) {
        TranslateMessage(&msg);
        DispatchMessage(&msg);
    }
    // 再无Windows消息需要处理,执行“真正”的游戏循环迭代一次
    RunOneIterationOfGameLoop();
}

1.2.2 回调驱动框架

游戏引擎子系统和第三方游戏中间套件既可以以程序库方式构成(提供函数和类供随意调用),也有以框架构成的,程序员需提供框架中空缺的自定义实现(编写回调函数),对应用软件的控制流程只有少量甚至没有。

OGRE引擎提供一套框架,程序员需要从Ogre::FrameListener派生一个类,并覆写两个虚函数:frameStarted()frameEnded(),OGRE在渲染主三维场景的前后会调用这两个函数。

1.2.3 基于事件的更新

在游戏中,事件是指游戏状态的改变,如玩家按下手柄上的按钮、发生爆炸、敌方角色发现玩家等等。多数游戏引擎都有一个事件系统,让各个引擎子系统登记其关注的某类型事件,当那些事件发生时就可以一一回应。

上述提到的以各种频率周期性更新子系统,就需要容许发送未来的事件的事件系统来实现,即事件先置于队列,在设定的时间间隔之后才取出处理。接着,代码可以发送一个新事件,并设定该事件在未来1/30s或1/60s生效,那么这个周期性更新就能一直延续下去。

2. 多处理器的游戏循环

2.1 多处理器游戏机的架构

上述讨论了基本的单线程游戏循环,而随着并行编程的架构和技术的发展,游戏引擎也需要最大化多核硬件的使用率。Xbox 360和PlayStation 3都是多处理器游戏机,为了有意义地讨论并行软件架构,需要先简单了解它们的内核架构。

Xbox 360和PlayStation 3的内核架构

多数现代CPU都会提供单指令多数据(SIMD)指令集,其可以让一个运算同时执行于多个数据之上,此乃一种细粒度形式的硬件并行。游戏中最常用的是并行操作4个32位浮点数,可以让三维矢量和矩阵运算加速至4倍。实际使用SIMD指令时,一般要采用封装良好的三维数学库中的函数来计算。

2.2 分叉与汇合

基本原理是把一个单位的工作分割成更小的子单位,再把这些工作量分配到多个核或硬件线程(分叉),最后待所有工作完成后再合并结果(汇合)。游戏循环应用分治法后,其结构看上去和单线程循环相似,不过更新循环的几个主要阶段都能并行化。

举个例子,若动画混合使用线性插值(LERP),其操作可以独立地施于骨骼上所有关节。假设要混合5个角色的一对骨骼姿势,每个骨骼有100个关节,总共要处理500对关节姿势,可以切割成N个批次,每批次含约500/N对关节姿势。其中N按可用的处理器资源来定,如Xbox 360是3或6(3个核,每核有2个硬件线程),PS3是1-6(视有多少个SPU可用)。

2.3 子系统独立线程与作业模型

主控线程负责控制及同步这些子系统的次级子系统,子线程用于某些需重复执行且较有隔离性的子系统,如渲染引擎、物理模拟、动画管道、音频引擎等。多线程架构需要线程库支持,Windows上会使用Win32的线程API,UNIX上用类似pthread的库。

使用多线程的问题之一就是,每个线程都代表相对较粗粒度的工作量(例如把所有动画任务都置于一个线程,把所有物理任务置于另一线程),这会限制多个处理器的利用率。若某个子系统线程未完成其工作,就可能阻塞主线程和其他线程。为充分利用并行硬件架构,另一种方法是把工作分割成多个细小、比较独立的作业(一组数据与操作代码结合成对),作业准备就绪就加入队列,待有闲置的处理器,作业才会从队列取出执行。PS3的SPURS库的作业模型就实现这种方法,其6个SPU只要有闲置就投入处理细粒度的作业。这样有助于最大化处理器的利用率,也可减少对主线程的限制,自然地对任何数量的处理单元进行扩展或缩减。

子系统独立线程与PS3的作业模型

3. 网络多人游戏循环

3.1 主从式模型

网游在在C/S模型下,大部分游戏逻辑运行在服务器上,客户端仅接收设备输入,渲染,处理音频,处理网络请求,以及加上一些预测玩家的代码(为了不让玩家觉得控制的游戏角色反应非常缓慢)。客户端和服务器不一定要运行于两个独立的机器上,运行在同一个机器上也很常见。网游的游戏循环可以实现为客户端和服务器为完全独立的进程;当两者在同一机器上时,可以置于同一进程的两个线程,或者为了节省本地通信的开销,都置于单个线程,由单游戏循环控制。

必须注意,客户端和服务器的代码可能以不同频率进行更新。假设服务器以20FPS运行(50ms/f),客户端以60FPS运行(16.6ms/f),可以让主游戏循环以频率快者运行(60FPS),服务器每次循环会计算上次更新至今的经过时间,若超过50ms,服务器就会运行一帧,然后重置计时器。

3.2 点对点模型

在这种架构下,游戏中每个动态对象,都由其对应的单一机器所管辖。每个机器对其拥有管辖权的对象就如同服务器,对于其他无管辖权的对象就如同是客户端,只负责渲染远端管辖者所提供的对象状态。主从模型中,客户端和服务器代码分离得比较开,而在点对点模型中,许多代码都要处理(或实现)为两种游戏对象,一种是本机有管辖权的完整“真实”游戏对象,另一种是“代理版本”,仅含远程对象状态的最小子集。

注意点对点架构可以设计得更复杂,如其中一机器离开游戏,则该机器所有对象的管辖权必须转移至其他参与该游戏的机器。若有新机器加入游戏,理想地该机器应接管其他机器的一些游戏对象,以平衡每部机器的工作量。以上的讨论带出的重点是,多人架构对于游戏主循环的结构有深远影响。

参考文献:电子工业出版社《游戏引擎架构》第7.1-7.3、7.6、7.7节

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