21

游戏引擎之ECS架构实现

 2 years ago
source link: https://zhuanlan.zhihu.com/p/405741649
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

游戏引擎之ECS架构实现

单身欢迎打扰,c++小达人数学爱好者游戏引擎开发独立游戏开发

从大约5月份初始构思,中间因为找不到理想的参照,而Unity的源码也不开放,一直搁置;终于在8月找到入口,经历半个多月的努力,终于初步实现了ECS架构

该架构实现主要参考了GitHub - Ubpa/UECS: Ubpa Entity-Component-System (U ECS) in Unity3D-style

的实现,github上的c++版本的ecs实现其实不多,目前主要有两个,一个是entt,还有一个就这个版本;个人认为这个版本实现的还不错,其中用了大量c++17,20和模板技术,这一点也影响了本人的实现。UECS的实现基本上忠于Unity原来的实现,而entt的似乎并没有抽象出原型这一个概念,而是直接按照组件划分的SOA。

本人的实现整体思路借鉴了UECS,部分代码复用,代码基于c++17.

原本个人定位只用c++11,但是在游戏引擎开发过程中,发现对于模板方面,c++17做了很大的优化,而ecs架构实现十分依赖模板,因此,个人游戏引擎c++全部升级为c++17,由于20还不稳定,暂且不用。

首先复习一下ecs架构,基本就是这个图:

v2-68fb61f9bbb3c91529c91ae7c4a5019c_720w.jpg

chunk是内存块,里面存着组件,不同的组件组合成为原型,同一个原型对应着若干chunk。

其中内存布局采用的是SOA结构,其好处是可以提高cpu缓存命中,经测试大约5倍提升,同时SOA结构可以自适应AOS结构,我只需要把所有组件合并为一个大组件,则等价于AOS实现。

我的整体架构保持一致,但是相比UECS,做了几个调整:

1 静态注册

一般来说ecs有一个EntityMgr,负责管理实体,提供了这样一个接口

createEntity<Com1,Com2,Com3,...> ,大意就是创建一个entity,这个entity包含Com1,Com2,Com3...这些组件。

由于Com1,Com2,Com3都是不同的类型,这个过程中不可避免的要做hash映射,把类型 映射为 数字,在UECS的实现中,我们可以看到大量hash相关的处理以及数据结构,如small_flatmap ,small__flat__set,std::unordered_map等

为何要用这些数据结构,主要是为了性能,但是flat由于是线性实现,一般查找需要O(log(n)),而我一直在思考如何能实现常数时间的操作,甚至连hash映射时间都省去。

经过思考之后,我发现,原型在ECS架构中扮演的作用类似于 类 在普通c++编程中扮演的角色,而在普通c++编程中,通常并不需要动态生成类,也不支持这一点(强反射),游戏中的类的个数是在编译期确定的。

经过思考,我认为有多少原型,每个原型长什么样也是可以在编译期确定的。原型就是组件的集合,也就是类型。

因此,我的实现做了以下调整,预定义所有原型,类似:

using Archetype0 = Archetype<int,float,Com1>

using Archetype1 = Archetype<Com1,Com2> ....

之后将原型类型作为模板参数来构建EcsMGR

ECSMgr<Archetype0,Archetyep1,....> mgr;

这种实现看起来有些奇怪,当用户增加原型时,必须在这里修改代码;但正如我所说,原型就像类型,如果你添加一个class,也必然要修改代码。

这个方法被我称为 静态注册 (上面mgr的模板参数可以看成是注册),也就是在编译期注册所有原型。

静态注册的好处就是,所有的hash操作都不需要了,用std::tuple取代了同型数组,一切映射都是在编译器完成了,因此所有操作的运行时 时间复杂度都为O(1) .

没有hash映射,没有hash碰撞检测,也没有2分查找,以及数据结构本身带来的额外开销,同时,相关代码也就不需要了,在UECS中有很多这样的处理。

当然缺陷也是有的,就是由于用tuple或者TypeList取代了数组,遍历变的稍微有点麻烦,需要重定义一个模板函数,但是相比而言,总体来看还是利大于弊。

2 system实现

其中system的实现,如同UECS,也使用了三方库taskflow, 可以看成是一个加入了任务依赖能力的高级线程池,我认为taskflow的实现稍微有点复杂,自己也仿照实现了一个简化版本,但是因为没有时间充分测试优化,可能尚不稳定,因此,主架构中仍然使用了taskflow的几个主要接口。

在整体实现上,做了简化,侧重于核心功能,

3组件移动策略

Unity的ecs在删除组件时会发生移动,操作,其方案是从当前chunk的末尾移动到删除位置,个人做了一个优化,直接从所有chunk中的末尾chunk的末尾移动到删除位置,而不是当前chunk。

这样做带来的好处是:同一个原型下,除了最后一个chunk可能不满,所有chunk都是满的,则除了最后一个,所有chunk中的元素个数都相同从而为高效迭代chunk中的元素提供了一个良好的数据结构

其附加好处是 代码变的更加简洁了。

4 泛型扩展

在泛型编程方面,我在UECS基础上做了扩展,由于用静态注册取代了动态注册,因此泛型方面加强了,代码中大量使用了c++11 - 17的模板元编程技术,这些代码通常放在1,2个头文件里,可以复用到其他工程。我也复用了UECS的TypeList,但是做了修改,其列表只支持固定 的TypeList了,我将其扩展为支持任意变长模板。

这里给大家科普一下TypeList的概念。

TypeLits是个类模板 ,其定义如下:

template<typename ...>

struct TypeList;

其表示一个类型的列表,和tuple类似,但是区别是,TypeList声明的对象中没有实际数据。

通过TypeList,可以实现对类型的 遍历操作 等等。 我将TypeList的能力扩展为对任意模板

template<typename ...>
struct XXX;

的各种不同操作能力(XXX为任意自定义类型)

5 entity数组分裂到每个原型中

由于采用静态注册 ,Entity 有了类型,变为了TEntity<_Archetype> entity,由于一个entity表示一个对象,而对象必然有类型,在UECS或者Unity的实现中,把类型信息屏蔽了,代价是底层的hash操作。我的实现中保留了类型信息,不同的原型创建的entity虽然数据结构相同,但类型却不同。

由此可能会引入一个问题,就是没有办法把所有entity存放在一个数组里,而是只能按照不同类型存放在各自的数组。但是由于ECS框架通常自动管理entity,并不需要额外存储

在UECS的实现中,每个entity是一个句柄,指向一个数组的索引,而我的实现将整个数组按照不同的原型拆分到原型中了,这就要求使用者必须指明entity的类型是什么。

下面简单介绍下我的架构:

1 ,ECSMgr,

这是个模板,是整个ECS框架的核心,所有的信息都存在这里面。相比UECS有EntityMgr和SystemMgr,我把结构简化了,合并为一个ECSMgr,减少心智负担。

2, Chunk

结构体,表示一块固定长度的内存buffer

3, Archetype

原型模板,表示一个原型,内部维护着一堆Chunk,以及一些偏移量信息

最核心的就是中三个结构,System在这个架构里没有单独抽象,目前就是作为std::function存在,然后配合ECSMgr就可以发布任务。

由于system表示函数,而函数的执行逻辑千变万化,如果抽象,抽象的层级过低,则通用性不行,层级过高则心智成本太过,而本架构只致力于提供核心功能,因此System由使用者自由定义,本架构不做过多抽象,暂时只提供最基本的ecs的按照组件类型筛选遍历所有entity的能力。

该框架代码目前开源,可供使用,本来这个ECS架构是作为我的游戏引擎的一个模块,但是其实现除了依赖Util库之外,并没复杂依赖关系,因此,完全可以复用于非游戏领域的其他场合。源码链接如下:

该开源框架将长期维护,如果读者有问题欢迎提出您宝贵的意见!


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK