Subsections

基于组件的对象

面向组件设计算是面向数据的高层设计的好开始。用组件开发,就是正确的思考第一步,避免不必要地将概念联系起来。这样构建对象,更容易按类型,而非实例去处理,也更好做性能分析。游戏开发中经常看到围绕它们建立的实体系统,实体有了数据驱动的功能集,设计人员因此可以触及程序员领域的东西。基于组件的实体不仅能更快速设计变化,且更易摆脱单体对象。大多数游戏设计师会要求更多新功能组件,而非扩展现有的。大多数新设计都需要迭代,而通过代码扩展现有组件来适应设计上的变化,游戏设计师无法来回切换,尝试不同的东西。通常情况下,添加另一个组件作为扩展或替代,会更灵活。

讨论面向组件开发时,一个问题是,有多少种不同类型的实体组件系统?为避免歧义,我们会描述一些面向组件设计不同的工作方式。

大多数人用到的第一种面向组件的方法是,复合对象。有些引擎是这样实现,其中的大多数利用其脚本语言的力量,从而以实现灵活、设计者友好的,编辑、创建组件中的对象。例如,Unity 的 GameObject 是基础实体类型,它会将组件添加到特定实例的组件列表中,这样就引入了组件。它们都建立在核心实体对象上,并通过它相互引用。这样,就表示每个实体仍然倾向于通过迭代根实例(root instance)来更新,而不是通过系统迭代。

通常讨论到创建复合对象时,常提到使用组件直接组成对象,将它们作为对象的成员。尽管优于单体类,但它仍不是完全基于组件。这种技术用组件让对象更易读,复用性,应对变化也更稳健。这些系统有足够的扩展性,足以支撑在项目间共享大型组件的生态。Unity资源商店 就从快速开发角度证明了组件的价值。

引入基于组件的实体时,就有机会颠覆定义对象的方式。在面向对象设计中,通常定义对象就是是命名它,然后在必要时填写细节。例如,汽车对象被定义为汽车,如果不扩展载具,那至少包括一些关于物理和网格的数据、车轮、车壳模型资源等的构造参数,可能还会根据是否属于 AI 或玩家而改变类别。面向组件设计中,定义对象没那么死板,也不是在命名后才变成定义,而是选择或编译定义,然后在必要时标记名称。例如,用四轮物理实例化物理组件,为每部分(车轮、外壳、悬架)实例化可渲染物,添加 AI 或玩家组件来控制物理组件的输入。增加的所有这些一起,我们将其标记为汽车;或者就不管它,让它成为隐式定义,而非显式和不变的定义。

真正基于组件的对象只不过是其各部分的总和。这意味着基于组件的对象的定义,也不过是带一些构造参数的列表。于是就与对象或定义无关,重构和重新设计也更容易。Unity 的 ECS 就是这样的解决方案。在 ECS 中,实体是无形的、隐式的,组件才是头等市民(first class citizen)。

野生组件

基于组件的开发方式经过了验证。许多著名工作室都通过使用组件驱动的实体系统,获得了巨大成功 [*],因为他们的开发人员很清楚,对象并不是存储所有数据和特征的好地方。对一部分人来说,这是个机会,借由展示用简单的组件,组成相当复杂的实体。设计师和 Mod 作者由此能够推理出,如何才让变化符合游戏框架。还有一部分人,意味着权力移交给了性能,因为组件更容易整合为由数组构成的结构 (structure-of-arrays) 来处理。

Gas Powered Games 发表的 Dungeon Siege Architecture 可能是最早有关游戏公司使用基于组件的方法的文档了。如果有机会,请读者务必读读这篇文章[#!sbilas!#],看看梦开始的地方。文章中解释道,使用组件意味着实体类型 [*] 什么能力都不需要。相反,所有属性和功能,都来自于实体的组件。

迁移到由管理器驱动,基于组件的方法有很多理由。接下来我们的尝试,至少涵盖其中几个。稍后,我们会讨论清晰的 update() 序列的好处;会提及组件如何让调试更容易;会谈到对象中,将意义应用于数据带来的问题、耦合,以及随着以对象为中心的实体瓦解,实例的专横如何得以缓解。

本节会展示如何将一个现有类,以基于组件的方式重写。这里要处理的是一个相当典型的复杂对象,玩家(player)类。通常,这种类很快会混乱、失控。本例中,假设玩家类是为常规的第三人称动作游戏设计的,并以典型的混乱作为起点。见代码 [*]


\begin{lstlisting}[caption={巨大的玩家类}, label={lst:4.1}, captionpos=b]
...
...
int SPEED, JUMP, STRENGTH, DODGE;
bool cheating;
};
\par
\end{lstlisting}

这个例子包含许多能在游戏中找到的类型,其中代码库已经有序开发了许久。玩家类通常有很多辅助函数,方便其他实现。从保存数据到渲染至屏幕,辅助函数通常将玩家类本身视为一个实例。玩家类常几乎涉及到游戏的方方面面,因为人类玩家是代码首要目标,玩家类也会引用几乎所有东西。

若 AI 角色更泛化,而不是更专门,那它们也会有类似奇怪的类。较小机器中的游戏里,专门的 AI 就比较普遍;但现在,因为玩家类在游戏过程中要与许多 AI 互动,所以它们往往会像玩家类一样,被统一为一种类型,如果与玩家不一样,则有助于简化互动的代码。截至目前,区分 AI 的方式主要是通过数据,而行为树则是驱动 AI 思考的主舞台。行为树属于另一个有多种解释的概念,所以有些形式是面向数据设计,有些则不是。

远离层级结构

人们从面向对象的游戏类层级结构,转向基于组件的方法时,常在文章和总结中提到这样一个主题:把类变成较小对象的容器的过渡状态,通常称为组合。这种过渡形式会用一个现有类,找到它内部概念之间的界限,尝试重构为新类,原来的类会拥有或指向这些新类。从前面巨大的玩家类中,可以看到很多东西不是直接相关的,但不意味着它们没有联系。

面向对象的层级结构是一个是什么(is-a)的关系,而组件和面向组合的设计则是有什么(has-a)的关系。前者变换到后者可以当作是下放责任,或从原本绑定在"是什么"上的角色,变得更松散,但在整个树中都维持着专业。组合能清除大多数常见的钻石继承问题,因为类的能力是通过积聚不同的基类增加的,就跟以前通过 override 增加一样。

我们要做的第一件事是,把单体类的有关系的部分移到各自的类中。按照组合的思路,把类从拥有数据以及能修改它们的操作的状态,变成包含数据的实例,并尽量把操作下放到这些专门的结构中。把数据移出到独立的结构中,这样以后就更容易组合成新类。一开始只要按理解中的系统边界去分类。如,将渲染与控制器输入、游戏细节(如背包)等分离,动画也独立出来。

现在看一下拆分玩家类的结果,如代码 [*] 中,可以做点初步评估。能看到,通过从较小类构建一个大类,第一遍可以帮助将数据组织成不同的、面向目的的集合,也可以看到类最终变得纠结混乱的原因。当考虑到每部分的需求,他们需要什么样的数据,耦合就越来越明显。渲染函数要访问玩家的位置和模型,而游戏功能,如 Shoot(Vec target) 需要访问背包、设置动画、造成伤害。受到伤害需要访问动画和生命。现在看起来已经比预期的更难处理了,但其本质,却已经很清楚:代码需要跨越不同的数据块。仅是这第一遍,就可以看出,功能和数据不适合在一起。


\begin{lstlisting}[caption={组合的玩家类}, label={lst:4.2}, captionpos=b]
...
...ender;
EntityInWorld inWorld;
Inventory inventory;
};
\par
\end{lstlisting}

第一步,我们把玩家类变成组件的容器。目前,玩家拥有组件,而玩家必须在玩家类实例化之后才会存在。为了尽可能干净地分离为例为组件,并尽力保证复用性,就不能依赖由其实体处理或更新,可以试试交由管理器。这样做的话,在迭代多个相关任务的实体时,从拥有者那里移除实体,仍能获得缓存局部性(cache locality)的优势。

现在就变得有些哲学了。每个系统都有它需要的数据,才能正常运作,但即便它们会有所重叠,也不会全面共享数据。考虑一下,序列化系统需要了解字符的什么信息。它不可能关心动画系统的当前状态,但它会关心背包库存。渲染系统会关心位置和动画,但不关心当前的弹药量。UI 渲染代码甚至不关心玩家的位置,但关心背包库存以及玩家的生命值和伤害。为什么把所有的数据放在一个类中,不是长久之计?问题的核心,便是这种利益上的差异。

一个类或对象的功能,来自于如何解释内部状态,以及如何解释随时间变化的状态。事实间的关系属于问题域,称之为意义,但事实只是原始数据。这种事实与意义的分离,在面向对象方法中是不可能的。这就是为什么每次事实获得一个新意义时,该意义必须作为包含该事实的类的一部分来实现。将类拆解,提取事实作为独立的组件;才得以摆脱那种,为了给类灌输永久意义,时常不得不通过间接方法去查找事实。与其按意义存储可能相关的数据,我们只在必要时赋予它。只有当意义是解决直接问题的一部分时,才需要赋予。

面向管理器

把类拆成组件后,我们的类现在看起来好像更笨拙了,它们在访问隐藏在新结构中的变量。但不是类应该去寻找变量,而是像渲染这样的普通操作,需要位置和模型信息,也需要访问渲染器。游戏开发中,这种对象越界访问通常是种妥协,在这里,则是从以类为中心的方法,转向面向数据的方法。我们的目标是,将数据转化为影响图形管道的渲染请求,且不触及渲染器与无关数据。

请看代码 [*],这里不再有玩家的更新,而是对组成玩家的每个组件执行更新。这样一来,每个实体的物理都会在渲染前,或在另一个线程渲染时更新。所有对实体的控制(无论是玩家或AI)都可以在执行动画前更新。管理器控制代码的执行时间是实现代码完全并行化的重要部分。这时可以更有信心地提升性能,而不必担心会对其他领域产生负面影响。分析哪些组件需要逐帧更新,哪些可以不那么频繁,优化由此产生,组件之间也相互解锁。


\begin{lstlisting}[caption={由管理器 tick 的组件}, label={lst:4.3}, capti...
...or( auto &inv : inventoryArray ) {
inv.Update();
}
}
};
\end{lstlisting}

在许多脚本语言的组件系统中,可以定义其组件或实体的行为。面向对象程序设计存在的低效同样会影响性能。要注意,调用 TickUpdate 函数的依赖反转(dependency inversion)的做法,通常必须以某种形式沙盒处理,这就会导致错误检查和其他安全措施包装内部调用。有一个很好的例子,旧版本的 Unity 中,他们基于组件的方法允许每个实例有自己的脚本,每帧会被 Unity 核心调用。主要的开销看似是进出脚本语言,反复跨越于核心 C++ 和描述组件行为的脚本间。Valentin Simonov 在他的文章 10,000 Update() calls[#!vsimonov!#] 中提供的信息,证明了迁移到管理器有重大意义。文章中详细介绍了在利用依赖反转来驱动一般代码更新策略时,什么开销最大。主要的成本是在不同代码区域间移动,但即便不需要跨越语言,管理器也有其意义:它能确保组件同步更新。

如果我们让玩家之外的其他类使用这些数组呢?通常会有独立逻辑处理玩家射击。假设将武器类重构为通用武器后,如通过可以被玩家或 NPC 指向的新武器类,NPC 也可以使用相同的武器代码了。但是,还有个方法可以分离武器射击的代码来实现共享,而非创造一个新类去包含射击行为。实际上,这里要做的,就是将射击拆分成它实际包含的不同的任务(task)。

任务有利于并行处理。有了基于组件的对象,就能把以前面向类的大部分处理,变成更通用的任务转移出去,交给任何能胜任的 CPU 或协处理器。

没有什么实体

如果彻底删除玩家类的话会怎么样?如果一个实体可以由它的组件集合来表示,那除了这些同质的组件,它还需要任额外的身份吗?如同表格各行的数值,组件一起描述了单一的实例;同样像表格中的各行一样,表格也是一个集合。在组件组合的多元宇宙中,构成实体的组件不是有关实体的信息,而是实体本身,即实体需要的唯一身份。既然实体就是其当前配置的组件,那就可以彻底删除核心玩家类。这就意味着我们不再认为玩家是游戏的中心。同时由于这个类不复存在,代码也不再与特定的单一实体有联系。代码 [*] 粗略展示了如何开发这种方案。


\begin{lstlisting}[caption={组件的稀疏数组}, label={lst:4.4}, captionpos=...
...artPoint);
velocityArray [ ID ] = VecZero();
return ID;
}
\end{lstlisting}

摆脱编译期定义的类,就可以创造更多其他的类,且不用增加太多代码。脚本能够通过组合或原型生成新的实体类,力量大大增强;干净利落地使游戏呈现的内容更复杂,但实际复杂度却并没有增加太多。终于,游戏中所有不同的实体,都能同时运行相同的代码了,处理也集中并简化了,于是有更多的机会共享优化,隐藏的 bug 也会减少。