你可能已经听说过数据导向型游戏引擎设计,这是一个相对较新的概念,它提出了一种不同于传统的面向对象设计的想法。我将在本文解释数据导向型设计(DOD)的概念,以及为何有些游戏引擎开发者认为它可以实现极佳的效果。
历史渊源
在游戏开发的早期时代,游戏及其引擎是由旧式语言编写,例如C语言。它们属于小众产品,并且对运行缓慢的硬件构成了挑战。在多数情况下,只有少数人能够编写出单款游戏的代码,并且知道整个游戏的代码库。他们所使用的是自己得心应手的工具,C语言可以让他们最大限度地得用CPU——而这些游戏仍然在很大程度上要受制于CPU,并产生了其自身的帧缓冲器,这是极为重要的一点。
因为GPU的出现处理了三角形、纹素、象素等方面的大量工作,我们对CPU的依赖有所减少。与此同时,游戏行业也开始持续发展:越来越多人想玩更多游戏,而这也推动了更多游戏开发团队的形成。
更大的团队需要更好的协作。很快,游戏引擎就因其复杂的关卡、AI、挑选,以及渲染逻辑而要求编码员掌握更多学科的知识,他们所选择的武器就是面向对象设计。
正如Paul Graham曾经所言:
在大型公司中,软件通常是由普通程序员组成(并且频繁变化)的大型团队所编写。面向对象型编程为这些程序员施加了一套准则,避免他们当中的任何一个人造成过多损害。
无论我们是否喜欢,都要承认这个事实——更大型的公司开始制作出更大更精良的游戏,并且随着标准化工具的出现,那些制作游戏的骇客也开始成为能够被轻易更换的零件。特定的单个开发者的优点开始变得无足轻重。
面向对象设计的问题
虽然面向对象设计是个有助于开发者完成游戏等大型项目的优秀理念,但它也产生了一些抽象层次,并且要求人人都瞄准自己的层次而工作,但却无需关心其背后的执行细节,这必定会给我们带来一些麻烦。
我们看到了平行编程的爆发——编码员收集所有处理器核心来生成极快的计算速度,但与此同时,游戏场景也变得越来越复杂,如果我们想跟上这一趋势并仍然输出玩家所期待的效果,我们就必须这么做。通过使用我们手头可用的所有速度,我们开启了一扇全新的大门:例如,使用CPU时间来减少传送到GPU的数据量。
在面向对象编程中,你必须保持在一个目标中的状态,如果你想进行多线程操作,这就要求你引进像同步原语等概念。你你所调用的每个虚拟函数都有一个新的间接层次。而由以面向对象方式编写的代码所生成的内存访问模式可能很糟糕——Mike Action(Insomniac Games,前Rockstar Games成员)就曾列举这方面的例子。
美国卡内基梅隆大学教授Robert Harper也曾发表过类似观点:
面向对象编程本质上兼具反模块化和反平行化的特点,因此不适用于现代CS课程。
要如此讨论面向对象编程(OOP)是一项棘手的事情,因为OOP包含大量属性,并非人人都能对OOP达成一致意见。在这一点上,我多数时候是将OOP视为由C++所执行的设计,因为它是目前游戏引擎领域最广泛使用的语言。
我们知道游戏必须平行化,因为CPU还有大量工作潜能有待开发,并且投入等待GPU完成处理过程的周期只是浪费时间的行为。我们还知道普遍的OOP设计方法要求我们引进昂贵的锁竞争,与此同时,这可能违反缓存局部性,或者在最出乎意料的情况下产生不必要的分支(这可能极耗成本)。
如果我们不能利用多个核心,我们可以持续使用相同数量的CPU资源,即便硬件获得了提升(拥有更多核心)。同时我们可以推动GPU到达其极限,因为它是平行设计,能够同时处理任意数量的工作。这可能会影响我们为玩家提供其硬件上的最佳体验的目标,因为我们没有发挥其所有潜能。
这就产生了一个问题:我们是否应该重新考虑我们的范例?
数据导向型设计
这种方法论的一些推崇者将其称为数据导向型设计,但事实上人们在更早前就获知了其普遍概念。其基本前提很简单:围绕数据结构创建代码,并描述你针对这些结构操作的目标。
我们之前听过这类说法:Linus Torvalds(Linux和Git开发者)曾在一篇博文中指出,他是“围绕数据设计代码而非根据代码设计数据”这一方法的超级拥护者,并将其称为Git获得成功的原因之一。他甚至声称优秀程序员与糟糕程序员的区别就在于他们考虑的是数据结构还是代码本身。
乍一看这似乎有违常理,因为它要求你颠倒自己的思维模式。但不妨这样想:一款游戏在运行的时候,会捕获所有的用户输入方式,以及它所有不依赖外部因素(例如网络或IPC)的高表现性能环节。游戏会消耗用户事件(鼠标移动,摁压摇杆按钮等)以及当前游戏状态,并且会把这些混合成新的数据集合——例如,发送到GPU的批量数据,发送到音频卡的PCM样本,以及一个新游戏状态。
这种“数据流失”可以划分成更多子过程。动画系统需要采用下一关键帧数据和当前状态,并生成一个新状态。粒子系统会采用它的当前状态(粒子定位、速度等)以及一个时间进展以生成一个新状态。挑选算法会采用一系列备选可渲染集合并生成更小的可渲染集合。游戏引擎中几乎一切都可以视为操纵数据块来生成另一数据块的过程。
处理器对应的是本地相关性和缓存实用性。所以,在数据导向型设计中,我们一般会将一切整合在大型而同类阵列中,并且在任何可行的平台运行优秀、缓存一致而强大的算法,以取代一个可能看似更好的算法(它可能具有成本优势,但却无法容纳其运行硬件的框架局限性)。
当我们处理对象时,必须将它们视为“黑匣子”并调用其方法,这反过来会访问数据,并让我们得到自己所需的东西,或者进行我们所需的变化。这有利于维护,但不了解我们的数据布局却可能极大损害表现性能。
例子
数据导向型设计要求我们考虑所有数据,所以我们要做一些不同寻常的操作。先看以下代码:
虽然它已经进行了极大简化,但却是面向对象游戏引擎中的常见模式。但是,如果大量可渲染对象并不可视时,我们就会遇到大量分支误预测了导致处理器废弃其执行的一些指令以便采用特定分支的原因。
对于较小的场景来说,这并不是什么问题。但想想你究竟有多少次遇到这种情况吧,不只是在排列可渲染对象时,在迭代场景照明,阴影地图、区域等时候不也是这样吗?那么AI或动画更新的时候呢?将你在整个过程中所遇到的次数相加起来,看看你究竟排除了多少时脉周期,计算你的处理器有多少次能够以稳定的120FPS速度传送所有的GPU批次,这样你就知道这些情况究竟有多棘手。
如果一名制作网络应用的程序员认为这不过是极小的微型优化那就很有趣了,但我们都知道游戏是即时系统,其资源限制极为严格,所以这种考虑很有必要。
为避免发生这种情况,让我们用另一种方式来看待:如果我们保留引擎中的可视可渲染对象列表会怎样?当然,我们会牺牲掉一个myRenerable->hide()句法,并违反相当数量的OOP准则,但我们之后还可以这样做:
这里没有分支误预测,假设mVisibleRenderables是一个很好的std::vector(这是一个邻接阵列),我们可以像memcpy调用一样对其进行重写。
这里的代码进行了大量简化。但说实话,我们所讨论的还只是一点皮毛。考虑数据结构及其关系可以让我们看到更多之前从未想过的可能。
平行化和向量化
如果我们拥有简单、定义明确且能像基础创建模块那样运行大量数据块的函数,那就很容易生成4个、8个或者16个工作线程,并为每一者都分配一个数据片段,从而令CPU核心保持忙碌状态。不需要互拆器、原子或锁竞争,当你需要数据时,你只需要加入所有线程并等待它们完工即可。如果你需要平行分类数据(这是我们准备发送到CPU的东西之时会频繁遇到的任务),你必须从不同角度来考虑这一点。
在一个线程内,你可以使用SIMD向量指令(例如SSE/SSE2/SSE3) 来实现一个额外的加速。有时候,你可以通过以不同方式布置数据来完成,例如以一个阵列结构(SoA)的方式(例如XXX…YYY…ZZZ…) 而非传统的结构阵列(AoS)方式(XYZXYZXYZ…)来布置向量阵列。
当我们的算法直接处理数据时,它在平行化时就会变得很琐碎,我们也可以避免一些速度上的瑕疵。
单元测试没有外部影响的简单函数很容易进行单元测试。针对你需要反复切换的算法,用回复测试的形式效果更好。
例如,你可以针对一个挑选算法行为创建一个测试组件,设置一个精心安排的环境,并衡量它的执行方式。当你设计一个新挑选算法时,你就要再次运行相同的测试。你衡量性能表现和正确性,这样就可以信手拈来进行评估了。
你越深入研究数据导向型设计方法,就会发现自己越容易测试自己的游戏引擎。
以整体数据结合类和对象
数据导向型设计绝非面象编程的对立面,只是其中的一些理念。因此,你可以用来自数据导向型设计的理念,并仍然使用你所习惯的多数抽象和心智模式。
例如,Matias Goldberg(OGRE 2.0版本开发者)就选择将数据存储在巨大的同类阵列中,并使用迭代整个阵列的函数而非仅迭代单个数据的函数,以便加快Ogre。根据一项基准显示,它现在的运行速度加快了3倍。不仅如此,他还保留了大量自己熟悉的旧类抽象,所以其API并没有经过彻底的重写。
总结
有大量证据显示这种方法可以创造游戏引擎。
Molecule Engine开发博客就有一系列标题为《Adventures in Data-Oriented Design》文章包含大量与DOD成功运用的范例。
DICE似乎也对数据导向型设计颇有好感,他们在Frostbite Engine的挑选系统中采用了这一方法。
除此之外,之前提到的开发者Mike Acton也似乎接纳了这一理念。有一些基准显示这一方法的确极大提升了性能表现,但我还没有看到数据导向型设计的活跃运用。当然,它也可能只是昙花一现,但其主要前提却似乎极为合乎逻辑。这当然与行业的惰性有关(其他软件开发领域亦是如此),所以这也可能会阻碍这一方法的大规模运用。