我们的游戏场景是由若干场景节点构成的,每个场景节点是一个 Entity 的 Component 。而一个复杂的场景可以在编辑器中生成一个预制件 Prefab,像搭建乐高积木那样堆砌已经做好的部件。关于预制件的设计,之前有过两篇 blog 讨论。分别是:游戏引擎中预制件的设计 和 预制件和对象集的管理 。
就目前的使用经验来看,几乎所有游戏中的 Entity 都是从 Prefab 实例化得来的。一个 Prefab 会实例化出 n 个 Entity ,但这 n 个 Entity 的生命期管理却很麻烦。
最自然的想法是:所有的 Entity 都必须是场景树上的节点(拥有场景组件),当我们删除一个场景节点时,它所有的子孙都一起移除。
这个做法有两个问题:
如果一个 Entity 被间接移除(祖先被删除),持有它的引用该如何处理?传统的方法是,只能对 Entity 保持弱引用。一般用 Entity ID 作弱引用。我们在对 Entity ID 解引用时,应检查引用是否有效。
有些 Entity 未必是场景节点,但也需要合理的自动化生命期管理怎么办?传统的方法要么让所有的东西都必须是场景节点,要么把这个东西变成场景某个节点的一个组件附着在场景节点上。
我不喜欢上面提到的传统解决方案。因为:生命期管理和场景管理其实是两件事。场景管理是按树结构组织的,而生命期管理更适合用集合这种结构。所有需要被管理的东西不应该都必须在场景树上。
动态给 Entity 增加 Component 的能力看似有用(我们的 ECS 系统刻意不支持它),但只是为了把一个不相干对象的生命期和另一个对象绑定起来,就强行把它作为 Component 和场景的 Component 组合在一起,我觉得也是对 ECS 的滥用。
回头来看 Prefab 。我们应当把它看成是对 Entity 组合的持久化,它持久化了 Entity 本身的数据,以及 Entity 之间的关系。然后,我们可以在运行时将持久化的模板实例化。
既然,prefab 的 instancing 是创建出一组预先设计好的 Entity 及其相互关系的主要方法;那么,销毁它们的过程也应该是对称的。即,如果我们通过 prefab 一起创建出 n 个 entity ,那么也应该让这 n 个 Entity 一起销毁。应该避免只销毁其中的一部分。
在实践中,我们销毁部分 Entity 往往是因为拿出了部分 Entity 挂接到已有的场景树上;或是让其它 Entity 挂接在它的挂接点上。前者拆分了生命期管理集,后者扩大了生命期管理集。
我最近重构了引擎中挂接这个特性。避免了场景节点间的任意挂接行为。挂接变成了某种弱引用,而不改变原本固有(在 Prefab 中预设)的场景树形态。就避免了这个问题。所以,理所当然的,就可以实施更简单的生命期管理了。
现在的方案是,当 prefab instancing 时,它在创建一组 Entity 的同时,还会返回一个 instance id ,用来引用这组 Entity 的生命期。这个 instance id 只用于一件事,就是日后销毁这组 Entity ,而不能做任何其它事情。控制这些 Entity 还是要通过 Entity ID 或 select 特定的 Component 完成。
这样,我们的系统的每个 Entity 都具有两个 ID ,一个是唯一的 EntityID ,另一个是被 prefab instancing 时赋予的 instance ID 。前者用于引用具体的 Entity ,后者用来在上层管理生命期。底层既然有通过 Entity ID 销毁特定 Entity 的接口;但上层不可以使用这个接口,而只能通过 Instance ID 销毁一组 Entity 。
在实现的时候,用一个小技巧就可以把两个 ID 合二为一。
用一个 64bit 数字作为 Entity ID ,即使 Entity 被销毁,新的 Entity 也不会复用旧 ID 。这样可以通过 ID 实现弱引用。
EntityID 是在 prefab instancing 的过程被赋予的。我们采用 48+16 的形式。有一个单调递增的 48bit 内部 ID ,每次 prefab instancing 时加一。当一次 instancing 的 Entity 新建数目少于 64K 时,剩下的 16bit 可以完美表示每个 Entity ,两者合并就得到最终的 Entity ID 。如果有巨大的 Prefab ,它会构建超过 64K 的 Entity 也没有关系,继续递增那个 48bit 内部 ID ,就可以有新的 64K 集合可用。当 prefab instancing 结束,我们看一共用了多少个 64K 集合,就能生成唯一的 Instancing ID 了。
比如,某次 instancing 过程生成了100K 个 Entity ,内部 ID 一开始是 42 ,那么这个过程就用掉了 42 和 43 两个内部 ID ,表示 64K + 36K 两组 Entity 。所以 Entity ID 是从 42 << 16 开始,到 (43 << 16) + 36K 结束。
最终的 Instance ID 就是 42 << 16 | 2 ,表示这个分组是从 42 开始的连续两组。Entity 身上不必专门记录 Instance ID ,只需要取 Entity ID 的高 48bit ,是 42 和 43 的都属于这个分组。