漫談Entity-Component-System

2022-10-29 21:00:42

原文連結

簡介

對於很多人來說,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遍歷所有符合要求的物件,根據最近輸入事件產生的時間,把長期沒有輸入事件的物件通知下線。

設計需要遵循的原則

  1. 設計並不是從Entity開始的,而是應該從System抽象出Component,最後組裝到Entity中。
  2. 設計的過程中儘量確保每個System都依賴很多Component去執行,也就是說System和Component並不是一對一的關係,而是一對多的關係。所以xxxCOM不一定有xxxSys,xxxSys不一定有xxxCOM。
    • System和Component的劃分很難在一開始就確定好,一般都是在實現的過程中看情況一步一步地去劃分System和Component。而且最終劃分出來的System和Component一般都是比較抽象的,也就是說通常不會對應現實世界中的具體物件,可以參考下圖守望先鋒System和Component劃分的例子。
  3. 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;
...

參考資料

感謝各位人才的點贊收藏關注

微信搜「三年遊戲人」收穫一枚有情懷的遊戲人,第一時間閱讀最新內容,獲取優質工作內推