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再返回,用的時候就很舒服了。