漫談Entity-Component-System
簡介
對於很多人來說,ECS只是一個可以提升性能的架構,但是我覺得ECS更強大的地方在於可以降低程式碼複雜度。
在遊戲項目開發的過程中,一般會使用OOP的設計方式讓GameObject處理自身的業務,然後框架去管理GameObject的集合。但是使用OOP的思想進行框架設計的難點在於一開始就要構建出一個清晰類層次結構。而且在開發過程中需要改動類層次結構的可能性非常大,越到開發後期對類層次結構的改動就會越困難。
經過一段時間的開發,總會在某個時間點開始引入多重繼承。實現一個又可工作、又易理解、又易維護的多重繼承類層次結構的難度通常超過其得益。因此多數遊戲工作室禁止或嚴格限制在類層次結構中使用多重繼承。若非要使用多重繼承,要求一個類只能多重繼承一些 簡單且無父類的類(min-in class),例如Shape和Animator。
也就是說在大型遊戲項目中,OOP並不適用於框架設計。但是也不用完全拋棄OOP,只是在很大程度上,程式碼中的類不再具體地對應現實世界中的具體物件,ECS中類的語義變得更加抽象了。
ECS有一個很重要的思想:數據都放在一邊,需要的時候就去用,不需要的時候不要動。ECS 的本質就是數據和操作分離。傳統OOP思想常常會面臨一種情況,A打了B,那麼到底是A主動打了B還是B被A打了,這個函數該放在哪裡。但是ECS不用糾結這個問題,數據存放到Component種,邏輯直接由System接管。借著這個思想,我們可以大幅度減少函數調用的層次,進而縮短數據流傳遞的深度。
基本概念
Entity由多個Component組成,Component由數據組成,System由邏輯組成。
Component(組件)
Component是數據的集合,只有變數,沒有函數,但可以有getter和setter函數。Component之間不可以直接通訊。
struct Component{
//子類將會有大量變數,以供System利用
}
Entity(實體)
Entity用來代表遊戲世界中任意類型的遊戲對象,宏觀上Entity是一個Component實例的集合,且擁有一個全局唯一的EntityID,用於標識Entity本身。
class Entity{
Int32 ID;
List<Component> components;
//通過觀察者模式將自己註冊到System可以提升System遍歷的速度,因為只需要遍歷已經註冊的entity
}
Entity需要遵循立即創建和延遲銷毀原則,銷毀放在幀末執行。因為可能會出現這樣的情況:systemA提出要在entityA所在位置創建一個特效,然後systemB認為需要銷毀entityA。如果systemB直接銷毀了entityA,那麼稍後FxSystem就會拿不到entityA的位置導致特效播放失敗(你可能會問為什麼不直接把entityA的位置記錄下來,這樣就不會有問題了。這裡只是簡單舉個例子,不要太深究(●’◡’●))。理想的表現效果應該是,播放特效後消失。
System(系統)
System用來制定遊戲的運行規則,只有函數,沒有變數。System之間的執行順序需要嚴格制定。System之間不可以直接通訊。
一個 System只關心某一個固定的Component組合,這個組合集合稱為tuple。
各個System的Update順序要根據具體情況設置好,System在Update時都會遍歷所有的Entity,如果一個Entity擁有該System的tuple中指定的所有Component實例,則對該Entity進行處理。
class System{
public abstract void Update();
}
class ASystem:System{
Tuple tuple;
public override void Update(){
for(Entity entity in World.entitys){
if(entity.components中有tuple指定的所有Component實例){
//do something for Components
}
}
}
}
一個Component會被不同System區別對待,因為每個System用到的數據可能只有其中一部分,且不一定相同。
World(世界)
World代表整個遊戲世界,遊戲會視情況來創建一個或兩個World。通常情況下只有一個,但是守望先鋒為了做死亡回放,有兩個World,分別是liveGame和replyGame。World下面會包含所有的System實例和Entity實例。
class World{
List<System> systems; //所有System
dictionary<Int32, Entity> entitys; //所有Entity,Int32是Entity.ID
//由引擎幀循環驅動
void Update(){
for(System sys in systems)
sys.Update();
}
}
由ECS架構出來的遊戲世界就像是一個資料庫表,每個Entity對應一行,每個Component對應一列,打了✔代表Entity擁有Component。
Component1 | Component2 | … | ComponentN | |
---|---|---|---|---|
EntityId1 | ✔ | |||
EntityId2 | ✔ | ✔ | ||
… | ||||
EntityIdN | ✔ | ✔ |
單例Component
在定義一個Component時最好先搞清楚它的數據是System數據還是Entity數據。如果是System的數據,一般設計成單例Component。例如存放玩家鍵盤輸入的 Component ,全局只需要一個,很多 System 都需要去讀這個唯一的 Component 中的數據。
單例Component顧名思義就是只有一個實例的Component,它只能用來存儲某些System狀態。單例Component在整個架構中的佔比通常會很高,據說在守望先鋒中佔比高達40%。其實換一個角度來看,單例Component可以看成是只有一個Component的匿名Entity單例,但可以通過GetSingletonIns介面來直接訪問,而不用通過EntityID。
例子
守望先鋒種有一個根據輸入狀態來決定是不是要把長期不產生輸入的對象踢下線的AFKSystem,該System需要對象同時具備連接Component、輸入Component等,然後AFKSystem遍歷所有符合要求的對象,根據最近輸入事件產生的時間,把長期沒有輸入事件的對象通知下線。
設計需要遵循的原則
- 設計並不是從Entity開始的,而是應該從System抽象出Component,最後組裝到Entity中。
- 設計的過程中盡量確保每個System都依賴很多Component去運行,也就是說System和Component並不是一對一的關係,而是一對多的關係。所以xxxCOM不一定有xxxSys,xxxSys不一定有xxxCOM。
- System和Component的劃分很難在一開始就確定好,一般都是在實現的過程中看情況一步一步地去劃分System和Component。而且最終劃分出來的System和Component一般都是比較抽象的,也就是說通常不會對應現實世界中的具體物件,可以參考下圖守望先鋒System和Component劃分的例子。
- System和Component的劃分很難在一開始就確定好,一般都是在實現的過程中看情況一步一步地去劃分System和Component。而且最終劃分出來的System和Component一般都是比較抽象的,也就是說通常不會對應現實世界中的具體物件,可以參考下圖守望先鋒System和Component劃分的例子。
- System盡量不改變Component的數據。
- 可以讀數據完成的功能就不要寫數據來完成。因為寫數據會影響到使用了這些數據的模組,如果對於其它模組不熟悉的話,就會產生Bug。如果只是讀數據來增加功能的話,即使出Bug也只局限於新功能中,而不會影響其它模組。這樣容易管理複雜度,而且給並行處理留下了優化空間。
使用心得
我在一個遊戲demo里嘗試使用ECS去進行設計,最大的感受是所有遊戲邏輯都變得那麼的合理,應對改動、擴展也變得那麼的輕鬆。加班變少了,也不再焦慮。在開始使用ECS來架構業務層之前,我對ECS還是存有一絲疑慮的。擔心會不會因為規矩太多了,導致有些功能寫不出來。中途也確實因為ECS的種種規矩,導致有些功能不好寫出來,需要用到一些奇技淫巧,劍走偏鋒。但這些技術最終造就了一個可持續維護的、解耦合的、簡潔易讀的程式碼系統。據說守望團隊在將整個遊戲轉成ECS之前也不確定ECS是不是真的好使。現在他們說ECS可以管理快速增長的程式碼複雜性,也是事後諸葛亮。
引擎層的System比較好定義,因為引擎相關層級劃分比較明確。但是遊戲業務邏輯層可能會出現各種奇奇怪怪的System,因為業務層的需求千變萬化,有時沒有辦法劃分出一個對應具體業務的System。例如我曾經在業務層定義過DamageHitSystem、PointForceSys。
推遲技術:不是非常必要馬上執行的內容可以推遲到合適的時再執行,這樣可以將副作用集中到一處,易於做優化。例如遊戲可能會在某個瞬間產生大量的貼花,利用延遲技術可以將這些需要產生的貼花數據保存下來,稍後可以將部分重疊的貼花刪除,再依據性能情況分到多個幀中去創建,可以有效平滑性能毛刺。
如果不知道該如何去劃分System,而導致System之間一定要相互通訊才能完成功能,可以通過將數據放在中的一個隊列里延遲處理。比如SystemA在執行Update的時候,需要執行SystemB中的邏輯。但是這個時候還沒輪到SystemB執行Update,只能先將需要執行的內容保存到一個地方。但是System本身又沒有數據,所以SystemA只好將需要執行的內容保存到單例Component中的一個隊列里,等輪到SystemB執行Update的時候再從隊列里拿出數據來執行邏輯。
但是System之間通過單例Component有個缺點。如果向單例Component中添加太多需要延遲處理的數據,一旦出現bug就不好查了。因為這類數據是一段時間之前添加進來的,到後面才出問題的話,不好定位是何處、何時、基於什麼情況添加進來的。解決方案是給每一條需要延遲處理的數據加上調用堆棧資訊、時間戳、一個用於描述為什麼添加進來的字元串。
各個System都用到的公共函數可以定義在全局,也可以作為對應System的靜態函數,這類函數叫做Utility函數。Utility函數涉及的Component最好儘可能少,不然需要作為參數傳進函數Component會很多,導致函數調用不太雅觀。Utility函數最好是無副作用的,即不對Component的數據做任何寫操作,只讀取數據,最後返回計算結果。要改Component的數據的話,也要交給System來改。
函數調用堆棧的層次變淺了,因為邏輯被攤開到各個System,而System之間又禁止直接訪問。程式碼變得扁平化,扁平化意味的函數封裝少了,所以閱讀、修改、擴展也很輕鬆。
如果可以把整個遊戲世界都抽象成數據,存檔/讀檔功能的實現也變得容易了。存檔時只需要將所有Component數據保存下來,讀檔時只需要將所有Component數據載入進來,然後System照常運行。想想就覺得強大,這就是DOP的魅力。
優點
模式簡單
結構清晰
通過組合高度復用。用組合代替繼承,可以像拼積木一樣將任意Component組裝到任意Entity中。
擴展性強。Component和System可以隨意增刪。因為Component之間不可以直接訪問,System之間也不可以直接訪問,也就是說Component之間不存在耦合,System之間也不存在耦合。System和Component在設計原則上也不存在耦合。對於System來說,Component只是放在一邊的數據,Component提供的數據足夠就update,數據不夠就不update。所以隨時增刪任意Component和System都不會導致遊戲崩潰報錯。
天然與DOP(data-oriented processing)親和。數據都被統一存放到各種各樣的Component中,System直接對這些數據進行處理。函數調用堆棧深度大幅度降低,流程被弱化。
易優化性能。因為數據都被統一存放到Component中,所以如果能夠在記憶體中以合理的方式將所有Component聚合到連續的記憶體中,這樣可以大幅度提升cpu cache命中率。cpu cache命中良好的情況下,Entity的遍歷速度可以提升50倍,遊戲對象越多,性能提升越明顯。ECS的這項特性給大部分人留下了深刻印象,但是大部分人也認為這就是ECS的全部。我覺得可能是被Unity的官方演示帶歪的。
易實現多執行緒。由於System之間不可以直接訪問,已經完全解耦,所以理論上可以為每個System分配一個執行緒來運行。需要注意的是,部分System的執行順序需要嚴格制定,為這部分System分配執行緒時需要注意一下執行先後順序。
缺點
在充滿限制的情況下寫程式碼,有時速度會慢一些。但是習慣之後,後期開發速度會越來越快。
優化
一個entity就是一個ID,所有組成這個entity的component將會被這個ID給標記。因為不用創建entity類,可以降低記憶體的消耗。如果通過以下方式來組織架構,還可以提升cpu cache命中率。
//數組下標代表entity的ID
ComponentA[] componentAs;
ComponentB[] componentBs;
ComponentC[] componentCs;
ComponentD[] componentDs;
...
參考資料
- 《守望先鋒》架構設計與網路同步 — GDC2017 精品分享實錄
- //gamadu.com/artemis/
- //gameprogrammingpatterns.com/component.html
- //t-machine.org/index.php/2014/03/08/data-structures-for-entity-systems-contiguous-memory/
- //blog.lmorchard.com/2013/11/27/entity-component-system/
- 淺談《守望先鋒》中的 ECS 構架
由於還要搬磚,沒有辦法一一回復私信把學習資料發給大家。我直接整理出來放在下面,覺得有幫助的話可以下載下來用於學習
鏈接://pan.baidu.com/s/1C-9TE9ES9xrySqW7PfpjyQ 提取碼:cqmd
感謝各位人才的點贊、收藏、關注
微信搜「三年遊戲人」收穫一枚有情懷的遊戲人,第一時間閱讀最新內容,獲取優質工作內推