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 的创建, 但是不能观察到 positionvelocity同时创建, 也就是说我们不能直接实现 让 positionvelocity 同时附加到了 实体 才将 实体 存入 响应式存储 这个逻辑, 需要使用特殊的解决方案

  • 多响应式存储视图
    为每个组件创建 独立响应式存储, 然后将它们结合为一个视图进行遍历
    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, 表示实体满足了 “同时拥有 positionvelocity” 的条件

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

我们可以发现, 响应式存储 可以根据用户自定义的逻辑来决定是否收集实体到存储中, 而不仅仅是简单地追踪事件, 这种灵活性允许我们定义复杂的条件, 例如仅当更新 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_constructon_update)的观察, 如果没有手动断开这些连接,在销毁 Reactive Storage 时会导致 未定义行为, 例如访问已经无效的存储
entt::registry = storage.registry();
 
registry.on_construct<position>().disconnect(&storage);
registry.on_construct<velocity>().disconnect(&storage);

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