【文档翻译】面向数据设计(以及为啥用OOP可能会搬起石头砸自己的脚)

发布时间 2023-11-09 09:43:41作者: ClickForWhat

本文档译自 gamesfromwithin.com 的文章"Data-Oriented Design(Or Why You Might Be Shooting Yourself In The Foot With OOP)",作者 Noel,原文参见此处


概述 - Overview

想象一下:在开发周期的末尾,你的游戏卡的像乌龟在爬,但是你却没有在 profiler 发现任何明显的性能热点。真正的元凶?其实是随机访问的模式和经常性的 cache misses。为了提高程序的性能,你尝试把一部分代码并行处理。然而到了最后,由于不得不进行一堆线程间的同步/等待,你最后只获得了一丝丝的性能提升。最糟糕的是,代码变得更加复杂,修复一个 bug 将会产生更多的 bug。添加更多功能的想法立刻被现实打得烟消云散。听起来是不是很熟悉?

这个场景非常准确地描述了我过去十年参与的几乎所有游戏。问题的原因不是我们所使用的编程语言,不是开发的工具链,更不是缺乏规范之类的东西。根据我的经验,面向对象编程(OOP)和围绕它的文化在很大程度上要为这些问题负责。面向对象编程可能会阻碍你的项目,而不是帮助它。


一切关于数据 - It's All About Data

OOP 在当前的游戏开发文化中如此根深蒂固,以至于在思考游戏时很难超越“对象”这一概念。毕竟,多年来我们一直在创建代表着交通工具、玩家和状态机的类。那么,替代的方案是什么?面向过程编程?函数式编程?还是某些奇异的编程语言(译注:这里是指 Exotic programming languages 的概念,详见这里这里)?

要解决所有这些问题,面向数据的设计是一种与众不同的程序设计方法。面向过程编程以处理过程调用为主,而 OOP 主要处理对象。请注意,这两种方法都把目光聚焦到代码中:一种是关注普通过程(或函数),另一种是与某些内部状态相关的一组代码。面向数据的设计将编程的视角从对象转移到数据本身:数据的种类,如何在内存中布局,以及如何在游戏中读取和处理数据。

编程,按照字面的定义,其实就是关于如何变换、处理数据:它是一种产生一系列机器指令的行为,描述如何处理输入数据并创建一些特定的输出数据。游戏也不过是一个以交互速率运行的程序,所以我们应该把注意力集中在数据上,而不是操作数据的代码上。

我想在这里澄清一下某些疑惑,并强调面向数据的设计并不意味着我说的某些东西是数据驱动的。数据驱动的游戏通常是指在显式的代码之外显示出大量的功能/特性,并让数据决定游戏行为的游戏。这和面向数据的设计并不属于同类概念,它可用于任何类型的编程方法。


完美的数据 - Ideal Data

如果我们从数据的角度来观察一个程序,理想的数据是什么样的呢?这取决于我们要如何使用这些数据。通常来说,我们花费最少的力气就能操作、处理的数据就是好的数据。在最好的情况下,这种数据直接就和我们期望输出的数据一致,这样处理就仅限于复制、移动该数据。通常情况下,理想的数据布局是可以按顺序处理的大型连续的、同构的数据块。在任何情况下,我们目标都是尽量减少转而处理其他格式数据的次数,并且在资产构建过程中,只要有可能,就应该离线将数据烘焙成这种理想的格式。

因为面向数据的设计将数据放在首位,所以我们可以围绕理想的数据格式构建整个程序。当然,我们并不总是能够使它完全地理想(同样的道理,代码也很少是按书本上的 OOP 标准范式编写的),但这是要时刻记住的主要目标。一旦我们实现了这一点,我在本文开头提到的大多数问题就会消失(下一节将详细介绍)。

当我们想到对象时,我们会立即想到继承树、容器树或消息传递树之类的东西,我们的数据就以这样的方式自然地排列。这就导致,当我们对一个对象执行操作时,通常会导致该对象依次访问树中更下方的其他对象。对一组相同行为的对象进行遍历会产生一连串调用,这每个调用实际上执行了完全不同的操作,如图 1a 所示。


image


为了实现最佳的数据布局,最好将每个对象分解为不同的组件,并将相同类型的组件为一组放在内存中,而不管它们来自什么对象。这种组织方式可以产生大量同构数据块,允许我们按顺序处理数据(如图 1b)。


image


面向数据的设计如此强大的关键原因就是它在大型对象集合上工作得非常好。而面向对象,顾名思义,工作在单个对象上。可是,回想一下你所参与的上一款游戏:在代码中有多少个地方你只使用单独的对象?游戏里只有一个敌人?只有一个粒子?绝无可能!游戏里有成千上万个这样的东西。OOP 忽略了这一点,它孤立地处理每个对象。相反,我们可以让事情对我们,以及我们的硬件更轻松,并以另一种方式组织我们的数据来处理具有大量相同类型的东西。

这听起来是不是很奇怪?你猜怎么着,你可能已经在代码的某些部分做过类似的东西——比如粒子系统!面向数据的设计正在把我们的整个代码库变成一个巨大的粒子系统。也许游戏程序员会对这种名字更熟悉——"粒子驱动编程"。


面向数据设计的优势 - Advantages of Data-Oriented Design

首先考虑数据并在此基础上构建程序会带来很多好处。

并行化 - Parallelization

如今,我们需要处理多个CPU核心,这是无法回避的事实。任何尝试过将一些 OOP 代码并行化的人都可以证明这是多么困难、容易出错,而且可能效率不高。通常,你最终会添加大量同步、原子语句来防止多个线程同时读写同一数据,并且许多线程最终会空转一段时间,等待其他线程完成它们的任务。因此,多线程对性能的改善可能微乎其微。

当我们应用面向数据的设计时,并行化变得简单得多:我们有输入数据、一个处理它的小函数和一些输出数据。我们可以很容易地将这样的东西分割到多个线程中,并最小化它们之间的同步。我们甚至可以更进一步,在具有本地内存的处理器(如Cell处理器上的spu)上运行该代码,而不需要做任何不同的事情。

缓存利用率 - Cache utilization

除了使用多核心之外,在现代硬件中实现出色性能的关键之一是进行缓存友好的内存访问,因为它具有多级指令流水线,以及具有多层快速缓存加慢速内存的体系。面向数据的设计可以非常有效地使用指令缓存,因为相同的代码会被反复执行。此外,如果我们将数据放置在大的、连续的块中,就可以按顺序处理数据,从而获得近乎完美的数据缓存利用率和出色的性能。当我们想到对象或函数时,我们倾向于在函数甚至算法级别上进行优化;重新排序一些函数调用,改变排序方法,甚至重写一些C代码。

这种优化当然是有益的,但首先考虑数据,我们就可以往后退一步,进行更大、更重要的优化。记住,游戏所做的只是将某些数据(资产、输入、状态)转换为其他数据(图像命令、新的游戏状态)。时刻关注数据流,我们可以根据数据的变换方式和使用方式做出更高层次、更明智的决策。用更传统的 OOP 方法实现这种优化可能非常困难和耗时。

模块化 - Modularity

到目前为止,面向数据设计的所有优势都是基于性能:缓存利用率、代码优化和并行化。毫无疑问,作为游戏程序员,性能是我们非常重要的目标。不过,在提高性能的技术和帮助可读性、易于开发的技术经常存在冲突。例如,用汇编语言重写一些代码可以提高性能,但通常会使代码更难阅读和维护。

幸运的是,面向数据的设计对性能和开发难易度都有好处。当编写专门用于处理数据的代码时,最终得到的通常是较小的函数,而且对代码的其他部分依赖性很小。代码库最终非常扁平,有很多互相没有依赖关系的叶函数(leaf functions)。这种级别的模块化和依赖关系的减少使得理解、替换和更新代码更加容易。

测试 - Testing

面向数据设计的最后一个主要优点是易于测试。正如我们在6月和8月的 Inner Product 专栏中看到的,编写单元测试来检查对象间的交互不是一件简单的事情。你需要设置模拟并间接地进行测试。坦白地说,这有点痛苦。另一方面,当直接处理数据时,编写单元测试再容易不过了:创建一些输入数据,调用处理函数,然后检查输出数据是否符合我们的预期。然后,没有别的了。这实际上是一个巨大的优势,使得代码非常容易测试,无论是在进行测试驱动开发,还是只是在代码之后编写单元测试。


面向数据设计的劣势 - Drawbacks of Data-Oriented Design

面向数据的设计不是解决游戏开发中所有问题的灵丹妙药。它确实能极大地帮助我们编写高性能代码,使程序更易于阅读和维护,但它本身也有一些缺点。

面向数据设计的主要问题是,它不同于大多数程序员的习惯或是在学校学到的东西。这需要我们对程序的思维模式进行90度的转变,并改变我们对它的思考方式。在它成为第二天性之前,它需要一些练习。

此外,因为它是一种如此不同的方法,所以与现有代码(这些代码以面向对象或过程的方式编写)进行交互可能颇具挑战。孤立地编写单个函数是很困难的,但是只要能够将面向数据的设计应用到整个子系统,你就应该能够获得很多好处。


使用面向数据设计 - Applying Data-Oriented Design

理论和综述的环节已经够多了。如何开始面向数据的设计呢?首先,在你的代码中选择一个特定的区域:寻路、动画、碰撞或者其他东西。之后,当你的大部分游戏引擎逻辑都围绕着数据展开时,你就可以思考从帧开始到结束的所有数据流了。

下一步是明确识别系统所需的数据输入,以及需要生成什么样的数据。现在用 OOP 的术语来帮助思考是OK的,只是为了帮助我们辨明数据。例如,在动画系统中,输入数据是骨骼、基本姿势、动画数据和当前状态。而生成的结果不是"播放动画"这种概括性的简单事件,而是当前正在播放的动画(根据规则)所生成的数据。在这种情况下,我们的输出将是一组新的姿势和一个新的状态。

重要的是要更进一步,根据输入数据的使用方式对其进行分类。是只读、读写还是只写?根据与程序其他部分的依赖关系,这种分类将有助于指导在哪里存储数据以及何时处理数据。

此时,不要再考虑单个操作所需的数据,而是考虑将其应用于数十或数百个条目。我们不再只有一个骨骼、一个基本姿势和一个状态,取而代之的是每个这些类型的块,而其中有许多实例。

我们需要非常仔细地思考在从输入到输出的转换过程中使用数据的方式。你可能意识到某些时候需要遍历结构中的特定字段来执行数据处理,然后需要使用处理结果来执行另一次处理。在这种情况下,将字段分割成一个可以独立处理的单独内存块可能更有意义,从而允许更好的利用缓存和潜在的并行化。或者可能需要对代码的某些部分进行向量化,这需要从不同位置获取数据,然后将其放入同一个向量寄存器。在这种情况下,数据连续存储,因此可以直接应用矢量操作,而不需要任何额外的转换。

现在你应该对数据有了很好的理解。编写转换它的代码要简单得多。就像在空白的地方写代码一样。你甚至会惊喜地发现,与等效的 OOP 代码相比,代码比最初想象的要简单得多,也要小得多。

如果你回想一下去年我们在本专栏中讨论的大多数主题,就会发现它们都是朝着这种类型的设计方向发展的。现在是时候回去看看 "数据如何对齐(2008年12月和2009年1月)","将数据直接烘焙成可以有效使用的输入格式(2008年10月和11月)",或者 "在数据块之间使用非指针引用,以便它们可以轻松重新定位(2009年9月)" 等主题了。


面向对象还有空间吗? - Is There Room For OOP?

这是否意味着 OOP 是无用的,你不应该在你的程序中应用它?我还没打算这么说。当每个对象只有一个(图形设备、日志管理器等)时,从对象的角度考虑并不是有害的,尽管在这种情况下,你还可以使用更简单的 C 风格函数和文件级静态数据来编写它。并且即使在这种情况下,围绕数据设计这些对象仍然很重要。

我发现自己仍然使用 OOP 的另一种情况是 GUI 系统。这可能是因为你正在使用一个已经以面向对象的方式设计的系统,也可能是因为性能和复杂性不是 GUI 代码的关键因素。在任何情况下,我都更喜欢轻量级继承和尽可能使用容器的 GUI API (Cocoa和CocoaTouch就是很好的例子)。面向数据的 GUI 系统很有可能是为游戏编写的,但我还没有看到这样的游戏。

最后,如果你喜欢按面向对象的方式来思考游戏,那么没有什么能阻止你在脑海中出现对象的概念。只是记住敌人实体不会全部位于内存中的同一物理位置。相反,它将被分割成更小的子组件,每个子组件构成类似组件的更大数据表的一部分。

面向数据的设计与传统的编程方法有点不同,但是经常性地考虑数据以及如何处理数据,你将能够在性能和易于开发方面获得巨大的好处。

感谢 Mike ActonJim Tilander 多年来对我的想法提出的挑战,以及他们对本文的反馈。

本文最初发表于2009年9月的《游戏开发者》杂志。