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 上的工作方式
这是一个使用稀疏集的 ECS 库,其做法就是为每个注册过的系统实例化一个稀疏集。在此之上还有一个约束:系统将拥有一个组件 pack 的所有权,用来加速整个迭代过程。
首先,系统遍历稀疏集的过程是极快的。拿近几年的 AppleSilicon 举例,L1 缓存在 128K~256K,L2 缓存就能有 1M~8M,有些不错的大核能到 12M。一个 Vector 向量组件(3×4B)就能存上一万个以上。也就是一万以内的向量遍历居然不会 cache miss,这听起来及其离谱。
其次,由于稀疏集的特性,保持系统更新的成本也非常低(取决于管理器的工作模式,有不同的策略)。管理器负责执行系统并管理实体和组件,在注册阶段,系统需要声明它与那些组件绑定,以及在每次迭代后,都需要重新更新稀疏集以保持特定的结构。
但是遇到遍历多组件的情景,就有点难受了。
无视稀疏表,对稠密表进行遍历固然很快,但前提是:毋需关心 entity。但当我们要同时遍历其他组件时,就会多出如下操作:
- 获取 entity id(搜索稀疏表)
- 获取另一个稀疏集 B(搜索稀疏集)
- 获取 entity 在 B 中的位置(搜索稀疏表 B)
- 获取组件 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 | template<typename... Owned, typename... Get, typename... Exclude> |
一个类型被获取所有权之后,很多操作都被仅用了,如排序、创建 view 等。