阐述数据导向型游戏引擎设计的概念
你可能已经听说过数据导向型游戏引擎设计,这是一个相对较新的概念,它提出了一种有别于传统面向对象设计的思路。本文将详细解释数据导向型设计(DOD)的概念,以及为何有些游戏引擎开发者认为它能实现极佳的效果。
历史渊源
在游戏开发的早期,游戏及其引擎多由旧式语言(如C语言)编写。彼时,游戏属于小众产品,运行硬件速度缓慢,开发面临诸多挑战。多数情况下,仅有少数人能够编写单款游戏的代码,并熟知整个游戏的代码库。他们使用得心应手的工具,C语言能让开发者最大限度地利用CPU。不过,这些游戏在很大程度上仍受制于CPU,并且需要自行产生帧缓冲器,这是一个极为关键的特性。
随着GPU的出现,它承担了三角形、纹素、像素等方面的大量处理工作,我们对CPU的依赖有所降低。与此同时,游戏行业持续发展,越来越多的人渴望玩更多的游戏,这推动了更多游戏开发团队的形成。
(摩尔定律表明,硬件呈指数式发展,并非与时间成线性关系)
更大的团队需要更高效的协作。很快,游戏引擎因复杂的关卡、AI、挑选以及渲染逻辑,要求编码员掌握更多学科的知识。此时,面向对象设计成为了他们的选择。
正如Paul Graham所言:“在大型公司中,软件通常由普通程序员组成(并且人员频繁变动)的大型团队编写。面向对象编程为这些程序员制定了一套准则,避免其中任何一人造成过多损害。”
无论我们是否认同,事实是更大型的公司开始制作出规模更大、品质更精良的游戏。随着标准化工具的出现,那些制作游戏的开发者逐渐成为可轻易替换的“零件”,特定单个开发者的优势变得不再重要。
面向对象设计的问题
尽管面向对象设计是一种有助于开发者完成游戏等大型项目的优秀理念,但它也带来了一些问题。它引入了一些抽象层次,要求开发者专注于自己负责的层次,而无需关心背后的执行细节,这必然会引发一些麻烦。
如今,并行编程蓬勃发展,编码员利用所有处理器核心来实现极快的计算速度。同时,游戏场景变得越来越复杂,为了跟上这一趋势并满足玩家的期待,我们必须充分利用手头的所有计算资源。通过使用所有可用的速度,我们开启了新的可能性,例如使用CPU时间来减少传送到GPU的数据量。
在面向对象编程中,需要保持对象的状态。若要进行多线程操作,就需要引入同步原语等概念。每次调用虚拟函数都会增加一个新的间接层次。而且,以面向对象方式编写的代码所产生的内存访问模式可能不佳,Mike Action(Insomniac Games,前Rockstar Games成员)曾列举过相关例子。
美国卡内基梅隆大学教授Robert Harper也有类似观点:“面向对象编程本质上兼具反模块化和反并行化的特点,因此不适用于现代CS课程。”
讨论面向对象编程(OOP)并非易事,因为OOP包含众多属性,并非所有人都能达成一致意见。在此,我大多将OOP视为由C++实现的设计,因为C++是目前游戏引擎领域使用最广泛的语言。
我们知道游戏必须实现并行化,因为CPU仍有大量的工作潜能有待挖掘,而让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》的文章,其中包含了大量数据导向型设计成功应用的范例。
DICE似乎也对数据导向型设计颇为青睐,他们在Frostbite Engine的挑选系统中采用了这一方法。
此外,之前提到的开发者Mike Acton也似乎接受了这一理念。一些基准测试显示,这种方法确实极大地提升了性能表现。不过,目前尚未看到数据导向型设计的广泛应用。当然,它也有可能只是昙花一现,但它的主要前提似乎非常合乎逻辑。这可能与行业的惯性有关(其他软件开发领域亦是如此),这种惯性可能会阻碍这一方法的大规模应用。