响应式存储
响应式系统 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) {
// ...
}
}回调的逻辑是
- 首先用
storage.contains(entity)检查存储中是否已经存储了该实体, 如果实体已经被存储了, 那么说明之前已经由另一个组件添加了该实体 - 如果实体已经存在, 那么用
storage.get(entity) = true;获取存储中该实体对应的布尔值, 并将其设置为true, 表示该实体已满足所有条件 (即拥有所有组件了) - 如果实体不存在, 则用
storage.emplace(entity, false);将实体加入存储,并初始化其状态为false, 表示该实体还未完全满足条件 (例如,还缺少一个组件)
可以发现, 回调实际上做了如下操作
- 当实体第一次被添加到存储时, 初始化其状态为
false, 表示当前条件尚未完全满足, 例如,position组件被添加,但velocity组件尚未添加 - 如果实体已存在于存储中, 说明某些条件已经满足, 此时更新状态为
true, 例如,velocity组件被添加后, 状态被更新为true, 表示实体满足了 “同时拥有position和velocity” 的条件
也就是说, 为每个实体在存储中维护一个布尔值状态
我们可以发现, 响应式存储 可以根据用户自定义的逻辑来决定是否收集实体到存储中, 而不仅仅是简单地追踪事件, 这种灵活性允许我们定义复杂的条件, 例如仅当更新 position 组件后落在一定范围内的实体才会被收集到存储中
void callback(reactive_storage &storage, const entt::registry ®istry, 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);注意如果不手动断开 响应式混合 的观察连接, 将会在销毁 响应式存储 时导致 未定义行为