EnTT: 响应式存储 Reactive Storage

整理 EnTT reactive storage 的创建、观察、组合访问和事件断开方式。

响应式存储

响应式系统 Reactive Systems 是一种高效的设计模式, 主要目标是只对发生变化的实体进行操作, 而不是每次都遍历所有实体, 在 EnTT 中这意味着迭代比 视图 或 组 所返回的实体更少的集合, 也就是那些发生了变化的实体所组成的集合

在 EnTT 中, 信号 是构建 响应式系统 的重要基础工具, 通过监听特定事件 (例如组件的构造, 更新, 销毁) 来触发相应逻辑, 但仅依靠 信号 并不足以实现完整的 响应式系统, 为进一步增强这种能力, EnTT 提供了 reactive mixin 响应式混合

基本原理是, 通过 响应式混合 观察 (监听) 一系列事件, 并将 发生这些事件 且 满足一定条件 的 实体 收集到一个容器中, 这个容器被称为 响应式存储 Reactive Storage, 然后我们可以访问这个容器中的对象并执行操作, 以此实现 响应式系统 的逻辑

创建

在 EnTT 中, 响应式混合 需要 独立存储 的配合, 我们需要使用 reactive_mixin 为 独立存储 提供响应式能力, 存储的值类型可以是 任何类型, 如此一来, 这个存储就被称为 响应式存储 Reactive Storage, 表示该 存储 中的实体都是 响应式 的

entt::storage 是一个 稀疏集

// 定义响应式存储
// 值类型为 void
// 是一种特殊的值类型, 用于表示存储中只需要记录实体, 而无需关联额外的数据, 即只存储实体 ID, 不存储任何组件 (没有 dense 数组的稀疏集)
using reactive_storage = entt::reactive_mixin<entt::storage<void>>;
 
// 创建注册表
entt::registry registry{};
// 创建响应式存储
reactive_storage storage{};
 
// 绑定到注册表
storage.bind(registry);

然后我们必须将它绑定至一个 注册表, 但即使我们将 响应式存储 绑定至 注册表 了, 它们之间的生命周期仍然是分离的, 需要手动进行清理

我们也可以在 注册表 内部直接创建 响应式存储, 如此一来, 它将自动与 注册表 绑定并且自动执行清理, 但代价是不能使用任意类型了, 必须使用 entt::reactive 类型

entt::registry registry{};
auto &storage = registry.storage<entt::reactive>("observer"_hs);

即在 注册表 中创建了一个名为 observer 且自动管理的 稀疏集, 该 稀疏集 存储的类型是 entt::reactive 类型, 这样一来如果实体被摧毁了, 那么它在 响应式存储 中也会遭到清理

需要注意 响应式存储 默认不启用 信号, 这和普通存储不一样, 当然如果有需要, 可以轻易的启用它

观察并收集

如果我们创建了 响应式存储 并将之绑定到了 注册表 (不管是手动绑定还是直接在注册表内创建响应式存储), 那么我们还需要告诉它需要观察哪些事件, 这些事件可以是针对 实体 或 组件 的操作, 包括 创建, 更新 和 销毁

storage
    // observe position component construction
    .on_construct<position>()
    // observe velocity component update
    .on_update<velocity>()
    // observe renderable component destruction
    .on_destroy<renderable>();

并且它可以同时观察多个同种或不同类型的事件

// 同时观察 `my_type` 类型的创建和更新
storage
    .on_construct<my_type>()
    .on_update<my_type>();

但是所有观察逻辑都是 or 而不是 and, 这是指如果我们配置如下

storage.on_construct<position>();
storage.on_construct<velocity>();

那么 响应式存储 storage 可以观察到 position 的创建 或 velocity 的创建, 但是不能观察到 position 和 velocity 的 同时创建, 也就是说我们不能直接实现 让 position 和 velocity 同时附加到了 实体 才将 实体 存入 响应式存储 这个逻辑, 需要使用特殊的解决方案

  • 多响应式存储视图
    为每个组件创建 独立 的 响应式存储, 然后将它们结合为一个视图进行遍历
    first_storage.on_construct<position>();
    second_storage.on_construct<velocity>();
    
    for(auto entity: entt::basic_view{first_storage, second_storage}) {
        // ...
    }
  • 自定义存储和跟踪逻辑
    使用非 void 类型的存储, 例如 entt::storage<bool>, 用来跟踪实体是否同时满足多个条件, 并且编写自定义回调函数, 在组件事件触发时, 更新存储中的状态
    using my_reactive_storage = entt::reactive_mixin<entt::storage<bool>>;
 
    // 自定义回调
    void callback(my_reactive_storage &storage, const entt::registry &, const   entt::entity entity) {
        storage.contains(entity) ? (storage.get(entity) = true) : storage.  emplace(entity, false);
    }
 
    // ...
 
    // 配置事件观察, 调用自定义回调
    my_reactive_storage storage{};
    storage
        .on_construct<position, &callback>()
        .on_construct<velocity, &callback>();
 
    // ...
    // 遍历存储, 但需要筛选符合条件的实体
    for(auto [entity, both_were_added]: storage.each()) {
        if(both_were_added) {
            // ...
        }
    }

回调的逻辑是

  1. 首先用 storage.contains(entity) 检查存储中是否已经存储了该实体, 如果实体已经被存储了, 那么说明之前已经由另一个组件添加了该实体
  2. 如果实体已经存在, 那么用 storage.get(entity) = true; 获取存储中该实体对应的布尔值, 并将其设置为 true, 表示该实体已满足所有条件 (即拥有所有组件了)
  3. 如果实体不存在, 则用 storage.emplace(entity, false); 将实体加入存储,并初始化其状态为 false, 表示该实体还未完全满足条件 (例如,还缺少一个组件)

可以发现, 回调实际上做了如下操作

  • 当实体第一次被添加到存储时, 初始化其状态为 false, 表示当前条件尚未完全满足, 例如, position 组件被添加,但 velocity 组件尚未添加
  • 如果实体已存在于存储中, 说明某些条件已经满足, 此时更新状态为 true, 例如, velocity 组件被添加后, 状态被更新为 true, 表示实体满足了 “同时拥有 position 和 velocity” 的条件

也就是说, 为每个实体在存储中维护一个布尔值状态

我们可以发现, 响应式存储 可以根据用户自定义的逻辑来决定是否收集实体到存储中, 而不仅仅是简单地追踪事件, 这种灵活性允许我们定义复杂的条件, 例如仅当更新 position 组件后落在一定范围内的实体才会被收集到存储中

void callback(reactive_storage &storage, const entt::registry &registry, const entt::entity entity) {
    storage.remove(entity);
 
    // 更新后位置位于特定范围内的实体才会被收集
    if(const auto x = registry.get<position>(entity).x; x >= min_x && x <= max_x) {
        storage.emplace(entity);
    }
}
 
// ...
 
storage.on_update<position, &callback>();

访问

最后, 一旦所有感兴趣的实体都被收集完成, 我们就可以访问收集它们的 响应式存储

for(auto entity: storage) {
    // ...
}

或者我们也可以将它包裹在 视图 view 中并和其他视图结合访问

for(auto [entity, pos]: (entt::basic_view{storage} | registry.view<position>(entt::exclude<velocity>)).each()) {
    // ...
}

这里 entt::basic_view{storage} 创建了一个包含 响应式存储 的视图, 然后使用管道 | (对多个视图取交集) 将存储与 registry.view<position> 组合, 并进一步筛选出仅包含 position 组件的实体 (用 entt::exclude<velocity> 排除包含 velocity 组件的实体), 所以最终我们将获得

  • 存储于 storage 中
  • 拥有 position 组件
  • 没有 velocity 组件

的实体 视图, 然后使用 .each() 方法遍历最终视图, 返回满足条件的每个实体及其组件数据, 最后用 for 循环访问结果

我们发现这有一些繁琐, 如果 响应式存储 本身就只存储了实体, 那么必然要通过其绑定的 注册表 去获取存储中实体所对应的组件, 因此有一个简化的方法, 通过调用存储的 view 函数, 可以直接创建一个过滤后的视图

for(auto [entity, pos]: storage.view<position>(entt::exclude<velocity>).each()) {
    // ...
}

这将自动使用与存储关联的 注册表 以简化过滤的过程

删除

需要注意的是

  • 响应式存储 Reactive Storage 不会自动清理删除其存储的实体
    不会主动移除其内部存储的实体或数据, 如果需要定期清理无用实体或数据, 用户必须手动调用 clear 函数
  • 响应式混合 Reactive Mixin 也不会自动断开与被观察存储的连接
    即 Reactive Mixin 在销毁时不会自动断开它对事件(如 on_construct 或 on_update)的观察, 如果没有手动断开这些连接,在销毁 Reactive Storage 时会导致 未定义行为, 例如访问已经无效的存储
entt::registry = storage.registry();
 
registry.on_construct<position>().disconnect(&storage);
registry.on_construct<velocity>().disconnect(&storage);

注意如果不手动断开 响应式混合 的观察连接, 将会在销毁 响应式存储 时导致 未定义行为