如果发现苹果已经售罄,你还会砍价吗?
存在性处理旨在消除:“是否要处理数据” 这一类冗余查询。大多数软件中,为了确保对象在工作开始前有效,会先检查其是否为空。那如果能始终保证指针不为空呢?如果可以确保其始终有效,并且一定会处理呢?
本章中,我们会展示一种可以用于面向数据的,运行时多态技术。当然,它不是唯一的面向数据设计友好型的运行时多态。但却是作者找到的第一个解决方案,并且很适合其他游戏开发技术,如:组件和计算着色器。
学习软件工程时,我们可能会看到,其中有提到过循环复杂度 (或条件复杂度)。这是一个用数字表示,用于分析大型软件项目程序复杂度的指标。循环复杂度只涉及到流程控制。在这里,该公式表示为为 (1 + 被分析系统中存在的条件数)。因此对于任何系统,它都从 1 开始。对于每个 if
, while
, for
, do-while
都加 1。另外 switch
语句中除 default
外的每个 case
也要加 1。
现在,仔细考虑虚函数调用的原理,即在函数指针表中查找,并进入类方法的分支。显然,虚函数调用实际同 switch
语句一样复杂。虚函数调用中,想要统计流程控制的数目会比较困难。要知道复杂度度,就必须知道满足的方法数目。因此必须计算对父类虚函数的 override
数目。如果方法是纯虚函数,那复杂度可以 -1。然而,有时无法看到所有运行时代码,如动态加载库,那潜在的代码分支数目就会增加一个未知量。对于允许接入第三方库的系统,有必要接受这种不可见或模糊的复杂度,但需要一定程度的信任,因为这表示任意环节都没法被彻底测试。
这种复杂度通常称为控制流复杂度。软件中还有一种固有的复杂度,就是状态复杂度。在 Out of the Tar Pit[#!bmospmark!#] 一文中有结论:最能提升软件复杂度的是状态。这篇论文提出了一个方案,以图最大限度减少所谓的意外状态,即:不直接解决问题,但在软件完成工作需要的状态。该方案还尝试废弃掉那些仅仅为了支持某种编程风格而引入的状态。
必要的控制是:实现设计时,一个功能必须在某些条件满足时才发生。例如:按下跳跃键时跳跃;存档数据变脏或计时器结束时,在检查点自动保存。
意外的控制,从使用者的角度看,对于程序是非必要的,但可能也是关乎程序能否工作的基础功能。这种控制的复杂度一般分为两种形式。第一种是结构性的,如支持某种编程范式、提供性能改进、驱动一种算法等。第二种,则是防御性编程或用于辅助开发者的,如引用计数、垃圾回收。这些技术在使用时会去确定数据是否存在,也会检查边界,因而也会增加复杂度。实践中,可以在使用容器和其他结构时看到它们,控制流会以边界检查确保数据没有以超出范围。垃圾回收也会增加复杂度。许多语言中,都难以确保回收会何时,怎样触发。也就意味很难推断对象的生命周期。使用这些语言时,人们倾向于在开发初期忽略内存分配,因此在临近交付时,可能很难修复内存泄漏。非托管语言的垃圾回收处理起来要容易些,因为引用计数更易查询,但也是因为非托管语言通常预先分配的频率较低。
在高复杂度的程序中,会遇到哪些问题?分析系统的复杂度有助于了解其测试难度,反过来也有助于了解其调试难度。有些问题可以归类为处于意外状态,但也无法更进一步了。其他的可以归类为进入坏的状态:由于对无效数据做出反应而表现出意外的行为。不过,还有一些问题可以归类为性能问题,而非正确性问题。某种程度上,这些问题虽然被大量的学术文献忽视,但在实践中代价很高,而且通常来自于复杂的状态依赖关系。
例如,由缓存等优化技术引入的复杂度,是状态复杂度问题。CPU 的缓存处于一种不知道的状态,且在工作中没能预期到,就会导致性能不佳或不稳定。
许多时候,调试中的困难来自于:没有完全了解所有的流程控制点,假设已经采取了一条措施,而实际上并没有。程序按我们的要求去做,而非遵照我们的意思,它们就会进入一个预料外状态。
使用虚拟调用的运行时多态,会大大增加这种情况发生的可能性。因为不确定我们是否已经完全知道代码所有不同的分支,除非使用日志记录代码,或者用调试器来查看它在运行时的走向。
真实的游戏开发案例中,显式的流程控制语句常常属于非必要集。实行防御性编程的地方,许多流程控制语句只是为了防止崩溃。阻止越界访问,保护为 NULL
的指针,防御其他会使程序终止的特殊情况。好在,GitHub 上有很多高质量的 C++ 源码,与这种趋势背道而驰。它们更倾向于使用引用类型,或尽可能使用值类型。游戏开发中,另一种常见的流控制是循环。虽然这种情况很多,但大多数编译器都能识别它们,并做出很好的优化,而且在去除不必要的条件检查方面做得很好。最后一种不重要但常见的流程控制来自多态调用,它对于实现一些游戏逻辑很有用,但主要是为了满足面向对象编写游戏的方法中,部分执行的 “更少代码,更多用途” 开发模式。
本质上,游戏设计中的流控制,不太会在性能分析文件中以分支控制出现,因为所有支持性代码会运行得更频繁。因此可能会忽视每个条件对软件性能的影响。用条件实现 AI,处理角色移动,决定何时加载关卡的代码,会在充满循环和树状遍历的系统中调用;或对访问中的数组边界检查,返回数据,产生布尔值,最终驱动 if
落入其中一个分支。也就是说,当代码库其他部分都很慢时,就很难验证为其中一项任务编写快速的代码。很难讲又增加了哪些额外成本。
如果觉得值得考虑一下清除控制流,就得先了解哪些控制流操作是可以清除的。如果从防御性编程开始着手,可以用数组的集合来表示工作数据集。这样就可以保证数据中没有 NULL
。仅这一步,就可以清除许多流程控制语句。这样并不会摆脱循环,但只要是运行纯函数式变换的数据的循环,就不必担心副作用,并且反而会更容易推理。
虚拟调用中固有的流程控制也可以避免。事实上,有许多程序是以非面向对象风格编写的。没有虚拟,还可以依赖 switch
语句。没有那些,还可以依赖函数指针表。再不济,还能用一连串 if
。有许多方式能实现运行时多态。可以认为,如果没有显式类型,就不需要对其进行切换。所以如果能根除面向对象的方法来解决问题,那些流程控制语句也会完全消失。
当涉及到游戏逻辑中的控制流时,就会发现,想要根除他没那么容易。这倒也没那么可怕,游戏开发中,游戏逻辑是我们能看到的最接近本源的复杂度。
减少条件语句,从而减少这种规模的循环复杂度,能带来不容忽视的好处,但也是有代价的。之所以能够避免检查 NULL
,是因为数据格式根本不允许出现 NULL
。我们很快会证明,这种不灵活性其实是种优势,但需要一种新的方法来处理实体。
以前,游戏中会有一片区域的对象实例,我们会查询它是否有去其他区域的出口;而现在,只需要查看一个只包含区域间链接的结构,并通过我们所在的区域做过滤。这种所有权的颠倒在调试中很有优势,但是当有时只想找出哪些出口可以离开一个区域时,就显得很落后。
如果读者用过购物清单或待办清单,肯定能理解,如果有个明确的要完成的清单时,效率会高很多。制定清单很容易,只要把东西添加上去即可。如果要去购物,很难用排除法得出自己需要什么。如果要规划三餐,一张清单就必不可少,除了要弄清楚要哪些原料,还要计算出所需的数量,才能保证膳食计划。如果有待办清单和日程,就可以知道谁会来,需要做什么准备。知道有多少张嘴要吃饭,要买多少食物和饮料,以及要为访客准备多少套床褥。
待办清单好就好在,可以设定一个最终目标,然后加入子任务,使一个庞大而遥远的目标看起来更加可行。加入估算可以提供一些紧迫感,而这种紧迫感,在最后期限如此遥远的情况下,通常是缺失的。许多公司使用软件来跟踪任务,这些软件通常提供一些功能,允许生产者确定关键路径、预估所需的开发人员时间,甚至是维持项目所需的技术平衡。不使用这种软件常常是公司没有不怎么关注效率,甚至是浪费的标志。如果关注项目中的效率和浪费,任务清单就不失为一个分析成本来源的好方法。通过记录并追踪这些清单,可以通过观察数据,了解软件执行中的操作的大致形态。不这样做的话,就很难定位真正的瓶颈,问题可能不是处理,而是处理数据的请求本身就已经失控了。
程序运行时,若不让它处理同质化的列表,而是有什么做什么,那效率就会很低,还会导致帧时长不规则、不稳定。低效利用硬件往往都是因为处理无法被预测。在指向异质类的大数组都被 update()
函数调用时,会遭遇大量的数据依赖,导致数据和指令缓存均出现未命中。原因详见第 14 章。
慢,还因看不到有多少工作待办,因而无法确定工作的优先级和规模,进而不能确定在给定的一帧时间内完成多少任务。若没有待办清单,也没能力估计每项任务的耗时,就难以在保证用户反馈的同时,决定最佳行动方案来减少开销。
面向对象编程工作能在程序运行时,模式较少的情况下,工作地很好。程序只处理少量的数据,或数据有难以置信的异质性,以至于有多少种事物就有多少类。
不规则的帧时长,往往是因为没有提前对远期目标采取行动。如果读者,作为一个开发者,知道必须为一个新的岛屿加载资产,当玩家冒险进入周围的海域时,流加载系统能收到通知,载入必要的数据。可能是一个房间和远处的其他房间。也可能是玩家视线范围内的地下城或洞穴的数据。我们把这种先发制人的数据流当作一种特殊情况,并创造提供这一级别规划的系统。依靠人类(哪怕是关卡设计师)来把这些联系在一起很容易出错。许多情况下,如果没有自动检查,就会漏掉一些依赖关系链。没有一种常规语言可以描述时间上的依赖关系,因而也无法让系统有足够的自我意识去执行自我加载。
许多游戏中,我们会用显式的触发器将事情串联起来,但对于许多其他游戏元素,往往没有这样的系统。几乎从未听过过 AI 向着某个目标执行寻路,是因为马上可能会去那边。最接近的做法是,开发者预填充一个导航图(navigation map),这样就可以迅速粗略地完成路径选择。
还有一个先期工作的深度问题。考虑一个小房间,创建为独立资产,一个等候室有两个相邻的门,都通向很大但不同的两张地图。当玩家在地图 A 中靠近等候室的门时,这个小房间就可以被抢先流加载进来。然而,在许多引擎中,地图 B 不会流加载,因为地图 B 到地图 A 的位置特性隐藏在等候室的逻辑层后面。
物理系统做预判也不太常见,比如为了执行下一步工作,检查未来是否会发生碰撞。如果它能感知更多,或许能实现一个更复杂的破碎模拟。
如果让游戏生成待办清单、购物清单、远期目标,并允许通过前瞻性思考来采取预防措施。那就可以把程序员的任务,简化为对目标和效果做优先级排序,或编写运行时生成优先级的代码。读者可以考虑如何将这些依赖关系连锁起来,以解决等候室的问题。也就可以开始抢先处理所有类型了。
存在性处理与待办清单有关。处理同质的数据集时,我们已经知道,要以相同方式处理每个元素。集合中的每个元素上会执行相同的指令。这里对输出没有明确的要求。但通常归结为三种操作:过滤(filter),突变(mutation),散发(emission)。突变是对数据执行一对一操作,接收数据输入和一些在变换前设置的常数,并为每个输入生成一个唯一元素。滤波同样接收传入的数据,在变换前设置一些常数,并为每个输入元素要么生成 0 或 1 个元素。散发是对传入数据的操作,能生成多个输出元素。和其他两种变换一样,散发可以使用常数,但输出表的大小没有限制,它能生成零到无穷个元素。
第四种,也是最后一种形式,叫做生成(generation),并不能算是真正的数据操作,但通常是变换管线的一部分。生成不需要数据输入,而只根据设置的常数产生输出。使用计算着色器时,就可能会遇到这样的函数,它只是对数组执行置 0 、置 1 、升序操作。
变换
|
这些类别可以帮助决定:要使用什么数据结构来存储数组;是否需要一个结构;或是否应该用管线将数据从一个阶段输送到另一个阶段,而非在中间缓冲区上操作。
每个 CPU 都能有效地在核心上处理同质数据集,也就是在连续的数据上反复做相同的操作。没有全局状态,没有累加器,就证明可以并行。可以看一下 map-reduce 和简单的计算着色器,用现有的技术举例,来说明如何在这些限制中,建立真正的工作应用。无状态变换在运用分布式处理技术时也是无害的。Erlang 依赖于没有副作用这一点,实现了线程间、进程间、乃至分布式计算的安全处理。对有状态的数据执行无状态的变换高度稳健,可以深度并行。
处理每个元素时,对于变换核心操作的每个数据,使用控制流非常合理。几乎所有的编译器都应该能将简单的局部分支指令,简化为平台首选的无分支表示。如 CMOV,或 SIMD 操作的 select
函数。在考虑变换内部的分支时,最好是比对着现有的流处理实现,如显卡着色器或计算核心。
在分支低阶断言(predication)中,不会忽略流控制语句,而是被用作如何合并两个结果的指标。若流控制不基于常量时,一个低阶断言 if
会生成代码,同时运行分支两边,并根据条件的值放弃其中一个结果,选择另一个。如前所述,许多 CPU 本身就有这个功能,不过所有 CPU 都可以使用位掩码去实现它。
SIMD (single-instruction-multiple-data, 单指令-多数据) 能够在指令相同时并行处理数据。数据可以不同,但都是局部的。没有条件语句时,SIMD 操作在我们的变换上很容易实现。在 MIMD (multiple-instruction-multiple-data,多指令-多数据) 中,每块数据都可以由一组不同的指令操作。每一块数据都可以用不同路径。它是最简单,也最容易出错的编码,目前大多数并行编程都是如此。每增加一个线程,就要用一个单独的执行线程处理更多数据。MIMD 包括多核通用 CPU。通常,它允许共享内存访问,也会有伴随而来的同步问题。目前为止,它最容易启动和运行,但也最容易出现那种,由状态复杂度引发的罕见的致命错误。因为操作顺序不确定,通过代码产生的的不同的可能路线的数量,向着超指数级爆炸。
研究压缩技术时,我们必须了解的最重要的一点:数据和信息之间的区别。系统中存储信息的方式有很多,从表明某物存在的可解析的明文字符串,到简单到用来描述某物具有某属性的单比特标记。例如,代码中声明的局部变量,或一个物理网格中用于查询会响应哪些碰撞类型的比特集。有时可以通过先进的算法(如算术编码)或领域知识让存储的信息少于比特集。领域知识标准化适用于大部分游戏开发。但它的应用越来越少,因为很多开发者都过度热衷于引用 “过早优化”,反而身陷囹圄。信息被编码进数据,而编码的信息量可以被领域知识放大。重点是,我们可以看到,压缩技术提供的建议是:真正编码的是概率。
举个例子,一个游戏中的实体有生命条 (一段时间不受伤害后就可以回复),会死亡,能互相射击。我们来看看怎样利用领域知识减少处理。
假设有以下领域知识:
现在来看代码 中的实体,可以看到这里的数据会引发常见的缓存行问题。此外,代码中会如何调用 update
相关的函数呢?如代码 示,每次 update
都会针对每个实体调用一次相关函数。
这里我们可以从流程控制语句入手,做些改进。如果生命值满,函数就不执行。如果实体已死亡,也不执行。回复函数只在距离上次受伤过了足够久才执行。考虑所有这些情况,其中血量回复并非常态。所以,这里应该尝试为常见的情况组织数据布局。
现在把结构体改为代码 中所示。更新函数不再针对实体执行,而是针对生命表。因此我们知道,只要这个函数在执行,实体就没死,它就会受伤。
只在实体受到伤害时才需要添加一个新的 Entitydamage
元素。如果实体在已有 Entitydamage
的情况下受伤,它就只需更新 health
状态和 timeoflastdamage
,无须再创建新的。如果想知道某人的生命情况,只需检查他是否有 Entitydamage
,或者查看 deadEntities
表中是否有他。之所以能这么做,是因为每个实体都有一个隐式的布尔值,藏在已有的行里。对于 entitydamages
表,这个布尔值就相当于第一个函数中的 isHurt
变量。同样的,deadEntities
表中的 isDead
也是隐式的,表示生命值为 0。这就可以省下资源用于其它系统。不必加载一个浮点数并判断其值是否小于 0,省去了浮点比较、转换为布尔值的过程。
消除布尔值也不是什么新鲜事,因为每有一个指向某物的指针时,都会引入一个非空的布尔值。正因为不想检查 NULL
,才促使我们为处理“对象是否存在”寻找不同的表示方法。
其他类似的情况包括:武器换弹、游泳时的氧气余量、任意会耗尽的有数值的事物、有极值的数据等。甚至汽车的行驶速度:如果参与交通,大部分时间都会在限速区间内行驶,而非需要算出某个速度。如果有群人都朝同一个方向走,那么进入这个群体的人会一直受阻,直到与群体不再相斥。此时他可以放弃独立的想法,在群体中随波逐流。这一点,会在第五章详细介绍。
转换为保存属性状态的列表,能实现更好的性能优化。与时间相关的属性,第一要务是将其放进有序的表中,按它们应被执行的时间排序。我们可以把回复时间放进有序表,然后不断 pop
出 entityDamage
,直到遇到无法被移到活动表中的元素,然后一次性跑完所有活动列表。现在就知道哪些对象受伤了,没有死,可以再生,并且可以开始回复生命了。
再来看不同时间间隔内更新的属性。动植物响应环境的机制有所不同。有的非常快,如远离危险的反应:把手抽离热锅。也有较慢的,如负责推理的脑区。也有快到近乎即时的,像是反射,是大脑在没时间详细思考时的反应,如接球、在自行车上保持平衡。大脑还有更慢的区域,比如现在,与其说你是在读这本书,不如说是在整理出一个模型,以便理解文字的含义,并最终吸收他们。还有更慢的:压力响应,如荷尔蒙弥散在体内的化学物质,当前体内能调动的糖分,当前的水合水平,所有这些组成一套相应的系统。能够在多个时间尺度上思考和反应的 AI 或许更节约资源,也不太可能出现奇怪的行为,或在决定间犹豫不定。保证每个系统每帧都更新,可能会陷入不可能的境地。将工作分成不同的更新率仍有规律可循,同时带来了能够平衡多帧工作的机会。
另一个用途位于状态管理中。若一个 AI 听到了枪声,那他可以在表格中添加一行,记录最后一次听到枪声的时间,可以用来确定他们是否处于高度警觉状态。若 AI 与玩家进行了交易,只要玩家有可能想起,那 AI 也就必记住。若玩家刚把 +5 的长剑卖给 AI,且只是离开商店一会儿,那这把剑就很有必要保存在店主 AI 的库存里。有些游戏甚至在交易过程中也不保留库存,如果玩家不小心卖掉了需要的东西,然后存档,大概会相当痛苦。
从游戏角度看,这些额外信息都是玩家与世界的互动。一些游戏中,玩家可以把自己的东西永远留在周围,它们会永远保持留下时的样子。一些开放世界 RPG 里,玩家丢在山洞里的所有东西,仍然准确出现在几个小时前丢下的位置,这已经是是相当大的成就了。
增补数据(tacking on data),或者说,用动态的附加属性补充加载的数据的基本概念,已经存在了相当长时间。保存游戏通常是将动态世界与基础状态比较后的差值编码,其中一个早期用途,是在完全动态环境中,加载世界,但其后可以被摧毁、改变。一些世界生成器使用程序化地形,允许其内容创作者添加额外的补丁信息:村庄、堡垒、前哨,甚至催生出大量地形工具,用于调整生成的数据。
枚举用于定义状态集。原本可以为回复中的实体设一个变量,包含 infullhealth
、ishurt
、isdead
三种状态。也可以给无效的实体设索引,枚举可用的组别。但这里,我们用表格表示所需的信息,毕竟只有两组。任何枚举都可以用各种表模拟。只需要为每个枚举值建一个表。设置枚举值即是插入,或是从一个表迁移到另一个。
使用表替代枚举时,可能会带来更多困难:找出一个实体中的枚举值会变难,因为需要检查所有能代表该实体状态的表。然而,需要获取该值的主要原因,也许是为了根据外部状态执行操作;又或是为了找出满足状态的实体以判断是否需要进一步操作。大多数情况下,这些都是不允许,也没必要。首先,访问外部状态在纯函数中是无效的。其次,任何依赖数据都应该已经是表元素的一部分了。
如果这个枚举是以前由 switch
或虚拟调用处理的状态或类型,就不需要再查询了。其实,要改变的是思考方式。解决方法是通过转换,将每个 switch case
、虚方法的内容,作为操作应用在相应的表中,即对应原始枚举值的表。
如果枚举是用来确定是否可以对一个实体进行操作,比如考量到兼容性,那可以考虑用一个辅助表,来表示处于兼容状态。如果情况是,查询结果需要返回一个实体,并且需要在确定提交修改前,知道它是否处于某种状态。那可以考虑,将兼容的数据,作为输出表标准的一部分先生成;也可以在提交一个过滤操作,创建正确形式的表。
总之,之所以把枚举转换为表的形式,是为了减少控制流的影响。鉴于此,如果不使用枚举来控制指令流,就不用管了。还有一种情况,枚举的值频繁变化时,因为表到表迁移对象也是有成本的。
合理的枚举的例子如:按键绑定、颜色枚举、命名合理的小的有限值集合。返回枚举的函数,如碰撞响应(无、穿透、通过)。任何实际表现为对另一种形式的数据的查询的枚举,都是好的。这些枚举用在那些较大、难以记忆的数据表中,将数据访问合理化。有些枚举还有一个好处,就是可以帮助你在 switch
中捕获未处理的情况,并且某种程度上,它们也是大多数语言中的自解释(self-documenting)功能。
现在来考虑如何实现多态。没必要使用虚表指针;可以使用枚举变量来指明类型。这个变量可以用于在运行时定义该结构应具备什么能力,以及要如何响应。也可以在对象上调用方法时,用来判断和选择函数。
类型的定义基于成员变量的类型时,虚函数通常会实现为基于它的 swtich
或函数数组。若要允许运行时加载库,就得有个系统去更新被调用的函数。简陋的 swtich
无法胜任,但函数数组可以在运行时修改。
现在有了一个既不优雅,也不高效的方案。数据仍然由指令负责,并且每当有意料外的虚函数出现,我们仍会在指令缓存未命中和分支预测错误的问题上煎熬。但真正避免使用枚举,并且用表来表示枚举的每个可能的值时,我们仍旧有可能,同基于指针的多态一样,兼容动态库加载。同时也能保证处理异质类型的数据流时的效率。
对于每个类,都有一个工厂类来替代类的声明,它能选择生成正确的表插入调用。同时利用存在性处理,替代了多态方法调用。表中的元素允许类的特征以隐式存在。用工厂创建的类可以很容易通过运行时加载的库扩展。只要有数据驱动的工厂方法,注册新的工厂也应该很简单。表的处理和它们的 update()
函数也会被添加到主循环中。
如果通过组合创建类,并允许通过对表做插入、删除来改变状态,那也就解锁了动态运行时多态。这是通常只在通过 switch
进行动态响应时才有的功能。
多态是指程序中的一个实例能够以不同的方式对一个共同的入口点作出反应,具体由该实例的性质决定。C++ 中,编译时的多态可以通过模板和重载实现。运行时多态是指一个类能够为一个共同的基础操作提供不同实现,而该类的类型在编译时未知。C++ 通过虚表处理这个问题,在运行时根据隐藏在虚表指针中的类型,从该指针所指向的内存调用正确的函数。动态运行时多态是指一个类可以根据其类型以不同的方式对一个共同的调用签名做出响应,并且其类型在运行时可以改变。C++ 没有明确实现这一点,但是如果类允许使用一个或多个内部状态变量,那它就可以根据状态,以及查询核心语言的运行时虚表提供不同的响应。其他能更流畅地定义其类的语言,如 Python,允许每个实例更新它响应消息的方式。但这些语言大多数总体性能非常差,因为调度机制已经建立在动态查找之上。
现在来看代码 ,我们希望通过运行时方法查找,来解决不知道类型但想知道大小的问题。允许对象在其生命周期内改变形状需要做出妥协。一种方法是在类中保存一个类型变量,如代码 ,对象作为类型变量的容器,而非特定形状的实例。
另一个更好的方法是通过变换函数来处理每种情况。实现详见代码 。
虽然这样有用,但所有指向旧类的指针现在都无效了。使用句柄可以减轻这些忧虑,但大部分情况下,又增加了一层间接访问,反而会进一步拖累性能。
如果使用存在性处理技术,类由它们所属的表定义,就可以运行时在表之间切换。因此可以在没有任何技巧的情况下改变行为,而不需要为需要的所有状态管理 union
来保存所有不同的数据。如果用不同的属性和能力来组合类,又要在创建后改变它们,也是可以的。如果正在更新表,实体的指针地址发生变化也影响甚微了。在基于表的处理过程中,实体在内存中移动是很正常的,所以意外反而较少。从硬件的角度来看,为了实现这种形式的多态,需要为每个类属性、能力中的实体引用提供一点额外空间,但不需要虚表指针来寻找要调用的函数。还可以优先遍历相同类型的实体来提升缓存利用率,尽管它已经提供了一种安全的方式来在运行时改变类型。
由于类是由它们所属的表隐式定义的,所以有机会将一个实体注册到多个表中。因此表明,一个类不仅可以在动态运行时具备多态性,还能具备多面性,即它可以在同一时间内成为多个类。单一的实体可能会对同一个触发器调用做出两种不同的反应,因为它适合该类的当前状态。
这种多维分类在传统的游戏代码中并不多见,但在渲染中,通常会有几个不同的维度,如材质、混合模式、某种蒙皮、其他顶点调整,会发生在某个特定的实例上。或许在代码中看不到这种灵活性,因为它不能通过语言的自然工具获得。可能我们确实看到了,但它其实就是一些人提到的 ECS (entity component system)。
过去,如果想监听系统中的事件,需要绑定到一个中断上。有时候可能还得琢磨一下这类代码,通常它们是给旧的、微控制器规模的硬件用。出发点也很简单,当时的处理器速度,还不足以快到轮询所有可能的信息来源并处理,但又足以接收事件并即时处理。游戏中通常这样处理事件,先注册某个感兴趣的事件,然后在事件发生时被告知。发布订阅模型已经存在了几十年,但一些语言中没有为它建立标准接口,另一些中则有太多的标准。它往往需要一些来自问题域的知识,以选择最有效的实现。
有些系统希望能获取系统中的每一个事件,并自行决定,如 Windows 事件处理。有些只订阅非常特殊的事件,但期望立即对事件做出响应,如 BIOS 事件处理程序、键盘中断。有些事件可能非常重要,并直接由发布事件的这一行为来调度,如回调。有些也可能是懒惰的,停留在某个队列中,等待之后的某个时刻被分发。最佳的方法由要解决的问题来确定。
通过利用在表中的存在性去注册的技术,让事情变得更简单,并且也能极大提升注册和取消的速度。订阅变成了插入,取消订阅变成了删除。可以用全局表来订阅全局事件。也可以有命名的表。通过命名的表,就可以使订阅者在发布者存在之前订阅事件。
发布事件时,我们会有一个选择。可以选择立即启动转换;或者排队等待新事件,直到整个转换完成,然后一次性全部派发。随着模型变得更简单、更可用,带着更通用的可能性,我们能够以新的方式,实现传统中通过轮询完成的代码。
例如:除非玩家角色在激活门的距离内,不然玩家的操作按钮的事件处理程序,不需要与门关联。当角色进入范围内时,就会在动作事件表中注册 has_pressed_action,并以 open_door_(X) 返回。这样就能避免 CPU 在弄清玩家到底试图激活什么东西上浪费时间,同时也有助于提供状态信息,如,在屏幕上显示按绿色按钮开门。
如果让所有的表都拥有类似 DBMS 中的触发器,就可以注册输入映射的变化及其响应。钩住低级别的表,如,插入 has_pressed_action 表 可以让用户界面知道:该更新提示信息了。
这种编码风格有点像面向方面编程,代码中很容易实现横切关注点。面向方面编程中,任何活动的核心代码都很干净,而任何副作用或禁止的活动行为,都借由其他关注点从外部钩住活动来处理。核心代码因此得以保持干净,但代价是,写代码时不知道哪些真正会调用。而使用注册机制的不同之处在于:响应从哪来?如何确定它?位于面向方面编程中,通常隐式存在的因果关系大大减少甚至移除,因而调试难度也大大降低。同时也能弱化面向对象的决策难以调整的性质,代码变得更加动态,并且免去了通常与数据驱动控制流有关的成本。