本文分析了开源项目EnTT v3.12.2的原理和实现。述说了ECS中的核心数据结构sparse set。
sparse set
结构介绍
sparse set是一个数据结构,用于极快速地对正整数进行增删改查并能够较好地利用CPU Cache。而EnTT中的Entity部分正式用正整数实现的。
sparse set由两部分组成:
packed:一个线性表,用于紧密存储所有正整数。也是真正保存所有数据的地方。sparse:一个线性表,比较稀疏,用于存储packed中整数在packed数组中的下标,主要是为了建立值和下标的映射以加快查找。
需要注意的是,packed和sparse必须是内存连续的线性表,这样才能发挥它易命中Cache的优势。
基本操作
增加
增加元素的操作如下,对于任意正整数A
- 将A无条件放在
packed数组末尾 - 得到A在
packed数组中的下标I(其实就是packed数组的长度-1) - 将I放在
sparse数组的,以A为下标的位置处
下面用一个例子说明:
| |
查询
很简单:对于任意元素A,看sparse[A]处是否有值即可。
如果有值,还可以通过sparse[A]得到A元素在packed数组中的下标。
删除
删除的话也很简单,对于任意元素A:
- 通过
sparse[A]得到A在packed中的下标I - 将
packed[A]和packed最后一位互换,记packed最后一位元素为L - 更新L在
sparse中的索引:sparse[L] = I - 置
sparse[A]为空删除其索引 - 弹出
packed末尾元素(就是之前换到末尾去的A)
迭代元素
packed数组中紧密存储着所有元素,所以直接迭代packed数组就行了(EnTT的迭代器sparse_set_iterator就是直接拿到packed数组的迭代器进行迭代)。
顺便说一句,sparse set是无序容器。
和HashMap的对比
sparse set的增删查复杂度都是O(1),而HashMap也是。但HashMap的效率总体来说不如sparse set高。因为sparse set总能在确定步数内完成操作,而HashMap因为冲突的问题,可能需要多次使用散列函数,真正的步骤是不确定的。而如果使用拉链法解决冲突,则更会导致难以命中Cache的问题。
sparse set的缺点就是只能对正整数进行操作。
源码分析
源码位于src/entity/sparse_set.hpp中。
分页的sparse数组
这个类是个模板类:
| |
Entity模板参数是EnTT中实体的类型。Allocator是内存分配器。
需要注意的是,basic_sparse_set的sparse数组不是一维数组,是二维的(用的时候其实还是视为一位数组,会将二维摊开成一维),差不多是std::vector<Entity[PageSize]>这个类型。本质上是将一位数组分为多个“页”(Page),每个页大小就是PageSize最终的页大小在src/entt/config/config.h中有定义:
| |
分页的原因应该是考虑到CPU的分页机制,当内存过大时方便CPU按照这个大小换页。也有可能是为了方便内存分配器Allocator一次分配这么多内存。
所以当你插入一个元素A的时候,他会把下标放在sparse[A / PageSize][A % PageSize]处。
成员变量和一些using
成员变量和其using如下:
| |
sparse_container_type和packed_container_type是std::vector这在意料之中,但是sparse_container_type的成员有些不明朗,是Allocator分配出的指针类型。这个类型在src/entt/entity/fwd.hpp中有说明:
| |
就是使用的标准库的allocator,旨在分配一个Entity。但实际的sparse_container_type中则是分配的alloc_traits::pointer,即Entity*,并且使用rebind_alloc将此allocator重绑定以让其分配Entity*(对std::allocator不熟悉可以看这个文章)。
某些函数简述
内部函数的话我觉得没什么好说的,毕竟算法已经说明白了,代码也就是实现的事。简单说一下我比较感兴趣的函数吧:
[[nodiscard]] auto sparse_ref(const Entity entt) const:得到sparse[entt / PageSize][entt % PageSize]元素的引用[[nodiscard]] auto sparse_ptr(const Entity entt) const:得到sparse[entt / PageSize][entt % PageSize]元素的指针。没有这个元素返回nullptr[[nodiscard]] auto &assure_at_least(const Entity entt):保证当前sparse数组可以容纳entt(会自动扩容),并返回容纳entt的那个元素。这个函数是个很好的辅助函数,因为你可以在任何插入entt的地方使用assure_at_least(entt) = entt插入entt倒sparse中,不需要进行很多if判断template<typename Compare, typename Sort = std_sort, typename... Args> void sort_n(const size_type length, Compare compare, Sort algo = Sort{}, Args &&...args):对开头的length个元素排序。EnTT是允许对sparse set排序的,这样遍历的时候会有一个顺序,在某些场景比较有用。