作者: Jeff Andrews
译者:百年孤寂
设计一个功能可分解的、数据可分解的系统可以提供大规模的并行化执行,同时保证发挥多核处理器的性能。
随着多核心处理器的降临,对可并行计算游戏引擎的需求已经变得越来越重要了。尽管仅仅依靠GPU和单线程游戏引擎依然是可行的,但是在一个系统上使用多核处理器所具有的优势会给用户带来更深刻的体验。譬如,使用多核CPU一个游戏可以增加更多的物理刚体对象来提升效果,或者开发出更加智慧的类人化的AI。
并行化游戏引擎框架,或者称为多线程引擎,目的是在开发平台上利用所有的处理器来提升性能。(引擎)通过并行化处理,各个功能模块可以利用所有可用的处理器。当然,说比做要容易,毕竟在游戏引擎中很多东西是互相交叉的,这通常会引起线程错误。因此,需要设计一套系统来合适地处理数据同步问题,同时避免被同步锁所限制。此外,也需要一套方法来保证在并行方式下处理数据同步时使串行处理消耗尽可能小。本文要求读者需要对现代计算机游戏发展以及游戏引擎线程编程有很好的理解和工作经验。
2.并行处理态
并行处理态的概念对于一个高效的具有多线程运行时态的引擎来说是非常重要的。引擎如果要实现真正意义上的并行处理——即尽可能少的同步损耗,则需要引擎内部各个系统在运行时坐到尽量少的交互。尽管数据需要共享,但是现在每个系统都应该有自己的一份数据拷贝,而不是通过一个公共的方式来访问数据。这样各个系统之间将不再有数据依赖关系。任何一个共享数据的变化都会被送到一个状态管理器那里,并且被加入一个变化队列,不妨称作消息提示队列。一旦各个系统完成处理任务,他们将会被提示改变自己的状态,同时更新各自内部的数据结构(作为消息队列的一部分)。使用这一机制将会大大减少同步损耗,使得各个系统能更加独立地工作。
2.1执行模式
当各个系统同步运行时(即各系统的操作被限制在同一个时钟内),对于执行状态的管理将会达到最优。这个时钟的频率可以等于帧速率,当然这并不是绝对的。这个时钟的频率甚至可以不是一个固定的值,然而若使这个跨度等于处理一帧所需要的时间——无论这一帧有多么长,我们就可以完全不考虑频率了。你对执行态的管理的实现将会决定这个时钟跨度。图示1描绘了不同系统在使用自由的时钟步进时的状态,这种状态下这些系统并非在同一个时钟内完成执行。除此之外,图示2描绘了所有系统在同一个锁定的时钟下是如何执行的。
![设计并行化游戏引擎的框架1 - 自由骑士笃志 - 永恒的遗失古都-亚特兰蒂斯](http://img.ph.126.net/mThy1TIc1AUCzwbzMqylzQ==/1610599816838726180.jpg)
图示1. 自由步进模式下的执行态
2.1.1 自由步进模式
在这一模式下系统的运行时间取决于任务所需要的时间。这里的自由并非指系统在完成任务之前是不自由的,而是指系统可以自由选择需要使用的时钟数。
在这个方式下,一个普通的对于状态变化的提示对于状态管理器来说是不够的,相关的数据也需要被包含在该提示中。这是因为当一个系统修改了共享数据时它仍有可能还在执行,而这时别的系统也需要更新这些数据。这就需要越来越多的内存做备份,这种方式显然不是最理想的。
2.1.2 锁定步进模式
这一模式要求所有的系统在同一个跨度内完成各自的处理。这样既易于实现同时又不需要将数据附加在提示中,因为系统的状态发生变化时可以在运行周期的结尾简单地通过访问别的系统来获取数据。
锁定步进模式可以通过在多个步骤中进行交叉执行来实现一个假的自由步进模式。譬如当AI在第一个时钟计算出它初始的“宏观视角”下的目标后,在下一个时钟内它可以在宏观目标下关注更具体的目标,而不仅仅是重复上一个宏观目标。
图示2. 锁定步进下的执行态
2.2 数据同步
基于多个系统可以对同一个共享数据做出改变,那么就需要确定在这些变化中到底那个值才是正确且可以使用的。有两种机制来解决这个问题:
l 时间,最后一个做出变化的系统的值是正确的。
l 权限,具有更高权限的系统的值是正确的。当多个系统拥有相同权限时可以与时间机制结合使用。
在这两种机制下,那些被认为是旧的数据将会被覆盖或者从提示队列中抛弃掉。
因为数据是共享的,那么在给数据赋相对值时可能因为这些数据是没有顺序的而变得难以掌握。为了消除这一障碍,当系统更新数据时使用绝对值来赋值以达到新旧交替。绝对值和相对值的结合使用是比较理想的,但是这也要根据情况而定。譬如,像位置,朝向这种公共数据,应该用绝对值来标识,这是因为在创建一个变换矩阵时需要考虑接收数据的顺序。然而,一个创建粒子的系统,在完全拥有粒子信息的情况下,可以只做相对值的更新。
3.引擎
设计引擎时应关注结构的弹性,以使得在扩展功能时更加简便。基于此,引擎在各种受到限制(譬如内存)的平台上应用时可以很好地做出调整。
引擎由两部分组成,一部分是框架,另一部分是管理器。框架(章节3.1)包含了游戏中会重复出现的拥有多个实例的那些部分,同时也包含那些出现在主循环的东西。管理器(章节3.2)作为单件存在并且独立于游戏逻辑。
下面的图描述了组成引擎的各个部分:
图示 3:引擎的高级框架
值得注意的是,处理游戏的功能,即某个系统,是与引擎区别对待的。基于模块化的目的,将引擎作为一种“胶水”将各个功能联结起来。模块化使得系统可以按照需要进行加载或者卸载。
接口是引擎和系统之间进行通信的途径。在系统实现了接口之后引擎就可以使用系统的功能了,相反在引擎实现了接口之后系统也可以访问引擎中的管理器。
附录A对这一概念做出了更加清晰的解释,“引擎示例图”。正如章节2所言,“并行执行态”的概念使得系统在本质上是离散的。这样系统在并行运行时就不会互相干扰。然而这种并行在系统之间需要通信时无法保证数据的稳定。系统间通信的理由有两个:
l 通知另一个系统共享数据已经发生了变化。(譬如位置,朝向)
l 请求一些自身并不包含的功能。(譬如AI系统要求地形/物理系统执行一次射线碰撞检测)
第一个通信问题通过实现前一章所述的状态管理器来解决。状态管理器将在章节3.2.3“状态管理器”进行更详细的讨论。
要解决第二个问题,需要在系统中加入一个用来给不同系统提供服务的机制。章节3.2.3“服务管理器”将会进行深入的解释。
3.1 框架
框架的作用是把引擎中不同的部分联结起来。引擎的初始化将在框架内完成,但是管理器的初始化是全局的,不受框架影响。场景的信息同样也保存在框架内部。基于弹性的考量,场景,或者称为通用场景,等于仅仅作为容器组成整个场景的通用对象。章节3.1.2对此提供了更详细的信息。
游戏循环同样在框架内执行,下面是游戏循环的流程:
图示 4:游戏主循环
由于引擎运行在一个窗口环境,那么游戏循环的第一步就是处理来自操作系统的窗口消息。如果这些消息没有被处理那么引擎也不需要做额外的工作。下一步是由调度器向任务管理器发布系统的任务。这一部分将在章节3.1.1进行更详细的讨论。接下来,由状态管理器(章节3.2.2)跟踪的消息被分发给需要做出响应的部分。最后,由框架来确认执行的状态并决定引擎是否退出,还是继续执行其他的任务,譬如进入下一个场景。引擎的执行态由环境管理器负责,这一部分将在章节3.2.4进行讨论。
3.1.1 调度器
调度器管理主时钟供执行时使用,主时钟频率应该是事先设置好的。时钟频率也可以是没有限制的,譬如在基准测试模式下需要时钟可以在运行结束前就停止。调度器通过任务管理器在一个时钟长度内将系统进行注册。在自由步进模式下(章节2.1.1)调度器和系统进行通信来决定系统完成执行所需要的时钟数,以及哪些系统做好了执行的准备或者在某一个时钟后完成执行。锁定步进模式(章节2.1.2)下所有的系统的起始和结束都分别在同一个时钟内,因此调度器只需要等待系统完成执行即可。
3.1.2 通用场景和对象
通用场景和对象作为某些功能的容器存在于系统之中。通用场景和对象自身并不拥有任何功能,除了与引擎进行交互的功能。然而它们可以被扩展成包含系统功能的容器。由此这些容器可以在松耦合关系下接管可用系统的属性,而不必与某个特定的系统进行粘合。松耦合这一特点使得系统之间可以互相独立,从而使得并行执行成为可能。下面的图标描述了通用场景和对象在系统内的扩展:
图示 5:通用场景和对象的扩展
扩展的工作实例如下:一个通用场景被扩展成可以包含图形、物理,以及其他属性的容器。图形场景扩展用来初始化屏幕和其他渲染对象,物理场景扩展用来设置刚体世界,譬如重力等等。场景包含对象,因此,一个通用场景会拥有若干通用对象。一个通用场景也可以被扩展成为包含图形、物理,以及其他属性的容器。图形对象扩展用来具体渲染屏幕上的某一对象,物理对象扩展用来进行刚体之间的碰撞交互。引擎与系统之间的进一步的关系可以在附录B的图示“引擎与系统关系图”中查看。
另一点需要指出的是,通用场景和通用对象需要将各自的扩展通过状态管理器进行注册,以此来响应其他扩展(譬如系统)造成的由变化产生的提示。譬如,某个图形扩展在注册后,可以捕获由物理扩展造成的位置和朝向的变化所产生的提示。
更多的关于系统组件的信息可以在章节5.2“系统组件”找到。