一个小巧的 ECS 开源框架 EntityX

November 15, 2021

ECS 框架相信大部分同学都听说过,其中包含 Entity-Component-System 三个核心概念:

  • Entity:多个 Component 的组合,类比 OOP 中的对象
  • Component:仅包含数据,类比 OOP 中的对象属性
  • System:仅包含逻辑,类比 OOP 中的对象方法

相比面向对象编程,ECS 一个显著的区别在于数据的存储结构,如下图:

在 OOP 中,同一个对象的所有数据(对象属性)存放在一起,而 ECS 中,同一类数据(Component)存放在一起,Entity 记录其所拥有的 Component 的 index,我们知道 CPU 在内存访问时会有缓存机制,连续内存的读写效率要远高于不连续内存,因此在遍历多个对象的同一个属性时,ECS 架构更具性能优势。同时也可以看到,ECS 的 Component 天然支持内存池实现,能有效降低系统的内存抖动,间接提升性能。

EntityX

EntityX 是一个小巧的 ECS 开源框架 https://github.com/alecthomas/entityx ,代码比较精简,这里简单梳理下其中几个核心类的实现:

1、Entity

Entity 本质只是一个 id,这里还增加了 version,version 和 index 组合起来用一个 uint64_t 来表示。这里 version 主要用来实现 id 的复用,即 index + version 唯一确定一个 Entity,在 EntityManager 中,主要由 3 个成员来进行 Entity 的管理:

uint32_t index_counter_ = 0;
std::vector<uint32_t> entity_version_;
std::vector<uint32_t> free_list_;

entity_version_ 中存储所有 Entity 的 version,初始为 1,free_list_ 记录的是已经被失效(destroy) 的 Entity 的 index,Entity 的 create/destroy 逻辑如下:

  • create:如果 free_list_ 不为空,pop 一个,返回该 index 和 entity_version_ 中对应的 version,否则在 entity_version_ 尾部新增 (index_counter_++);
  • destroy:将 index 加入 free_list_ , 并将 entity_version_ 中对应的 version++

2、Component

如前面所述,Component 需要尽量只包含数据而不包含逻辑,EntityX 使用了内存池来存储 Component,Component 的基类 BaseComponent 对 delete 进行了禁用:

// NOTE: Component memory is *always* managed by the EntityManager.
// Use Entity::destroy() instead.
void operator delete(void *p) { fail(); }
void operator delete[](void *p) { fail(); }

Component 基类只有一个 size_t 类型的 family 静态成员,每新增一个 Component 的子类,family++,family 可理解为 Component 的类型标识。

内存池

接下来看 Component 的内存池实现( Pool.h/Pool.cc ),其结构如下图:

整个内存池由多个内存块组成,blocks_ 为内存块指针的列表,chunk_size_ 表示每个内存块容纳多少个元素,EntityX 为每类 Component 创建一个 Pool:

std::vector<BasePool*> component_pools_;

其下标为每类 Component 的 family,具体每个 Component 实例的存储位置则取决于所属 Entity 的 index:将所有内存块看作一个整体,取 index 下标,如上图 index 为 6 的 Component 存储在第二个内存块的第二个元素位置。

映射关系

EntityX 使用 bitset 来记录 Entity 与 Component 的映射关系:

typedef std::bitset<entityx::MAX_COMPONENTS> ComponentMask;
std::vector<ComponentMask> entity_component_mask_;

其中 entity_component_mask_ 的下标为 Entity 的 index,来看一下给 Entity 添加 Component 的实现:

const BaseComponent::Family family = component_family<C>();

// Placement new into the component pool.
Pool<C> *pool = accomodate_component<C>();
::new(pool->get(id.index())) C(std::forward<Args>(args) ...);

// Set the bit for this component.
entity_component_mask_[id.index()].set(family);

// Create and return handle.
ComponentHandle<C> component(this, id);
return component;

首先获取 Component 的类别 family,然后通过 placement new 在内存池中构造该 Component 实例,再记录 Entity 和 Component 的从属关系,即完成整个过程。这里的 ComponentHandle 是具体某个 Component 实例 的 wrapper,其本身保存有该 Component 对应 Entity 的 index,对外提供各类 Component 操作。

迭代器

EntityManager 提供了多种 Entity 的迭代访问工具类:

  • BaseView:遍历所有 Entity
  • TypedView:遍历拥有指定 Component 列表的 Entity
  • UnpackingView:遍历拥有指定 Component 列表的 Entity,并返回对应的 ComponentHandle

各类 View 都基于迭代器 ViewIterator 实现,其基本的迭代逻辑就是遍历所有 Entity,并检查 component mask 是否命中,因此如果用这种方式来获取特定 Component 的 Entity,效率并不高,后面有介绍我们的优化方案。

3、System

与 Component 对应,System 尽量只包含逻辑而不包含数据,EntityX 提供的 System 基类只有一个 size_t 类型的 family 静态成员,每新增一个 System 子类,family++,System 定义了 configure() 和 update() 两个主要的虚方法,可以分别理解为初始化和逻辑执行,SystemManager 则是所有 System 的管理类,逻辑相对简单。

4、Event

除了 Entity、Component、System,框架还提供了一套事件系统,用于 System 之间的通信,事件系统主要包括 Event、Receiver、EventManager 几个基础类:

如图,Event 主要包含一个 size_t 类型的 family 类型标识,Receiver 则持有所有已经订阅的 Signal 的弱引用,EventManager 中的 handlers_ 则是保存了所有 Event 对应的 Signal 对象,这里的 Signal 源于另一个开源库 SimpleSignal (详见 https://testbit.eu/2013/cpp11-signal-system-performance ),其实现了一套事件调用链机制,如下图:

SimpleSignal 内部维护一个环形的双链表,链表节点持有注册进来的事件回调,上层通过 connect 函数注册事件回调,本质上是向链表中添加一个节点,反之 disconnect 则是移除一个节点,而事件的发送 emit 函数,则是遍历链表,逐个调用节点的回调函数,SimpleSignal 还支持不同的终止条件,根据节点的回调结果决定是否继续回调下一个节点,不过在 EntityX 中,只使用了最简单的 void 类型 Collector:

typedef Simple::Signal<void (const void*)> EventSignal;

即不考虑节点的回调返回值,emit 会遍历链表的所有节点。

EntityX 中内置了几个事件,如 Entity 的创建&销毁,Component 的添加&删除等,通常情况下,System 可以定义为事件 Receiver,在 configure 中进行事件的订阅。

优化

EntityX 提供了一个精简的 ECS 架构基础框架,经过前面的实现机制分析,可以看到其中存在一些可以优化的点:

  • Entity 查询性能
    前面有提到,EntityManager 提供了多种 Entity 的迭代器,但都基于对所有 Entity 的遍历,其时间复杂度为 O(n),当 Entity 数量较多,查询较频繁时性能不高。

  • Component Pool 内存利用率
    由 Pool 的实现可以看出,当新增 Component 实例时,对应的 Pool 的大小会扩充到最大 Entity 数,虽然这种实现下 Component 的查询性能高,然而并不是所有 Entity 都会包含该类 Component,当 Entity 较多而 Component 较少的情况下,Component Pool 会存在较多的空间浪费。

  • 代码膨胀
    EntityX 大量地使用了模版类以实现类型安全,同时代码比较精简,然而当模版实例化类型比较多时,会生成较多的实例化代码,从而导致编译出来的二进制比较大,并且也会降低编译速度。