原文鏈接
簡介
對于很多人來說,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 精品分享實錄
- http://gamadu.com/artemis/
- http://gameprogrammingpatterns.com/component.html
- http://t-machine.org/index.php/2014/03/08/data-structures-for-entity-systems-contiguous-memory/
- http://blog.lmorchard.com/2013/11/27/entity-component-system/
- 淺談《守望先鋒》中的 ECS 構架
由于還要搬磚,沒有辦法一一回復私信把學習資料發給大家,我直接整理出來放在下面,覺得有幫助的話可以下載下來用于學習
鏈接:https://pan.baidu.com/s/1C-9TE9ES9xrySqW7PfpjyQ 提取碼:cqmd
感謝各位人才的點贊、收藏、關注
微信搜「三年游戲人」識訓一枚有情懷的游戲人,第一時間閱讀最新內容,獲取優質作業內推
轉載請註明出處,本文鏈接:https://www.uj5u.com/qita/523122.html
標籤:其他
上一篇:云原生之旅 - 4)基礎設施即代碼 使用 Terraform 創建 Kubernetes
下一篇:Http和Https
