Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

实体-组件-系统 (ECS)

目录

简介

EnTT 提供了一个仅头文件 (header-only)、小巧且易于使用的 entity-component system (ECS) 模块,采用现代 C++ 编写。
实体-组件-系统(Entity-Component-System,简称 ECS)是一种主要用于游戏开发的架构模式。

设计决策

无类型且无 bitset

本库实现了一种基于稀疏集 (sparse set) 的模型,不需要用户在编译时或运行时指定组件集合。
这就是为什么用户可以简单地实例化核心类:

entt::registry registry;

而不是其更烦人且容易出错的对应物:

entt::registry<comp_0, comp_1, ..., comp_n> registry;

此外,没有必要提前声明组件类型的存在。当需要使用时,直接使用即可。

自行构建

ECS 模块(以及库的其余部分)被设计为一组按需使用的容器,就像 vector 或任何其他容器一样。它绝不试图接管用户的代码库,也不控制其主循环或进程调度。
与其他或多或少知名的模型不同,它还利用通过 静态 mixin (static mixins) 扩展的独立池 (independent pools)。内置的信号 (signal) 支持就是这种灵活设计的一个例子:它被定义为一个 mixin,如果不需要可以轻松禁用。同样,storage 类也有一个特化版本,展示了如何将一切定制到最细微的细节。

按需付费

一切设计都围绕用户只需为他们想要的东西付费的原则。

在使用 ECS 时,权衡通常在性能和内存使用之间。速度越快,使用的内存就越多。更糟糕的是,一些方法倾向于严重影响其他功能(如组件的构造和销毁)以偏向迭代,即使并非严格需要。事实上,在非关键路径上牺牲一点性能,是减少内存使用并获得整体更好性能的正确代价。
EnTT 采用了完全不同的方法。它从基础数据结构中榨取最大价值,并让用户能够在需要时为更高性能付出更多代价。

全有或全无

根据经验,T ** 指针(或自定义池返回的任何内容)始终可用于直接访问给定组件类型 T 的所有实例。
这是本库的基石之一。提供的许多工具都是围绕这一需求设计的,并提供了获取此信息的可能性。

使用须知 (Vademecum)

entt::entity 类型实现了 实体标识符 (entity identifier) 的概念。实体(ECS 中的 E)是一个不透明的元素,应直接使用。不建议检查它,因为其格式在未来可能会改变。
组件(ECS 中的 C)可以是任何类型,没有任何限制,甚至不需要是可移动的 (movable)。无需注册它们或其类型。
系统(ECS 中的 S)是普通的函数、仿函数 (functors)、lambda 等。在任何情况下都不需要声明它们,也没有任何要求。

接下来的部分将详细介绍如何使用 EnTT 库的 entity-component system 部分。
该模块可能比下面描述的更大。有关更多详细信息,请参阅内联文档。

Registry、Entity 与 Component

Registry 存储并管理实体(或 标识符)和组件。
类模板 basic_registry 让用户决定表示实体的首选类型。因为 std::uint32_t 对几乎所有情况都足够大,所以还存在 包装 它的 enum class entt::entity 以及 entt::basic_registry<entt::entity> 的别名 entt::registry

实体由 实体标识符 表示。实体标识符包含有关实体本身及其版本 (version) 的信息。
允许用户定义的标识符作为 enum class 和定义了 std::uint32_tstd::uint64_t 类型的 entity_type 成员的 class type。

Registry 既用于构造也用于销毁实体:

// 构造一个没有任何组件的裸实体并返回其标识符
auto entity = registry.create();

// 销毁一个实体及其所有组件
registry.destroy(entity);

create 成员函数还接受一个 hint。此外,它有一个重载,接收两个 iterator 以一次性高效生成多个实体。同样,destroy 成员函数也适用于实体范围:

// 销毁范围内的所有实体
auto view = registry.view<a_component, another_component>();
registry.destroy(view.begin(), view.end());

除了提供一个重载以在销毁时强制指定版本外。
此函数在释放实体之前会从中移除所有组件。还存在一种 更轻量 的替代方法,它不查询组件池,用于处理孤儿实体 (orphaned entities):

// 释放孤儿标识符
registry.release(entity);

destroy 函数一样,在这种情况下也支持实体范围,并且可以强制指定 version

在这两种情况下,当标识符被释放时,registry 可以在内部自由地重用它。特别是,实体的版本会增加(除非使用强制指定版本的重载而不是默认重载)。
然后,用户可以通过 registry 测试 标识符:

// 如果实体仍然有效则返回 true,否则返回 false
bool b = registry.valid(entity);

// 获取给定实体的实际版本
auto curr = registry.current(entity);

或者使用一些旨在按原样解析标识符的函数来 检查 它们,例如:

// 获取实体标识符中包含的版本
auto version = entt::to_version(entity);

组件可以在任何时候分配给实体或从实体中移除。
emplace 成员函数模板创建、初始化给定的组件并将其分配给实体。它接受可变数量的参数用于构造组件本身:

registry.emplace<position>(entity, 0., 0.);

// ...

auto &vel = registry.emplace<velocity>(entity);
vel.dx = 0.;
vel.dy = 0.;

默认 storage 在内部 检测 聚合类型 (aggregate types) 并在可能时利用聚合初始化。
因此,并非严格需要为每种类型定义构造函数。

insert 成员函数适用于 范围 (ranges) 并用于:

  • 当将类型指定为模板参数或将实例作为参数传递时,一次性将同一组件分配给所有实体:

    // 默认初始化的类型通过拷贝分配给所有实体
    registry.insert<position>(first, last);
    
    // 用户定义的实例通过拷贝分配给所有实体
    registry.insert(from, to, position{0., 0.});
    
  • 当提供范围时,将一组组件分配给实体(组件范围的长度 必须 与实体范围的长度相同):

    // first 和 last 指定实体的范围,instances 指向组件范围的第一个元素
    registry.insert<position>(first, last, instances);
    

如果实体已经拥有给定的组件,则使用 replacepatch 成员函数模板来更新它:

// 就地替换组件
registry.patch<position>(entity, [](auto &pos) { pos.x = pos.y = 0.; });

// 从参数列表构造一个新实例并替换组件
registry.replace<position>(entity, 0., 0.);

当不知道实体是否已经拥有组件实例时,应改用 emplace_or_replace 函数:

registry.emplace_or_replace<position>(entity, 0., 0.);

这是以下代码段的稍快替代方案:

if(registry.all_of<velocity>(entity)) {
    registry.replace<velocity>(entity, 0., 0.);
} else {
    registry.emplace<velocity>(entity, 0., 0.);
}

如果对实体是否拥有集合中的所有组件或其中任何一个组件有疑问,all_ofany_of 成员函数也可能很有用:

// 如果实体拥有所有给定的组件则为 true
bool all = registry.all_of<position, velocity>(entity);

// 如果实体至少拥有给定组件之一则为 true
bool any = registry.any_of<position, velocity>(entity);

如果目标是从拥有它的实体中删除组件,则使用 erase 成员函数模板:

registry.erase<position>(entity);

当不确定实体是否拥有该组件时,请改用 remove 成员函数。它的行为类似于 erase,但仅在组件存在时才删除它,否则安全地返回给调用者:

registry.remove<position>(entity);

clear 成员函数的行为类似,用于:

  • 从拥有给定组件的实体中擦除它们的所有实例:

    registry.clear<position>();
    
  • 或一次性销毁 registry 中的所有实体:

    registry.clear();
    

最后,获取组件的引用非常简单:

const auto &cregistry = registry;

// const 和非 const 引用
const auto &crenderable = cregistry.get<renderable>(entity);
auto &renderable = registry.get<renderable>(entity);

// const 和非 const 引用
const auto [cpos, cvel] = cregistry.get<position, velocity>(entity);
auto [pos, vel] = registry.get<position, velocity>(entity);

如果不确定组件是否存在,则 try_get 是更合适的函数。

观察变更

默认情况下,每个 storage 都带有一个 mixin,为其添加 signal 支持。
这允许实现诸如依赖关系和响应式系统 (reactive systems) 等高级功能。

on_construct 成员函数返回一个 sink(这是一个用于连接和断开 listener 的对象),供那些对在创建给定组件类型的新实例时收到通知感兴趣的人使用:

// 连接自由函数
registry.on_construct<position>().connect<&my_free_function>();

// 连接成员函数
registry.on_construct<position>().connect<&my_class::member>(instance);

// 断开自由函数
registry.on_construct<position>().disconnect<&my_free_function>();

// 断开成员函数
registry.on_construct<position>().disconnect<&my_class::member>(instance);

同样,on_destroyon_update 分别用于接收有关实例销毁和更新的通知。
由于 C++ 的工作方式,附加到 on_update 的 listener 仅在调用 replaceemplace_or_replacepatch 后被调用。

通过向上述函数提供标识符,也支持运行时池 (runtime pools):

registry.on_construct<position>("other"_hs).connect<&my_free_function>();

有关运行时池的更多信息,请参阅以下部分。
在所有情况下,listener 的函数类型等同于以下内容:

void(entt::registry &, entt::entity);

所有 listener 都会收到触发通知的 registry 和涉及的实体。还要注意:

  • 构造信号的 listener 在组件创建 之后 被调用。
  • 旨在观察变更的 listener 在组件更新 之后 被调用。
  • 销毁信号的 listener 在组件销毁 之前 被调用。

对 listener 可以做什么和不能做什么也有一些限制:

  • 应避免在 listener 的函数体内连接和断开其他函数。在某些情况下,这可能会导致未定义行为。
  • 不允许在观察给定类型实例构造或更新的 listener 的函数体内移除组件。
  • 应避免在观察给定类型实例销毁的 listener 的函数体内分配和移除组件。在某些情况下,这可能会导致未定义行为。此类 listener 旨在为用户提供一种执行清理的简单方法,仅此而已。

请参阅 signal 类的文档以了解其提供的所有功能。
有许多有用但不太为人熟知的功能在此未描述,例如连接对象 (connection objects) 或使用比 signal 本身更短的参数列表附加 listener 的可能性。

自动绑定

用户无需每次都手动创建绑定。对于托管类型,他们可以让 EnTT 自动设置 listener。
库会在类型中搜索具有特定名称和签名的函数,如以下示例所示:

struct my_type {
    static void on_construct(entt::registry &registry, const entt::entity entt);
    static void on_update(entt::registry &registry, const entt::entity entt);
    static void on_destroy(entt::registry &registry, const entt::entity entt);

    // ...
};

一旦为这种定义的类型创建了 storage,这些函数就会与各自的 signal 关联。函数名称不言自明地指示了目标 signal。

Entity 生命周期

也可以观察实体。在这种情况下,用户必须使用 entity 类型而不是 component 类型:

registry.on_construct<entt::entity>().connect<&my_listener>();

由于 entity storage 在 registry 中是唯一的,如果提供了 name,它将被忽略并因此被丢弃。
至于函数签名,这与组件完全相同。

实体支持所有类型的 signal:构造、销毁和更新。后者可能有些歧义,因为实体并未真正被 更新。相反,它的标识符被创建并最终被释放。
实际上,update signal 旨在发送有关实体的 一般通知。它可以像组件一样通过 patch 函数触发:

registry.patch<entt::entity>(entity);

销毁实体然后更新标识符的版本在任何情况下都 不会 引发这些类型的 signal。
最后,请注意,观察 实体销毁的 listener 会在所有组件被移除 之后 被调用,而不是 之前。这是因为否则在删除其元素之前实体会被失效,从而使用户难以编写组件 listener。

Listener 断开连接

storage 类的销毁顺序以及因此 listener 的断开连接是完全随机的。
目前没有任何保证,虽然逻辑很容易推断,但不能保证将来会保持如此。

例如,在由于池销毁而丢弃组件后断开连接的 listener 很可能是引发问题的根源。
相反,建议在销毁 registry 之前调用其 clear 函数。这会强制删除所有组件和实体,而永远不会丢弃池。
因此,想要访问组件、实体或池的 listener 可以安全地对仍然有效的 registry 执行此操作,同时适当地检查各个元素的存在。

响应式 Storage

Signal 是构建响应式系统 (reactive systems) 的基本工具,即使它们本身还不够。EnTT 试图通过其 响应式 mixin (reactive mixin) 朝着这个方向再迈出一步。
为了解释什么是响应式系统,这是首次引入此工具的库 Entitas 文档中稍作修改的引用:

想象一下,你在战场有 100 个战斗单位,但只有 10 个改变了位置。你可以使用响应式系统,它只更新这 10 个改变的单位,而不是使用普通系统并根据位置更新所有 100 个实体。如此高效。

EnTT 中,这意味着迭代一组比从 view 或 group 返回的实体和组件更小的集合。
然而,到此为止,与 Entitas 提案的相似之处也结束了。语言的规则和库的设计显然强加并允许了不同的事物。

响应式 mixin 可用于具有任何值类型的独立 storage(也许使用别名以简化其使用):

using reactive_storage = entt::reactive_mixin<entt::storage<void>>;

entt::registry registry{};
reactive_storage storage{};

storage.bind(registry);

在这种情况下,必须为其提供一个引用的 registry 以进行后续操作。
或者,当使用 EnTT 提供的值类型时,也可以直接在 registry 内创建响应式 storage:

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

在后一种情况下,优点是,如果实体被销毁,此 storage 也会被自动清理。
还要注意,与所有其他 storage 不同,这些类默认不支持 signal(尽管如果需要可以启用)。

一旦创建并与 registry 关联,响应式 mixin 需要被告知它应该 观察 什么。
这里的选择归结为影响所有元素(实体或组件)的三个主要事件,即创建、更新或销毁:

storage
    // 观察 position 组件的构造
    .on_construct<position>()
    // 观察 velocity 组件的更新
    .on_update<velocity>()
    // 观察 renderable 组件的销毁
    .on_destroy<renderable>();

不言而喻,可以使用同一个 storage 观察相同类型或不同类型的多个事件。
例如,要知道哪些实体被分配或更新了某种类型的组件:

storage
    .on_construct<my_type>()
    .on_update<my_type>();

请注意,所有配置都是 或 (or) 关系,绝不是 与 (and) 关系。因此,要跟踪被分配了两个不同组件的实体,有几个选项:

  • 创建两个响应式 storage,然后将它们组合到一个 view 中:

    first_storage.on_construct<position>();
    second_storage.on_construct<velocity>();
    
    for(auto entity: entt::basic_view{first_storage, second_storage}) {
        // ...
    }
    
  • 使用具有非 void 值类型的响应式 storage 和用于此目的的自定义跟踪函数:

    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) {
            // ...
        }
    }
    

正如最后一个示例所强调的,响应式 mixin 跟踪满足给定条件的实体并将它们保存在一旁。但是,可以更改此行为。
例如,可以 捕获 所有且仅当特定值在给定范围内时某个组件被更新的实体:

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>();

这使得响应式 storage 极其灵活,可用于大量情况。
最后,一旦收集了感兴趣的实体,就可以像任何其他 storage 一样 访问 它:

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

将其包装在 view 中并与其他 view 组合是另一种选择:

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

为了简化最后一种用例,响应式 mixin 还提供了一个特定函数,该函数返回已根据提供的要求过滤的 storage 的 view:

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

在这种情况下使用的 registry 是与 storage 关联的 registry,也可以通过 registry 函数获得。

应该注意的是,响应式 storage 永远不会删除其实体(以及元素,如果有的话)。要定期处理然后丢弃实体,请参阅默认情况下每种 storage 类型都可用的 clear 函数。
同样,响应式 mixin 在销毁时不会自行从观察的 storage 中断开连接。因此,用户必须自己执行此操作:

entt::registry = storage.registry();

registry.on_construct<position>().disconnect(&storage);
registry.on_construct<velocity>().disconnect(&storage);

如果不从观察的池中断开连接就销毁响应式 storage,将导致未定义行为。

排序:可行吗?

可以使用不需要内存分配的就地算法 (in-place algorithm) 对实体和组件进行排序,因此非常方便。
有两个函数响应略有不同的需求:

  • 直接对组件进行排序:

    registry.sort<renderable>([](const renderable &lhs, const renderable &rhs) {
        return lhs.z < rhs.z;
    });
    

    或者通过访问它们的实体:

    registry.sort<renderable>([](const entt::entity lhs, const entt::entity rhs) {
        return entt::registry::entity(lhs) < entt::registry::entity(rhs);
    });
    

    当使用模式已知时,还可以使用自定义排序函数对象。

  • 根据另一个组件施加的顺序对组件进行排序:

    registry.sort<movement, physics>();
    

    在这种情况下,movement 的实例在内存中排列,以便在两个组件一起迭代时最小化缓存未命中 (cache misses)。

顺便提一下,group 的使用限制了组件池排序的可能性。有关更多详细信息,请参阅特定文档。

辅助工具 (Helpers)

所谓的 helpers 是主要设计用于为最基本的功能提供内置支持的小型类和函数。

Null Entity

entt::null 变量建模了 null entity 的概念。
库保证以下表达式始终返回 false:

registry.valid(entt::null);

Registry 在所有情况下都拒绝 null entity,因为它不被认为是有效的。这也意味着 null entity 不能拥有组件。
null entity 的类型是内部的,除了定义 null entity 本身之外,不应用于任何其他目的。但是,存在从 null entity 到任何允许类型的标识符的隐式转换:

entt::entity null = entt::null;

同样,null entity 可与任何其他标识符进行比较:

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

至于其整数形式,null entity 仅影响标识符的 entity 部分,而对其 version 部分完全透明。

请注意,entt::null 和实体 0 是不同的。同样,零初始化的实体也不等同于 entt::null。因此,尽管 entt::entity{} 在某种意义上是实体 0 的别名,但它们都不用于创建 null entity。

Tombstone

与 null entity 类似,entt::tombstone 变量建模了 tombstone(墓碑)的概念。
一旦创建,这两个值的整数形式是相同的,尽管它们影响标识符的不同部分。事实上,tombstone 仅使用其 version 部分,而对 entity 部分完全透明。

同样在这种情况下,以下表达式始终返回 false:

registry.valid(entt::tombstone);

此外,用户在释放实体时不能设置 tombstone 版本:

registry.destroy(entity, entt::tombstone);

在这种情况下,会隐式生成一个不同的版本号。
tombstone 的类型是内部的,随时可能改变。但是,存在从 tombstone 到任何允许类型的标识符的隐式转换:

entt::entity null = entt::tombstone;

同样,tombstone 可与任何其他标识符进行比较:

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

请注意,entt::tombstone 和实体 0 是不同的。同样,零初始化的实体也不等同于 entt::tombstone。因此,尽管 entt::entity{} 在某种意义上是实体 0 的别名,但它们都不用于创建 tombstone。

To entity

此函数接受一个 storage 和该 storage 类型的组件实例,然后返回与后者关联的实体:

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

其中 instanceposition 类型的组件。如果实例不属于该 registry,则返回 null entity。

依赖关系

registry 类旨在在其成员函数之间创建短路。这极大地简化了 依赖关系 (dependency) 的定义。
例如,每当将 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

invoke helper 允许将 signal 传播 到组件的成员函数,而无需 扩展 它:

registry.on_construct<clazz>().connect<entt::invoke<&clazz::func>>();

它所做的只是为接收到的实体挑选 正确 的组件并调用请求的方法,必要时传递参数。

连接辅助工具

连接 signal 很快就会变得繁琐。
此工具旨在通过对调用进行分组来简化该过程:

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

通过在调用 with 时提供标识符,也支持运行时池,如前一个代码段所示。有关运行时池的更多信息,请参阅以下部分。
显然,此 helper 不会让代码消失,但它至少应该减少最复杂情况下的样板代码。

Handle

Handle 是围绕实体和 registry 的轻量级包装器。它通过提供诸如 getemplace 之类的函数来 复制 registry 的 API。区别在于实体被隐式传递给 registry。
它可以默认构造为一个包含 null registry 和 null entity 的无效 handle。当它包含 null registry 时,调用将执行委托给 registry 的函数会导致未定义行为。如果有疑问,建议使用其隐式转换为 bool 来测试有效性。
Handle 也是非拥有的 (non-owning),这意味着它可以自由复制和移动而不影响其实体(事实上,handle 往往是 trivially copyable 的)。其推论是可变性 (mutability) 成为类型的一部分。

有两个别名使用 entt::entity 作为其默认实体:entt::handleentt::const_handle
用户还可以轻松地为自定义标识符创建自己的别名:

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 handle 也可以开箱即用地隐式转换为 const handle,反之则不行。

此类旨在简化函数签名。如果函数接受 registry 和实体并在该实体上执行大部分工作,用户可能需要考虑使用 handle,无论是 const 还是非 const。

Organizer

organizer 类模板支持从一组函数及其对资源的需求创建执行图 (execution graph)。
在任何情况下都不会执行生成的任务。这不是此工具的目标。相反,它们以允许安全执行的图的形式返回给用户。

所有函数都按执行顺序添加到 organizer 中:

entt::organizer organizer;

// 将自由函数添加到 organizer
organizer.emplace<&free_function>();

// 将成员函数和要调用它的实例添加到 organizer
clazz instance;
organizer.emplace<&clazz::member_function>(&instance);

// 直接添加 decayed lambda
organizer.emplace(+[](const void *, entt::registry &) { /* ... */ });

这些是自由函数或成员函数可以接受的参数:

  • 对 registry 的可能为 const 的引用。
  • 具有 storage 类的任何可能组合的 entt::basic_view
  • 对任何类型 T 的可能为 const 的引用(即 context 变量)。

作为参数传递给 emplace 的自由函数和 decayed lambda 的函数类型则是 void(const void *, entt::registry &)。第一个参数是一个可选指针,指向注册时提供的用户定义数据:

clazz instance;
organizer.emplace(+[](const void *, entt::registry &) { /* ... */ }, &instance);

在所有情况下,还可以在创建任务时将其与名称关联。例如:

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

当向 organizer 注册函数时,它访问的所有内容都被视为 资源(view 被 解包,其类型被视为资源)。类型的 constness 也决定了其访问模式(RO/RW)。反过来,这会影响生成的图,因为它影响并行启动任务的可能性。
至于 registry,如果函数没有显式请求它或需要对其的 const 引用,则视为只读访问。否则,视为读写访问。所有函数都将 registry 作为其资源之一。

在注册函数时,用户还可以要求不在函数本身参数列表中的资源。这些被声明为模板参数:

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

同样,用户可以再次通过模板参数覆盖类型的访问模式:

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

在这种情况下,即使 renderable 作为非常量出现在函数的参数中,在生成任务图时它也被视为常量。

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

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

图以邻接表 (adjacency list) 的形式返回。每个顶点提供以下功能:

  • ro_countrw_count:以只读或读写模式访问的资源数量。
  • ro_dependencyrw_dependency:与底层函数参数关联的 type info 对象。
  • top_level:如果节点是顶层节点(没有进入边),则为 true,否则为 false。
  • info:与底层函数关联的 type info 对象。
  • name:与给定顶点关联的名称(如果有),否则为空指针。
  • callback:指向要执行的函数的指针,其函数类型为 void(const void *, entt::registry &)
  • data:提供给 callback 的可选数据。
  • children:从给定节点可达的顶点,以邻接表内的索引形式表示。

由于 registry 内池和资源的创建不一定是线程安全的,每个顶点还提供一个 prepare 函数,用于设置 registry 以使用创建的图执行:

auto graph = organizer.graph();
entt::registry registry;

for(auto &&node: graph) {
    node.prepare(registry);
}

任务的实际调度由用户负责,用户可以使用首选的工具。

Context 变量

每个 registry 都有一个与之关联的 context,这是一个 any 对象映射 (object map),为了方便起见,可以通过类型和 name 访问。不过,name 并不是真正的名字。事实上,它是一个 id_type 类型的数字 id,用作变量的键。接受任何值,甚至是运行时的值。
context 通过 ctx 函数返回,并提供一组最小的功能,包括以下内容:

// 按类型创建一个新的 context 变量并返回它
registry.ctx().emplace<my_type>(42, 'c');

// 按类型创建一个新的命名 context 变量并返回它
registry.ctx().emplace_as<my_type>("my_variable"_hs, 42, 'c');

// 按(推导的)类型插入或分配一个 context 变量并返回它
registry.ctx().insert_or_assign(my_type{42, 'c'});

// 按(推导的)类型插入或分配一个命名 context 变量并返回它
registry.ctx().insert_or_assign("my_variable"_hs, my_type{42, 'c'});

// 从非 const registry 中按类型获取 context 变量作为非 const 引用
auto &var = registry.ctx().get<my_type>();

// 从 const 或非 const registry 中按名称获取 context 变量作为 const 引用
const auto &cvar = registry.ctx().get<const my_type>("my_variable"_hs);

// 按类型重置 context 变量
registry.ctx().erase<my_type>();

// 重置与给定名称关联的 context 变量
registry.ctx().erase<my_type>("my_variable"_hs);

对 context 变量的类型没有严格要求,例如它必须是默认可构造或可移动的。但是,如果使用 name 时提供的类型与变量的类型不匹配,则操作会失败。
对于所有想使用 context 但不想创建元素的用户,还可以使用 containsfind 函数:

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

同样在这种情况下,这两个函数都支持常量类型并接受要查找的变量的 nameat 也是如此。

别名属性

context 还支持为不直接由 registry 管理的现有变量创建 别名 (aliases)。也接受 const 且因此只读的变量。
为此,构造时使用的类型必须是引用类型,并且必须提供左值 (lvalue) 作为参数:

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

只读别名属性改用 const 类型创建:

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

请注意,insert_or_assign 不支持别名属性,用户必须为此目的使用 emplaceemplace_as
当使用 insert_or_assign 更新别名属性时,它会将属性本身 转换 为非别名属性。

从用户的角度来看,由 registry 管理的变量和别名属性之间没有区别。但是,只读变量不能作为非 const 引用访问:

// 只读变量仅支持 const 访问
const my_type *ptr = registry.ctx().find<const my_type>();
const my_type &var = registry.ctx().get<const my_type>();

别名属性的擦除与任何其他变量一样。同样,也可以为它们分配 name

Snapshot:完整与连续

此模块附带对序列化 (serialization) 的最低限度支持。
它不直接将组件转换为字节,因为不需要另一个序列化工具。相反,它接受一个具有合适接口(即 archive)的不透明对象,以序列化其内部数据结构并在以后恢复它们。将类型和实例转换为一堆字节的方式完全由 archive 负责,因此也由最终用户负责。

序列化部分的目标是允许用户对整个 registry 进行 dump,或者进行更窄的 snapshot,即仅选择他们感兴趣的组件。
直观地说,用例是不同的。例如,第一种方法适用于本地保存/恢复功能,而后者适用于创建客户端-服务器应用程序并以某种方式在两端之间传输部分表示。

要对 registry 进行 snapshot,请使用 snapshot 类:

output_archive output;

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

不必每次都调用所有函数。在哪种情况下使用哪些函数主要取决于目标。

获取 entity 类型时,snapshot 类会序列化所有实体及其版本。
在所有其他情况下,来自给定 storage 的实体和组件将传递给 archive。也支持命名池:

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 和 archive。

Snapshot Loader

Snapshot loader 要求目标 registry 为空。它一次性加载所有数据,同时保持实体最初拥有的标识符完整:

input_archive input;

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

不必每次都调用所有函数。在哪种情况下使用哪些函数主要取决于目标。
出于明显的原因,重要的是数据必须按照它们被序列化的完全相同的顺序恢复。

获取 entity 类型时,snapshot loader 会恢复所有实体及其在源端最初拥有的版本。
在所有其他情况下,实体和组件在给定 storage 中恢复。如果 registry 不包含该实体,也会相应地创建它。与 snapshot 类一样,也支持命名池:

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

最后,orphans 成员函数会释放恢复后没有组件的实体(如果有的话)。

Continuous Loader

Continuous loader 旨在将数据从源 registry 加载到(可能)非空的目标 registry。loader 在 registry 中容纳多个 snapshot,以一种 连续加载 的方式逐步更新目标。
实体最初拥有的标识符不会转移到目标。相反,loader 在恢复 snapshot 时将远程标识符映射到本地标识符。包装 archive 是自动更新作为组件一部分的标识符的便捷方法(参见下面的示例)。
与 snapshot loader 的另一个区别是,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();

不必每次都调用所有函数。在哪种情况下使用哪些函数主要取决于目标。
出于明显的原因,重要的是数据必须按照它们被序列化的完全相同的顺序恢复。

获取 entity 类型时,loader 会恢复实体组,并在需要时将每个实体映射到本地对应物。对于 loader 尚未注册的每个远程标识符,都会创建一个本地标识符,以使本地实体与远程实体保持同步。
在所有其他情况下,实体和组件在给定 storage 中恢复。如果 registry 不包含该实体,也会相应地跟踪它。与 snapshot 类一样,也支持命名池:

loader.get<a_component>(input, "other"_hs);

最后,orphans 成员函数会释放恢复后没有组件的实体(如果有的话)。

Archives

Archive 必须公开一组预定义的成员函数。API 非常简单,仅包含由 snapshot 类和 loader 调用的一组函数调用运算符。

特别是:

  • Output archive(创建 snapshot 时使用的 archive)公开一个具有以下签名的函数调用运算符以存储实体:

    void operator()(entt::entity);
    

    其中 entt::entity 是 registry 使用的实体类型。
    请注意,snapshot 类的所有成员函数还会进行初始调用,以将要存储的集合的 大小 存储在一旁。在这种情况下,函数调用运算符的预期函数类型为:

    void operator()(std::underlying_type_t<entt::entity>);
    

    此外,archive 接受要序列化的组件类型的 (const) 引用。因此,给定类型 T,archive 提供具有以下签名的函数调用运算符:

    void operator()(const T &);
    

    Output archive 可以自由决定如何序列化数据。Registry 完全不受该决定的影响。

  • Input archive(恢复 snapshot 时使用的 archive)公开一个具有以下签名的函数调用运算符以加载实体:

    void operator()(entt::entity &);
    

    其中 entt::entity 是 registry 使用的实体类型。每次调用该函数时,archive 都会从底层存储中读取下一个元素并将其复制到给定变量中。
    loader 类的所有成员函数还会进行初始调用,以读取它们将要加载的集合的 大小。在这种情况下,函数调用运算符的预期函数类型为:

    void operator()(std::underlying_type_t<entt::entity> &);
    

    此外,archive 接受要恢复的组件类型的引用。因此,给定类型 T,archive 包含具有以下签名的函数调用运算符:

    void operator()(T &);
    

    每次调用此运算符时,archive 都会从底层存储中读取下一个元素并将其复制到给定变量中。

一个示例统御全局

EnTT 附带了一些示例(实际上是一些测试),展示了如何将著名的序列化库集成为 archive。它在底层使用了 Cereal C++,主要是因为在编写代码时我想了解它是如何工作的。

代码 并非 生产就绪 (production-ready),它既不是唯一的方法,也(可能)不是最好的方法。但是,请随意自行承担风险使用它。
基本思想是将所有内容存储在内存中的一组队列中,然后使用不同的 loader 将所有内容带回 registry。

Storage

组件池是 sparse set 类的 特化版本。每个池包含单个组件类型的所有实例以及分配给它的所有实体。
稀疏数组 (Sparse arrays) 是 分页的 (paged) 以避免浪费内存。组件的紧凑数组 (packed arrays) 也是分页的,以便在添加时保持指针稳定性。而实体的紧凑数组则不是。
所有池都会重新排列其项目,以保持内部数组紧密打包并最大化性能,除非启用了完全的指针稳定性。

Component Traits

EnTT 中,几乎所有东西都是可定制的。池也不例外。
在这种情况下,访问所有组件属性的 标准化 方式是 component_traits 类。

库的各个部分通过此类访问组件属性。只要 component_traits 的特化实现了所有必需的功能,就可以将任何类型用作组件。
此类的非特化版本包含以下成员:

  • in_place_delete:如果存在 Type::in_place_delete,对于不可移动类型为 true,否则为 false。
  • page_size:如果存在 Type::page_size,对于非空类型为 ENTT_PACKED_PAGE,否则为 0。

其中 Type 是任何类型的组件。通过特化上述类并定义其成员,或仅将感兴趣的成员添加到组件定义中来定制属性:

struct transform {
    static constexpr auto in_place_delete = true;
    // ... 其他数据成员 ...
};

component_traits 类模板负责从提供的类型中 提取 属性。
此外,它可以特化并使用 concept 进行约束,以按类型或按功能进一步定制它。

空类型优化

空类型 T 是指 std::is_empty_v<T> 返回 true 的类型。它们也是可以进行 空基类优化 (EBO) 的类型。
EnTT 以特殊方式处理这些类型,在性能和内存使用方面进行优化。但是,这也带来了一些值得一提的后果。

当检测到空类型时,默认情况下不会实例化它。因此,仅提供分配给它的实体。没有从 storage 或 registry 获取 空类型的方法。View 和 group 也永远不会返回它们的实例(例如,在调用 each 期间)。
另一方面,迭代更快,因为仅考虑分配给该类型的实体。此外,使用的内存更少,主要是因为无论分配给多少个实体,都不存在该组件的任何实例。

更一般地说,库提供的功能都不受影响,除了那些需要返回实际实例的功能。
通过定义 ENTT_NO_ETO 宏可以禁用此优化。在这种情况下,空类型被视为与其他所有类型一样。通过 component_traits 类模板在组件级别设置 page size 是另一种选择性地而非全局禁用此优化的方法。

Void Storage

Void storage(entt::storage<void>entt::basic_storage<void, Entity>)是一种功能齐全的 storage 类型,用于创建不与特定组件类型关联的池。
从技术角度来看,它在所有方面都类似于启用优化时的空类型的 storage。分页和指针稳定性(因为不需要)都被禁用。
但是,这应该优于使用简单的 sparse set。特别是,void storage 提供通常由其他 storage 类型提供的所有功能。因此,它是一个完全有效的池,可与 view 和 group 或在 registry 内使用。

Entity Storage

这种 storage 的组件类型与实体类型相同,例如 entt::storage<entt::entity>entt::basic_storage<Type, Type>
对于这种类型的池,EnTT 中有一个特定的特化。事实上,实体受不同于组件的规则约束(尽管如果需要仍可由用户定制)。特别是:

  • 实体从未真正被 删除。它们被移出 使用中 的实体列表,并且它们的版本会自动更新。
  • 其接口中没有 emplaceinsert 函数。相反,提供了一系列 generate 函数用于创建或回收实体。
  • each 函数返回一个可迭代对象,以访问 使用中 的实体,即那些未标记为 准备好重用 的实体。要迭代所有实体,必须改为迭代底层的 sparse set。

这种 storage 设计用于可以使用任何其他 storage 的地方,因此可以与 view、group 等结合使用。

保留标识符

由于 entity storage 负责生成标识符,因此也可以请求保留其中一些标识符并永不返回。
通过这样做,用户可以根据需要自主生成和管理它们。

要设置起始标识符,请按如下方式调用 start_from 函数:

storage.start_from(entt::entity{100});

请注意,版本无关紧要,在所有情况下都会被忽略。标识符始终以默认版本生成。
通过如上所述调用 start_from,前 100 个元素将被丢弃,返回的第一个标识符是实体 100 和版本 0 的那个。

Registry 中的唯一性

在 registry 内,entity storage 在所有方面都被视为与其他任何 storage 一样。
因此,可以向其添加 mixin 以及通过 storage 函数检索它。它也可以用作 view 中的 storage(例如,用于 exclude-only view):

auto view = registry.view<entt::entity>(entt::exclude<my_type>);

然而,它也受到几个例外的约束,部分是出于必要,部分是为了易于使用。

特别是,不可能创建多个这种类型的元素。
这意味着用于检索这种 storage 的 name 将被忽略,registry 将永远只向调用者返回相同的元素。例如:

auto &other = registry.storage<entt::entity>("other"_hs);

在这种情况下,标识符将被原样丢弃。该调用在所有方面都等同于以下内容:

auto &storage = registry.storage<entt::entity>();

因为 entity storage 没有名称,所以它也不能通过不透明的 storage 函数检索。
无论如何尝试都没有意义,因为 registry 的类型以及因此其实体类型都是已知的。

最后,当用户要求 registry 提供一个可迭代对象以访问其中的所有 storage 元素时,如下所示:

for(auto [id, storage]: registry.storage()) {
    // ...
}

Entity storage 永远不会被返回。这简化了许多任务(例如复制实体),并且完全符合这种 storage 在 registry 内没有标识符的事实。

指针稳定性

为一个、多个或所有组件实现指针稳定性的能力是 EnTT 及其默认 storage 设计的直接结果。
事实上,尽管它包含通常称为 紧凑数组 (packed array) 的内容,但默认 storage 是分页的,并且在空间不足必须重新分配时不会遭受引用失效 (invalidation of references)。
然而,这不足以确保在删除情况下的指针稳定性。因此,还提供了一种 稳定 的删除方法。这种方法通过在删除时创建 tombstone 而不是试图填补创建的空洞来保留元素的位置。

出于性能原因,EnTT 在所有情况下都倾向于 storage 压缩,尽管通常访问组件主要是随机的,或者用户在用户端以非线性顺序遍历池(如在层级结构的情况下)。
换句话说,指针稳定性不是自动的,而是按需启用的。

就地删除

该库开箱即用地支持就地删除 (in-place deletion),从而提供具有完全稳定指针的 storage。这是通过在需要时特化 component_traits 类或将所需属性添加到组件定义来实现的。
当 view 和 group 检测到具有与默认不同的删除策略的 storage 时,它们会相应地进行调整。特别是:

  • Group 与稳定 storage 不兼容,甚至拒绝编译。
  • 多类型和 runtime view 对 storage 策略完全透明。
  • 稳定 storage 类型的单类型 view 提供与多类型 view 相同的接口。例如,只有 size_hint 可用。

换句话说,在稳定 storage 的情况下,即使是单类型 view 也提供更通用版本的 view。
在任何情况下,view 本身都不会返回 tombstone。同样,不存在的组件也不会被返回,否则可能会导致 UB。

层级结构及类似情况

EnTT 绝不试图提供具有隐藏或不明确成本的内置方法来促进层级结构 (hierarchies) 的创建。
针对该问题有多种解决方案,例如使用以下类:

struct relationship {
    std::size_t children{};
    entt::entity first{entt::null};
    entt::entity prev{entt::null};
    entt::entity next{entt::null};
    entt::entity parent{entt::null};
    // ... 其他数据成员 ...
};

然而,应该指出的是,为一个、多个或所有类型拥有稳定指针的可能性在许多情况下从根本上解决了层级结构的问题。
事实上,如果某种类型的组件主要以随机顺序或根据层级关系访问,使用直接指针有许多优势:

struct transform {
    static constexpr auto in_place_delete = true;

    transform *parent;
    // ... 其他数据成员 ...
};

此外,一组元素在时间上接近创建并因此落入相邻位置是非常常见的,因此即使在随机访问时也倾向于局部性 (locality)。鉴于 storage 位置的稳定性,这种局部性不会随着时间的推移而牺牲,具有毋庸置疑的性能优势。

运行时的邂逅

EnTT 利用了语言在编译时提供的优势。然而,这也可能有其缺点(熟悉类型擦除 (type erasure) 技术的人都很清楚)。
为了填补这一空白,该库还提供了一堆工具和特性,对于在运行时处理类型和池非常有价值。

统御全局的基类

Storage 类是完全独立的类型。它们通过 mixin 扩展 以添加更多功能(通用的或特定于类型的)。此外,它们提供了一组基本函数,已经允许用户走得很远。
目标是尽可能限制定制的需求,提供绝大多数情况下通常需要的功能。

当通过其基类使用 storage 时(例如,当其实际类型未知时),始终有可能接收到一个 type_info 对象,用于与实体关联的元素类型(如果有的话):

if(entt::type_id<velocity>() == base.info()) {
    // ...
}

此外,所有功能都依赖于内部函数,这些函数将调用转发给 mixin。然后,mixin 可以利用通过 bind 设置的任何信息:

base.bind(registry);

bind 函数通过引用或值接受任何元素并将其转发给派生类。
这就是 registry 将自身 传递 给所有支持 signal 的池的方式,也是为什么 storage 继续发送事件而不需要每次都传递 registry 的原因。

除了这些更具体的东西之外,还有几个旨在解决一些常见需求的函数,例如复制实体。
特别是,storage 背后的基类提供了通过不透明指针 获取 与实体关联的值的可能性:

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

同样,非特化的 push 函数接受一个可选的不透明指针,并根据情况表现不同:

  • 当指针为空时,函数尝试默认构造要绑定到实体的对象实例,并在成功时返回 true。
  • 当指针不为空时,函数尝试复制构造要绑定到实体的对象实例,并在成功时返回 true。

这意味着,从对基类的引用开始,可以在不知道其实际类型的情况下将组件与实体绑定,甚至可以在需要时通过复制初始化它们:

// 逐个组件地创建实体的副本
for(auto &&curr: registry.storage()) {
    if(auto &storage = curr.second; storage.contains(src)) {
        storage.push(dst, storage.value(src));
    }
}

这对于以不透明的方式克隆实体特别有用。此外,功能的解耦允许根据类型过滤或使用不同的复制策略。

传送我吧,Registry

EnTT 允许用户为类型分配一个 name(或者更确切地说,一个数字标识符),然后创建同一类型的多个池:

using namespace entt::literals;
auto &&storage = registry.storage<velocity>("second pool"_hs);

如果未提供名称,则始终返回与给定类型关联的默认 storage。
由于 storage 也是独立的,registry 不会为它们 复制 自己的 API。然而,使用的可能性仍然没有限制:

auto &&other = registry.storage<velocity>("other"_hs);

registry.emplace<velocity>(entity);
other.push(entity);

可以通过 registry 接口完成的任何事情也可以直接在引用的 storage 上完成。
另一方面,涉及所有 storage 的那些调用保证也能 到达 手动创建的 storage:

// 从两个 storage 中移除实体
registry.destroy(entity);

最后,这种类型的 storage 适用于任何 view(如果需要,它也接受同一类型的多个 storage):

// 直接初始化
entt::basic_view direct{
    registry.storage<velocity>(),
    registry.storage<velocity>("other"_hs)
};

// 连接
auto join = registry.view<velocity>() | entt::basic_view{registry.storage<velocity>("other"_hs)};

直接使用 storage 的可能性与能够创建和使用多个同类型 storage 的自由相结合,为在 运行时 使用 EnTT 打开了大门,而以前这受到很大限制。

Views 与 Groups

View 是一种非侵入式工具,用于处理实体和组件,而不会影响其他功能或增加内存消耗。
Group 是一种侵入式工具,用于提高关键路径的性能,但也需要为此付出代价。

主要有两种 view:编译时(也称为 view)和运行时(也称为 runtime_view)。
前者需要组件(或 storage)类型的编译时列表,并因此可以进行多种优化。后者在运行时使用数字类型标识符构建,迭代速度稍慢。
在这两种情况下,创建和销毁 view 都非常便宜,因为它们没有任何类型的初始化。

Group 分为三种不同的风格:全拥有 (full-owning) group、部分拥有 (partial-owning) group 和 非拥有 (non-owning) group。它们之间的主要区别在于性能。
Group 可以字面上 拥有 一种或多种组件类型。它们被允许重新排列池以加快迭代速度。粗略地说:group 拥有的组件越多,迭代它们的速度就越快。

Views

单类型 view 和多类型 view 的行为不同,并且 API 也略有不同。

单类型 view 经过专门化,在所有情况下都能提供性能提升。没有什么比单类型 view 更快的了。它们只是遍历元素的紧凑(实际上是分页)数组并直接返回它们。
这种 view 还允许获取它们将要返回的确切元素数量。
有关所有详细信息,请参阅内联文档。

多类型 view 迭代至少具有所有给定组件的实体。在构造期间,它们查看每个池中可用的元素数量,并使用最小的集合以加快迭代速度。
这种 view 只允许获取它们将要返回的估计元素数量。
有关所有详细信息,请参阅内联文档。

不需要将 view 存储在一旁,因为它们的构造极其便宜。事实上,当从 const registry 创建 view 时,甚至不鼓励这样做。由于所有 storage 都是延迟初始化的,因此在创建 view 时它们可能不存在。因此,虽然完全可用,但 view 可能包含永远不会用实际 storage 重新初始化的挂起引用。

View 共享通过 registry 创建的方式:

// 单类型 view
auto single = registry.view<position>();

// 多类型 view
auto multi = registry.view<position, velocity>();

还支持通过组件过滤实体:

auto view = registry.view<position, velocity>(entt::exclude<renderable>);

要迭代 view,可以在 range-for 循环中使用它:

auto view = registry.view<position, velocity, renderable>();

for(auto entity: view) {
    // 一次一个组件 ...
    auto &position = view.get<position>(entity);
    auto &velocity = view.get<velocity>(entity);

    // ... 多个组件 ...
    auto [pos, vel] = view.get<position, velocity>(entity);

    // ... 一次性所有组件
    auto [pos, vel, rend] = view.get(entity);

    // ...
}

或者依赖 each 成员函数一次性迭代实体和组件:

// 通过 callback
registry.view<position, velocity>().each([](auto entity, auto &pos, auto &vel) {
    // ...
});

// 使用输入迭代器
for(auto &&[entity, pos, vel]: registry.view<position, velocity>().each()) {
    // ...
}

请注意,当通过 callback 接收实体时,也可以将它们从参数列表中排除,这可以进一步提高迭代期间的性能。
由于它们没有被显式实例化,因此在任何情况下都不会返回空组件。

顺便提一下,在单类型 view 的情况下,get 接受但不严格要求模板参数,因为类型是隐式定义的。但是,当未指定类型时,为了与多类型 view 保持一致,将使用 tuple 返回实例:

auto view = registry.view<const renderable>();

for(auto entity: view) {
    auto [renderable] = view.get(entity);
    // ...
}

注意:在迭代期间,优先使用 view 的 get 成员函数而不是 registry 的 get 成员函数来获取 view 本身迭代的类型。

一次创建,多次复用

View 支持延迟初始化以及 storage 交换 (storage swapping)
空的(或部分初始化的)view 在转换为 bool 时返回 false(让用户知道它未完全初始化),但它也像任何其他 view 一样按原样工作。

为了逐段初始化 view,它允许用户在可用时注入 storage 类:

entt::storage_for_t<velocity> storage{};
entt::view<entt::get_t<position, velocity>> view{};

view.storage(storage);

如果有多个相同类型的 storage,可以使用要替换的元素的 index 来消除歧义:

view.storage<1>(storage);

从字面上 替换 view 中的 storage 的能力也为使用不同的实体集重用 view 打开了大门。
例如,要基于具有不同特征的两组实体 过滤 view,无需重新初始化任何内容:

entt::view<entt::get<my_type, void>> view{registry.storage<my_type>()};

entt::storage_for_t<void> the_good{};
entt::storage_for_t<void> the_bad{};

// 根据需要初始化上述集合

view.storage(the_good);

for(auto [entt, elem]: view) {
  // 这里是好实体及其组件
}

view.storage(the_bad);

for(auto [entt, elem]: view) {
  // 这里是坏实体及其组件
}

最后,应该注意的是,缺少 storage 在所有意图和目的上都被视为 元素。
因此,get storage(如 entt::get_t)会自动使 view 为空,而 exclude storage(如 entt::exclude_t)将被忽略,就好像过滤器的该部分不存在一样。

仅排除 (Exclude-only)

EnTT 中,Exclude-only view 并不是真正的东西。
然而,通过将正确的 storage 组合到简单的 view 中可以实现相同的结果。

如果探究问题的根源,exclude-only view 的目的是返回不满足某些要求的实体。
由于 entity storage 与 exclude-only view 不同,在 EnTT确实 存在,用户可以利用它进行此类查询。它还保证在 registry 中是唯一的,并且在创建 view 时始终可访问:

auto view = registry.view<entt::entity>(entt::exclude<my_type>);

返回的 view 将仅返回没有 my_type 组件的实体,而不管它们具有什么其他组件。

View Pack

View 与 storage 对象以及彼此组合以创建新的、更具体的 查询
将多个元素组合在一起时返回的类型本身就是一个 view,更一般地说是多组件 view。

组合不同元素试图模仿 ranges:

auto view = registry.view<position>();
auto other = registry.view<velocity>();
const auto &storage = registry.storage<renderable>();

auto pack = view | other | renderable;

类型的 constness 会被保留,它们的顺序取决于 view 组合的顺序。例如,上述 pack 首先返回 position 的实例,然后是 velocity,最后是 renderable
由于组合元素会生成 view,链可以是任意长度,并且上述类型顺序规则按顺序应用。

迭代顺序

默认情况下,view 沿着包含最少元素数量的池进行迭代。
例如,如果 registry 包含的 velocity 少于 position,则以下 view 返回的元素的顺序取决于 velocity 组件在其池中的排列方式:

for(auto entity: registry.view<position, velocity>()) { 
    // ...
}

此外,构造 view 时类型的顺序无关紧要。view pack 中 view 的顺序也无关紧要。
然而,可以通过 use 函数 强制 按给定的组件顺序迭代 view:

auto view = registry.view<position, velocity>();
view.use<position>();

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

另一方面,如果用户只想以相反的顺序迭代元素,这对于单类型 view 使用其反向迭代器 (reverse iterators) 是可能的:

auto view = registry.view<position>();

for(auto it = view.rbegin(), last = view.rend(); it != last; ++iter) {
    // ...
}

不幸的是,多类型 view 不提供反向迭代器。因此,在这种情况下,必须手动实现此功能或使用单类型 view 来引导迭代。

Runtime Views

多类型 view 迭代至少具有所有给定组件的实体。在构造期间,它们查看每个池中可用的元素数量,并使用最小的集合以加快迭代速度。
它们提供与多类型 view 或多或少相同的功能。然而,它们不公开 get 成员函数,用户应参考生成该 view 的 registry 来访问组件。
有关所有详细信息,请参阅内联文档。

Runtime view 的构造非常便宜,在任何情况下都不应存储在一旁。它们应在创建后立即使用,然后应被丢弃。
要迭代 runtime view,可以在 range-for 循环中使用它:

entt::runtime_view view{};
view.iterate(registry.storage<position>()).iterate(registry.storage<velocity>());

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

或者依赖 each 成员函数迭代实体:

entt::runtime_view{}
    .iterate(registry.storage<position>())
    .iterate(registry.storage<velocity>())
    .each([](auto entity) {
        // ...
    });

在这两种情况下,性能完全相同。
对于这种 view,也支持通过组件过滤实体:

entt::runtime_view view{};
view.iterate(registry.storage<position>()).exclude(registry.storage<velocity>());

Runtime view 适用于用户在编译时不知道要 使用 哪些类型来迭代实体的情况。registry 的 storage 成员函数在这方面可能很有用。

Groups

Group 旨在一次性迭代多个组件,并提供比多类型 view 更快的替代方案。
Group 克服了其他可用工具的性能,但需要获得组件的所有权 (ownership)。这对其池设置了一些约束。另一方面,group 不是一种增加内存消耗、影响功能并试图为所有可能的组件组合优化迭代的自动化机制。用户可以决定何时为 group 付费以及付费的程度。
Group 最有趣的方面是它们适应 使用模式 (usage patterns)。周围的其他解决方案通常试图优化一切,因为已知在 一切 之中的某处也有我们的使用模式。然而,这在性能和内存使用方面都有不可忽视的成本。具有讽刺意味的是,用户也为他们不想要的东西付出了代价,这不是我喜欢的。更糟糕的是,人们不能轻易禁用这种行为。Group 的工作方式不同,旨在仅在用户发现需要时优化真正的用例。
Group 的另一个很好的特性是它们对内存消耗没有影响,除了非常罕见且应尽可能避免的全非拥有 (full non-owning) group。

所有 group 都在一定程度上影响其组件的创建和销毁。这是因为它们必须 观察 感兴趣的池中的变化,并在需要时为它们拥有的类型 正确 排列数据。
在所有情况下,group 都允许获取它将要返回的确切元素数量。
有关所有详细信息,请参阅内联文档。

不需要将 group 存储在一旁,因为它们的创建极其便宜,尽管有效的 group 可以毫无问题地复制并自由重用。
Group 在第一次被请求时执行初始化步骤,这可能非常昂贵。为了避免这种情况,请考虑在尚未分配任何组件时创建 group。如果 registry 为空,准备 (preparation) 速度极快。

要迭代 group,可以在 range-for 循环中使用它:

auto group = registry.group<position>(entt::get<velocity, renderable>);

for(auto entity: group) {
    // 一次一个组件 ...
    auto &position = group.get<position>(entity);
    auto &velocity = group.get<velocity>(entity);

    // ... 多个组件 ...
    auto [pos, vel] = group.get<position, velocity>(entity);

    // ... 一次性所有组件
    auto [pos, vel, rend] = group.get(entity);

    // ...
}

或者依赖 each 成员函数一次性迭代实体和组件:

// 通过 callback
registry.group<position>(entt::get<velocity>).each([](auto entity, auto &pos, auto &vel) {
    // ...
});

// 使用输入迭代器
for(auto &&[entity, pos, vel]: registry.group<position>(entt::get<velocity>).each()) {
    // ...
}

请注意,当通过 callback 接收实体时,也可以将它们从参数列表中排除,这可以进一步提高迭代期间的性能。
由于它们没有被显式实例化,因此在任何情况下都不会返回空组件。

注意:在迭代期间,优先使用 group 的 get 成员函数而不是 registry 的 get 成员函数来获取 group 本身迭代的类型。

全拥有 Groups

Full-owning group 是用户可以期望用于一次性迭代多个组件的最快工具。它直接迭代所有组件,不需要间接寻址 (indirection)。
这种类型的 group 的表现差不多就像用户按顺序访问一堆完全相同排序的组件紧凑数组,没有跳转或分支。

Full-owning group 的创建方式如下:

auto group = registry.group<position, velocity>();

还支持通过组件过滤实体:

auto group = registry.group<position, velocity>({}, entt::exclude<renderable>);

一旦创建,group 就会获得模板参数列表中指定的所有组件的所有权,并根据需要排列它们的池。

一旦创建了 group,就不再允许对拥有的组件进行排序。然而,full-owning group 使用其 sort 成员函数进行排序。对 full-owning group 进行排序会影响其所有实例。

部分拥有 Groups

Partial-owning group 对于它拥有的组件的工作方式类似于 full-owning group,但依赖于间接寻址来获取其他 group 拥有的组件。
这不如 full-owning group 快,但当只有一两个自由组件需要检索时(可能是最常见的情况),它已经比 view 快得多。在最坏的情况下,它无论如何也不会比 view 慢。

Partial-owning group 的创建方式如下:

auto group = registry.group<position>(entt::get<velocity>);

还支持通过组件过滤实体:

auto group = registry.group<position>(entt::get<velocity>, entt::exclude<renderable>);

一旦创建,group 就会获得模板参数列表中指定的所有组件的所有权,并根据需要排列它们的池。相反,通过 entt::get 提供的类型的所有权不会传递给 group。

一旦创建了 group,就不再允许对拥有的组件进行排序。然而,partial-owning group 使用其 sort 成员函数进行排序。对 partial-owning group 进行排序会影响其所有实例。

非拥有 Groups

Non-owning group 通常足够快,肯定比 view 快,并且非常适合大多数情况。然而,它们需要自定义数据结构才能正常工作,并且会增加内存消耗。
根据经验,如果可能,用户应避免使用 non-owning group。

Non-owning group 的创建方式如下:

auto group = registry.group<>(entt::get<position, velocity>);

还支持通过组件过滤实体:

auto group = registry.group<>(entt::get<position, velocity>, entt::exclude<renderable>);

在这种情况下,group 不会接收任何类型组件的所有权。因此,这种类型的 group 通常是性能最差的,但也是唯一可以在任何情况下使用以稍微提高性能的 group。

Non-owning group 使用其 sort 成员函数进行排序。对 non-owning group 进行排序会影响其所有实例。

类型:const、non-const 及其间的所有情况

在构造 view 和 group 时,registry 类提供两个重载:const 版本和非 const 版本。前者仅接受 const 类型作为模板参数,而后者同时接受 const 和非 const 类型。
这意味着由 const registry 生成的 view 和 group 也会将 constness 传播到涉及的类型。例如:

entt::view<entt::get_t<const position, const velocity>> view = std::as_const(registry).view<const position, const velocity>();

相反,考虑非 const view 的以下定义:

entt::view<entt::get_t<position, const velocity>> view = registry.view<position, const velocity>();

在上面的示例中,view 用于访问只读或可写的 position 组件,而 velocity 组件在所有情况下都是只读的。
同样,这些语句都是有效的:

position &pos = view.get<position>(entity);
const position &cpos = view.get<const position>(entity);
const velocity &cpos = view.get<const velocity>(entity);
std::tuple<position &, const velocity &> tup = view.get<position, const velocity>(entity);
std::tuple<const position &, const velocity &> ctup = view.get<const position, const velocity>(entity);

相反,不可能从同一个 view 获取对 velocity 组件的非 const 引用。因此,这些会导致编译错误:

velocity &cpos = view.get<velocity>(entity);
std::tuple<position &, velocity &> tup = view.get<position, velocity>(entity);
std::tuple<const position &, velocity &> ctup = view.get<const position, velocity>(entity);

each 成员函数也将其 constness 传播到其 返回值

view.each([](auto entity, position &pos, const velocity &vel) {
    // ...
});

调用者仍然可以通过 const 引用引用 position 组件,因为语言的规则幸运的是已经允许这样做。

同样的概念也适用于 group。

给我全部

View 和 group 是整个实体列表的狭窄窗口。它们通过根据实体的组件过滤实体来工作。
在某些情况下,可能需要迭代所有仍在使用中的实体,而不管它们的组件如何。这是通过访问 entity storage 来完成的:

for(auto entity: registry.view<entt::entity>()) {
    // ...
}

根据经验,如果目标是迭代具有确定组件集的实体,请考虑使用 view 或 group。这些工具通常比使用一堆自定义测试过滤实体要快得多。
在所有其他情况下,这就是要走的路。例如,可以将此 view 与 orphan 成员函数结合使用,以清理孤儿实体(即仍在使用且没有分配组件的实体):

for(auto entity: registry.view<entt::entity>()) {
    if(registry.orphan(entity)) {
        registry.release(entity);
    }
}

通常,迭代所有实体可能会导致性能不佳。不应频繁执行以避免性能下降的风险。
然而,在初始化编辑器或回收挂起的标识符时,这很方便。

允许与不允许的操作

大多数可用的 ECS 不允许在迭代期间创建和销毁实体和组件,也不允许具有指针稳定性。
EnTT 通过一些限制部分解决了这个问题:

  • 在大多数情况下,允许在迭代期间创建实体和组件,并且它永远不会使已存在的引用失效。
  • 允许在迭代期间删除当前实体或移除其组件,但这可能会使引用失效。对于所有其他实体,销毁它们或移除其迭代的组件是不允许的,并且可能导致未定义行为。
  • 当为引导迭代的类型启用指针稳定性时,添加相同类型的实例可能会也可能不会导致涉及的实体被返回。相反,始终允许销毁实体和组件,即使当前未迭代,也没有使任何引用失效的风险。
  • 在反向迭代的情况下,在任何情况下都不允许添加或移除元素。它可能很快导致未定义行为。

换句话说,迭代器很少失效。此外,当添加新元素时,组件引用不会失效,而由于 swap-and-pop 策略,它们在销毁时可能会失效,除非引导迭代的类型进行就地删除。
例如,考虑以下代码段:

registry.view<position>().each([&](const auto entity, auto &pos) {
    registry.emplace<position>(registry.create(), 0., 0.);
    // 添加新实例后引用保持稳定
    pos.x = 0.;
});

each 成员函数不会中断(因为迭代器保持有效),任何引用也不会失效。相反,应该更加注意实体的销毁或组件的移除。
使用普通的 range-for 循环并直接从 view 获取组件,或者将实体和组件的删除移到函数末尾以避免悬空指针 (dangling pointers)。

对于所有不提供稳定指针的类型,迭代器也会失效,如果修改或销毁的实体不是迭代器当前返回的实体,也不是新创建的实体,则行为是未定义的。
为了解决这个问题,可能的方法是:

  • 将要移除的实体和组件存储在一旁,并在迭代结束时执行操作。
  • 使用适当的标签组件 (tag component) 标记实体和组件,指示它们必须被清除,然后执行第二次迭代以逐一清理它们。

此功能的一个显著副作用是,在大多数情况下,所需的分配数量进一步减少。

更高性能,更多约束

Group 是 view 的更快替代方案。然而,性能越高,对允许和不允许的操作的约束就越大。
特别是,group 在极少数情况下增加了对迭代期间创建组件的限制。这发生在非常特殊的情况下。鉴于 group 的性质和范围,这可能不是经常会遇到的事情,但无论如何了解一下是件好事。

首先,必须说明的是,在迭代 group 时创建组件完全不是问题,并且可以像 view 一样自由完成。这同样适用于组件和实体的销毁,适用上述提到的规则。

额外的限制反而出现在迭代 group 拥有的给定组件之外时。在这种情况下,添加属于 group 本身的组件可能会使迭代器失效。对组件和实体的销毁没有进一步的限制。
幸运的是,这并不总是正确的。事实上,它几乎从不正确,并且仅在某些条件下发生。特别是:

  • 使用单类型 view 迭代属于 group 的组件类型,并向实体添加将其加入 group 所需的所有组件,可能会使迭代器失效。
  • 使用多类型 view 迭代属于 group 的组件类型,并向实体添加将其加入 group 所需的所有组件,可能会使迭代器失效,除非用户指定另一种组件类型用于诱导 view 的迭代顺序(在这种情况下,前者被视为自由类型,不受该限制的影响)。

换句话说,只要类型被视为自由类型(例如,使用多类型 view 以及 partial- 或 non-owning group)或使用其自己的 group 进行迭代,该限制就不存在,但如果该类型用作主导迭代的主要类型,则可能发生。
这是因为 group 拥有其组件的池并在内部组织数据以最大化性能。因此,只有在作为其 group 的一部分或使用多类型 view 和 group 作为自由类型进行迭代时,才能保证拥有组件的完全一致性。

多线程

通常,整个 registry 本身不是线程安全的。由于几个原因,线程安全不是用户应该开箱即用地想要的东西。仅举其中之一:性能。
View、group 以及因此 EnTT 采用的方法是该规则的伟大例外。确实,view、group 和一般的迭代器本身不是线程安全的。因此,用户不应尝试迭代一组组件并同时修改同一组。然而:

  • 只要一个线程迭代具有组件 X 的实体或从一组实体中分配和移除该组件,另一个线程就可以安全地对组件 YZ 执行相同的操作,并且一切正常。作为一个简单的例子,用户可以自由执行渲染系统并迭代可渲染实体,同时在单独的线程上并发更新物理组件。
  • 同样,只要在迭代期间既不分配也不移除组件,单个组件集就可以被多个线程迭代。换句话说,一个假设的移动系统可以启动多个线程,每个线程都将访问携带有关其实体的速度和位置信息的组件。

这种 entity-component systems 可用于单线程应用程序以及异步事务或多线程。此外,典型的基于线程的 ECS 模型不需要完全线程安全的 registry 即可工作。实际上,用户可以使用现有的 registry 实现目标,同时处理大多数常见模型。

由于上述几个原因以及许多未提及的原因,用户完全负责同步(如果需要)。另一方面,他们可以不用诉诸特定的权宜之计。

最后,EnTT 通过一些编译时定义进行配置,以使其某些部分隐式线程安全,粗略地说,只有那些真正有意义且无法扭转的部分。
当将多个线程与 EnTT 一起使用时,除非您确切知道自己在做什么,否则应定义 ENTT_USE_ATOMIC。即使每个线程仅使用线程本地数据也是如此。有关更多信息,请参阅 此部分

Iterators

对于 view 和 group 返回的迭代器需要特别说明。大多数时候它们满足随机访问迭代器 (random access iterators) 的要求,在所有情况下它们至少满足前向迭代器 (forward iterators) 的要求。
换句话说,它们适合与标准库的并行算法 (parallel algorithms) 一起使用。如果不清楚,这是一件很棒的事情。

例如,这种迭代器与 std::for_eachstd::execution::par 结合使用,以并行化访问并因此更新由 view 或 group 返回的组件,只要遵守前面讨论的约束:

auto view = registry.view<position, const velocity>();

std::for_each(std::execution::par_unseq, view.begin(), view.end(), [&view](auto entity) {
    // ...
});

这可以显著增加吞吐量,甚至无需诉诸随着时间推移难以维护的复杂技巧。

不幸的是,由于标准当前修订版的限制,并行的 std::for_each 仅接受前向迭代器。这意味着库提供的默认迭代器不能返回代理对象 (proxy objects) 作为引用,而 必须 返回实际的引用类型。
这在未来可能会改变,迭代器几乎肯定迟早会默认返回实体及其组件的引用列表。多遍保证 (Multi-pass guarantee) 在任何情况下都不会中断,性能甚至应该从中进一步受益。

Const Registry

Const registry 也是完全线程安全的。这意味着当生成 view 时,它无法延迟初始化缺失的 storage。
原因很容易解释。为了避免要求提前 声明 类型,registry 会延迟为不同的组件创建 storage 对象。然而,这对于线程安全的 const registry 是不可能的。

返回的 view 始终有效,并在调用者的上下文中按预期运行。然而,当从 const registry 创建时,它们可能包含对不存在的 storage 的悬空引用。
因此,如果将这种 view 放在一旁以备第二次使用,它可能会随着时间的推移表现不佳。
因此,如果一般建议是在必要时创建 view 并立即丢弃它们,那么当涉及到从 const registry 生成的 view 时,这几乎成为了一条规则。

幸运的是,当有疑问或有特殊要求时,也有一种方法可以提前实例化 storage 类。
调用 storage 方法等同于 声明 特定的 storage,从而避免遇到问题。对于那些感兴趣的人,还有其他替代方法,例如用于 registry 预热的单线程 tick,但这些并不总是适用的。
在这种情况下,view 永远不会面临变得 无效 的风险。

文档之外

本文档中未列出许多其他特性和函数。
EnTT,特别是其 ECS 部分,处于持续开发中,有些事情可能会被遗忘,有些事情可能是为了减小此文件的大小而故意省略的。不幸的是,有些部分甚至可能已经过时,仍有待更新。

有关更多信息,建议参考代码本身包含的文档或加入官方渠道提问。