ECS: 混合存储 Hybrid Storage

比较 ECS 中的独立池、表格存储和 EnTT 分组模型,梳理混合存储带来的收益与隐藏复杂度。

混合存储是一种结合不同存储模型(如稀疏集合和表格存储)的方法,用于在 ECS(实体组件系统)中管理组件数据,然而,尽管混合存储看似解决了多个模型的局限性,但实际上它也带来了许多隐藏的问题

混合存储的核心在于

  1. 跨类型存储:它并不是只关注单个组件的存储方式,而是关注多种组件的存储布局及其相互影响
  2. 典型实现
    • 独立池模型:独立存储每种组件,按实体 ID 索引访问,适合稀疏数据
    • 表格模型:将共享相同组件集的实体存储在一个连续的内存块中,适合批量操作和高密度数据 混合存储尝试结合这些模型的优点,例如通过 分组 grouping 在稀疏集合中实现类似表格存储的功能

独立池 Independent Pools

稀疏集合模型中的独立池设计是许多 ECS 实现的核心,每种组件类型都有一个独立的存储池,这种设计提供了灵活性,但也带来了特定的限制和权衡

独立池意味着每种组件类型有自己的存储池,互不依赖,稀疏集合通常用于实现这些存储池,提供快速的查找和操作性能,因为每种组件类型的存储是独立的,所以不同组件池之间没有直接关联,需要通过实体 ID 进行间接访问

独立池的设计使得多线程操作变得简单而高效

  • 组件类型隔离:只要线程不同时操作同一个组件池,就可以安全地添加、删除或更新组件
  • 无队列或暂存区:与需要命令队列或暂存区的设计不同,独立池允许直接并发操作,无需延迟更新
std::thread threadA([&] { registry.emplace<ComponentA>(entityA); });
std::thread threadB([&] { registry.emplace<ComponentB>(entityB); });
threadA.join();
threadB.join();

线程 AB 操作不同的组件池,因此不会互相干扰,可以在迭代过程中安全地添加或删除其他组件池的组件,而不影响当前池的迭代,例如,在迭代组件 A 时,可以安全地向实体添加组件 B

并且可以自定义存储设计,只要满足存储接口要求(鸭子类型支持),开发者可以自由替换存储池的内部实现

  • 使用简单数组优化读取性能
  • 使用分页存储优化内存使用

当然独立池也有缺陷,独立池无法直接访问实体的所有组件,而需要通过实体 ID 间接访问,并且会导致不同组件的数据分散在内存中,缺乏表格存储的缓存友好性、

表 Tables

表存储是另一种常见的 ECS 组件存储方式,与 独立池 Independent Pools 相比,它提供了完全不同的设计哲学和规则

表格存储将共享相同组件集的实体存储在一个连续的内存块(表)中,例如

  1. 原型(Archetype)模型:动态创建包含特定组件集合的表格
  2. 固定表格(Fixed Table)模型:在编译时定义表格结构

在这种模型中,实体的组件数据存储在一个表格中,任何组件的添加或移除都会导致实体从一个表格移动到另一个表格

每个表格将存储一组固定的组件类型,每个实体的数据被打包存储在表格的一个行中

  • 表格 TableA 存储组件 PositionVelocity
  • 表格 TableB 存储组件 PositionHealth

当实体的组件集合变化时(例如添加或移除组件),需要将实体从一个表格移动到另一个表格,这可能涉及数据复制或重分配,由于实体数据和其他组件打包存储在一起,任何对表格的修改都有可能导致数据竞态,因此需要同步点(sync point)确保安全

组件类型的组合是表格的核心特性,这使得对特定组合的实体操作变得高效,可以说它具有以下优势

  1. 缓存友好:数据存储在连续的内存块中,可以显著提高迭代访问效率,适合需要频繁处理共享组件的批量操作
  2. 对于以 块 chunked 存储的表格,也可以比较容易的实现线程的负载均衡,将每个块分配个不同的线程来进行处理

但也限制了组件的灵活性,由于实体可能需要在表格之间移动,操作成本包括复制数据和更新元数据,并且所有修改操作都需要在同步点上进行延迟处理,即使是在单线程场景中,而且组件与其他组件共享表格,无法为某个组件类型独立优化存储设计

分组:EnTT 的混合模型

EnTT 的分组功能在独立池模型中引入了类似表格存储的行为,从而创造了一种混合存储模式,这种设计通过将共享组件集的实体组合在一起,提升了特定场景的性能,但也带来了独立池模型中未曾出现的复杂性

分组功能的实现原理

  • 独立池模型的扩展
    分组功能将原本独立的组件池逻辑连接起来,形成一种按实体关联组件的 “表格”,这种设计仍然依赖稀疏集合作为底层实现,但通过索引实体创建一种虚拟表格结构
  • 动态调整的表格
    当实体的组件状态发生变化(如添加或移除组件)时,分组表会动态调整,将实体从一个状态迁移到另一个状态,这种动态调整是分组功能的核心,但同时也引入了复杂性和开销

前面实际上已经讨论过分组功能的具体实现,在这里我们只列出一些优劣

分组功能的优点

  1. 高效的批量处理
    分组后的实体数据更接近表格存储模型,能够高效地迭代和处理共享组件集,适合需要频繁操作多个组件组合的场景
  2. 灵活性
    分组是按需创建的,用户可以根据具体需求选择哪些组件组合需要分组,不像表格存储那样需要预先定义固定的结构
  3. 保持独立池的特性
    独立池的优势在分组之外仍然有效。用户可以灵活选择是否启用分组功能,而不影响原本的独立池模型
  4. 多线程支持
    在使用分组的同时,未参与分组的组件池仍然保持线程安全,可以与其他线程并行操作

分组功能的缺点

  1. 引入表格模型的限制
    分组功能在独立池之间引入了隐式依赖,这将导致
    • 迭代中添加/移除的复杂性:实体在分组内外动态迁移会导致迭代器失效,可能需要延迟操作或同步点
    • 分组打破了独立池的完全隔离性,使得某些多线程操作不再简单
  2. 动态调整的开销 添加或移除实体组件时,需要更新分组表格,涉及数据迁移和元数据更新
  3. 线程竞争
    分组引入了组件池之间的依赖,因此需要特别注意多线程操作,尽量避免在迭代分组时修改组件,如果需要修改,使用命令队列或同步点

分组功能适合高频操作特定组件集的场景,例如,处理所有具有 PositionVelocity 组件的实体,用于更新物理状态,但不应滥用,对于不频繁一起操作的组件,维持独立池模式会更简单高效