EnTT: Helper

整理 EnTT helper 中空实体、墓碑、依赖、连接助手、handle、organizer、上下文和快照工具。

Helper 是指一些小型类和函数, 主要用于为基本功能提供支持

空实体 Null Entity

entt::null 用于表示空实体, 在任何情况下, 下列表达式都会返回 false

registry.valid(entt::null);

注册表在任何时候都会认为 空实体 是无效的, 所以空实体不能拥有任何 组件

entt::entity null = entt::null;
const auto entity = registry.create();
const bool null = (entity == entt::null);

entt::null 是标识符部分为保留值的实体

墓碑实体 Tombstone Entity

与 空实体 类似, entt::tombstone 是版本部分为保留值的实体, 同样, 以下表达式始终返回 false

registry.valid(entt::tombstone);

我们无法在摧毁实体时将其版本号设置为 墓碑, 因为 墓碑 是保留值, 用于在 组件池 中保持指针稳定性, 而如果我们试图这样做, 结果是一个与被摧毁实体当前版本不同的版本被生成并赋予实体的版本号 (默认应该是递增), 并将该实体添加到回收的隐式链表中 (默认行为)

// 这只会将被摧毁实体的版本递增, 而不是设置为墓碑版本
registry.destroy(entity, entt::tombstone);

以下操作是允许的

entt::entity null = entt::tombstone;
const auto entity = registry.create();
const bool tombstone = (entity == entt::tombstone);

要注意, entt::tombstone 和 实体 0 并不是一回事, 0 初始化实体也不是 entt::tombstone, 也就是说 entt::entity{} 是实体 0 的别名, 而不是 entt::tombstone

To Entity

entt::to_entity 可用于将 组件 实例映射回其关联的实体, 这可以找到是哪个 实体 拥有该组件

const auto entity = entt::to_entity(registry.storage<position>(), instance);

执行该操作需要一个 存储, 且存储的类型必须和组件类型相符, 如果被查找的 组件 实例不属于该存储, 则将返回 entt::null, 所以一般来说被查找的实例应该是 & 类型

组件依赖 Dependencies

EnTT 提供了优雅的方式来处理 组件之间 的 依赖关系, 这是通过 注册表 的 生命周期事件 实现的

例如, 可以实现一旦 my_type 组件被附加到实体, 就立即也 附加/替换 一个 a_type 组件

registry.on_construct<my_type>().connect<&entt::registry::emplace_or_replace<a_type>>();

类似的, 也可以一旦 my_type 组件被附加到实体, 就立即 移除 a_type 组件

registry.on_construct<my_type>().connect<&entt::registry::remove<a_type>>();

这种依赖在建立以后也可以被打破

registry.on_construct<my_type>().disconnect<&entt::registry::emplace_or_replace<a_type>>();

还有很多其他类型的依赖, 不再赘述

调用 Invoke

entt::invoke 用于在组件上调用其 成员函数, 它会在 信号 发生时自动调用触发该信号的实体所拥有对应组件的成员函数, 而无需手动传播信号

// 将 on_construct 信号与 clazz 的成员函数 func 绑定
registry.on_construct<clazz>().connect<entt::invoke<&clazz::func>>();
// 在绑定时传递额外参数
registry.on_construct<clazz>().connect<entt::invoke<&clazz::func>>(42);

并且会传递绑定时提供的参数

连接助手 Connection helper

连接 信号 很快将变得很麻烦, 连接助手 可以简化这一过程

entt::sigh_helper{registry}
    .with<position>()
        .on_construct<&a_listener>()
        .on_destroy<&another_listener>()
    .with<velocity>("other"_hs)
        .on_update<yet_another_listener>();

with("identifier"_hs) 允许使用 运行时池 runtime pool 也就是在运行时创建的拥有特定标识符的自定义池

#include <entt/entt.hpp>
#include <iostream>
 
void on_health_construct(entt::registry &, entt::entity entity) {
    std::cout << "Health component added to entity " << static_cast<uint32_t>(entity) << ".\n";
}
 
void on_health_update(entt::registry &, entt::entity entity) {
    std::cout << "Health component updated for entity " << static_cast<uint32_t>(entity) << ".\n";
}
 
int main() {
    entt::registry registry;
 
    // Define the runtime pool identifier
    const auto health_pool_id = "health"_hs;
 
    // Create the runtime pool for health values
    auto &health_pool = registry.storage<float>(health_pool_id);
 
    // Use sigh_helper to connect signals to the runtime pool
    entt::sigh_helper{registry}
        .with(health_pool_id)
            .on_construct<&on_health_construct>() // Signal for when health is added
            .on_update<&on_health_update>();      // Signal for when health is updated
 
    // Create an entity and add a health component
    auto entity = registry.create();
    health_pool.emplace(entity, 100.0f); // Add health with initial value 100.0
 
    // Update the health component
    health_pool.replace(entity, 75.0f); // Update health to 75.0
 
    // Add health to another entity
    auto another_entity = registry.create();
    health_pool.emplace(another_entity, 50.0f); // Add health with initial value 50.0
 
    return 0;
}

句柄 Handle

句柄 是计算机科学中的常见概念, 它提供更高层次的抽象来间接的访问资源, 是对底层资源的一种 抽象引用, 强调了对资源的 间接管理 和 安全性

EnTT 中的 句柄 是对 实体 及其所属 注册表 的薄包装, 并且不拥有或存储 实体 的 组件, 它提供了访问操作注册表的可调用功能 (例如 get, emplace 等), 但是因为绑定了 实体, 所以无需再传递 实体 作为参数

句柄 等默认构造是无效的, 这意味着它将含有 空实体 和 空注册表, 当它包含的注册表为空时, 对它的调用将导致未定义行为, 所以通常需要在进行调用前使用隐式转换 (转换为 bool) 检测其是否有效

因为 句柄 不管理它所引用的 实体 的生命周期, 所以它是 可被轻松复制 trivially copyable 的, 也就是说它复制或移动不会影响实体或注册表的状态且效率很高

entt::handle 和 entt::const_handle 是两种使用默认实体类型 entt::entity 的句柄别名, 用户也可以创建自定义的类型

using my_handle = entt::basic_handle<entt::basic_registry<my_identifier>>;
using my_const_handle = entt::basic_handle<const entt::basic_registry<my_identifier>>;

显然非 const 句柄可以隐式转换到 const 句柄, 但是不允许相反的转换

句柄 的设计意图是用于简化函数签名, 如果函数需要 注册表 和 实体 作为输入参数来完成大部分工作, 那么应该考虑使用 句柄

Organizer 组织者

entt::organizer 是一个模版类用于根据一组函数及其对资源的需要生成执行图, 这些任务不会被执行, 执行图会被返回给用户

作为模版参数 添加到执行图的 自由函数 和 成员函数 可以接受以下参数类型:

  • entt::registry 的 (可能是常量) 引用
  • entt::basic_view 可组合任意存储类的视图
  • 任意 T 类型的 (可能是常量) 引用, 也就是 上下文变量
entt::organizer organizer;
 
// 添加全局函数到 organizer
organizer.emplace<&free_function>();
 
// 添加成员函数到 organizer,同时指定实例
clazz instance;
organizer.emplace<&clazz::member_function>(&instance);

在 emplace 方法中 作为传入参数 进行注册时, 自由函数 和衰减以后的 lambda 函数 的签名需要是 void(const void *, entt::registry &), 其中第一个参数是用于传递用户自定义数据的可选指针, 数据是在注册时指定的

// 添加 lambda 函数到 organizer
// 使用一元 + 操作符可以将无捕获 Lambda 强制转换 (衰减) 为普通函数指针
organizer.emplace(+[](const void *, entt::registry &) { /* 自定义逻辑 */}, &instance);

不管使用哪种注册方法, 都可以为任务指定一个名字, 例如

organizer.emplace<&free_function>("func");

当一个函数在 组织者 内部进行注册以后, 它所访问的一切都被考虑为所需的资源 (视图 被解包), 资源类型的常量性将影响它的访问模式 (只读/读写), 然后将根据这些性质来构建任务依赖图, 从而决定任务之间是否可以并行执行

资源

当一个函数被注册到 organizer 中时, 它访问的所有内容都会被视为资源, 包括以下几类

  • 视图 Views
    视图 中的 组件 类型被视为资源, 例如, entt::basic_view<Position, Velocity> 会将 Position 和 Velocity 两个组件类型标记为资源
  • 注册表 Registry
    注册表本身始终被视为资源
  • 上下文变量 Context Variables
    如果函数显式使用上下文变量 (如 T &), 这些变量也会被视为资源

当注册函数时, 用户可以指定函数参数中没有的 额外参数, 将它们填入函数的模版参数

organizer.emplace<&free_function, position, velocity>("func");

资源访问模式 (只读 vs 读写)

函数对资源的访问模式由其参数的类型决定, 这对任务图的生成有重要影响

  • 只读访问 Read-Only, RO
    如果资源以 const T & 的形式使用, 系统会将其标记为 只读, 多个任务可以同时读取同一资源, 允许并行执行
  • 读写访问 Read-Write, RW
    如果资源以 T & 的形式使用, 系统会将其标记为 读写, 读写访问会强制任务依次执行, 防止资源竞争

由于每个注册的函数都会将注册表视为依赖资源, 所以它是特殊的,

  • 如果函数不显式使用注册表,或者以 const entt::registry & 的形式使用, 系统将注册表标记为 只读
  • 如果函数以 entt::registry & 的形式使用, 系统将注册表标记为 读写

用户可以在注册时, 通过模版参数手动覆盖资源的 访问模式

organizer.emplace<&free_function, const renderable>("func");

在这种情况下, 即使 renderable 在注册函数的参数中不是 const 的, 也将被视为 只读资源

图 graph

为了生成任务图, organizer 提供了 graph 成员函数

std::vector<entt::organizer::vertex> graph = organizer.graph();

图 graph 以 邻接表 adjacency list 的形式返回, 每个 顶点 vertex 提供以下功能:

  • ro_count 和 rw_count
    访问模式为 只读 / 读写 的资源数量
  • ro_dependency 和 rw_dependency 与底层函数的参数相关的类型信息对象
    • ro_dependency: 函数的参数中以只读模式访问的资源的类型信息
    • rw_dependency: 函数的参数中以只读模式访问的资源的类型信息
  • top_level
    如果节点是顶层节点 (没有进入边), 则为 true, 否则为 false
  • info
    与函数相关联的类型信息 type_info, 表示该节点所对应的具体函数的类型
  • name
    如果注册任务时为该节点指定了名称, 则该属性保存任务名称, 如果未指定名称, 则为 nullptr
  • callback
    指向要执行的函数的指针, 其函数类型为 void(const void *, entt::registry &) 这是 entt::organizer 用于执行任务的回调函数
  • data
    可选的数据指针, 提供给 callback 用于任务执行时的上下文
  • children
    从当前节点可以到达的其他节点的列表, 表示为邻接表中的索引数组, 即这些索引指向的节点是当前节点的直接后续任务

邻接表明确了任务之间的依赖关系, 可用于确保任务按照正确的顺序执行, 例如 top_level 节点表示可以独立执行的任务, 通常是并行执行的起点, 根据 ro_count 和 rw_count, 可以分析任务的资源访问模式, 优化并行任务的调度, name 和 info 提供了对任务的标识, 便于调试和分析任务依赖图

由于 注册表 中创建资源和池的操作并不一定是线程安全的, 每个 顶点 提供了一个 prepare 函数, 用于在执行任务之前初始化 注册表 中所需的 资源

auto graph = organizer.graph();
entt::registry registry;
 
// 遍历任务图中的每个顶点
for (auto &&node : graph) {
    // 调用 prepare 函数为注册表初始化资源
    node.prepare(registry);
}
 
// 此时,所有任务所需的资源都已准备好

entt::organizer 的职责是生成任务依赖图, 但它并不负责具体的任务调度, 任务调度是用户的责任, 可以选择自己熟悉的工具或框架来实现并行任务调度

上下文变量 Context variables

每一个 注册表 都有一个与之相关联的 上下文 context, 它是一个可以通过 类型 和 名称 访问的 any 对象 映射 map, 这里 名称 并不是一个真正的名字, 而是一个 id_type 类型的数字id, 用作变量的 键 key, 而值可以是任何对象, 甚至是运行时的

上下文 通过 ctx 函数返回, 并提供以下功能

// creates a new context variable by type and returns it
registry.ctx().emplace<my_type>(42, 'c');
 
// creates a new named context variable by type and returns it
registry.ctx().emplace_as<my_type>("my_variable"_hs, 42, 'c');
 
// inserts or assigns a context variable by (deduced) type and returns it
registry.ctx().insert_or_assign(my_type{42, 'c'});
 
// inserts or assigns a named context variable by (deduced) type and returns it
registry.ctx().insert_or_assign("my_variable"_hs, my_type{42, 'c'});
 
// gets the context variable by type as a non-const reference from a non-const registry
auto &var = registry.ctx().get<my_type>();
 
// gets the context variable by name as a const reference from either a const or a non-const registry
const auto &cvar = registry.ctx().get<const my_type>("my_variable"_hs);
 
// resets the context variable by type
registry.ctx().erase<my_type>();
 
// resets the context variable associated with the given name
registry.ctx().erase<my_type>("my_variable"_hs);

一个 上下文变量 必须是 默认可构造 和 默认可移动 的 也可以使用 contains 和 find 函数

const bool contains = registry.ctx().contains<my_type>();
const my_type *value = registry.ctx().find<const my_type>("my_variable"_hs);

它们都支持常量类型查找

别名属性

上下文 支持为不由 注册表 直接管理的变量创建别名, 也接受只读的常量

为此, 构造时使用的类型必须是引用类型, 并且必须提供左值作为参数

time clock;
registry.ctx().emplace<time &>(clock);
registry.ctx().emplace<const time &>(clock);

注意 insert_or_assign 不支持别名, 需要使用支持别名的 emplace 和 emplace_as, 当 insert_or_assign 用于更新别名对象时, 它会将其本身转换为非别名

从用户的角度来看, 注册表管理的变量和别名属性之间没有区别, 但是, 只读变量不能作为非常量引用访问

// read-only variables only support const access
const my_type *ptr = registry.ctx().find<const my_type>();
const my_type &var = registry.ctx().get<const my_type>();

别名属性会像其他变量一样被删除, 同样, 也可以为它们分配一个名称

快照: 完整 vs 连续 Snapshot: complete vs continuous

这个模块对 序列化 功能仅提供了最基本的支持, 它并不直接将组件转换为字节流, 相反, 它接受一个具有适当接口的透明对象 (通常是一个 存档 archive 对象) 来序列化其内部数据结构, 并在需要时恢复这些数据, 至于如何将类型和实例转换为字节流, 完全交由存档对象处理, 因此最终实现也取决于用户

序列化 部分的目标是允许用户完成两种操作:完整存档 和 精确快照, 完整存档指 是对整个注册表的全部数据进行保存, 而 精确快照 则是根据需要选择特定的组件进行保存

这两种方式的适用场景直观上是不同的, 例如, 完整存档 适合于本地保存和恢复功能, 而 精确快照 更适用于创建客户端-服务器应用场景, 或在不同端之间传输部分数据表示

存储快照

要对 注册表 进行 快照, 需要使用 snapshot 类

output_archive output;
 
entt::snapshot{registry}
    .get<entt::entity>(output)
    .get<a_component>(output)
    .get<another_component>(output);

没有必要每次都调用所有函数, 在什么情况下使用哪种函数完全取决于目标需求

当获取 实体 类型时, snapshot 类将序列化所有实体及其版本

在其他情况下, 给定 存储 中的 实体 和 组件 将被传递给 存档, 也支持 命名池

entt::snapshot{registry}.get<a_component>(output, "other"_hs);

存在另一个版本的 get 函数, 它接受一系列要被序列化的实体 范围, 这可以用于 过滤掉那些不该被序列化的实体

const auto view = registry.view<serialize>();
output_archive output;
 
entt::snapshot{registry}
    .get<a_component>(output, view.begin(), view.end())
    .get<another_component>(output, view.begin(), view.end());

读取快照

一旦 快照 被创建, 有两种方式来加载它: 整体加载 或 连续加载

快照加载器 Snapshot loader

快照加载器 要求目标 注册表 为空, 它会一次性加载所有数据, 同时保留实体原有的 标识符

input_archive input;
 
entt::snapshot_loader{registry}
    .get<entt::entity>(input)
    .get<a_component>(input)
    .get<another_component>(input)
    .orphans();

没有必要每次都调用所有函数, 在什么情况下使用什么函数主要取决于目标, 出于显而易见的原因, 重要的是数据以与序列化时完全相同的顺序恢复

当恢复 实体 类型时, 快照加载器 将会恢复所有实体及其版本信息, 就像它们被存储时那样

对于其他类型, 实体 和 组件 都将在相应的 存储 内被恢复, 如果 注册表 不含有相应的 实体 则其将被创建, 同样的, 支持 命名池

entt::snapshot_loader{registry}.get<a_component>(input, "other"_hs);

最后, orphans 函数用于释放执行恢复后不拥有任何 组件 的 孤儿实体

连续加载器 Continuous loader

连续加载器 用于将数据加载到可能非空的目标 注册表 中, 它允许在目标 注册表 中以一种 “连续加载” 的方式处理多个快照, 并逐步更新目标 注册表

在恢复快照时, 实体 的 原始标识符 不会直接传递到目标 注册表 中, 相反, 加载器会将 远程标识符 映射到 本地标识符, 这种映射机制确保了目标 注册表 的标识符空间不与源注册表冲突, 同时可以正确重建实体间的关系

连续加载器 与 快照加载器 的另一个区别在于它具有一个必须长期存续的内部状态, 因此, 它的生命周期不应该仅限于一个临时对象

entt::continuous_loader loader{registry};
input_archive input;
 
auto archive = [&loader, &input](auto &value) {
    input(value);
 
    if constexpr(std::is_same_v<std::remove_reference_t<decltype(value)>, dirty_component>) {
        value.parent = loader.map(value.parent);
        value.child = loader.map(value.child);
    }
};
 
loader
    .get<entt::entity>(input)
    .get<a_component>(input)
    .get<another_component>(input)
    .get<dirty_component>(input)
    .orphans();
  • 加载器内部状态
    entt::continuous_loader 维护了一个内部状态, 用于记录远程标识符和本地标识符之间的映射关系, 这种状态对多次调用至关重要, 因为它保证了每次加载快照时, 标识符可以正确地映射
  • 回调函数封装
    通过 archive 回调函数, 加载器可以处理组件数据, 在这个示例中, 如果当前组件是 dirty_component, 它的 parent 和 child 标识符会通过 loader.map 函数映射到本地标识符, 这种机制确保了组件中存储的实体标识符在加载时能正确映射到目标注册表
  • 支持多次调用
    loader.get 方法支持依次加载不同类型的组件, 在这个例子中, 连续加载了 entt::entity, a_component, another_component 和 dirty_component 最后调用的 .orphans() 方法用于释放那些没有父级或其他关联的孤立实体
  • 生命周期管理
    由于加载器的内部状态对于多次加载快照至关重要, 因此它的生命周期需要超越临时对象的范围, 这使得 entt::continuous_loader 需要长时间存在的上下文

没有必要每次都调用所有函数, 在什么情况下使用什么函数主要取决于目标, 出于显而易见的原因, 重要的是数据以与序列化时完全相同的顺序恢复

当加载器恢复实体时, 它会恢复一组实体, 并在需要时将每个 远程实体 映射到一个 本地实体

  • 远程标识符的映射
    对于加载器尚未注册的 远程标识符, 加载器会创建一个对应的 本地标识符, 这种机制保证了 本地实体 与 远程实体 的同步关系, 从而能够在两个注册表之间正确维护实体的引用
  • 实体和组件的恢复
    实体 和 组件 会恢复到指定的 存储 中, 如果目标 注册表 尚未包含该实体, 加载器会进行相应的跟踪, 以确保该实体在注册表中的一致性
  • 命名池的支持
    加载器还支持恢复命名池中的组件
    loader.get<a_component>(input, "other"_hs);

这是指将 a_component 恢复到 注册表 中一个名为 "other"_hs 的 特殊组件池 中

存档 Archives

存档必须公开一组预定义的 成员函数, API 非常简单, 仅包含一组由 快照类 和 加载器 调用的函数调用运算符

  • 输出归档 Output Archive
    用于创建快照 (序列化数据), 并将 实体 和 组件 写入存储
    • 存储实体
      输出归档需要实现以下签名的函数调用操作符, 用于存储实体
        void operator()(entt::entity);

entt::entity 是注册表中实体的类型, 每次调用时, 该函数 会接收一个实体, 并将其写入底层存储中

  • 实体数量
    快照类 的所有成员函数都会调用一个函数, 来初始化要存储的实体集合的大小, 这个初始化函数的签名是
        void operator() (std::underlying_type_t<entt::entity>);

std::underlying_type_t<entt::entity> 是实体类型的实际底层整数类型

  • 存储组件
    归档还需要实现以下签名的函数调用操作符, 用于序列化组件
        void operator()(const T &);

T 是组件的类型, 每次调用时, 该函数接收一个组件的 常量引用, 并将其写入底层存储中

输出归档可以自由决定如何序列化数据 (例如, 二进制, JSON,XML 等) 这种设计使得注册表本身完全独立于归档的实现方式

  • 输入归档 Input Archive
    用于恢复快照 (反序列化数据), 从存储中读取实体和组件
    • 加载实体
      输入归档需要实现以下签名的函数调用操作符, 用于加载实体
        void operator()(entt::entity &);

每次调用时, 该函数从底层存储中读取下一个实体, 并将其复制到提供的变量中 (函数签名的参数)

  • 实体数量
    与输出时类似, 恢复时 加载器类 的成员函数也需要先调用一个函数以读取实体集合的大小, 签名是
        void operator()(std::underlying_type_t<entt::entity> &);

同样, 会将结果写到参数中

  • 加载组件
    输入归档还需要实现以下签名的函数调用操作符, 用于反序列化组件
        void operator()(T &);

T 是组件的类型, 每次调用时, 该函数从底层存储中读取下一个组件, 并将其复制到提供的变量中 (函数签名的参数)