面向数据的设计(或者为什么使用OOP可能会令自己措手不及)

图片

想象一下这幅图:开发周期即将结束,您的游戏勉强能够满足您的需求,但是在探查器中您找不到明显的问题区域。 谁该怪? 随机存取存储器模式和持久性高速缓存未命中。 为了提高性能,您尝试对代码的各个部分进行并行化,但这值得进行英勇的努力,最后,由于必须添加所有同步,因此加速几乎不明显。 另外,代码是如此复杂,以至于修复错误会导致更多问题,并且添加新功能的想法立即被放弃。 听起来很熟悉?

这样的事件发展相当准确地描述了我过去十年参与开发的几乎所有游戏。 原因不在编程语言或开发工具中,甚至没有纪律。 以我的经验,在很大程度上应该归咎于面向对象编程(OOP)及其周围的文化。 OOP可能无济于事,但会干扰您的项目!

都是关于数据的


OOP渗透了视频游戏开发的现有文化,以至于您想到游戏时,很难想象除了对象之外的其他任何事物。 多年来,我们一直在为汽车,玩家和状态机创建类。 有哪些选择? 过程编程? 功能语言? 异国编程语言?

面向数据的设计是设计用于解决所有这些问题的软件的另一种方法。 过程编程的主要元素是过程调用,而OOP主要处理对象。 请注意,在两种情况下,代码都放在中间:在一种情况下,它们是普通的过程(或函数),在另一种情况下,它们是与某个内部状态关联的分组代码。 面向数据的设计将注意力从对象转移到数据本身:数据的类型,其在内存中的位置以及在游戏中读取和处理数据的方法。

根据定义进行编程是一种转换数据的方式:创建一系列机器指令的动作,这些机器指令描述了处理输入数据和创建输出数据的过程。 游戏不过是一个互动程序,所以只专注于数据而不是专注于处理数据的代码是否更合乎逻辑?

为了不引起您的困惑,我将立即说明:面向数据的设计并不意味着程序是数据驱动的。 数据驱动的游戏通常是一种功能基本上不在代码范围内的游戏。 它允许数据确定游戏行为。 此概念独立于面向数据的设计,并且可以在任何编程方法中使用。

完美的数据


面向对象方法的调用序列

图1a。 用面向对象的方法调用序列

如果我们从数据的角度看程序,那么理想的数据将是什么样? 它取决于数据本身以及如何使用它。 通常,理想数据采用的格式可以轻松使用。 在最佳情况下,格式将与预期的输出结果完全一致,也就是说,处理仅在于复制数据。 很多时候,理想的数据方案看起来像是可以顺序处理的相邻同类数据的大块。 尽管如此,目标是最大程度地减少转换次数。 如果可能,在创建游戏资源的阶段,以这种理想格式提前“烘焙”数据。

由于面向数据的设计将数据放在首位,因此我们可以围绕理想的数据格式创建整个程序的体系结构。 我们不会总是成功地使其完全完美(就像代码很少像教科书中的OOP一样),但这是我们的主要目标,我们始终牢记。 当我们实现这一目标时,本文开头提到的大多数问题都将解决(在下一节中将有更多介绍)。

考虑对象时,我们会立即回想起树-继承树,嵌套树或消息树,并且我们的数据自然以这种方式排序。 因此,当我们对对象执行操作时,通常会导致该对象依次访问树下的其他对象。 在多个对象上进行迭代时,执行相同的操作将为每个对象生成下游完全不同的操作(请参见图1a)。

面向数据的方法的调用序列

图1b。 面向数据的技术中的调用序列

为了获得最佳的数据存储方案,将每个对象分成不同的组件并在内存中对相同类型的组件进行分组可能会很有用,而与我们从中获取对象的对象无关。 这样的排序导致创建大块的同类数据,从而使我们能够顺序处理数据(参见图1b)。 面向数据的设计概念之所以强大,主要是因为它可以与大量对象一起很好地工作。 根据定义,OOP可用于单个对象。 记住上一次您玩过的游戏:在代码中有多少次只需要使用一个元素的地方? 一个敌人? 一辆车? 一种寻找节点的方法? 一发子弹? 一件吗 永不! 那里有一个,那里还有几个。 OOP会忽略这一点,并单独处理每个对象。 因此,我们可以通过整理数据来简化自己和设备的工作,从而有必要处理许多相同类型的元素。

这种方法对您来说看起来很奇怪吗? 但是你知道吗? 最有可能的是,您已经在代码的某些部分中使用了它:即在粒子系统中! 面向数据的设计将整个代码库变成一个庞大的粒子系统。 游戏开发人员似乎可能更熟悉这种方法;必须将其称为粒子驱动编程。

面向数据的设计的好处


如果我们首先考虑数据并在此基础上创建程序的体系结构,那么这将给我们带来很多好处。

平行性


如今,不可能摆脱我们需要使用多个内核的事实。 那些尝试并行化OOP代码的人可以确认任务是多么复杂,容易出错并且可能不是特别有效。 通常,您必须添加许多同步原语,以避免同时从多个线程访问数据,通常许多线程长时间处于空闲状态,以等待其他线程完成工作。 结果,生产率的提高非常中等。

如果我们应用面向数据的设计,那么并行化将变得更加简单:我们拥有输入数据,一个处理它们并输出数据的小函数。 类似的东西可以很容易地划分为几个流,而它们之间的同步最少。 您甚至可以再向前迈一步,并在具有本地内存的处理器上(例如,在Cell处理器的SPU中)执行此代码,而无需更改任何操作。

缓存使用率


除了使用多核之外,在具有深指令流水线和具有多个高速缓存级别的慢速存储系统的现代设备上实现高性能的关键方法之一是实现便于高速缓存的数据访问。 面向数据的设计允许非常高效地使用命令缓存,因为在其中不断执行相同的代码。 另外,如果我们将数据排列在较大的相邻块中,则可以按顺序处理数据,从而几乎完美地利用了数据缓存并获得了出色的性能。

优化选项


考虑对象或函数时,我们通常将重点放在函数甚至算法的优化上:我们尝试更改函数调用的顺序,更改排序方法,甚至以汇编语言重写部分C代码。

这样的优化当然很有用,但是如果您首先考虑数据,我们可以退后一步,创建更具雄心和重要的优化。 不要忘记,该游戏仅处理某些数据(资源,用户输入,状态)到其他数据(图形命令,新游戏状态)的转换。 考虑到此数据流,我们可以根据数据的转换和应用方式做出更高级别,更明智的决策。 在更传统的OOP技术中进行此类优化可能非常复杂且耗时。

模块化


面向数据设计的所有上述优势都与性能有关:缓存使用,优化和并行化。 毫无疑问,对于我们的游戏程序员而言,性能至关重要。 在提高生产率的技术与促进代码可读性和易于开发的技术之间经常会发生冲突。 例如,如果我们用汇编语言重写部分代码,则可以提高性能,但这通常会导致可读性下降,并使对代码的支持复杂化。

幸运的是,面向数据的设计既提高了生产率,又简化了开发。 如果您专门为数据转换编写代码,那么您会得到一些小的功能,这些功能与代码其他部分的依赖关系非常少。 代码库保持非常“平坦”,具有许多没有较大依赖性的“叶”功能。 这种级别的模块化和无依赖关系极大地简化了代码的理解,替换和更新。

测试中


面向数据设计的最后一个主要好处是易于测试。 许多人都知道编写单元测试来测试对象的交互是一项艰巨的任务。 您需要间接创建布局和测试元素。 老实说,这很痛苦。 另一方面,直接使用数据,编写单元测试是绝对容易的:我们创建一些输入数据,调用将其转换的函数,并检查输出是否与预期数据匹配。 仅此而已。 实际上,这是一个巨大的优势,可以极大地简化代码测试,无论是测试驱动开发还是在代码之后编写单元测试。

面向数据的设计的缺点


面向数据的设计不是解决游戏开发中所有问题的“灵丹妙药”。 它确实有助于编写高性能代码和创建更具可读性和易于维护的程序,但它本身也有一些缺点。

面向数据的设计的主要问题是:它不同于大多数程序员所学和惯用的知识。 这需要将我们的程序思维模型转变为90度,并改变其观点。 为了使这种方法成为第二性质,需要实践。

另外,由于方法上的差异,在与以过程或OOP风格编写的现有代码进行交互时可能会造成困难。 单独编写一个函数很困难,但是一旦您可以将面向数据的设计应用于整个子系统,就可以获得很多优势。

使用面向数据的设计


足够的理论和评论。 如何开始实施面向数据的设计方法? 首先,请选择代码的特定区域:导航,动画,碰撞或其他内容。 稍后,当游戏引擎的主要部分将重点放在数据上时,您将能够从帧的开头到结尾沿整个路径调整数据流。

接下来,有必要清楚地识别系统所需的输入数据及其应生成的数据类型。 您可能现在正考虑使用OOP术语,只是为了识别数据。 例如,对于动画系统,部分输入数据将是骨骼,基本姿势,动画数据和当前状态。 结果不是“动画动画代码”,而是当前正在播放的动画生成的数据。 在这种情况下,输出将是一组新的姿势和一个更新的状态。

重要的是应根据输入数据的使用方式退后并对其分类。 它们是只读,读写还是仅写? 由于对程序其他部分的依赖性,这种分类将有助于决定存储数据的位置和何时处理数据。

在此阶段,您需要停止考虑一项操作所需的数据,而开始考虑将其应用于数十个或数百个元素。 我们不再具有一个骨架,一个基本姿势和当前状态:我们拥有每种类型的一个块,每个块中都有许多实例。

仔细考虑在从输入到输出的转换过程中将如何使用数据。 您可能会意识到,要传输数据,您需要扫描结构中的特定字段,然后您需要使用结果来执行另一遍操作。 在这种情况下,将这个源字段划分为一个单独的存储块(可以单独处理)可能更合乎逻辑,这将更好地利用缓存并为潜在的并行化准备代码。 或者,如果需要从不同位置接收数据以将其放入一个向量寄存器,则可能需要对部分代码进行向量化。 在这种情况下,数据将相邻存储,因此可以直接应用向量运算,而无需进行不必要的转换。

现在,您应该对数据有了很好的理解。 编写代码来转换它们将变得更加容易。 就像通过填充空格来创建代码一样。 与相同的OOP代码相比,该代码比您原来想的更简单,更紧凑,您会感到惊喜。

我博客上的大多数帖子都为您准备了这种设计。 现在我们需要注意数据的排列方式,以输入格式烘焙数据,以便可以有效地使用它,并在数据块之间使用没有指针的链接,以便可以轻松移动它们。

还可以使用OOP吗?


这是否意味着OOP是无用的,并且在创建程序时不应该使用它? 我不能那么说 如果我们只讨论每个对象的一个​​实例(例如,图形设备,日志管理器等),则在对象的上下文中进行思考并不会有害,尽管在这种情况下,可以基于更简单的C样式函数和静态函数来实现代码。文件级数据。 即使在这种情况下,设计对象时也要强调数据转换,这一点仍然很重要。

我仍然使用OOP的另一种情况是GUI系统。 可能是因为此处我们正在使用已经以面向对象的方式设计的系统,或者可能是因为性能和复杂性不是GUI代码的关键因素。 不管怎样,我更喜欢GUI API,它们很少使用继承并最大化嵌套(这里的很好的例子是Cocoa和CocoaTouch)。 对于游戏,您可能可以编写面向数据的美观的GUI系统,但到目前为止,我还没有看到这种情况。

最后,如果您更喜欢以这种方式来思考游戏,那么无论如何都无法阻止您基于对象创建心理图景。 只是敌人的本质不会在内存中占据一个物理位置,而是会被分成较小的子组件,每个子组件都构成一个类似组件的大型数据表的一部分。

面向数据的设计与传统的编程方法有点不同,但是如果您始终考虑数据及其转换的必要方法,它将在生产率和易于开发方面为您带来巨大的优势。

Source: https://habr.com/ru/post/zh-CN472052/


All Articles