EnTT: 运行时 runtime

记录 EnTT 在运行时处理组件类型、存储、命名存储、克隆和视图支持的方式。

虽然 EnTT 主要利用编译期功能来提升性能, 这是其核心优势之一, 然而一些运行时技术也是相当有用的, 例如 类型擦除, EnTT 提供了一系列实用工具和特性, 能够非常高效地在运行时处理类型和池

基类 base class

在 EnTT 中, 存储类 Storage classes 是完全自包含的类, 它们通过 混合 mixin 来进行扩展, 以添加更多功能 (无论是通用功能还是特定于类型的功能), 此外, 存储类提供了一组基础函数, 已经足够让用户实现许多复杂的需求

在 ECS 中, 实体 是一个抽象的标识符, 本身并不携带数据, 组件 是实际的数据信息, 它们附加到实体上, 使实体 具备特定的属性或行为, 例如, 一个 实体 可以附加一个 velocity 组件来表示速度

type_info 对象的作用

在编译期, 我们可以通过模板知道组件的具体类型, 比如 registry.get<velocity>(entity) 但在运行时, 如果不清楚组件的实际类型 (可能是通过某种动态查询获取), 如何检查某个 实体 是否附加了某种类型的 组件 ?

EnTT 的 type_info 为了解决这个问题而设计, 提供了一种对 类型 的运行时描述, 它允许我们在运行时检查组件的类型, 而无需直接依赖模板或编译期信息

通过 entt::type_id<T>(), 我们可以获取组件类型 Ttype_info 对象, 然后, 可以使用该对象与存储类的 type() 方法返回的 type_info 进行比较

if (entt::type_id<velocity>() == base.type()) 
{
    // base 是存储类的基类,type() 返回其管理的组件类型。
    // 如果 base 管理的是 velocity 组件,我们就可以安全地处理相关逻辑。
    std::cout << "This storage manages velocity components!" << std::endl;
}
 

当通过基类使用存储时, 即使存储的实际类型未知, 仍然可以获取与该存储类相关联的 type_info 对象 (如果有的话)

此外, base.bind(registry) 是一种 绑定机制, 用于将 存储类 storage注册表 registry 关联起来, 从而实现更高级的功能, 例如事件通知、信号处理或依赖注入, 这是通过 bind 方法实现的, 它在存储类内部保存对注册表的引用, 从而在存储类内部可以访问注册表的功能, 而无需每次都显式的传递注册表

#include <entt/entt.hpp>
#include <iostream>
 
// 示例组件
struct Velocity {
    float x, y;
};
 
int main() {
    entt::registry registry;
 
    // 创建组件的存储
    auto &storage = registry.storage<Velocity>();
 
    // 绑定注册表
    storage.bind(registry);
 
    // 现在存储类可以与注册表交互
    auto entity = registry.create();
    storage.emplace(entity, 1.0f, 2.0f);
 
    std::cout << "Entity " << int(entity) << " has Velocity (" 
              << storage.get(entity).x << ", " 
              << storage.get(entity).y << ")" << std::endl;
 
    return 0;
}

默认情况下直接使用 registry.storage<T>() 获取的存储类不会自动触发信号或事件, 也就是说, 存储和注册表是解耦的

当调用 base.bind(registry) 后:

  1. 存储类会接收注册表的引用
  2. 存储类中的混入逻辑可以动态触发注册表的事件系统
  3. 存储无需显式传递注册表, 就可以发送事件

从注册表中获取的存储只是管理了组件的存储和生命周期, 它默认并不会触发事件或访问注册表的上下文信息

运行时处理

EnTT 中, base.value(entity)base.push(entity, pointer) 是底层存储类提供的高级功能, 允许以 类型无关 的方式处理 实体 及其 组件, 这种机制非常适合在运行时动态管理组件, 尤其是在 实现实体克隆动态组件操作 的场景下

value(entity)

可以从存储中获取与实体相关联的组件, 并以 void* 的形式 (不透明指针) 返回, 这可以在不知道组件类型的情况下, 通过 value 提取组件值

const void *instance = base.value(entity);

base 是一个存储类的基类引用, value(entity) 返回实体 entity 对应组件 (或者说相关联的,存储在 base 中的值, 如果存在的话)

push(entity, void *pointer = nullptr)

这会将 组件 绑定到 实体, 并根据传入指针选择默认 构造复制 构造组件

base.push(entity, instance);  // 通过复制构造为 `entity` 绑定组件。
base.push(entity);            // 通过默认构造为 `entity` 绑定组件。
  • 当指针为 nullptr: 尝试默认构造组件并绑定到实体
  • 当指针非空时: 尝试通过复制构造的方式, 将指针指向的值作为新组件绑定到实体

克隆

通过 valuepush 的组合, 可以在不依赖组件实际类型的情况下, 实现实体及其组件的克隆

以下代码展示如何将一个实体 src 的所有组件克隆到另一个实体 dst

for (auto &&curr : registry.storage()) 
{
    auto &storage = curr.second; // 获取存储引用
    if (storage.contains(src)) { // 检查 `src` 是否有该类型 (storage 存储的类型) 的组件
        storage.push(dst, storage.value(src)); // 将组件从 `src` 复制到 `dst`
    }
}

存储管理

EnTT 提供了一个非常灵活的机制, 允许用户为组件类型创建多个 存储, 并通过唯一的 标识符 (通常是一个 entt 编译时哈希字符串) 来管理它们

默认存储与命名存储

  • 默认存储: 每种组件类型在注册表中会有一个默认的存储
  • 命名存储: 可以通过提供标识符 (如字符串哈希) 为同一组件类型创建额外的存储
using namespace entt::literals;
 
// 获取默认存储
auto &&defaultStorage = registry.storage<velocity>();
 
// 创建一个命名存储
auto &&secondPool = registry.storage<velocity>("second pool"_hs);
auto &&otherPool = registry.storage<velocity>("other"_hs);

如果未提供标识符, 则会始终返回默认存储, 如果提供标识符, 则会创建或返回对应的命名存储

存储的独立性

所有存储都是 自包含 self-contained, 它们独立管理与实体关联的数据, 这种设计允许

  • 直接通过存储操作组件, 而不必依赖注册表的 API
  • 为同一组件类型创建多个存储, 满足不同场景的需求
registry.emplace<velocity>(entity);  // 在默认存储中添加组件
secondPool.push(entity);             // 在名为 "second pool" 的存储中添加组件
 
// 访问默认存储中的组件
if (registry.all_of<velocity>(entity)) {
    auto &vel = registry.get<velocity>(entity);
    std::cout << "Default velocity: (" << vel.dx << ", " << vel.dy << ")\n";
}
 
// 访问名为 "second pool" 的存储中的组件
if (secondPool.contains(entity)) {
    auto &vel = secondPool.get(entity);
    std::cout << "Second pool velocity: (" << vel.dx << ", " << vel.dy << ")\n";
}

注册表操作对所有存储生效

虽然存储是独立的, 但某些注册表操作 (如销毁实体) 会影响所有关联的存储, 这意味着即使组件存储是手动创建的, 仍然能够与注册表的核心逻辑保持一致

registry.destroy(entity);  // 从所有存储中移除该实体的数据

视图支持

EnTT 的 视图不仅可以处理默认存储, 还支持多个同类型的存储, 这种功能使得在运行时动态组合不同存储的数据成为可能

entt::basic_view direct{
    registry.storage<velocity>(),           // 默认存储
    registry.storage<velocity>("other"_hs)  // 名为 "other" 的存储
};
 
// 迭代视图中的实体
for (auto entity : direct) {
    std::cout << "Entity in multiple storages: " << int(entity) << std::endl;
}

或者

auto join = registry.view<velocity>() | entt::basic_view{registry.storage<velocity>("other"_hs)};
 
for (auto entity : join) 
{
    std::cout << "Entity in combined view: " << int(entity) << std::endl;
}