Entt笔记-(ECS)实体组件系统

Introduction(介绍)

EnTT提供了一个用现代C++编写的header-only、微小的、易于使用的实体组件系统模块。
实体-组件-系统(也称为ECS)是一种主要用于游戏开发的架构模式。

Design decisions(设计决策)

Type-less and bitset-free

该库实现了一个基于稀疏集的模型,它不需要用户在编译时或运行时指定组件集。
这就是为什么用户可以简单地像下面这样实例化核心类:

entt::registry registry;

它代替了更令人讨厌和容易出错的如下方式:

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

此外,没有必要声明组件类型的存在。当时机成熟时,直接使用它,仅此而已。

Build your own(构建自己的)

ECS模块(以及库的其余部分)被设计为一组容器,可以根据需要使用,就像向量或任何其他容器一样。
它不会试图以任何方式接管用户代码库,也不会控制其主循环或进程调度。
与其他或多或少为人熟知的模型不同,它还利用了通过_static mixins_扩展的独立池。内置信号支持是这种灵活设计的一个例子:定义为mixin,如果不需要,它很容易被禁用。同样,存储类有一个特例化,它展示了如何从最小的细节开始就可以对所有东西进行定制。

Pay per use(按次使用付费)

一切都是围绕着用户只需为他们想要的东西付费的原则设计的。
在使用实体组件系统时,通常需要在性能和内存使用之间进行权衡。
速度越快,占用的内存就越多。
更糟糕的是,一些方法往往会严重影响其他功能,如构建和销毁组件以支持迭代,即使在不是严格要求的情况下也是如此。
事实上,在非关键路径上略差的性能是减少内存使用并获得总体更好性能所要付出的合理代价。
EnTT遵循一种完全不同的方法。
它最大限度地利用了基本数据结构,并使用户有可能在需要的地方为更高的性能支付更高的费用。

All or nothing(全部或什么都没有)

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

Vademecum(手册)

entt::entity类型实现了_entity identifier_的概念。
实体(ECS的E)是按原样使用的不透明元素。不建议检查它,因为它的格式将来可能会改变。
组件(ECS的C)是任何类型的,没有任何限制,甚至没有可移动的限制。不需要注册它们和它们的类型。
系统(ECS的S)是纯函数、函数器、lambdas等等。在任何情况下都不需要宣布它们,也没有要求。
下一节将详细介绍如何使用EnTT库的实体组件系统部分。
此模块可能比下面描述的更大。
有关更多详细信息,请参阅内联文档。

Storage(存储)

组件池是稀疏集的一种特殊版本。 specialized version
每个池都包含单个组件类型的所有实例以及分配给它的所有实体。
稀疏数组被分页以避免浪费内存。
组件的压缩数组也会在添加时进行分页,以具有指针稳定性。
而实体的压缩数组则不是。
除非启用了指针稳定性,否则所有池都会重新排列它们的项,以保持内部数组的紧密排列并最大限度地提高性能。

The Registry, the Entity and the Component(注册表、实体和组件)

注册中心存储和管理实体(或者更好地说是标识符)和池。
类模板basic_registry允许用户决定表示实体的首选类型。
因为std::uint32_t在几乎任何情况下都足够大,所以还存在一个枚举类entt::entity来包装它,还有一个别名entt::registry用于entt::basic_registryentt::entity。
Entities 由_entity identifiers _表示。实体标识符包含有关实体本身及其版本的信息。
允许将用户定义的标识符定义为,定义了entity_type类型为std::uint32_t或std::uint64_t的成员变量的枚举类和类类型。
注册表既用于构造实体,也用于销毁实体:

// constructs a naked entity with no components and returns its identifier
auto entity = registry.create();

// destroys an entity and all its components
registry.destroy(entity);

create成员函数也接受一个提示。此外,它有一个重载,可以使用两个迭代器来一次有效地生成多个实体。类似地,destroy成员函数也适用于实体范围:

// destroys all the entities in a range
auto view = registry.view<a_component, another_component>();
registry.destroy(view.begin(), view.end());

除了提供重载来强制销毁版本之外。
此函数在释放实体之前从实体中移除所有组件。还有一个更轻的替代方案,它不查询组件池,用于孤立实体:

// releases an orphaned identifier
registry.release(entity);

与destroy函数一样,在这种情况下也支持实体范围,并且可以强制执行版本。

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

// returns true if the entity is still valid, false otherwise
bool b = registry.valid(entity);

// gets the actual version for the given entity
auto curr = registry.current(entity);

或者使用一些用于解析标识符的函数来检查它们,例如:

// gets the version contained in the entity identifier
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.;

默认存储在内部检测聚合类型,并在可能的情况下利用聚合初始化。
因此,没有必要严格地为每个类型定义构造函数。

insert成员函数与范围一起工作,用于:

  • 当一个类型被指定为模板形参或一个实例被作为实参传递时,一次将同一个组件赋值给所有实体:
// default initialized type assigned by copy to all entities
registry.insert<position>(first, last);

// user-defined instance assigned by copy to all entities
registry.insert(from, to, position{0., 0.});
  • 当提供范围时,将一组组件分配给实体 (组件范围的长度必须与实体的长度相同):
// first and last specify the range of entities, instances points to the first element of the range of components
registry.insert<position>(first, last, instances);

如果一个实体已经有了给定的组件,用replace和patch成员函数模板来更新它:

// replaces the component in-place
registry.patch<position>(entity, [](auto &pos) { pos.x = pos.y = 0.; });

// constructs a new instance from a list of arguments and replaces the component
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_of和any_of成员函数可能也很有用:

// true if entity has all the given components
bool all = registry.all_of<position, velocity>(entity);

// true if entity has at least one of the given components
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 and non-const reference
const auto &crenderable = cregistry.get<renderable>(entity);
auto &renderable = registry.get<renderable>(entity);

// const and non-const references
const auto [cpos, cvel] = cregistry.get<position, velocity>(entity);
auto [pos, vel] = registry.get<position, velocity>(entity);

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

Observe changes(观察变化)

默认情况下,每个存储都附带一个mixin,为其添加信号支持。
这允许诸如依赖关系和响应式系统之类的花哨的东西。
on_construct成员函数返回一个接收器(用于连接和断开侦听器的对象),用于通知对创建给定组件类型的新实例感兴趣的调用方:

// connects a free function
registry.on_construct<position>().connect<&my_free_function>();

// connects a member function
registry.on_construct<position>().connect<&my_class::member>(instance);

// disconnects a free function
registry.on_construct<position>().disconnect<&my_free_function>();

// disconnects a member function
registry.on_construct<position>().disconnect<&my_class::member>(instance);

类似地,on_destroy和on_update分别用于接收关于实例销毁和更新的通知。
由于c++的工作方式,附加到on_update的监听器只在调用replace、emplace_or_replace或patch之后调用。
监听器的函数类型类似于以下内容:

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

在所有情况下,都会向监听程序提供触发通知的registry和相关实体
还需注意:

  • 用来监听构造信号的监听器,在将组件分配给实体之后被调用
  • 用来监听观察变化的监听器,在组件更新后被调用
  • 用来监听析构信号的监听器,在组件从实体中删除之前被调用

监听器能做什么和不能做什么也有一些限制:

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

请参考Signal类的文档以了解它提供的所有功能。这里没有描述许多有用但鲜为人知的功能,例如连接对象或使用比信号本身的参数更短的参数列表附加监听程序的可能性

Listeners disconnection(监听断开连接)

存储类的销毁顺序以及侦听器的断开是完全随机的。
目前没有保证,虽然逻辑很容易辨别出来,但不能保证它在未来会保持这样的状态。
例如,监听程序在组件由于池破坏而被丢弃后断开连接,这很可能是导致问题的原因。
相反,建议您在销毁注册表之前调用它的clear函数。这会强制删除所有组件和实体,而不会丢弃池。
因此,想要访问组件、实体或池的侦听器可以从仍然有效的注册表安全地执行此操作,同时根据需要检查各种元素的存在

They call me Reactive System(他们叫我反应系统)

信号是构建反应性系统的基本工具,即使它们本身还不够。尝试使用类模板朝该方向迈出另一步。
为了解释什么是反应性系统,这里稍微修改了一下,引用了observer,第一次引入这个工具的库的文档,Entitas
假设你在战场上有100个战斗单位,但其中只有10个单位改变了位置。比起使用普通系统并根据位置更新所有100个实体,你可以使用一个反应系统,它只会更新10个改变的单位。所以更高效。
在EnTT中,这意味着迭代的实体和组件集比从视图或组返回的实体和组件集要少。
然而,在这些话上,与entitas建议的相似之处也结束了。语言的规则和图书馆的设计显然强加和允许了不同的东西。
使用注册表的一个实例和一组描述要拦截的实体的规则来初始化observer。举个例子

entt::observer observer{registry, entt::collector.update<sprite>()};

observer类是默认可构造的,并且可以随时通过connectd成员函数重新配置。此外,观察者通过成员函数disconnect与基础注册表断开连接。
observer类还提供了查询其内部状态以及了解其是否为空或包含多少实体所需的信息。此外,它还可以返回指向其包含的实体列表的原始指针。
然而,这个类最重要的特性是:

  • 它是可迭代的,因此用户可以通过range-for循环或成员函数each轻松遍历实体列表。
  • 它是可清除的,因此用户可以使用实体,并在每次迭代后重置观察者。

这些方面使observer成为一个非常强大的工具,可以随时了解自上次询问以来符合给定规则的实体:

for(const auto entity: observer) {
    // ...
}

observer.clear();

上面的代码片断相当于以下内容:

observer.each([](const auto entity) {
    // ...
});

这意味着each的非const重载也会在返回给调用者之前重置底层数据结构,而const重载则不会,原因很明显
收集器是一种实用程序,旨在生成一组匹配器(实际规则),以便与观察者一起使用。
有两种类型的匹配器:

  • 观察匹配器:观察者至少返回一个或多个给定组件已更新且尚未销毁的实体。
entt::collector.update<sprite>();

其中updated表示调用附加到on_update的所有侦听器。为了实现这一点,必须使用特定的函数,如patch。有关更多详细信息,请参阅特定的文档。

  • 分组匹配器:观察者返回至少已进入给定组(如果该组存在)且尚未离开该组的实体
entt::collector.group<position, velocity>(entt::exclude<destroyed>);

分组匹配器还支持排除列表和单个组件
粗略地说,观察匹配器拦截更新给定组件的实体,而分组匹配器跟踪自上次请求以来分配给定组件的实体。
如果一个实体已经拥有除了一个之外的所有组件,并且将缺失的类型分配给它,则该实体将被分组匹配器拦截。
此外,matchers支持通过子句:where进行过滤

entt::collector.update<sprite>().where<position>(entt::exclude<velocity>);

如果它们不匹配,则观察者不会返回它们,无论它们是否匹配给定的规则。
这一条款引入了一种截取实体的方法,当且仅当它们已经是假设组sprite的一部分时。如果它们不匹配,则观察者不会返回它们,无论它们是否匹配给定的规则。
在上面的例子中,每当实体sprite的组件position、velocity更新时,观察者都会检查实体本身,以验证它是否至少更新了。如果两个条件中的一个不满足,则无论如何都会丢弃实体。
where子句理论上可以接受无限数量的类型以及排除列表中的多个元素。此外,每个匹配器都可以有自己的子句,同一个匹配器的多个子句可以组合在一个子句中。

Sorting: is it possible?(排序: 有可能吗?)

使用不需要内存分配的就地算法可以对实体和组件进行排序,因此非常方便。
有两种功能可以满足略有不同的需求:

  • 组件直接排序:
registry.sort<renderable>([](const auto &lhs, const auto &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的实例被安排在内存中,以便在两个组件一起迭代时最小化缓存丢失
顺便说一句,使用组限制了对组件池进行排序的可能性。有关更多详细信息,请参阅特定的文档。

Helpers助手

所谓的helper是一些小的类和函数,主要用于为最基本的功能提供内置支持。

Null entity空实体

entt::null变量为空实体的概念建模。
标准库保证下面的表达式总是返回false

registry.valid(entt::null);

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

entt::entity null = entt::null;

类似地,null实体与任何其他标识符进行比较:

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

至于其整数形式,null实体只影响标识符的实体部分,而对其版本完全透明。
请注意,entt::null和实体0不是一回事。初始化实体与entt::null 也不是一回事 。因此,尽管在某种意义上entt::entity{}是实体0的别名,但它们都不用于创建空实体

Tombstone 墓碑

与空实体类似,变量对tombstone概念进行建模
创建后,这两个值的整型形式是相同的,尽管它们影响标识符的不同部分。
实际上,tombstone只使用它的版本部分,对实体部分是完全透明的
一旦创建,两个值的积分形式是相同的,尽管它们影响标识符的不同部分。
同样在这种情况下,下面的表达式总是返回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的别名,但它们都不用于创建墓碑

To entity对实体

该函数接受一个注册表和一个组件实例,并返回与后者关联的实体

const auto entity = entt::to_entity(registry, position);

如果组件不属于registry,则返回空实体。

Dependencies依赖关系

registry类旨在创建其成员函数之间的短路。
这大大简化了依赖项的定义。
例如,只要将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允许将信号传播到组件的成员函数,而不必扩展它:

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

它所要做的就是为接收到的实体选择正确的组件,并调用请求的方法,如果需要的话,传递参数。

Connection helper连接助手

连接信号可能很快就会变得很麻烦。
该实用程序旨在通过对呼叫进行分组来简化该过程:

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

显然,它不会使代码消失,但至少应该在最复杂的情况下减少代码。

Handle(句柄)

Handle是实体和注册表的薄包装。它通过提供get或emplace等函数来复制注册表的API。不同之处在于实体隐式地传递给注册中心。
它的默认构造形式是一个包含空注册表和空实体的无效句柄。当它包含空注册表时,调用将执行委托给注册表的函数会导致未定义的行为。如果有疑问,建议测试其有效性,并将其隐式转换为bool。
句柄也是非拥有的,这意味着它可以自由地复制和移动,而不会影响其实体(事实上,句柄恰好是可复制的)。这意味着可变性成为类型的一部分。
有两个别名使用entt::entity作为它们的默认实体:entt::handle和entt::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句柄也可以隐式地转换为开箱即用的const句柄,而不是反过来。
这个类旨在简化函数签名。如果函数接受一个注册表和一个实体,并在该实体上完成大部分工作,用户可能想要考虑使用句柄,无论是const还是非const。

Organizer

Organizer类模板支持从一组函数及其对资源的需求创建执行图。
结果任务在任何情况下都不会执行。这不是该工具的目标。相反,它们以允许安全执行的图形的形式返回给用户。
所有功能都按执行顺序添加到管理器中:

entt::organizer organizer;

// adds a free function to the organizer
organizer.emplace<&free_function>();

// adds a member function and an instance on which to invoke it to the organizer
clazz instance;
organizer.emplace<&clazz::member_function>(&instance);

// adds a decayed lambda directly
organizer.emplace(+[](const void *, entt::registry &) { /* ... */ });

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

  • 可能是对注册表的常量引用。
  • 具有任何可能的存储类组合的entt::basic_view。
  • 对任何类型 T (即,上下文变量)的可能常量引用。

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

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

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

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

当一个函数向组织者注册时,它所访问的一切都被视为资源(视图被解包,它们的类型被视为资源)。类型的稳定性也决定了它的访问模式(RO/RW)。反过来,这会影响结果图,因为它会影响并行启动任务的可能性。
至于注册表,如果函数没有显式地请求它或需要对它的常量引用,则将其视为只读访问。否则,它被认为是读写访问。所有函数的资源中都有注册表。
在注册函数时,用户还可以要求函数本身的参数列表中没有的资源。这些被声明为模板形参:

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

类似地,用户可以通过模板形参再次覆盖类型的访问模式:

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

在这种情况下,即使renderable在函数的参数中显示为非常量,它在生成任务图时也被视为常量。
为了生成任务图,组织者提供了graph成员函数:

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

graph以邻接表的形式返回。每个顶点提供以下特性:

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

由于在注册表中创建池和资源不一定是线程安全的,每个顶点还提供了一个prepare函数,用于设置注册表,以便与创建的图一起执行:

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

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

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

Context variables (上下文变量)

每个注册中心都有一个与其关联的上下文,为方便起见,它是一个any对象映射,可以通过类型和名称访问。但这个名字并不是一个真正的名字。实际上,它是一个id_type类型的数字id,用作变量的键。任何值都可以接受,甚至是运行时值。
上下文通过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);

同样,在这种情况下,两个函数都支持常量类型,并接受要查找的变量的名称,就像at一样。

Aliased properties (别名属性)

上下文还支持为不直接由注册中心管理的现有变量创建别名。Const和只读变量也被接受。
要做到这一点,构造时使用的类型必须是引用类型,并且必须提供左值作为实参:

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

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

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

注意insert_or_assign不支持别名属性,为此用户必须使用emplace或emplace_as。
当insert_or_assign用于更新别名属性时,它会将属性本身转换为非别名属性。
从用户的角度来看,注册中心管理的变量和别名属性之间没有区别。然而,只读变量不能作为非const引用访问:

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

别名属性会像其他变量一样被擦除。类似地,也可以为它们分配一个名称。

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;
    // ... other data members ...
};

component_traits类模板负责从提供的类型中提取属性。
此外,它是_sfinae-friendly_的,还支持feature-based的特例化。

Pointer stability(指针稳定性)

为一个、几个或所有组件实现指针稳定性的能力是EnTT设计及其默认存储的直接结果。
事实上,尽管它包含通常称为打包数组的内容,但默认存储是分页的,并且在空间耗尽并必须重新分配时不会受到引用无效的影响。
然而,这还不足以确保指针在删除时的稳定性。为此,还提供了一种稳定的删除方法。这种方法是通过在删除时创建墓碑来保存元素的位置,而不是试图填充所创建的洞。
出于性能原因,
EnTT在所有情况下都支持存储压缩,尽管通常访问组件是随机发生的,或者在用户端以非线性顺序遍历池(如在层次结构的情况下)。
换句话说,指针稳定性不是自动的,而是根据请求启用的。

In-place delete (就地删除)

该库为就地删除提供了开箱即用的支持,从而提供了具有完全稳定指针的存储。这可以通过特例化component_traits类或在需要时向组件定义中添加所需的属性来实现。
当视图和组检测到具有不同于默认删除策略的存储时,它们会相应地进行调整。特别是:

  • 组与稳定存储不兼容,甚至拒绝编译。
  • 多类型视图和运行时视图对存储策略是完全透明的。
  • 稳定存储类型的单一类型视图为多类型视图提供了相同的接口。例如,只有size_hint可用。

换句话说,在稳定存储的情况下,提供了更通用的视图版本,即使是单一类型的视图。
在任何情况下都不会从视图本身返回墓碑。同样,不存在的组件也不会返回,否则可能导致UB。

Hierarchies and the like (层次结构之类的)

EnTT不试图以任何方式提供具有隐藏或不明确成本的内置方法来促进层次结构的创建。
这个问题有多种解决方案,例如使用以下类:

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};
    // ... other data members ...
};

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

struct transform {
    static constexpr auto in_place_delete = true;

    transform *parent;
    // ... other data members ...
};

此外,一组元素的创建时间很近,因此会退回到相邻的位置,因此即使在随机访问时也有利于局部性。考虑到存储位置的稳定性,局部性不会随着时间的推移而牺牲,这无疑具有性能优势的。

Meet the runtime (满足运行时要求)

EnTT利用了该语言在编译时提供的功能。然而,这也有它的缺点(熟悉类型擦除技术的人都知道)。
为了填补这一空白,该库还提供了一系列实用工具和特性,这些工具和特性对于在运行时处理类型和池非常有用。

A base class to rule them all(一个基类来统治它们)

存储类是完全自包含的类型。它们通过mixins进行扩展,以添加更多的功能(泛型或特定类型)。此外,它们还提供了一组基本的功能,这些功能已经允许用户走得很远。
其目的是尽可能地限制对定制的需求,提供大多数情况下通常需要的东西。
当存储通过其基类使用时(例如,当它的实际类型未知时),总是有可能接收到与实体相关的元素类型的type_info对象(如果有的话):

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

此外,所有特性都依赖于将调用转发给mixins的内部函数。后者可以使用通过bind设置的任何信息:

base.bind(entt::forward_as_any(registry));

bind函数接受一个entt::any对象,它是一个类型化的类型擦除值。
这就是注册表将自身传递给所有支持信号的池的方式,也是存储不断发送事件而不需要每次都将注册表传递给它的原因。
该函数接受一个对象,即类型化类型擦除值。
除了这些更具体的功能之外,还有一些功能旨在解决一些常见的需求,例如复制实体。
特别是,存储后的基类提供了通过不透明指针获取与实体关联的值的可能性:

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

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

  • 当指针为null时,该函数尝试默认构造一个对象的实例以绑定到实体,并在成功时返回true。
  • 当指针non-null时,该函数尝试复制构造对象的实例以绑定到实体,并在成功时返回true。

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

// create a copy of an entity component by component
for(auto &&curr: registry.storage()) {
    if(auto &storage = curr.second; storage.contains(src)) {
        storage.push(dst, storage.value(src));
    }
}

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

Beam me up, registry (把我传送上去,注册)

EnTT允许用户分配一个名称(或者一个数字标识符)给一个类型,然后创建多个相同类型的池:

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

如果没有提供名称,则总是返回与给定类型关联的默认存储空间。
由于存储也是自包含的,所以注册表不会为它们复制自己的API。然而,使用的可能性仍然没有限制:

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

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

任何可以通过registry接口完成的操作都可以直接在引用存储中完成。
另一方面,那些涉及所有存储空间的调用也保证会到达手动创建的那些:

// removes the entity from both storage
registry.destroy(entity);

最后,这种类型的存储可以用于任何视图(如果有必要,它还可以接受多个相同类型的存储):

// direct initialization
entt::basic_view direct{
    registry.storage<velocity>(),
    registry.storage<velocity>("other"_hs)
};

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

直接使用storage的可能性,以及创建和使用多种相同类型的自由,为在运行时使用EnTT打开了大门,这在以前是非常有限的。

Snapshot: complete vs continuous (快照: 完整的vs连续的)

该模块对序列化提供了最低限度的支持。
它不会直接将组件转换为字节,也不需要其他工具进行序列化。相反,它接受一个具有适当接口的不透明对象(即存档),以序列化其内部数据结构并稍后恢复它们。将类型和实例转换为一组字节的方式完全由归档负责,因此也完全由最终用户负责。
序列化部分的目的是让用户既可以生成整个注册表的转储,也可以生成较小范围的快照,即只选择他们感兴趣的组件。
直观地说,用例是不同的。例如,第一种方法适用于本地保存/恢复功能,而后者适用于创建客户机-服务器应用程序,并以某种方式将表示的部分传递到另一端。
要对注册表进行快照,使用snapshot类:

output_archive output;

entt::snapshot{registry}
    .entities(output)
    .component<a_component, another_component>(output);

没有必要每次都调用所有函数。在这种情况下使用什么函数主要取决于目标。
entities成员函数使快照序列化所有实体(包括仍然存在的和已发布的)及其版本。
另一方面,component成员函数模板旨在存储组件。
还有另一个版本的component成员函数,它接受一个要序列化的实体范围。这个版本比另一个版本慢一些,主要是因为它为了内部目的多次迭代实体范围。然而,由于某些原因,它可以用来过滤那些不应该被序列化的实体:

const auto view = registry.view<serialize>();
output_archive output;

entt::snapshot{registry}.component<a_component, another_component>(output, view.begin(), view.end());

请注意,component存储项目和实体。这意味着它在不调用entities成员函数的情况下也能正常工作。
创建快照后,主要有两种方式加载它:以整体模式加载和以一种连续模式加载。
下面几节将详细介绍加载器和归档。

Snapshot loader (快照加载程序)

快照加载程序要求目标注册表为空。它一次加载所有数据,同时保持实体最初拥有的标识符不变:

input_archive input;

entt::snapshot_loader{registry}
    .entities(input)
    .component<a_component, another_component>(input)
    .orphans();

没有必要每次都调用所有函数。在这种情况下使用什么函数主要取决于目标。
很明显,重要的是恢复数据的顺序与序列化时的顺序完全相同。
entities成员函数恢复实体集合和它们在源中最初拥有的版本。
component成员函数恢复所有且仅指定的组件,并将它们分配给正确的实体。模板参数列表必须与序列化过程中使用的相同。
orphans成员函数释放没有组件(如果有的话)的实体。

Continuous loader(连续加载程序)

连续加载器设计用于将数据从源注册表加载到(可能)非空目标。加载器在注册表中容纳多个快照,以一种每次一步更新目标的连续加载方式。
实体最初拥有的标识符不会传输到目标。相反,加载器在恢复快照时将远程标识符映射到本地标识符。正因为如此,这种加载器提供了一种方法来自动更新作为组件一部分的标识符(例如,作为数据成员或聚集在容器中)。
与快照加载器的另一个区别是,连续加载器的内部状态必须随着时间的推移而持续。因此,没有理由将其生命周期限制为临时对象:

entt::continuous_loader loader{registry};
input_archive input;

loader.entities(input)
    .component<a_component, another_component, dirty_component>(input, &dirty_component::parent, &dirty_component::child)
    .orphans()
    .shrink();

没有必要每次都调用所有函数。在这种情况下使用什么函数主要取决于目标。
很明显,重要的是恢复数据的顺序与序列化时的顺序完全相同。
entities成员函数恢复实体组,并在需要时将每个实体映射到本地对应的实体。对于每个尚未被加载器注册的远程实体标识符,将创建一个本地标识符,以保持本地实体与远程实体同步。
component成员函数恢复所有且仅指定的组件,并将它们分配给正确的实体。如果组件本身包含实体(无论是作为entt::entity类型的数据成员还是在容器中),加载器可以自动更新它们。要做到这一点,只需指定要更新的数据成员,如示例所示。
orphans成员函数在恢复后释放没有组件的实体。
最后,shrink有助于清除不再有远程对应对象的本地实体。用户应该在恢复每个快照之后调用这个成员函数,除非他们确切知道自己在做什么。

Archives(档案)

档案必须公开公开一组预定义的成员函数。API很简单,只包含一组函数调用操作符,由snapshot类和加载器调用。
特别是:

  • 输出归档文件(创建快照时使用的归档文件)向存储实体暴露了一个具有以下签名的函数调用操作符:
void operator()(entt::entity);

其中entt::entity是注册中心使用的实体的类型。
请注意,snapshot类的所有成员函数也会在初始调用时存储它们要存储的集合的大小。在这种情况下,函数调用操作符的预期函数类型是:

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

此外,归档文件接受一对实体和组件,用于每种类型的序列化。因此,给定类型T,归档文件提供了一个函数调用操作符,其签名如下:

void operator()(entt::entity, const T &);

输出归档文件可以自由地决定如何序列化数据。注册表完全不受这个决定的影响。

  • 输入存档(在恢复快照时使用的存档)公开了一个具有以下签名的函数调用操作符来加载实体:
void operator()(entt::entity &);

其中entt::entity是注册中心使用的实体的类型。每次调用该函数时,归档文件都会从底层存储中读取下一个元素,并将其复制到给定的变量中。
加载器类的所有成员函数也会进行初始调用,以读取它们要加载的集合的大小。在这种情况下,函数调用操作符的预期函数类型是:

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

此外,归档文件接受对要恢复的每种类型的实体及其组件的一对引用。因此,给定类型T,归档文件中包含一个函数调用操作符,其签名如下:

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

每次调用该操作符时,归档文件都会从底层存储中读取下一个元素,并将它们复制到给定的变量中。

One example to rule them all (一个例子可以统治所有的例子)

EnTT提供了一些示例(实际上是一些测试),展示了如何将一个著名的库集成为归档。它在底层使用了Cereal C++,主要是因为我在编写代码时想了解它是如何工作的。
代码还不能用于生产环境,它既不是唯一的,也不是(可能)最好的方法。但是,请随意使用它,风险自负。
其基本思想是将所有内容存储在内存中的一组队列中,然后使用不同的加载器将所有内容带回注册表。

Views and Groups (视图和组)

视图是一种非侵入式工具,用于在不影响其他功能或增加内存消耗的情况下使用实体和组件。
组是一种侵入性工具,用于提高关键路径上的性能,但它也有代价。

视图主要有两种:编译时(也称为视图)和运行时(也称为runtime_view)。
前者需要一个组件(或存储)类型的编译时列表,并因此可以进行一些优化。后者是在运行时使用数值类型标识符构建的,迭代速度稍慢。
在这两种情况下,创建和销毁视图的开销都不大,因为它们没有任何类型的初始化。

组有三种不同的类型:完全拥有组、部分拥有组和非拥有组。它们之间的主要区别在于性能。
组可以拥有一个或多个组件类型。它们可以重新排列池,以加快迭代速度。粗略地说:一个组拥有的组件越多,迭代它们的速度就越快。
一个给定的组件只能属于嵌套的多个组。用户必须仔细定义组,以获得最佳效果。

Views (试图)

单类型视图和多类型视图的行为不同,api也略有不同。

单一类型视图被专门用于在所有情况下提升性能。没有什么比单一类型视图更快的了。它们只是遍历打包(实际上是分页)的元素数组,并直接返回它们。
这种视图还允许获取它们将要返回的元素的确切数量。
有关所有细节,请参阅内联文档。

多类型视图迭代至少具有所有给定组件的实体。在构造过程中,它们会查看每个池中可用的元素数量,并使用最小的集合来加速迭代。
这种视图只允许获取它们将返回的元素的估计数量。
有关所有细节,请参阅内联文档。

不需要存储aside视图,因为它们构建起来非常便宜。事实上,在从const注册表创建视图时,甚至不鼓励这样做。由于所有的存储都是延迟初始化的,因此在创建视图时它们可能不存在。因此,视图可以引用空的占位符,而不会被重新分配实际的存储空间。
在所有情况下,当调用begin或end时,视图都会返回它们所引用的存储空间新创建并正确初始化的迭代器。

视图通过注册表共享创建它们的方式:

// single type view
auto single = registry.view<position>();

// multi type view
auto multi = registry.view<position, velocity>();

还支持按组件过滤实体:

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

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

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

for(auto entity: view) {
    // a component at a time ...
    auto &position = view.get<position>(entity);
    auto &velocity = view.get<velocity>(entity);

    // ... multiple components ...
    auto [pos, vel] = view.get<position, velocity>(entity);

    // ... all components at once
    auto [pos, vel, rend] = view.get(entity);

    // ...
}

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

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

// using an input iterator
for(auto &&[entity, pos, vel]: registry.view<position, velocity>().each()) {
    // ...
}

请注意,当通过回调函数接收到实体时,也可以从参数列表中排除,这可以进一步提高迭代期间的性能。
由于它们没有显式地实例化,因此在任何情况下都不会返回空组件。
注意,对于单类型视图,get接受模板参数,但并不严格要求模板参数,因为模板参数的类型是隐式定义的。然而,当没有指定类型时,为了保证视图的多类型一致性,实例会以元组的形式返回:

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

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

注意:在迭代期间,最好使用视图的get成员函数,而不是注册表的get成员函数,以获取视图本身迭代的类型。

View pack (视图包)

视图被组合起来创建新的更具体的查询。
将多个视图组合在一起时返回的类型本身就是一个视图,更一般地说,是一个多组件的视图。

组合不同的视图试图模仿c++ 20的范围:

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

auto pack = view | other;

类型保持不变,它们的顺序取决于视图组合的顺序。例如,上面的包首先返回position实例,然后返回velocity实例。
因为组合视图会生成视图,所以链可以是任意长度的,并且上面的类型顺序规则是顺序应用的。

Iteration order(迭代顺序)

默认情况下,视图会沿着包含最少元素的池迭代。
例如,如果注册表中包含的velocity少于它包含的position,那么以下视图返回的元素顺序取决于velocity组件在它们的池中是如何安排的:
默认情况下,沿着包含最小数量元素的池迭代视图。

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

此外,构建视图时类型的顺序并不重要。视图包中的视图顺序也不重要。
然而,使用use函数可以按照给定的组件顺序强制视图迭代:

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

另一方面,如果用户只想逆序迭代元素,则可以使用单一类型视图的反向迭代器:

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

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

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

Runtime views(运行时视图)

多类型视图迭代至少具有所有给定组件的实体。在构造过程中,它们会查看每个池中可用的元素数量,并使用最小的集合来加速迭代。
它们提供了或多或少与多类型视图相同的功能。但是,它们不公开get成员函数,用户应该引用生成视图的注册表来访问组件。
有关所有细节,请参阅内联文档。

构建运行时视图非常便宜,在任何情况下都不应该存储在一边。它们应该在创建后立即使用,然后应该扔掉。
要迭代运行时视图,可以在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) {
        // ...
    });

两种情况下的性能完全相同。
这种视图也支持按组件过滤实体:

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

运行时视图适用于用户在编译时不知道使用什么类型来迭代实体的情况。在这方面,注册中心的storage成员函数可能是有用的。

Groups (组)

组旨在一次迭代多个组件,并提供多类型视图的更快替代方案。
组克服了其他可用工具的性能,但需要获得组件的所有权。这对他们的池设置了一些限制。另一方面,分组并不是一种会增加内存消耗、影响功能并试图为所有可能的组件组合优化迭代的自动行为。用户可以决定何时为群组付费以及付费程度。
组最有趣的地方在于它们符合使用模式。周围的其他解决方案通常试图优化所有内容,因为众所周知,在所有内容的某个地方也存在我们的使用模式。然而,这在性能和内存使用方面都有不可忽视的代价。讽刺的是,用户也会为他们不想要的东西支付价格,这不是我喜欢的东西。更糟糕的是,人们无法轻易阻止这种行为。组的工作方式不同,它只在用户发现需要时优化真正的用例。
组的另一个优点是它们对内存消耗没有影响,不考虑完全不拥有的组,这些组非常罕见,应该尽可能避免。

所有群体都在一定程度上影响着其组成部分的创造和破坏。这是因为它们必须观察感兴趣的池中的变化,并在需要时为它们拥有的类型正确地安排数据。
在所有情况下,group都允许获取它将要返回的确切元素数量。
有关所有细节,请参阅内联文档。

不需要将组存储在旁边,因为创建组的成本非常低,即使复制有效的组并自由重用也不会有问题。
一个组在第一次被请求时执行初始化步骤,这可能是非常昂贵的。要避免这种情况,请考虑在尚未分配组件时创建组。如果注册表是空的,准备工作将非常快。无论何时调用begin或end,组都会返回新创建并正确初始化的迭代器。

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

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

for(auto entity: group) {
    // a component at a time ...
    auto &position = group.get<position>(entity);
    auto &velocity = group.get<velocity>(entity);

    // ... multiple components ...
    auto [pos, vel] = group.get<position, velocity>(entity);

    // ... all components at once
    auto [pos, vel, rend] = group.get(entity);

    // ...
}

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

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

// using an input iterator
for(auto &&[entity, pos, vel]: registry.group<position>(entt::get<velocity>).each()) {
    // ...
}

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

注意:在迭代期间,宁可使用组的get成员函数,而不是注册表的get成员函数,以获取组本身迭代的类型。

Full-owning groups (Full-owning组)

一个完全拥有的组是用户一次迭代多个组件的最快工具。它直接迭代所有组件,不需要间接迭代。
这种类型的组执行或多或少类似于用户顺序访问一组组件的打包数组,这些组件都是相同排序的,没有跳转或分支。

全属组的创建方式如下:

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

也支持按组件过滤实体:

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

创建后,组将获得模板参数列表中指定的所有组件的所有权,并根据需要安排它们的池。

一旦创建了组,就不再允许对拥有的组件进行排序。但是,完全拥有的组使用其sort成员函数进行排序。对完全拥有的组进行排序会影响其所有实例。

Partial-owning groups(Partial-owning组)

对于其拥有的组件,部分拥有组的工作方式类似于完全拥有组,但依赖于间接获得由其他组拥有的组件。
这没有完全拥有的组快,但当只有一两个免费组件需要检索时(这是最常见的情况),它已经比视图快得多了。在最坏的情况下,它也不会比视图慢。

不完全拥有的组可以这样创建:

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

也支持按组件过滤实体:

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

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

一旦创建了组,就不再允许对拥有的组件进行排序。但是,部分拥有的组使用其sort成员函数进行排序。对部分拥有的组进行排序会影响它的所有实例。

Non-owning groups(Non-owning组)

无拥有者组通常足够快,肯定比视图快,适合大多数情况。然而,它们需要自定义数据结构才能正常工作,并且会增加内存消耗。
根据经验,用户应该尽可能避免使用无拥有者组。

无拥有者组可以这样创建:

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

也支持按组件过滤实体:

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

在这种情况下,组没有获得任何类型的组件的所有权。因此,这种类型的组通常是性能最差的,但也是唯一可以在任何情况下使用以略微提高性能的组。

无拥有者组使用其sort成员函数进行排序。对无拥有者组进行排序会影响其所有实例。

Nested groups(嵌套组)

一个组件类型不能由两个或多个相互冲突的组拥有,例如:

  • registry.group<transform, sprite>().
  • registry.group<transform, rotation>().

不过,同一个类型可以被属于同一个族的组所拥有,也称为嵌套组,例如:

  • registry.group<sprite, transform>().
  • registry.group<sprite, transform, rotation>().

幸运的是,这些即使不是最常见的情况,也是非常常见的情况。
它允许在更多组件组合上提高性能。
两个嵌套组至少拥有一种组件类型,其中一个组所涉及的组件类型列表完全包含在另一个组的列表中。更具体地说,这独立地应用于用于定义组的所有组件列表。
因此,定义两个或多个组是否嵌套的规则如下:

  • 一个组涉及相对于另一个组的一个或多个附加组件类型,无论它们是拥有的、观察的还是排除的。
  • 最严格的组拥有的组件类型列表与其他组相同或完全包含其他组的组件类型。这也适用于观察到的和排除的成分列表。

这意味着嵌套组通过以新组件的形式添加更多条件来扩展其父组。
如前所述,组件不必全部拥有,这样两个组就可以看作是嵌套的。下列定义是完全有效的。

  • registry.group(entt::get).
  • registry.group<sprite, transform>(entt::get).
  • registry.group<sprite, transform>(entt::get<renderable, rotation>).

排除列表在这方面也发挥了作用。在定义嵌套组时,被排除的组件类型T被视为可观察类型not_T。因此,考虑下面两个定义。

  • registry.group<sprite, transform>().
  • registry.group<sprite, transform>({}, entt::exclude).

它们被视为用户定义了下列组:

  • group<sprite, transform>().
  • group<sprite, transform>(entt::get<not_rotation>).

其中,not_rotation是一个空标签,仅当rotation不存在时才会出现。
因此,要定义一个比现有组更具限制性的新组,只需通过添加可拥有、可观察或可排除的新类型来扩展另一个组的组件列表即可。
反之亦然。要定义一个更大的组,从其父组中移除约束就足够了。
注意,一个组所涉及的组件类型的数量越多,它的限制就越严格。
尽管嵌套组具有极大的灵活性,允许独立使用拥有、观察或排除的组件类型,但此工具的真正优势在于可以定义更多拥有相同组件的组,从而在更多情况下提供最佳性能。
事实上,给定一个组所涉及的组件类型的列表,拥有的组件类型数量越多,组本身的性能就越好。

顺便说一下,在定义嵌套组时,不再可能对所有组进行排序。这是因为限制最严格的组与限制较少的组共享其元素,对后者排序将使前者无效。
然而,对于一个嵌套组族,仍然可以对它们进行最严格的排序。为了防止用户必须记住他们的哪个组是最受限制的,registry类提供了sortable成员函数来知道一个组是否支持排序。

Types: const, non-const and all in between

在构建视图和组时,registry类提供了两种重载:const版本和非const版本。前者只接受const类型作为模板参数,后者既接受const类型也接受非const类型。
这意味着const注册表生成的视图和组也会将常量传播到涉及的类型。举个例子:

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

参考下面的非const视图定义:

entt::view<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);

不可能从同一个视图中获取对velocity组件的非常量引用。因此,这会导致编译错误:

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成员函数还将常量传播到其返回值:

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

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

Give me everything

视图和组是整个实体列表上的窄窗口。它们的工作原理是根据组件过滤实体。
在某些情况下,可能需要迭代仍然在使用的所有实体,而不管它们的组件。注册表提供了一个特定的成员函数来完成这项工作:

registry.each([](auto entity) {
    // ...
});

根据经验,如果目标是迭代具有确定组件集的实体,请考虑使用视图或组。这些工具通常比将each函数与一堆自定义测试组合起来要快得多。
在所有其他情况下,这是正确的方法。例如,可以将each与orphan成员函数组合起来,以清理orphan实体(即仍然在使用且没有指定组件的实体):

registry.each([&registry](auto entity) {
    if(registry.orphan(entity)) {
        registry.release(entity);
    }
});

通常,迭代所有实体可能会导致性能低下。不应该经常这样做,以避免性能下降的风险。
但是,在初始化编辑器或回收待决标识符时,它很方便。

What is allowed and what is not

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

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

换句话说,迭代器很少会失效。此外,组件引用在添加新元素时不会失效,而在拆放策略导致销毁时可能会失效,除非引导迭代的类型进行了原地删除。
举个例子,请看下面的代码片段:

registry.view<position>().each([&](const auto entity, auto &pos) {
    registry.emplace<position>(registry.create(), 0., 0.);
    // references remain stable after adding new instances
    pos.x = 0.;
});

each成员函数不会中断(因为迭代器仍然有效),任何引用也不会失效。相反,应该更多地关注实体的破坏或组件的移除。
使用通用的range-for循环,直接从视图中获取组件,或者将删除实体和组件的操作移动到函数末尾,以避免悬空指针。

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

  • 将要删除的实体和组件存储在一边,并在迭代结束时执行操作。
  • 使用适当的标记组件标记实体和组件,表明它们必须被清除,然后执行第二次迭代逐个清除它们。

该特性的一个显著副作用是,在大多数情况下,所需分配的数量会进一步减少。

More performance, more constraints(更多的性能,更多的约束)

组是比视图更快的选择。然而,性能越高,对什么是允许的,什么是不允许的限制就越大。
特别是,在某些罕见的情况下,组在迭代过程中对组件的创建添加了限制。它发生在非常特殊的情况下。考虑到群体的性质和范围,它可能不会碰巧遇到,但无论如何,了解它是好的。

首先,必须指出的是,在迭代组时创建组件根本不是问题,并且可以自由地完成,就像在视图中发生的那样。这同样适用于组件和实体的销毁,对于上述规则适用。

相反,当一个组拥有的给定组件在其外部迭代时,就会出现额外的限制。在这种情况下,添加属于组本身的组件可能会使迭代器失效。对于组件和实体的销毁没有进一步的限制。
幸运的是,这并不总是正确的。事实上,它几乎永远不会发生,只会在特定的条件下发生。特别是:

  • 使用单一的类型视图迭代组中的组件类型,并将所有需要的组件添加到实体中,可能会使迭代器失效。
  • 用多类型视图迭代组中某一类型的组件,并向实体中添加使其进入组所需的所有组件,会使迭代器失效,除非用户指定另一种类型的组件来引导视图的迭代顺序(在这种情况下,前者被视为自由类型,不受限制)。

换句话说,只要一个类型被视为自由类型(作为多类型视图和部分或无归属组的例子)或用它自己的组迭代,该限制就不存在,但如果该类型用作在迭代中进行规则的主要类型,则可能发生该限制。
这是因为组拥有它们的组件池,并在内部组织数据以最大化性能。因此,只有将自有组件作为其组的一部分进行迭代,或者作为自由类型使用多类型视图和组进行迭代时,自有组件的完全一致性才能得到保证。

Empty type optimization (空类型优化)

对于空类型T, std::is_empty_v会返回true。它们也是可以使用空基优化(EBO)的类型。
EnTT以一种特殊的方式处理这些类型,在性能和内存使用方面都进行了优化。然而,这也有值得一提的后果。

当检测到空类型时,默认情况下不会实例化它。因此,只有被赋值给它的实体才可用。不存在从注册表中获取空类型的方法。视图和组永远不会返回它们的实例(例如,在每个调用期间)。
另一方面,迭代速度更快,因为只考虑了指定类型的实体。此外,使用的内存更少,主要是因为不存在任何组件的实例,无论它被分配给多少实体。

一般来说,除了那些需要返回实际实例的特性,库提供的任何特性都不会受到影响。
通过定义ENTT_NO_ETO宏来禁用这种优化。在这种情况下,空类型的处理方式与其他所有类型一样。通过component_traits类模板在组件级别设置页面大小是有选择地禁用此优化而不是全局禁用此优化的另一种方法。

Multithreading (多线程)

一般来说,整个注册表并不是线程安全的。出于几个原因,线程安全不是用户想要的开箱即用的东西。举一个例子:performance。
视图、组以及EnTT所采用的方法是该规则的巨大例外。确实,视图、组和迭代器本身通常都不是线程安全的。因此,用户不应该尝试迭代一组组件并并发修改同一组组件。然而:

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

这种实体组件系统可以用于单线程应用程序,也可以与异步或多线程一起使用。此外,典型的基于线程的ECS模型不需要完全线程安全的注册表才能工作。实际上,用户可以通过注册表实现目标,因为它是在使用大多数常见模型时。

由于上面提到的几个原因和许多其他没有提到的原因,无论是否需要同步,用户都要完全负责。另一方面,他们可以侥幸逃脱,而不必诉诸特殊的权宜之计。

最后,EnTT通过一些编译时定义来配置,以使其某些部分隐式地线程安全,粗略地说,只有那些真正有意义且不能被反转的部分。
特别是,当在不同的线程中使用引用类型索引生成器的多个对象实例(例如registry类)时,定义ENTT_USE_ATOMIC可能有用。
更多信息请参阅相关文档。

Iterators (迭代器)

需要特别提到视图和组返回的迭代器。大多数情况下,它们满足随机访问迭代器的要求,在所有情况下,它们至少满足前向迭代器的要求。
换句话说,它们适合与标准库中的并行算法一起使用。如果不清楚,这是件好事。

例如,这种迭代器可以与std::for_each和std::execution::par结合使用,以并行化访问,从而更新视图或组返回的组件,只要遵守前面讨论的约束:

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

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

这可以大大提高吞吐量,即使不依赖于谁知道什么工件随着时间的推移难以维护。

不幸的是,由于标准的当前版本的限制,并行std::for_each只接受前向迭代器。这意味着标准库提供的默认迭代器不能将代理对象作为引用返回,而必须返回实际的引用类型。
这在将来可能会改变,迭代器迟早会默认返回实体和对其组件的引用列表。多遍保证在任何情况下都不会中断,性能甚至会进一步受益。

Const registry (Const注册表)

const注册表也是完全线程安全的。这意味着在生成视图时,它不能延迟初始化缺少的存储空间。
原因很容易解释。为了避免要求提前声明类型,注册表惰性地为不同的组件创建存储对象。然而,对于线程安全的const注册表来说,这是不可能的。
另一方面,创建视图时必须存在所有池。因此,需要使用静态占位符来填补缺失的存储空间。

请注意,返回的视图始终是有效的,并且在调用者的上下文中的行为符合预期。唯一的区别是静态占位符(如果有的话)永远不会更新。
因此,从const注册表创建的视图如果保留为第二次使用,随着时间的推移,它的行为可能会不正确。
因此,如果一般的建议是在必要时创建视图,然后立即丢弃它们,那么对于从const注册表生成的视图来说,这几乎成为一种规则。

幸运的是,当有疑问或有特殊需求时,还有一种方法可以尽早实例化存储类。
调用storage方法相当于宣布某个存储空间已经存在,以避免出现问题。对于感兴趣的人来说,还有其他替代方法,例如注册表预热的单线程时钟,但这些方法并不总是适用。
在这种情况下,没有使用占位符,因为所有存储都存在。换句话说,视图永远不会有失效的风险。

Beyond this document

还有许多其他特性和功能没有在本文档中列出。
EnTT,特别是它的ECS部分还在持续发展中,有些东西可能会被遗忘,有些可能会被故意忽略以减少文件的大小。不幸的是,有些部分可能已经过时了,仍然需要更新。

有关进一步信息,建议参阅代码本身包含的文档,或加入官方频道提出问题。

  • 0
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值