EnTT: 存储 Storage

整理 EnTT storage 的组件特征、空类型优化、void storage、实体存储和指针稳定性。

组件存储池 是特殊版本的 稀疏集, 每个池都含有该池所代表组件类型的所有实例以及拥有该组件实例的对应 实体

稀疏数组 被分页以防内存浪费, 组件的 紧密数组 也被分页, 这样在添加新组件时可以保持一定的 指针稳定性 (但是无法完全保证稳定, 完全的指针稳定性需要通过 墓碑 机制来保持), 但实体的 紧凑数组 则没有采用分页机制, 这是因为实体的 紧凑数组 通常用于表示一组固定的, 线性分配的实体 ID, 这种情况下无需分页来维护指针稳定性

所有池都会自动重新排列它们所拥有的项目以保证其内部数组紧密的排列, 如此最大化性能, 除非该池启用了 指针稳定性

组件特征 Component traits

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

库的各个不同部分使用该类来访问组件属性, 可以用任意类型作为组件类型, 只要该类型的 component_traits 特化实现了所有所需的功能

此类的 非特化版本 包含以下成员

  • in_place_delete: 如果 Type 存在 Type::in_place_delete 成员则使用它, 如果不存在则对于不可移动的类型是 true, 否则为 false
  • page_size: 如果 Type 存在 Type::page_size 成员则使用它, 如果不存在则对于非空类型是 ENTT_PACKED_PAGE, 否则为 0

其中 Type 是任何组件类型, 通过为 component_traits 特化, 或者在组件定义中添加感兴趣的属性, 用户可以定制组件行为

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

在这里, transform 组件通过定义静态成员变量 in_place_delete, 指示该组件支持 就地删除 in-place delete, component_traits 会自动提取这个属性, 而无需用户显式特化

加上 component_traitssfinae 友好的, 如果没有提供相应属性, 则会使用默认行为, 不会导致编译错误

空类型优化

一个类型 T 是空类型, 如果 std::is_empty_v<T> 返回 true, 它们也是可以进行 空基类优化 EBO 的类型

EnTT 使用特殊的方式处理这种类型, 同时在性能和内存用量两方面进行优化

当一个 组件 被检测为 空类型 时, 默认不会为该类型分配实例, 也就是说这种组件的存储空间完全被优化掉, 只会记录哪些实体实际附加了该组件

struct EmptyComponent {}; // 空类型
entity.assign<EmptyComponent>(); // 附加该组件的实体会被记录

也就是只记录哪些实体附加了该组件, 但不会创建 EmptyComponent 的实际实例

空类型组件也不会被视图或组返回, 任何需要返回组件实例的操作 (如 each) 都无法获取空类型的具体对象

registry.view<EmptyComponent>().each([](auto entity, auto &comp) {
    // comp 不存在,因为空类型没有实例化
});

这种做法有很大优势, 不需要为空类型分配存储空间, 无论有多少实体附加此组件, 内存开销都固定, 并且迭代更高效, 只需处理附加了此组件的实体, 不需加载组件实例, 除了需要返回组件实例的功能 (直接访问组件实例、传递引用等) 外, 其他功能都不受影响, 例如视图迭代, 实体操作等仍然正常

这种优化默认开启, 有两种方式来禁用它

  • 全局禁用: ENTT_NO_ETO
    定义 ENTT_NO_ETO 后,框架会像普通类型一样对待空类型。每个附加了空类型的实体都会有对应的实例,即使它为空
  • 选择性禁用: 通过 component_traits 设置分页大小 (page_size) 可以单独为某个空类型组件禁用优化
    template<>
    struct component_traits<EmptyComponent> 
    {
        static constexpr std::size_t page_size = 128; // 禁用优化,分配存储空间
    };

空存储 Void Storage

一个 空存储 (entt::storage<void>entt::basic_storage<Type, void>), 是一个功能齐全的存储类型, 它用于创造一个不关联任何类型组件的池, 与传统的组件存储不同, void storage 不存储任何具体类型的实例, 它提供了一个 轻量化标记机制, 可以管理哪些实体属于某个池, 可用作标记型组件的替代方案, 但不需要实际的组件类型定义 (空类型组件也会进行存储优化, 但是仍然需要定义该空类型组件)

当启用空类型的优化时, 空类型存储 storage<empty_type>void storage 技术上非常相似

  • 没有实际的组件实例: 只记录哪些实体属于该存储池
  • 禁用分页和指针稳定性: 因为不需要分配具体的存储空间, 也不需要考虑组件的指针稳定性

它们直接的区别是, void storage 是更通用的设计, 不依赖组件类型, 因此无需为标记目的定义一个 空类型, 而 空类型 存储依赖于组件类型的定义, 无法脱离具体的 Type 存在

当然, 也可以直接使用 稀疏集, 但 void storage 更强大

  • 功能齐全
    void storage 提供了其他存储类型的所有功能, 例如与视图和组的互操作性, 这使得它能直接用于 viewgroup, 而 稀疏集 通常需要额外的逻辑支持
  • 框架一致性
    在 EnTT 中, void storage 是一种完全与其他存储类型兼容的设计, 可以与 注册表 registry 直接集成
#include <entt/entt.hpp>
 
int main() {
    entt::registry registry;
 
    // 创建 void storage 并附加实体
    auto &pool = registry.storage<void>();
    auto entity = registry.create();
    pool.emplace(entity);
 
    // 检查实体是否在 void storage 中
    if (pool.contains(entity)) {
        std::cout << "Entity is marked!" << std::endl;
    }
 
    // 使用视图迭代标记的实体
    registry.view<void>().each([](auto entity) {
        std::cout << "Marked entity: " << entt::to_integral(entity) << std::endl;
    });
 
    return 0;
}

实体存储 Entity storage

实体存储 可以看作是一种特殊类型的 组件存储, 其存储的 “组件类型” 与 实体类型 一致, 也就是 entt::storage<entt::entity>entt::basic_storage<Type, Type>

对于这种类型的池, EnTT 存在一种特殊的特化, 这使得存储 实体 的池和存储普通 组件 的池具有不同的行为

  • 实体从不真正删除
    它们只是移出正在使用的实体列表, 并且它们的版本会被自动更新

  • emplaceinsert 的含义略有不同
    在池中存储的是 实体 而不是 组件 时, 当调用 emplaceinsert

    • 如果没有之前回收的标识符可用, 会生成新的实体标识符
    • 如果有之前回收的标识符可以重用, 则会使用这些标识符

    这与组件存储中的行为不同, 在组件存储中, 这些方法是为已有实体分配组件

  • each 仅迭代当前被使用的实体
    会跳过那些已标记为回收的实体, 要迭代所有实体则需要访问底层的 稀疏集

这个存储虽然是特化的, 但是仍然保证了可以在其他需要组的地方无缝使用, 例如视图和组

唯一性

在注册表中, 实体存储 的行为和其他 组件存储 一致, 可以通过 storage 函数访问实体存储, 可以向其中添加 mixin, 也可以将其作为视图的一部分

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

也有一些不同之处, 无法创建多个实体存储, 这是出于设计的需要, 因为注册表只有一个实体存储来管理所有实体, 即使通过 storage函数传递不同的标识符, 这些标识符都会被忽略

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

实体存储是注册表的核心部分, 因此它没有名称

当用户通过以下代码迭代注册表中的所有存储时

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

实体存储 不会被返回, 这种行为有助于简化许多任务, 例如复制实体, 并且完全符合 实体存储 没有名称标识符的事实

指针稳定性

EnTT 中存储的 紧密数组 packed array 默认是 分页 paged 的, 这避免了因 重新分配 reallocate 导致的引用失效问题, 当存储空间不足时, 分页设计允许分配新的页, 而不是对整个数组进行重新分配, 从而保证了现有指针的稳定性

虽然分页数组提供了插入和扩展时的稳定性, 但在 删除操作 中仍需要额外的处理以维持指针稳定性, EnTT 提供了一种稳定删除方法, 通过在删除组件时创建 墓碑 来保持其他元素的位置不变, 而不是尝试填补由删除操作产生的空洞, 但是这种特性需要手动启用

默认情况下, EnTT 更倾向于 存储紧凑性, 即通过重新排列存储位置来减少空间浪费, 存储紧凑性对性能有利, 但会破坏指针稳定性

就地删除 In-place delete

在 EnTT 中, 原地删除 是一个为用户提供完全稳定指针存储的功能, 这种存储模式专注于解决组件删除时指针稳定性的问题, 并自动适配相关的视图和分组行为

通过对 component_traits 类进行特化, 或在组件定义中添加所需的属性, 可以为组件启用 原地删除 功能, 使用原地删除时, 组件的存储空间不会移动, 位置保持完全稳定

分组行为

分组与稳定存储不兼容, 使用稳定存储的组件无法参与 分组 groups, 因为分组的设计需要紧凑存储以优化内存访问和迭代性能, 如果尝试将稳定存储的组件加入 分组, 编译会直接失败, 避免潜在的运行时问题

视图行为

  • 多类型视图
    多类型视图 multi-type views运行时视图 runtime views 对存储策略透明, 即使存储使用了稳定删除, 这些视图仍然按照正常方式工作, 无需额外调整
  • 单类型视图
    对于启用了稳定存储的组件, *单类型视图 single-type views 会退化为多类型视图的接口, 只有 size_hint 可用

即使启用了稳定存储的视图, 也永远不会返回墓碑, 所以无需费心检测并跳过它们, 不存在的组件同样不会被视图返回, 避免了潜在的未定义行为

层次结构 Hierarchies

在 EnTT 中, 层次结构 hierarchies 并没有被直接作为内置功能提供, 主要原因是这样的设计会隐藏实现的开销, 并可能导致不可控的性能问题, 但是 EnTT 提供了灵活的工具, 用户可以根据实际需求自行实现层次结构的管理

  • 基于关系的组件
    使用关系组件来明确表示实体之间的父子关系
    struct relationship {
        std::size_t children = 0;     // 子节点数量
        entt::entity first = entt::null; // 第一个子节点
        entt::entity prev = entt::null;  // 上一个兄弟节点
        entt::entity next = entt::null;  // 下一个兄弟节点
        entt::entity parent = entt::null; // 父节点
    };
  • 直接指针引用 如果稳定指针启用 in_place_delete = true, 可以直接通过指针引用实现层次结构
    struct transform {
        static constexpr auto in_place_delete = true;
 
        transform *parent = nullptr;   // 指向父节点
        std::vector<transform *> children; // 子节点列表
        // ... 其他成员变量 ...
    };

启用 稳定指针存储后, 即使组件被删除, 其它组件的内存位置也不会受到影响, 对于层次结构来说, 稳定的存储位置可以避免层次关系因删除操作而失效

事实上, 如果某种类型的组件主要以 随机顺序根据层次 关系进行访问, 则使用 直接指针 有许多优点

EnTT 的存储分配方式通常使得在接近时间点创建的实体存储在内存中的位置相邻 (局部化), 这种特性提升了 随机访问层次遍历 时的缓存命中率, 显著优化性能