github地址:https://github.com/yangrc1234/Resecs
在做大作业的时候自己实现了一个简单的ECS,起了个名字叫Resecs。
这里提一下一些实现的细节,作为回顾。
用到最多的是C++11的可变参数模板的feature。多亏了它,很多想法可以用很少的代码实现。
最后用这个ecs系统作为逻辑层,加上之前做的openGL练习,拼拼凑凑做了个贪吃蛇出来,当作业交了
Components存储
在Resecs中,Component的存储由World对象来管理。
每当World被实例化,它就会为每种Component去申请一整块连续的内存,可容纳数量等于Entity个数上限。
连续一整块内存的好处,就是减少cache miss,提高性能。(虽然做一个贪吃蛇并不需要什么性能)
每个Component内存块中,相同的下标的Component组合起来,表示一个Entity。
在Resecs中,所有自定义的Component都要继承自Component类,这个类仅有一个字段,actived,表示该Component是否被激活。
只有当一个Component的actived为true时,才表示这个Entity的确拥有这个Component。
这里似乎是有优化空间,我们把actived从每个Component里提出来,用一个bitset做存储。这样可以得到一些额外的内存。
Entity表示
在实现中,我们用2个整数去唯一表示一个Entity(唯一的意思是,删除了这个Entity,这个Entity就找不回来了,不会因为下标相同又活过来),一个是index,另一个是generation,两个数合起来我们称为EntityID(见EntityID.hpp)。其中index表示它在内存中的位置,generation需要更多解释。
在实现这个系统的过程中,有一个需求是,当我们手里拿着一个EntityID的时候,我们需要知道它是不是已经被摧毁。
方案一,我们仅保存一个index。然后world里保存一个alive数组,alive[index] == true,表示这个Entity活着。
这个方法,乍看上去很美好,实际上这个方法要求一个index被使用后就不能再次使用了,否则我们在一个系统中摧毁一个Entity,再重建这个Entity,此时alive[index]==true成立,但是该Entity已经不再是原来那个Entity了。
方案二,我们在EntityID中增加一个字段generation,world里也有一个generation数组,默认为全0。当我们创建一个Entity时,不仅把alive[index] = true,同时将generation[index]赋值给被创建的EntityID中去。
同时,在World摧毁一个Entity的时候,我们需要把被摧毁的Entity对应的generation的数字增加1。
当alive[index] == true,并且generation[index] == generation的时候,我们才认为这个Entity活着。
如果同样位置被创建了一个Entity,此时我们拿着被摧毁Entity的EntityID进行比较时,因为generation不同,我们还是认为这个Entity已经被摧毁。
这个方案,当generation数组中某个元素溢出之后会出现问题,但是这个概率,emmmmmm
在代码中,我用uint32_t表示index,int表示generation。实际上这个大小完全可以压缩一下。
实际使用的时候,如果每次都根据EntityID去手动设置world里对应Component,有点寒酸;我这里加了一个包装类Entity(这里是类名Entity,上文中的不是),这个类是World的内部类,通过world->GetEntityHandle(EntityID)获得,实际内容就是一个EntityID和指向world的指针;用户通过这个Entity类进行操作,就很舒服;然后把world的操作Component的方法设为private,让用户只能通过Entity来操作Component,接口就很干净了。
Singleton Component
实现中加了一个接口,ISingletonComponent,这个接口没有任何作用,但是通过std::is_base_of,我们可以知道一个类是否继承了这个接口。
如果一个Component继承了该接口,我们在申请内存的时候,只为它申请大小为1的空间,于是我们就可以通过下标为来访问Singleton Component了。
在World被创建时,会自动创建一个Entity,该Entity相当于占住了0号内存空间,来防止意外操作。
Get<T>、Set<T>的实现细节
在开始实现Resecs之前,因为我对模板编程并不熟悉,这是让我最担心的一部分。
理想状态下,我们使用Get<T>,应该能直接计算出内存地址,并返回这个Component的指针,没有任何的多余操作。
先来看一下我在World里是怎么保存这些Component的内存池的。
using pComponent = Component*;
/* all components stores here.
The pComponent type actually doesn't do anything. Replace the pointer with (void*) will also work. Doing so makes it easier to understand.
To actually get to a component, a static_cast<T*> is needed before using index.
*/
pComponent* components;
非常简单,一个二级指针,用pComponent仅仅是为了方便理解;
在释放内存的时候,要注意先cast到对应的type的指针,再去delete[],不然就ub了
对于每一种Component的内存池,我们把它们安排在对应的位置。在初始化中,我们有如下代码:
World() {
components = new pComponent[sizeof...(TComps)];
InitializeComponents<0, TComps...>();
memset(generation, 0, sizeof(generation));
memset(alive, 0, sizeof(alive));
CreateEntity(); //create the singleton Entity.
}
template<int index, class T>
void InitializeComponents() {
if (std::is_base_of<ISingletonComponent, T>::value) {
components[index] = static_cast<Component*>(new T[1]);
}
else {
components[index] = static_cast<Component*>(new T[entityPoolSize]);
}
}
template<int index, class T, class V, class... U>
void InitializeComponents() {
InitializeComponents<index, T>();
InitializeComponents<index + 1, V, U...>();
}
这里用到了C++11的新特性,可变参数模板。如果你不熟悉的话,我在这里简单讲讲执行流程:
在构造函数第一行,我们初始化components为 new pComponent[sizeof...(TComps)]。其中sizeof...(TComps)返回TComps中参数的个数。这一行相信都能懂。
之后调用初始化InitializeComponents,该方法会递归地在对应的components下标上执行一个new,最终完成初始化。
初始化完毕后,components里,对应下标存储着对应模板参数中的Component数据。
比如我们创建一个World<CompA,CompB,CompC>。那么components[0],保存的是所有的CompA,components[1],保存的是所有CompB……
那么问题来了,要怎么实现T* Get
所幸的是万能的谷歌有答案,通过模板元编程,我们是可以获得T在Ts...中对应下标的,代码如下:
template <typename T, typename... Ts>
struct Index;
template <typename T, typename... Ts>
struct Index<T, T, Ts...> : std::integral_constant<std::uint16_t, 0> {};
template <typename T, typename U, typename... Ts>
struct Index<T, U, Ts...> : std::integral_constant<std::uint16_t, 1 + Index<T, Ts...>::value> {};
使用该代码的GetComponent方法:
template<class T>
T* GetComponent(EntityIndex_t entityID) noexcept {
auto index = Index<T, TComps...>::value;
T* ptr = static_cast<T*>(components[index]);
return ptr + entityID;
}
同时index的求值发生在编译期,可以说是非常理想了。
Group
Group的实现用到了上一篇文章中的监听者系统;
其实非常简单,World实现了Component添加删除的事件,Group去监听事件,然后对每个有状况的Entity,都去查一下是否符合条件就ok了。符合条件的,塞到自己的Hashset(unordered_set)里,不符合的,从HashSet里删掉(如果有)。
目前来看这个实现颇为暴力,有机会想想能不能优化。
HashSet中保存的是EntityID,但是我另外实现了一个Iterator,在迭代的时候,先用EntityID生成一个Entity再返回,用的时候就很舒服了。