在 EnTT 中, 视图 和 组 是两种用于操作 实体 和 组件 的工具, 它们各自有不同的开销和性能
视图
视图是非浸入式的, 它不会改变或拥有它所观察的实体和组件, 它们是轻量级的, 不会增加额外的内存开销或修改底层存储结构
视图 有两种类型
- 编译时视图 Compile-time View
在编译时通过 静态组件类型列表 定义, 可以利用 静态优化, 因此迭代速度比 运行时视图 更快, 适用于组件类型已知且固定的场景, 效率优先 - 运行时视图 Runtime View
在运行时通过 数值类型标识符 动态构建, 更灵活, 但因为缺乏编译时优化, 迭代速度稍慢
单类型视图 Single Type Views
单类型视图 是指视图只包括单一类型的组件, 这可以直接遍历该组件池的 紧密数组 (实际上是分页存储), 因此迭代速度最快, 并且可以精确获取视图 将要 返回的实体数量
auto single = registry.view<position>();
for(auto entity : single) {
auto &pos = single.get<position>(entity);
// 使用 `pos` 进行操作
}多类型视图 Multi Type Views
用于迭代同时拥有指定 多个组件 的实体, 在构造时会自动选择组件数量最少的集合 (即最小的组件池), 该视图仅提供 将要 返回实体数量的估算值, 而不是精确值, 支持组件过滤, 通过 entt::exclude 排除指定组件类型的实体
auto multi = registry.view<position, velocity>();
for(auto entity : multi) {
auto &pos = multi.get<position>(entity);
auto &vel = multi.get<velocity>(entity);
}
// 或者获取多个组件:
for(auto entity : multi) {
auto [pos, vel] = multi.get<position, velocity>(entity);
}
// 过滤示例
auto view = registry.view<position, velocity>(entt::exclude<renderable>());
// 通过回调迭代
registry.view<position, velocity>().each([](auto entity, auto &pos, auto &vel) {
// 直接访问实体和组件
});构造视图非常便宜, 需要时可以直接从 注册表 创建, 因此不推荐将视图实例存储为成员变量或全局变量, 当视图从 只读注册表 const registry 创建时, 如果相关组件尚未初始化, 可能导致视图包含待处理的引用, 如果组件是空的 (即不包含数据), 视图迭代时不会返回它们
当你通过一个
const registry创建视图时, EnTT 会尝试根据请求的组件类型在注册表中查找相关的存储, 但由于const registry的特性, 无法写入或初始化新存储, 如果某些组件类型尚未初始化, 视图仍然会被创建, 但其内部引用的存储仍是 “待处理的” (即它引用了一个不存在或未初始化的存储)
在迭代视图时, 优先使用视图的 get 函数, 而非直接通过注册表访问组件, 这样可以利用视图的优化
视图的重用和存储替换
视图支持 延迟初始化 和 存储替换, 这使得视图可以 一次创建, 多次重用
延迟初始化
视图的 延迟初始化 允许用户在构造之后逐步为其注入 存储, 这种机制使视图即使在部分初始化时, 也可以工作,但状态上会标记为未完全初始化
当视图未完全初始化时, 可以通过转换为布尔值来检查其状态
if (!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 [entity, elem] : view) {
// 遍历 "the_good" 集合中的实体
}
view.storage(the_bad);
for (auto [entity, elem] : view) {
// 遍历 "the_bad" 集合中的实体
}如果某个 get 存储缺失, 视图会将其视为一个空存储, 因此视图将自动为空
entt::view<entt::get_t<position, velocity>> view{};
// 如果 velocity 的存储未初始化,则视图为空如果某个 exclude 存储缺失, 视图会将其忽略, 等同于该过滤条件不存在
auto view = registry.view<position>(entt::exclude<velocity>());
// 如果 velocity 的存储未初始化,过滤条件会被忽略排除视图
在 EnTT 中, 没有专门的 排除视图 Exclude-only view 的概念, 但可以通过使用正确的组件存储和视图组合, 轻松实现相同的功能
排除视图的目标是返回 不符合某些条件 的实体, 例如没有特定 组件 的 实体, 实现方式是通过 排除组件 的方式 entt::exclude<T> 在普通视图中进行筛选
在 EnTT 中, 每个实体的 存储 entt::entity 都默认存在, 并且可以用于视图创建, 这使得可以通过如下代码实现 “排除视图”
// 单组件排除
auto view = registry.view<entt::entity>(entt::exclude<my_type>);
// 多组件排除
auto view = registry.view<entt::entity>(entt::exclude<my_type, other_type>);
entt::view<entt::entity> 创建了一个包含所有实体的基本视图, entt::exclude<my_type> 从视图中排除了包含 my_type 组件的所有实体, 如此一来视图中将只包含没有 my_type 组件的实体, 无论它们是否拥有其他组件
#include <entt/entt.hpp>
#include <iostream>
struct my_type {};
struct other_type {};
int main() {
entt::registry registry;
// 创建实体
auto entity1 = registry.create();
registry.emplace<my_type>(entity1);
auto entity2 = registry.create();
registry.emplace<other_type>(entity2);
auto entity3 = registry.create();
// 创建排除视图:排除拥有 my_type 组件的实体
auto view = registry.view<entt::entity>(entt::exclude<my_type>);
for (auto entity : view) {
std::cout << "Entity: " << static_cast<uint32_t>(entity) << " does not have my_type" << std::endl;
}
return 0;
}视图组合 View Pack
在 EnTT 中, 视图可以通过组合生成更复杂和更具体的查询逻辑, 视图组合的结果仍然是一个视图 (通常是一个多组件视图), 这使得操作实体和组件时更加灵活
多个视图可以通过 管道操作符 | 组合在一起, 生成一个新的视图, 新视图允许同时访问多个组件的数据, 组合后的视图本质上是一个 多组件视图, 结果视图中, 组件的顺序取决于组合操作的顺序, 视图组合可以以链式方式进行, 长度不受限制
auto view = registry.view<position>();
auto other = registry.view<velocity>();
auto pack = view | other; // 组合 position 和 velocity 视图
for (auto [entity, pos, vel] : pack.each()) {
// entity: 实体 ID
// pos: position 组件
// vel: velocity 组件
}如果视图是只读的 (例如从 const registry 创建), 组合后的视图仍然是只读的, 组件的常量性会被保留
迭代顺序 Iteration Order
在 EnTT 中, 视图的迭代顺序由组件存储池的结构和配置决定
默认情况下, 视图会基于组件存储中 实体数量最少 的池进行迭代, 这优化了迭代性能, 因为可以减少无效实体的访问
auto view = registry.view<position, velocity>();
for (auto entity : view)
{
// 默认按照 velocity 存储的顺序迭代,因为 velocity 的数量较少
}迭代结果的顺序取决于选定池 (数量最少的那个) 的内部排列方式, 其他组件存储会根据该池的迭代顺序进行数据匹配
通过 use 函数, 可以 强制 指定视图的迭代顺序, 以某个组件的存储顺序为准
auto view = registry.view<position, velocity>();
view.use<position>(); // 强制按 position 的顺序迭代
for (auto entity : view) {
// 现在按照 position 的顺序迭代
}对于 单类型视图, 可以使用 反向迭代器 reverse iterator 实现逆序迭代
auto view = registry.view<position>();
for (auto it = view.rbegin(), last = view.rend(); it != last; ++it) {
auto entity = *it;
auto &pos = view.get<position>(entity);
// 逆序访问 position
}多组件视图 不支持直接的反向迭代器, 如果需要实现多组件视图的逆序迭代, 则需手动处理逻辑, 例如选择一个 单类型视图 view<position>,按逆序迭代, 并在每次迭代时访问其他组件
运行时视图 Runtime Views
运行时视图是 EnTT 提供的一种灵活视图类型, 用于在运行时动态构建实体迭代逻辑, 特别是在组件类型在编译时未知的场景中非常有用, 运行时视图的构建代价非常低, 可以随用随建, 不推荐将运行时视图存储为成员变量或全局变量, 因为它们的设计目标是即时使用
运行时视图会筛选出包含所有指定组件的实体, 默认按组件存储中实体数量最少的存储进行迭代, 以优化性能, 与多组件视图不同, 运行时视图不提供 get 成员函数来直接访问组件, 必须通过 注册表 访问组件数据
运行时视图通过 entt::runtime_view 动态创建, 并通过 iterate 添加组件存储
entt::runtime_view view{};
view.iterate(registry.storage<position>()).iterate(registry.storage<velocity>());运行时视图提供两种方式迭代实体
- 范围 for 循环
for (auto entity : view) {
// 使用实体 ID 操作
}- each 成员函数
view.each([](auto entity) {
// 在回调中处理实体
});这两种方法的性能完全一致
运行时视图也支持通过 exclude 添加排除条件
entt::runtime_view view{};
view.iterate(registry.storage<position>()).exclude(registry.storage<velocity>());
for (auto entity : view) {
// 只迭代拥有 position 且没有 velocity 的实体
}组 Group
组 是一种优化的 多组件 迭代机制, 提供比 多组件视图 更快的方案, 它需要组件的所有权, 这将对关联的 组件池 施加一些约束, 组专注于特定的使用场景, 而不是尝试全局优化所有可能的组合
组 首次创建时会进行初始化, 也就强制重新排列组内包含的组件的池, 以符合组所需要的迭代顺序, 这在注册表非空时可能较为昂贵, 如果在注册表为空时创建组, 可以显著降低初始化成本
组需要监控相关池的变化, 并在必要时重新排列数据, 这会对组件的创建和销毁过程产生一定影响
使用范围 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, rend] = group.get(entity); // 同时访问多个组件
}在迭代时, 尽量使用组自身的 get 方法访问组件, 而不是通过注册表访问, 以获得更好的性能
基于回调函数的迭代
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()) {
// 同时访问实体和组件
}完全拥有组 Full-owning Groups
完全拥有组 是 EnTT 中用于迭代多个组件的最快工具, 它的核心特点是直接迭代组件, 无需任何 间接访问, 这是因为
- 组件在内存中是紧凑排列的, 所有组件池的排列顺序完全一致
- 没有跳跃或分支操作, 性能类似于访问一系列已经排序的数组
完全拥有组通过以下代码创建
auto group = registry.group<position, velocity>();组在创建后将拥有模板参数中指定的所有组件 (position 和 velocity), 它会根据需要排列这些组件的池以满足迭代要求
可以通过排除特定组件来过滤实体
auto group = registry.group<position, velocity>({}, entt::exclude<renderable>);这将创建一个仅包含 position 和 velocity 的组, 同时排除拥有 renderable 组件的实体
一旦创建 完全拥有组, 就不允许再对它所拥有的组件进行 排序, 但是, 可以通过 组 的 sort 方法对 完全拥有组 进行排序, 这会同时影响所有相关实例
group.sort([](const position &lhs, const position &rhs) {
return lhs.x < rhs.x; // 根据 `position.x` 排序
});由于完全拥有组直接迭代组件, 没有间接访问, 因此可以达到最佳性能, 内存中的组件顺序一致, 避免了缓存不命中和分支跳转带来的开销
部分拥有组 Partial-owning Groups
部分拥有组是介于完全拥有组和视图之间的一种优化工具, 它对自己拥有的组件进行高效迭代, 但通过间接访问其他组所拥有的组件, 这种设计在性能和灵活性之间做出了权衡, 虽然没有完全拥有组快, 但比视图快得多 (特别是在仅需访问 1-2 个非拥有组件时), 即使在最糟糕的情况下, 部分拥有组的性能也不会比视图差
基本创建方式
auto group = registry.group<position>(entt::get<velocity>);该组拥有 position 组件, 但 velocity 组件通过间接访问获取, 创建时会根据需要排列组件池
可以通过排除特定组件来进一步筛选实体
auto group = registry.group<position>(entt::get<velocity>, entt::exclude<renderable>);这个组包含拥有 position 和 velocity 的实体, 同时排除了拥有 renderable 的实体
部分拥有组 模板参数中指定的组件 (如 position) 会被组拥有, 使用 entt::get 提供的组件 (如 velocity) 并不会转移所有权, 仍然由其他组或注册表管理, 同样需要通过 sort 方法进行排序
group.sort([](const position &lhs, const position &rhs) {
return lhs.x < rhs.x; // 按 `position.x` 排序
});对拥有的组件 (如 position) 的访问和完全拥有组一样高效, 对非拥有组件 (如 velocity) 的访问需要间接操作, 性能稍逊
非拥有组 Non-owning Groups
非拥有组是一种性能介于视图和部分拥有组之间的工具, 提供了一种在不改变组件所有权的情况下优化迭代的手段, 非拥有组不会拥有任何组件类型, 所有组件的管理依然由 注册表 或其他组负责, 适合那些无法转移组件所有权的场景
非拥有组 性能通常比视图更快, 但比部分拥有组和完全拥有组慢, 由于需要额外的自定义数据结构来实现功能, 非拥有组会增加一定的内存消耗, 通常应尽量避免使用非拥有组,除非有明确需求
创建方式
auto group = registry.group<>(entt::get<position, velocity>);非拥有组通过 entt::get 指定所需的组件类型
同样可以通过排除特定组件来筛选实体
auto group = registry.group<>(entt::get<position, velocity>, entt::exclude<renderable>);仅包含拥有 position 和 velocity 的实体, 同时排除拥有 renderable 的实体
const 和 non-const 类型
EnTT 的 视图 和 组 在设计上充分利用了 const 修饰符的语义, 确保了在操作 组件 时的编译时安全性
EnTT 的 view 以及 group 方法根据是否涉及 const 类型提供了两种重载
常量 const
当对 const registry 调用 view (或者 group) 或在模板参数中明确指定 const 类型时, 所有组件类型都视为 只读
entt::view<const position, const velocity> view = std::as_const(registry).view<const position, const velocity>();在这个例子中, 通过视图访问的 position 和 velocity 组件都是只读的, 无法修改
非常量 non-const
使用非 const registry 调用 view (或者 group), 并至少有一个组件类型是非 const 的, 则非 const 类型的组件可以 读写, const 类型的组件依然是 只读 的
entt::view<position, const velocity> view = registry.view<position, const velocity>();这里 position 是可变的, 因此支持 读写访问, velocity 是只读的, 因此仅支持 读取访问
each 方法也会传递 const 修饰符
view.each([](auto entity, position &pos, const velocity &vel) {
// 可以修改 `pos`,但 `vel` 是只读的
pos.x += vel.dx;
});当然, 即使 position &pos 是可变的, 调用者仍然可以通过语言规则将其引用为 const
const position &cpos = pos;访问所有实体
EnTT 提供了多种工具来操作 实体 和 组件, 其中 视图 和 组 是用来处理特定实体子集的高效方法, 然而, 有时我们需要操作所有在用的 实体, 无论它们是否有组件绑定, 在这种情况下, 可以直接访问实体存储来实现遍历
要遍历注册表中所有仍然存在的实体 (即使它们没有绑定任何组件), 可以通过以下代码实现
for(auto entity: registry.view<entt::entity>())
{
// 对每个实体执行操作
}通常来说, 当目标是 迭代拥有特定组件的实体 时, 建议使用 视图 或 组, 它们在底层实现中针对特定组件集进行了优化, 速度比手动过滤快得多, 但是手动访问所有实体也在一些场景很有用
- 清理孤立实体 (没有绑定任何组件的实体)
- 初始化编辑器 (例如场景编辑器需要展示所有实体)
for(auto entity: registry.view<entt::entity>())
{
if(registry.orphan(entity)) { // 判断实体是否为孤立实体
registry.release(entity); // 释放实体,回收其标识符
}
}一般而言, 迭代所有实体会导致性能不佳, 不应频繁执行此操作以避免性能下降的风险
操作限制
EnTT 框架在迭代期间提供了灵活性, 可以创建、销毁和修改实体及组件, 但需要注意一些限制和规则, 以避免未定义行为
允许的操作
创建实体和组件
在迭代期间, 可以安全地创建新的 实体 和 组件, 创建操作 不会使现有的引用或迭代器失效
registry.view<position>().each([&](const auto entity, auto &pos)
{
registry.emplace<position>(registry.create(), 0., 0.); // 创建新的组件
pos.x = 0.; // 现有引用仍然有效
});销毁当前实体或移除其组件
在迭代期间, 可以 销毁 当前正在迭代的 实体 或移除它的 组件, 注意, 这可能会使该实体的组件 引用失效
但是, 如果迭代的主导类型启用了 指针稳定性, 则可以安全地销毁实体或组件, 而不会使引用失效, 添加新组件可能导致新的实体被迭代, 但不会影响现有实体的引用
不允许的操作
销毁非当前实体或移除其组件
在迭代期间, 销毁非当前正在迭代的实体, 或移除它们的组件, 都是不被允许的, 这会导致 未定义行为, 可能使迭代器失效, 除非启用了指针稳定性
反向迭代期间添加或移除元素
在 反向迭代 时, 不允许添加或移除实体或组件, 这会导致 未定义行为, 在任何情况下都不可以
修改非稳定类型
对于 不提供指针稳定性 的类型, 如果 实体 被修改或销毁, 并且它不是迭代器当前返回的 实体 也不是新创建的 实体, 则迭代器也会失效, 并且行为未定义, 要解决这个问题, 可能的方法是
- 将要删除的实体和组件存储起来, 并在迭代结束时执行操作
- 使用适当的 标签组件 标记实体和组件, 表明它们必须被清除, 然后执行第二次迭代来逐一清理它们
组的特殊限制
EnTT 提供了 组 作为比 视图 更高效的选项, 但随之而来的约束也更严格, 尤其是在某些特殊情况下, 这些限制主要与组中组件的迭代方式和组件池的内部管理有关
组直接拥有组件的 池, 并对其进行内部管理和优化, 这种优化确保了在组中迭代时的高性能, 但也导致了一些潜在的限制, 尤其是在组件的动态添加和组外迭代时
组内操作
在 组中 迭代时创建新的组件是完全允许的, 就像视图中一样, 销毁组件或实体同样遵循一般规则, 不会产生额外的限制
组外操作
当组件在组外迭代时, 会出现以下特定限制
- 不可以迭代组中组件并动态添加其所需组件
如果你正在使用一个视图迭代属于某组的组件, 并且动态向某实体添加了 所有 加入组所需的组件, 这种操作可能会使迭代器失效, 除非有额外的 自由类型 来主导迭代顺序