ECS 储存数据的方式

ECS 最终目的是在冯诺依曼存算分离的架构上,降低缓存命中失效,以实现高效的运算。

Archetypes / 原型

这是 Unity ECS 提出的一个概念。其实理解起来也比较简单,就是将 Entity 与一个组件集合强关联,以方便遍历。

优点:非常契合多线程。一个 Component 可能被多个 Entity 使用,但不同 System 遍历时有读有写,故多次遍历时,很大概率要错开来执行。如果使用原型,我们就可以用更高层级去管理组件。相同 Archetype 的组件往往是连续放置的,多个 System 也就可以同时遍历需要的区块,减少共享内存的数量。

缺点:添加组件与移除组件,会导致实体切换所属原型。期间会发生各种拷贝、构造。

正因如此,当组件集合在运行时变化不大时,这种模式就会非常高效。例如在一些逻辑中,会有很多固定模式的 Entity,它的所有组件基本与 Entity 生命周期相同。同时依靠辅助数据(secondary data structure)来处理其他频繁添加删除的组件。

在使用过程中,原型模式遇到的最大问题还是碎片化。原型类型会比想象中要多很多,尤其是在运行时,添加删除组件都有可能新增一个全新的原型,导致若想遍历一个组件,会触发很多次跳转来查找关联原型。

Sparse set / 稀疏集

稀疏集是另外一种组织数据的方式。简单来说,是用两个数组(稀疏 sparse 和稠密 dense)来表示一个 map 或 set。通过多一层间接访问,在稀疏表中映射键与 指向稠密数组中的间接键

用代码描述就是:value = dense[sparse[key]]。其中 key 可以是 entity,而 value 便对应 component。在稠密数组中,所有数据都是连续的,在 ECS 框架下,我们也确实不需要直到这个值是谁的,只管遍历就好。因此这种数据结构提供了 O(1) 的访问与 O(n) 的遍历性能。

写到这里时突然想起 unordered_map,感觉有点相似,也是用多层映射找值 value = bucket[hash(key)],但是瓶颈却在扩容与碰撞。

稀疏集在 System 上的工作方式

vittorioromeo/ecst

这是一个使用稀疏集的 ECS 库,其做法就是为每个注册过的系统实例化一个稀疏集。在此之上还有一个约束:系统将拥有一个组件 pack 的所有权,用来加速整个迭代过程。

首先,系统遍历稀疏集的过程是极快的。拿近几年的 AppleSilicon 举例,L1 缓存在 128K~256K,L2 缓存就能有 1M~8M,有些不错的大核能到 12M。一个 Vector 向量组件(3×4B)就能存上一万个以上。也就是一万以内的向量遍历居然不会 cache miss,这听起来及其离谱。

其次,由于稀疏集的特性,保持系统更新的成本也非常低(取决于管理器的工作模式,有不同的策略)。管理器负责执行系统并管理实体和组件,在注册阶段,系统需要声明它与那些组件绑定,以及在每次迭代后,都需要重新更新稀疏集以保持特定的结构。

但是遇到遍历多组件的情景,就有点难受了。

无视稀疏表,对稠密表进行遍历固然很快,但前提是:毋需关心 entity。但当我们要同时遍历其他组件时,就会多出如下操作:

  1. 获取 entity id(搜索稀疏表)
  2. 获取另一个稀疏集 B(搜索稀疏集)
  3. 获取 entity 在 B 中的位置(搜索稀疏表 B)
  4. 获取组件 B 内容(搜索稠密表 B)

绕一大圈,缓存失效的可能性大幅增长!

所幸的是,可以结合之前的原型来解决这一问题。也就是遍历前,我们将若干个组件打包在一起,如果组件数量不多,整体性能也挺不错的。

Entt.Group

再扯一下 EnTT 实现的 Group,结合了稀疏集与原型,使其灵活性更高。

Group 以注册的方式作用在 Registry 上,它会将若干个组件打包在一起,用于更快地迭代多个组件。

原来是:

  • pool<CompA>:[A,A,A,A,A,...]
  • pool<CompB>:[B,B,B,B,B,...]

将变成:

  • pool<CompA,CompB>:[AB,AB,AB,AB,AB,...]

Group 有三种筛选模式,对应其不同的行为。

  • Owned: 获得所有权。已被获取过所有权的组件如果再次被所有,就会触发 conflict assert。
  • Get:仅引用。
  • Exclude:仅用于 contain(e)->bool 排除。
1
2
3
template<typename... Owned, typename... Get, typename... Exclude>
entt::registry::group(get_t<Get...>, exclude_t<Exclude...> = {})
-> basic_group<entity_type, owned_t<Owned...>, get_t<Get...>, exclude_t<Exclude...>>

一个类型被获取所有权之后,很多操作都被仅用了,如排序、创建 view 等。

Share