ECS: 大矩阵模型 The Big Matrix Model
介绍 ECS 的大矩阵模型,并讨论分页、指针稳定性、最短集和 fast forward 迭代优化。
大矩阵模型 也是 ECS 常用的一种实现,每个 实体 是矩阵中的一行,每个 组件 是矩阵中的一列,矩阵中的单元格存储组件的数据或组件的存在状态,这种设计对应了理论上的 ECS 表示法,容易理解和维护
Big Matrix Model
entity rows x component columns
它具有很明显的优势,易于实现和维护
- 添加和移除组件的性能非常高,因为组件是按实体的索引存储的,直接操作数组即可
- 可以通过索引快速访问,实体的索引直接对应到矩阵的列号,无需额外的查找操作
当然它也存在一些需要解决的问题
分页
首先要解决的是内存使用问题,分页 Pagination 是一种方法,具体来说,是将 组件 数组划分为多个较小的 页 page,并只在需要时为某一页分配内存,这样做可以显著减少内存浪费,特别是当实体的组件很稀疏时
在这种情况下,每个组件的存储池不再是一个连续的大数组,而是由多个小页组成,并且使用使用一个小型数组保存所有页的指针,动态分配需要的页,并且这对于扩展数组很有好处,在传统实现中,扩展一个大数组需要拷贝整个数组,而分页只需拷贝指针数组,性能开销小得多
当然这肯定会影响随机访问性能,在访问组件时,必须先检查对应的页是否存在,再跳转到具体的内存位置,这种增加的检查逻辑和跳转可能对频繁的随机访问操作产生影响
指针稳定性
在 ECS 系统中,指针稳定性 非常重要,它的具体含义是:一旦创建了一个对象,它的内存地址(指针)在其生命周期内保持不变,这意味着可以安全地将指针传递给其他地方使用,而不用担心后续的内存重分配会导致指针失效
实际场景中,可能涉及到 指针稳定性 的地方是
- 组件指针的长期引用: 系统可能需要长期保存某个组件的指针,比如用于建立复杂的数据依赖关系或层级结构
- 外部数据结构的集成: 如果组件需要被集成到其他数据结构中(比如一个树形结构或图),指针稳定性是基本要求
在大矩阵模型中,默认的实现可能基于 动态数组(std::vector) 来存储组件,它有一个很大的缺陷:当数组需要扩容时,所有元素会被移动到新的内存块中,导致其指针失效,因此,默认的实现并不能保证组件指针的稳定性
而 分页 通过将组件划分到独立的 页 中,解决了以上问题
- 页内组件不移动
一旦一个组件被分配到某一页,它在该页内的内存位置是固定的,即使页的整体指针数组需要调整(比如扩容),页内的数据不会受影响,因此指针稳定性得以保证 - 避免动态数组的整体重分配
动态数组 扩容时需要移动所有元素,而 分页 只需管理页指针数组的扩容,页指针数组很小,通常不会影响实际的组件存储
稳定的指针允许系统在需要时直接访问组件,而无需通过复杂的索引逻辑,既支持快速的随机访问,也支持长期的直接引用,并且我们可以直接将组件的指针传递到其他模块、外部数据结构甚至持久化存储中,而无需担心指针失效
迭代
在 大矩阵模型 的基础实现中,迭代 多个组件 的成本是 系统数量 × 实体数量,原因是我们要对每个 系统 检查每个 实体 的 掩码 mask 是否匹配,如果匹配,则访问组件数据,否则跳过
最短集 Shortest Set
为了加速迭代,我们可以在迭代多个组件时,选择 最小池 来驱动迭代,也就是组件数量最少的池,如果最小池的大小为 ,则无需检查超过 的实体,因为这些实体肯定不在所有池中,这也是相当显然的一种优化方式
快进 Fast Forward
这种方法通过利用 分页 的优势,跳过未创建的页来加速迭代,每个分页池由多个页组成,未分配组件的页会以空指针表示,在迭代时,检查当前页是否存在,如果不存在,直接跳过该页的范围,这可以避免大量不必要的掩码匹配和组件检查,节省内存访问和计算成本
混合方法 Hybrid Solution
也有结合了上述两种方式的混合方法
-
按元素数量:可以按 元素数量最少 的池进行迭代,而不是实体索引最小的池,当然前提是可以获取到这一信息,这样将增大遇到空白页的概率
-
按最小实体索引:因为在 大矩阵模型 中,每个实体在所有组件池中的索引是相同的,所以如果一个实体的索引超出了最小扩展范围,那么在其他池中它肯定也不存在组件,我们假设系统需要迭代同时拥有有三个组件的实体
- 组件池 A:实体索引范围为 0 到 50(扩展为 51)
- 组件池 B:实体索引范围为 0 到 30(扩展为 31)
- 组件池 C:实体索引范围为 0 到 40(扩展为 41)
此时最小扩展是 31(来自组件池 B),在迭代时,当索引达到 30(31-1)后就可以停止,因为超出 30 的实体肯定在池 A 和池 C 中也不存在