ECS: 共享数据

整理 ECS 中共享数据的常见模型,包括 flyweight、chunk、层级、主从、ownerless、copy-on-write 与 prototype。

共享数据的使用场景并不多,但在某些情况下确实非常有用,例如

  1. 资源管理
    对于像纹理、模型这样的资源,显然没有必要为每个实体创建一份拷贝,共享资源既节省内存,也简化管理
  2. 高级系统
    一个常见例子是敌人群体共享一个共同的目标,然而,这种设置也会引发一些问题
    • 这个目标能共享多久?
    • 是否存在一个 领导实体 来做决策,而其他实体只是跟随?
    • 如果个别实体脱离群体,目标是否还需要动态更新?
  3. 持久属性 比如,一组敌人可能共享 种族 这个属性,这个属性通常是持久的,但这又引出了其他问题
  • 谁拥有这个共享的组件
  • 如果拥有组件的实体被回收或移除,会发生什么

共享数据并不是免费的,通常会带来额外的复杂性,但如果用得当,它的价值往往能超过这些问题

享元模式 flyweight

享元模式的名字来源于知名的设计模式,其核心思想是通过共享数据来最小化内存使用,同时保持系统性能

在处理纹理、模型等资源时,避免重复是关键,这样可以减少内存消耗并提高效率,然而,直接将资源存储在 EnTT 的注册表中并不是一个理想的做法,主要原因包括

  1. 关注点分离
    注册表的职责是管理实体及其组件,而不是资源,资源管理通常包括以下操作

    • 预加载资源
    • 资源使用的预测
    • 延迟卸载资源
    • 缓存控制

    这些操作与注册表的核心功能无关

  2. 资源绑定的复杂性
    如果直接在注册表中存储资源,资源必须绑定到某个实体上,即使资源当前未被使用,这会引入额外的复杂性

    • 需要设计机制隐藏未使用的实体,避免它们被系统处理
    • 当资源变为活跃状态时,还需要逻辑 取消隐藏 对应实体
    • 由于资源是运行时对象,且可能被多个实体共享,在实体中查找资源会变得非常繁琐

    将资源直接存储在注册表中,会让系统复杂化,难以维护

享元模式的解决方案是通过一个专用的类或子系统来管理资源,可以简化资源管理流程,同时实现关注点分离,关键在于使用一个 句柄 Handle 来引用资源,而不是直接将资源嵌入到 组件

句柄 是一种中间媒介,用于访问底层资源,它可以是

  • 资源数组中的索引
  • 一个带有引用计数的指针(例如,std::shared_ptr

拥有同一个 句柄 的所有 组件共享同一个资源,这既节省了内存,又提供了统一的访问方式

可以看到,享元模式有以下好处

  1. 减少内存消耗:资源通过共享,而不是在多个组件中重复存储
  2. 关注点分离:资源管理逻辑(如加载、卸载、预测)由专门的子系统处理,注册表只需关注实体和组件的管理
  3. 简化维护:资源访问集中统一,系统架构更加清晰,维护成本更低

一个可能的实现是

// 资源管理器:负责加载和共享资源
class ResourceManager {
public:
    // 加载资源(如果不存在则创建)
    std::shared_ptr<Resource> loadResource(const std::string &name) {
        if (resources.find(name) == resources.end()) {
            resources[name] = std::make_shared<Resource>(name);
        }
        return resources[name];
    }
 
private:
    // 资源的集中存储
    std::unordered_map<std::string, std::shared_ptr<Resource>> resources;
};
 
// 使用资源句柄的组件
struct MyComponent {
    std::shared_ptr<Resource> resourceHandle;
};
 
// 示例:分配共享资源给多个实体
ResourceManager resourceManager;
 
// 加载资源句柄
auto resourceHandle = resourceManager.loadResource("example_texture");
 
// 为多个实体分配共享的资源
registry.emplace<MyComponent>(entity1, resourceHandle);
registry.emplace<MyComponent>(entity2, resourceHandle);
 
// 现在,这两个实体共享同一个资源

块模式 Chunk

基于块的设计进一步优化了实体与组件的存储方式,其核心思想是将实体和组件存储在 块 Chunk中,而不是简单的数组,这种方法在某些场景下能提供更高的性能,但也带来了复杂性和权衡

简单来说,基于块的设计将实体和组件组织为一个个固定大小的 ,每个块最多可以容纳 N 个实体及其组件,可以将其视为一个 页面数组 array of pages (or chunks),每个 页面 存储一组相关的数据

当前已知的两种结合了 原型(Archetype) 和块设计的实现是

  1. Unity DOTS
  2. decs

此外,也可以将 稀疏集 sparse sets 与块进行结合,但这种组合需要在实现前仔细权衡利弊,因为引入块的同时也意味着放弃一些其他特性

根据 Unity 引擎的文档,当你为实体添加一个 共享组件 时,EntityManager 会将所有具有相同共享数据值的实体放置在同一个块中,共享组件允许系统一起处理相似的实体,例如,在渲染时,处理具有相同字段值的 3D 对象会更高效

这种做法的优势是,通过将具有相同共享数据的实体分组在同一个块中使得数据局部性更强,访问块中的数据更快,减少了内存缓存失效的可能,这将使得系统的处理效率更高,因为系统可以批量处理同一块中的实体,特别是在渲染等需要高效批量操作的场景中

但是这也同样存在一起潜在问题,过度使用共享组件会导致块利用率低下,如果共享组件的值组合过多,会导致大量块未被完全利用,这将导致内存浪费与碎片化,而碎片化会降低迭代效率造成整体性能的损耗

此外,两个实体是否可以分配到同一块的条件非常严格,只有在它们拥有 完全相同的组件 时,它们才能共享一个原型和块,如果仅共享部分组件,这些实体仍会分布在不同的块中,这进一步限制了块设计的适用场景,然而即使多个实体共享相同的组件,数量过多时也可能超出单个块的容量,导致数据分散到多个块中,降低潜在的性能优势

块设计更适用于 静态类型 的实体,即和属性不频繁改变的实体,例如

  1. 渲染系统中的几何体、纹理数据
  2. 固定目标的敌人群体

尽管存在这些问题,块设计在某些场景下仍能提供优势,尤其是与 享元模式 结合时,共享组件的引用可以通过句柄实现,比如指向预加载的资源,减少内存开销,块的分组处理与享元的去重逻辑相辅相成,提升性能和数据组织的效率

层级模式

我们也可以借助之前讨论过的 基于层级的模型,它通过定义组件的层级结构来实现共享,这种方法允许实体之间通过一个 父级 关系共享数据,从父级实体可以轻松迭代所有子级实体

在这种模型中,实体共享一个父实体,父实体通常包含一个可以访问所有子实体的组件结构

  • 每个实体都有一个 父组件,指向其父实体
  • 父实体维护一个包含所有子实体的列表,方便迭代

通过这种方式,子实体之间的共享由它们的父实体间接管理

与之前提到的基于 的模型相比:

  • 内存使用:块模型更节省内存,而层级模型需要为每个实体增加指针
  • 迭代效率:层级模型更适合动态迭代,块模型更适合静态分组

享元模式 相比:

  • 灵活性:享元模式用于共享不可变数据(如资源),层级模型适合动态关系
  • 复杂性:享元模式实现简单,而层级模型需要设计父子关系及其管理逻辑

主从共享模型 Master and Slaves

前面讨论的共享模型主要基于以下两种方式:

  1. 外部管理的实例(例如资源管理器)
  2. 通过专用组件链接的实体(例如层级模型)

现在我们来探索直接共享数据的模型,这种模型无需依赖外部实例或父子关系,而是让多个实体直接访问和共享同一份数据

主从模型是一种常见的数据共享方式,其中数据由一个 主要实体(主节点,Master) 拥有,而其他多个 次级实体(从节点,Slaves) 希望访问这些数据

这种模型与基于层级的共享有相似之处,在多级层级结构中,子叶节点可以通过树的根节点间接访问数据,即使根节点不是它们的直接父节点,这种设计本质上就是一种共享方式,但它也可以被设计为 扁平化 的结构,主从模型 就是执行了这种 扁平化

这有几种常见的实现方式

双组件法

主实体 拥有 数据组件从实体 拥有一个指向 主实体数据指针组件,这种设计简单直观,主实体管理数据,而从实体通过指针或引用访问数据

struct DataComponent {
    int sharedValue;
};
 
struct PointerToData {
    DataComponent* dataPtr;
};
 
// 主实体拥有数据组件
registry.emplace<DataComponent>(masterEntity, DataComponent{42});
 
// 从实体拥有指针组件,指向主实体的数据
registry.emplace<PointerToData>(slaveEntity, registry.get<DataComponent>(masterEntity));

显然这种方法需要维护指针的有效性(例如,主实体被销毁时如何处理从实体的指针)

同构组件法

每个实体都拥有相同类型的组件,该组件可以是数据本体或指向数据的指针(类似于标记联合或 std::variant)这种方法让组件本身具有双重角色

struct SharedDataComponent {
    std::variant<DataComponent, DataComponent*> data;
};
 
// 主实体直接存储数据
registry.emplace<SharedDataComponent>(masterEntity, DataComponent{42});
 
// 从实体存储指向主实体数据的指针
registry.emplace<SharedDataComponent>(slaveEntity, &registry.get<DataComponent>(masterEntity));

这在一个统一的组件中管理主从关系,结构简洁,并且可以动态判断实体是数据的持有者还是观察者,但这显然增加了组件的复杂性,且性能可能略低于双组件法,尤其是在频繁访问 std::variant

可以看到,主从模型的关键问题是:主实体的生命周期,当主实体被销毁或退出场景时,必须解决以下问题

  1. 数据继承 让从节点选举一个新的主节点继承数据,例如

    • 新主节点可以是第一个可用的从节点
    • 使用自定义算法,允许所有从节点协商选举新的主节点

    在层级模型中,这相当于重新选择一个新的父节点

  2. 数据分解 解散主从关系,从节点分离并各自存储一份数据的副本,这种方法适合需要从主节点独立运行的场景

    for (auto slaveEntity : slaveEntities) {
        auto& pointer = registry.get<PointerToData>(slaveEntity);
        registry.emplace<DataComponent>(slaveEntity, *(pointer.dataPtr)); // 拷贝数据
        registry.remove<PointerToData>(slaveEntity); // 移除指针
    }

    如此一来所有从节点可独立运作,当然这会增加内存开销

  3. 解散组 直接删除所有从节点的引用,组解散,从节点不再指向任何主节点,相关逻辑终止,这种方式适合一些特定的场景,例如

    • 主节点是临时的状态提供者,当其消失后,状态不再有意义
    • 主节点代表短期共享资源,不再需要时可以完全释放

主实体生命周期的不同处理方式可能会根据具体应用场景而有所不同,例如

  • 对于资源型数据(如纹理、模型):通常选择 数据继承解散组
  • 对于状态型数据(如 AI 决策树、团队策略):可能选择 数据分解

可以认为主从模型适用于以下场景

  1. 逻辑共享:一组从节点共享一个逻辑或规则(如 AI 决策树)
  2. 资源管理:多个从节点共享一个资源(如光源、纹理)
  3. 短期状态共享:短时间内多个实体共享某一状态或目标(如队伍 Buff)

无主模型 Ownerless

无主模型是一种更通用、更灵活的数据共享方式,在这种模型中,多个实体可以共享同一份数据,但没有任何实体真正 拥有 这些数据,无主模型在资源管理、跨实体数据共享等场景中非常实用,以下是其核心概念和实现方式

数据是共享的,但无主

  • 数据被集中管理:数据的生命周期由专用管理器或共享机制控制,而不是由某个实体直接拥有
  • 实体仅引用数据:实体通过组件引用数据,但不负责数据的创建或销毁

可以看到实体被分配了引用某些资源的组件,但它们实际上都不拥有这些资源,当没有实体引用共享数据时,共享数据并不强制立即被清除,就像惰性销毁的资源一样

前面介绍的 享元 模式实际上都属于 无主模型

  1. 享元模式:数据管理由专用类处理,实体通过引用(如指针或句柄)共享资源
  2. 块引用:块中的数据由外部管理,多个实体通过索引或标识符共享数据

无主模型的常见实现方式有

  1. 共享指针 标准化的方式是使用 std::shared_ptr 来共享数据,数据的生命周期由引用计数控制

    // 使用普通类型组件
    registry.emplace<T>(entity, T{/* 初始化数据 */});
    auto& component = registry.get<T>(entity); // 简单直接
     
    // 使用 std::shared_ptr
    auto shared = std::make_shared<T>(/* 初始化数据 */);
    registry.emplace<std::shared_ptr<T>>(entity, shared); // 插入需要 std::shared_ptr
    auto& component = registry.get<std::shared_ptr<T>>(entity); // 获取也需要   std::shared_ptr

    这样做的优势是数据生命周期的管理是自动,当没有实体引用数据时,数据会自动销毁(惰性销毁),并且实现简单,无需额外的类或逻辑,而缺点则是需要通过 std::shared_ptr<T> 获取组件,而不是直接使用类型 T 并且引用计数操作可能引入一些性能损失

    当然可以使用别名来缓解这种问题,但是引用计数带来的开销无法消除

    // 定义别名,减少对 std::shared_ptr 的显式引用
    using SharedT = std::shared_ptr<T>;
     
    // 插入组件
    auto shared = std::make_shared<T>(/* 初始化数据 */);
    registry.emplace<SharedT>(entity, shared);
     
    // 获取组件
    auto& component = registry.get<SharedT>(entity);
  2. 多容器 (多注册表) 当我们使用 EnTT 或其他允许使用多个容器(在 EnTT 中称为注册表)ECS 库时,还有一种可行的解决方案,这种方法不需要设计新类来管理共享数据,也无需使用 std::shared_ptr,而是用一个注册表存储模拟所需的实体和组件,而另一个注册表用于存储共享数据(例如原型数据)

    • 实体注册表:存储实际的实体和其相关的组件,负责模拟和逻辑处理
    • 资源注册表:专门存储共享的数据,如配置、资源或原型(prototypes),这些数据可以被多个实体引用
    // 数据注册表
    entt::registry dataRegistry;
    auto prototype = dataRegistry.create();
    dataRegistry.emplace<SharedData>(prototype, SharedData{/* 初始化数据 */});
     
    // 实体注册表
    entt::registry entityRegistry;
    auto entity = entityRegistry.create();
    entityRegistry.emplace<MyComponent>(entity, prototype); // 引用共享数据的标识符

    通过将共享数据存储在独立的注册表中,其生命周期与实际实体分离,带来了以下好处

    • 独立控制数据生命周期:即使实体被销毁,共享数据仍然存在,直到显式删除它为止
    • 数据持久化:共享数据可以跨多个实体和场景使用,而不会因单个实体的销毁而丢失

写时复制 Copy-on-Write

写时复制(Copy-on-Write, COW)是一种常见的共享数据策略,用于在需要动态分离数据时高效管理内存,它的核心思想是让多个实体共享同一份数据,直到需要修改时才创建私有副本,也就是说实体初始共享一组数据,但某些实体需要脱离共享组,创建自己的数据副本

我们希望 COW 有以下特性

  • 不需要在运行时区分实体是共享数据还是拥有独立副本
  • 访问数据时要尽可能减少间接访问(如指针跳转)
  • 在共享数据的情况下,应尽量节省内存;但在实体分离后,内存消耗不可过高

我们假设,

  • N 是实体数量,S 是组件数据大小 那么有两种极端情况
  1. 最好情况 (所有实体共享数据):内存消耗为 S(一个共享实例)
  2. 最差情况(所有实体都有独立数据):内存消耗为 N × S

大多数情况下,实际消耗与最终解决方案的易用性密切相关,节省的越多,实际调用时的代码就越丑陋,所以 COW 的实现需要在这两种极端之间找到平衡

双组件方案

通过为实体分配两种组件:

  • 共享数据组件(存储指针或引用)
  • 独立数据组件(存储实体的私有数据)

这种模式在 主从模型 中已经说过了,但是不同的是如果一个实体从共享数据中分离,它会从共享组件切换到独立组件,在实现上,这种方法需要两种循环逻辑来处理两种组件

优势

  • 数据访问直接且高效(共享组件与私有组件分别存储)
  • 独立数据组件(存储实体的私有数据)

缺陷

  • 需要两类组件,代码复杂度增加
  • 无法统一迭代,共享与独立组件必须分开处理

指针和数据结合的双组件方案

每个实体都有一个 指针到数据 组件,如果实体共享数据,则指针指向外部存储的共享数据;否则,指针指向本地的私有数据

struct DataComponent {
    int value; // 实际数据
};
 
struct PointerToData {
    DataComponent* dataPtr; // 指向共享数据或私有数据
};
 
// 共享数据存储在独立容器中
DataComponent sharedData{42};
 
// 实体 A 和 B 共享数据
registry.emplace<PointerToData>(entityA, &sharedData);
registry.emplace<PointerToData>(entityB, &sharedData);
 
// 实体 A 需要私有数据时,创建独立副本
DataComponent privateData = *registry.get<PointerToData>(entityA).dataPtr;
registry.emplace<DataComponent>(entityA, privateData);
registry.get<PointerToData>(entityA).dataPtr = &registry.get<DataComponent>(entityA);

优势

  • 循环统一:只需要遍历 PointerToData,无论数据是共享的还是私有的
  • 支持排序和操作:可以基于 PointerToData 进行操作
  • 在最佳情况下(所有实体共享数据),内存消耗较低

缺陷

  • 在最差情况下(所有实体都有私有数据),内存消耗高(每个实体都有两部分:指针 + 数据)

数据和指针结合的单组件方案

在组件中同时存储数据和指针:

  • 如果实体共享数据,指针指向共享实例,数据部分被忽略
  • 如果实体拥有私有数据,数据部分存储实际值,指针指向本地数据
struct Component {
    DataComponent data;         // 数据
    DataComponent* dataPtr;     // 指针到数据
};
 
// 所有实体共享数据
Component sharedInstance{DataComponent{42}, nullptr};
 
registry.emplace<Component>(entityA, Component{DataComponent{}, &sharedInstance.data});
registry.emplace<Component>(entityB, Component{DataComponent{}, &sharedInstance.data});
 
// 实体 A 创建私有数据副本
auto& comp   = registry.get<Component>(entityA);
comp.data    = *comp.dataPtr;  // 复制共享数据到私有数据
comp.dataPtr = &comp.data;     // 指针指向私有数据

优势

  • 统一数据存储,共享和私有数据都存储在一个组件中
  • 访问私有数据时无需间接访问(无指针跳转)

缺陷

  • 内存开销固定,即使实体共享数据,每个实体也必须分配完整的组件(数据 + 指针)

实际应用

在实际应用中,组件共享的一个重要场景是实现 实体原型 Prototype,这种模式在游戏开发和复杂场景的系统设计中非常常见,也被称为 模板 Template预制件 Prefab

原型模式允许创建一种 基础实体(称为 base),并基于这个基础实体实例化多个实体(称为 instance),这些实例会继承基础实体的组件和数据,从而实现数据共享

  • 基础实体:包含共享的组件和数据,作为模板
  • 实例实体:引用基础实体的数据,而不持有这些数据,当基础实体的数据发生变化时,实例也会同步更新
// 创建基础实体,并赋予共享组件
entity base;
base.set<Position>({10, 20});
 
// 创建实例实体,并引用基础实体
entity instance_1;
instance_1.add_instanceof(base);
 
entity instance_2;
instance_2.add_instanceof(base);
 
// 修改基础实体的 Position
base.set<Position>({30, 40});
// instance_1 和 instance_2 的 Position 也会同步更新

在这个示例中,base 持有 Position 组件,instance_1instance_2 通过共享引用访问 basePosition 数据,当 basePosition 修改为 {30, 40} 时,instance_1instance_2Position 也会自动更新

覆盖 Overriding

覆盖 Overriding 是一种灵活的共享机制,允许在共享组件的基础上,为实例分配私有值,当覆盖发生时,实例会从基础实体 base 复制共享组件的值,并创建自己的私有副本,覆盖后的实例既可以独立持有组件值,也可以随时切换回共享基础实体的组件

entity base;
base.set<Position>({10, 20});
 
// 创建一个实例,初始共享 base 的 Position
entity instance;
instance.add_instanceof(base);
 
// 覆盖共享组件,实例拥有自己的 Position,值为 {10, 20}
instance.add<Position>();
 
// 移除覆盖,实例再次共享 base 的 Position
instance.remove<Position>();

在某些 ECS 系统,例如flecs,可以通过定义一种类型(type)来简化覆盖逻辑,避免手动管理 addremove 操作

entity base;
base.set<Position>({10, 20});
base.set<Velocity>({1, 1});
 
// 定义一个类型,包含对 base 的引用,并指定覆盖的组件
type prototype;
prototype.add_instanceof(base);
prototype.add<Position>();
 
// 创建实例,并应用类型
entity instance;
instance.add(prototype);
 
// 结果:
// - instance 拥有自己的 Position,值为 {10, 20}(从 base 复制)
// - instance 共享 base 的 Velocity

变体 Variants

变体机制允许通过创建 原型层次结构 来实现原型的进一步特化,利用嵌套的基础实体,变体可以同时继承和覆盖来自不同层级的组件,使得实体具有灵活的继承与重定义能力

// 创建基础实体:白色圆形
entity circle;
circle.set<Circle>({10});              // 圆的半径
circle.set<Color>({255, 255, 255});    // 白色
 
// 创建变体:红色圆形,覆盖颜色
entity red_circle;
red_circle.add_instanceof(circle);
red_circle.set<Color>({255, 0, 0});    // 红色
 
// 创建实例:继承红色圆形
entity my_red_circle;
my_red_circle.add_instanceof(red_circle);
 
// 结果:
// - my_red_circle 继承了 red_circle 的 Color(红色)。
// - my_red_circle 继承了 circle 的 Circle(半径 10)。

陷阱

原型是一种强大的设计工具,尤其是结合组件共享时,它可以为原型和实体定义提供一致的方式,但是这种机制也伴随着一些需要注意的陷阱和问题

系统匹配问题

原型在 ECS 中是作为普通实体实现的,这意味着它们可能会被系统匹配到并参与运行,对于原型来说,这是不必要的,甚至是有害的,因为它们通常只作为模板存在,而不需要逻辑处理,一个简单的解决方法是引入一个特殊的 标记,用来标识哪些实体是原型,并在系统中过滤掉这些标记的实体

struct PrototypeTag {};
 
entity base;
base.add<PrototypeTag>();
 
// 在系统中过滤掉原型
system([](const Position& pos, const Velocity& vel) {
    // 处理实体逻辑
}).exclude<PrototypeTag>();

这样可以确保系统只处理非原型的实体

碎片化问题

由于 ECS 的数据存储通常基于组件的布局,原型的层级关系可能导致数据分布在不同的数组中

entity base;
 
entity e1;
e1.add<Position>();
 
entity e2;
e2.add_instanceof(base);
e2.add<Position>();

虽然 e1e2 拥有相同的组件,但由于 e2 还具有与 baseinstanceof 关系,它会被存储在与 e1 不同的数组中,这种分离可能会降低数据局部性,从而影响性能,一种解决方式是将具有相同组件的实例分组存储,并且我们需要进行权衡,如果实例数量远多于基础实体,性能影响可以忽略不计;但在实例数量较少时,可能需要重新评估使用原型的场景

原型删除问题

原型是普通实体,这意味着它们可以被删除,如果删除一个原型,而其实例仍然引用该原型,会发生什么

entity base;
base.add<Position>();
 
entity e1;
e1.add_instanceof(base);
 
base.delete_entity(); // 原型被删除

上述代码中,e1 仍然引用 base,但 base 已被删除,这看似会导致未定义行为,但实际上可以通过 ECS 的实体标识符特性优雅地解决,在 ECS 中,实体不是对象,而是标识符(通常是整数),即使删除了实体的所有组件,其标识符仍然是有效的

  • 原型被删除后,实例仍然可以引用它的标识符
  • 应用程序可以通过标识符检测是否已删除原型,并采取适当的处理逻辑
entity base;
base.add<Position>();
 
entity e1;
e1.add_instanceof(base);
 
// 删除原型
base.delete_entity();
 
// 检查原型状态
if (!registry.valid(base)) {
    // 原型已删除,采取恢复或清理逻辑
}