非正經ECS實現方案

非正經ECS實現方案

來自專欄 游研感悟

文:普通熊貓

源:github.com/lixianmin/wr


0x00. 引言

ECS是Entity-Component-System(實體-組件-系統) 的縮寫,是一種框架設計模式,多用於遊戲開發。但我下面要講的ECS並不是正經的ECS實現方案,只是借了ECS的殼。

正經ECS可簡述為:"Entities as IDs", "Components as raw Data", and "Code stored in Systems, not in Components or Entities"。意譯為:Entity就是一個ID,Component是純數據,System是純邏輯。在我的設計中(暫時 -____- )沒有恪守這些準則,我的目的是可以像填配置一樣訂製代碼。從實現效果上看,更像Unity3d中的Component實現方案。希望研究正經ECS設計方案的同學,請直接移步文末的參考文獻區,那裡有一些鏈接也許對你有用。

方案基於Unity3d引擎,使用C#編碼,示例語法也都使用C#。框架代碼以及下文中我使用Part一詞指代Component,原因有兩個:一是Component這個單詞已經被Unity3d佔了,二是我覺得Component這個單詞太長了。


0x01. Entity不需要知道正在使用哪些Part

就目前我了解到的一些實現方案中,Entity都明確知道自己會使用哪些組件。我認為這樣的實現方案與OO中的組合模式(Composite Pattern)區別不大。我可以接受Part強引用Entity,但不能接受Entity強引用Part。之所以這樣,有兩個比較重要的原因:

  1. 越簡單越易用:理想的情況下,Entity在任意時刻需要任意Part時可隨時添加,不需要某個Part時則隨時刪除。如果Entity代碼中硬編碼了Part成員變數,那麼就需要同樣手工調用所有其它相關的操作,比如:命名,Initialize(), Dispose()等,刪除或重命名相關代碼時也需要手動調整。這些屬於常見操作,在編寫代碼時經常遇到,我認為每次都手工調用這些操作。參考文獻中的Entitas是一個Unity3d的插件,它通過自動生成代碼的方式簡化了這個過程。
  2. Entity在編譯期與Part解耦:我們項目有一個跟《守望先鋒》類似的需求。我們希望部分Logic層的Client代碼(比如MovePart)可以直接在Server上運行,此時需要完全剝離出View層的代碼(比如RenderPart)。這要求Logic層代碼不能強引用任何View層代碼的信息,否則會編譯不過。同時因為所有相關代碼的生命周期都是一樣的,因此動態增刪Part是一個favorable的設計方案。具體就是在Client端Entity會動態掛接所有相關Part,而在Server端Entity只需要掛接Logic層的Part。

綜合以上原因,相對理想的理想的方案就是類Unity3d中的組件使用方式:

var entity = new GameObjectEntity(); entity.AddPart(typeof(MovePart)); // Logic層 entity.AddPart(typeof(RenderPart)); // View層 ... entity.RemovePart(typeof(RenderPart));

具體實現時,Entity中的Part全部存儲在一張中心Hashtable(Type => Part)中,但也因此付出了速度和內存的代價,詳見第《0x04 設計缺陷》小節。


0x02. 緩存友好

從實現效果上,ECS設計傾向於以屬性為中心(可參考《遊戲引擎架構P655》)。Entity中只需要存儲實際用到的Part(即:我們不會有一些Entity,內含未使用的Part成員),這對於有效使用內存是有益的。不過,考慮到我們使用哈希表存儲Part成員,一正一負,實際內存佔用不好說是升了還是降了。

Update Method是遊戲設計中的一種常規設計手法,具體方法可能命名為Update()或Tick(),其含義在文中不作區分,框架中使用Tick()。在以對象為中心的設計中,很多宿主對象與其屬性對象都需要寫一個Tick()方法,用於在每一幀更新相關數據。實際上,因為Tick()調用通常是自上而下逐級進行的,因此只要有一個屬性對象需要Tick()方法,都會強迫其所在的宿主對象及每一個上游對象都擁有Tick()方法。這樣,以遊戲代碼中的初始Tick()方法為根節點,自上而下,由外到內,對所有對象Tick()方法的調用可以看成是一個樹形結構,我們可稱之為Tick樹。以對象為中心的設計模型有一些缺點,其中之一便是Tick樹中相鄰葉節點的類型通常是不一樣的,因此在遍歷整個Tick樹的過程中,緩存命中失敗的概率(cache miss rate)比較大。

以屬性中心設計則可能更加緩存友好。在我們的ECS實現方案中,設計了一個名為PartTickSystem的類,收集所有包含Tick()的Part,將它們存儲在同一個array中並按type排序。這樣,相同type的Part在內存中是連續存儲的,數據布局符合數組之結構(struct of array, SoA)的要求。在遍歷調用所有Part的Tick()方法時,能夠減少或消除緩命中失敗。

具體到PartTickSystem類的實現細節,由於我們使用array存儲Part對象,在添加或刪除Part時,不需要立即調整array中的內容,否則會導致頻繁移動array中的數據,可能引起不必要的CPU開銷。添加Part時,可以先將新的Part對象append到數組尾部,在真正遍歷array中的Part之前,將其按type排序(因此在最壞的情況下PartTickSystem.Tick()的時間複雜度為O(NlogN))。刪除Part時,也不需要立即從array中移除,只需要在遍歷結束後的某個時刻調用一個RemoveAll()方法統一移除即可(類似於List<T>.RemoveAll(),只移動一次內存)。

不同type的Part之間對Tick()方法的調用先後順序可能有要求,Unity3d中專門區分了Update()與LateUpdate()應對這件事情。我們通過以下方式可以控制的更加細緻:給每一種type提供一個typeIndex值,並將array中的Part按typeIndex的順序排序(在C#中,Array.Sort(keys, items)方法可以幫忙)。很多情況下,不同Part之間的Tick()調用順序並無特殊要求,因此只需要在第一次訪問這種Part的type時候自動生成一個typeIndex即可。對於需要嚴格控制Tick()調用順序的Part,則需要在系統初始化時為它們設置指定的typeIndex值。

在初版設計中,我們將typeIndex作為property放到Part類中,但經過幾周的迭代發現,該變數只在排序Tick()的調用順序時有用,因此將其轉移到了PartTickSystem類中,作為Array.Sort(keys, items)的keys參數。細心的讀者已經注意到,這種設計也是違反ECS中System不能含有狀態的準則的。


0x03. Part基類與IPart介面

創建和刪除組件分別由Entity類中一對名為AddPart()/RemovePart()的方法負責。代碼如下:

public class Entity{ public IPart AddPart(Type type) { if (null != type) { var part = Activator.CreateInstance(type) as IPart; if (null != part) { var initPart = part as IInitPart; if (null != initPart) { initPart.InitPart(this); } _parts.Add(type, part); if (null != OnPartCreated) { OnPartCreated(part); } return part; } } return null; } public bool RemovePart(Type type) { if (null != type) { var part = _parts[type]; if (null != part) { var disposable = part as IDisposable; if (null != disposable) { disposable.Dispose(); } _parts.Remove(type); return true; } } return false; } public static event Action<IPart> OnPartCreated; private readonly Hashtable _parts = new Hashtable();}public interface IPart{}public class Part : IPart, IInitPart, IDisposable, IIsDisposed{...}

框架實現了一個Part基類和IPart等一系列介面。

多數邏輯比較複雜的組件類應該通過繼承Part基類實現。它默認實現了IPart(組件標誌)、IInitPart(組件創建回調)、IDisposable(組件釋放回調)和IIsDisposed(查詢組件是否已經被釋放)介面,這些介面背後的方法對應著組件對象的完整生命周期。

在有些情況下,我們可能不希望或無法使用Part基類。一種情況是,我們有時需要非常輕量級的組件,它可能只需要包含一個int值,此時創建一個Part的子類會顯得過於重度。另一種情況是,目標組件類已經擁有一個基類了,但在C#中我們無法使用多重繼承。

使用IPart系列介面可以創建與Part子類等價能力的組件對象。從前面的示例代碼可以看到,AddPart()方法完全基於介面編程,它可以創建任何實現了IPart介面的類對象(特別注意到IPart是一個空介面)。如果需要其它IInitPart, IDisposable等能力的話,只要實現對應的介面就可以。

完整的代碼地址請參考:github.com/lixianmin/cl


0x04. 設計缺陷

為了降低Entity與Part之間的耦合度,實現機制上我們使用Hashtable存儲Entity中的Part組件,也因此需要注意潛在的速度與內存開銷。

首先是速度。因為對Part的Add/Remove/Get全部通過Hashtable進行,因此速度比直接訪問類成員變數慢很多。如果把class想像成一個存儲類成員變數的容器,那麼速度最快的容器實現方式就是使用數組。在測試中,我假設獲取類成員變數的速度與從數組中按下標獲取數組元素的速度相仿(並沒有證據,但我認為這是一個相對合理的假設)。另外,測試中Hashtable與Dictionary都使用了默認參數,沒有調整loadFactor。測試細節如下:

測試代碼:MBHashtableSpeedTest

測試目標:測試各類容器與數組相比獲取元素的耗時比

測試環境:MacBook Pro(13-inch, 2016) + macOS 10.12.5 + Unity2017.1.1f1 + C#

測試方案:容器大小50,各類容器按type獲取數據10000次的總耗時與數組按下標訪問數據10000次的總耗時相除

| 容器 | 耗時比 |

| --- | --- |

| Array | 1: 1 |

| Hashtable(Type => Part) | 13 : 1 |

| Dictionary<Type, Part> | 25 : 1 |

從表中可以看到,從Hashtable和數組中獲取相同元素的耗時比大概為13:1,因此可以大致認為GetPart()的耗時是獲取普通類成員變數的13倍。這種速度落差對偶發的組件訪問可能影響不大,但需要警惕在Tick()/Update()中反覆調用GetPart()的情況。

其次是內存。同樣的數據,存儲在Hashtable中比存儲在數組中要佔用更多的內存。測試內存的方案與測試速度的方案類似:仍然使用Array, Hashtable, Dictionary三種容器對比,仍然使用容器的默認參數,未調整loadFactor。測試細節如下:

測試代碼:MBHashtableMemoryTest

測試目標:測試各類容器的內存佔用情況

測試環境:MacBook Pro(13-inch, 2016) + macOS 10.12.5 + Unity2017.1.1f1 + C#

測試方案:容器大小50,每種類型的容器各生成10000份,取平均容器的大小

| 容器 | 平均大小 |

| --- | --- |

| Array | 1.9KB |

| Hashtable(Type => Part) | 2.6KB |

| Dictionary<Type, Part> | 4.4KB |


0x05. 設計權衡

  1. 為什麼沒有使用Component/Part是純數據,System是純邏輯的實現方案?

框架並未否定正經的ECS實現方案。如前所述,只要實現了IPart空介面的類都可以作為組件被Entity使用---這對組件類幾乎沒有增加額外數據,可能是理論上能做到的最小的約束了。我們完全可以使用純數據的Part和無狀態的System。

只所以沒有強制要求Part是raw data,是因為很多組件的專用性太強,它們就只能是為某些Entity服務,如果再把行為拆出來,感覺有些設計過度了。

在正經的System實現中,Entity或Part通常集中存儲在某個地方。由於每個System只處理某些特定類型的Entity/Part,因此需要在每次訪問前先按預定義的條件過濾一遍。在我們的應用中,Entity與Part的創建頻率不是特別頻繁,我認為使用每次過濾的方式是一種CPU浪費,因此更傾向於使用在System中做緩存的方式,於是System就包含了狀態。

好吧,其實作者受OO思想影響多年,暫時無法轉變思想也是一個~~次~~重要的原因。

  1. Entity是否可以同時是一個IPart

    可以,完全可以,我已經在項目中這麼用了。
  2. Part是否可以是struct?

可以但不能這麼使用。理論上只要實現了IPart空介面的struct就可以作為組件被Entity使用,但因為我們使用了Hashtable存儲Part對象,如果使用struct的話,會導致裝箱拆箱問題,所以不建議使用。

  1. 為什麼要IInitPart介面初始化組件對象,直接使用構造方法不更直接嘛?

在初始化時我們可能需要Entity對象,無參的默認構造方法里無法找到Entity對象。

  1. Part是否應該有一個id標識符?

初版設計時Part的確有一個全局唯一的id標識符,後來移除了。這個全局唯一id是通過一個static的int變數自加得來,通過它我們可以跟蹤到所有處於alive狀態的組件對象。一開始我覺得這會很有用,但經過幾個星期的迭代,我發現實際上用途不是很廣泛,就移除了。

唯一的一次應用是將某個組件id傳遞給lua腳本作為查詢id使用,後來被我使用宿主Entity的id替代了。這個替代方案可能具備一定程度上的普適性,因為目前框架中每個Entity上相同類型的Part同時只能有一個,這樣「宿主id+組件類型」就可以唯一確定是哪一個Part了。

  1. 為什麼單個Entity上同種類型的Part只支持一個?

的確,Unity3d在同一個gameObject上可以同時擁有多個相同類型的Component。正是因為參考了Unity3d,最初設計的時候,每個Entity上是可以同時有多個同種類型的Part的。這樣定位一個組件就需要兩個數據:type+id。這個方案給接下來的一系列組件相關的操作都帶來了一些設計複雜度,包括存儲、查詢、排序、遍歷等等。經過幾周的代碼迭代,我們發現似乎沒有哪個需求是需要在同一個Entity上同時包含一個以上的相同類型的組件的。另外,調研了一下業界內的一些實現方案(包括Entitas),發現他們也沒有支持這個特性,這說明在實踐中至少可以繞過這個特性,於是後來在重構代碼的時候把這個特性移除了。這大大簡化了很多方法的設計,並減少了代碼量,簡直是普天同慶。

  1. 為什麼沒有使用AddPart<T>()這種泛型介面,而是使用了AddPart(Type type)?

在定義了AddPart(Type type)後,泛型版本的方法可以使用擴展方法實現,即:AddPart(typeof(T)) as T;

  1. Activator.CreateInstance(type)比起泛型版的new T()會不會慢?

我反編譯了Mono的實現,泛型版的new T()最後就是使用了Activator.CreateInstance(typeof(T));實現的,dotnet的實現手法沒有查過,不清楚。

  1. 根據《守望先鋒》的經驗,它們最終有大約40%的組件是Singleton,在框架中如何支持?另外,某些組件可能需要頻繁的創建和銷毀,是否應該考慮加入Pool的方案?

目前框架中的Part對象都是直接new出來的,對於Singleton和Pool還沒有想好解決方案。以實現Singleton為例,有幾種參考方案:

方案一:使用Attribute屬性或ISingleton介面來標記Part是一個Singleton類,並在AddPart()的時候獲取這些信息。Attribute屬性可能更友好一些,因為它可以帶一些控制參數,比如用於控制Pool的大小。該方案的問題是:每次調用AddPart()的時候都需要查詢這些標記信息,這是一筆額外開銷,特別是對於那些原本不需要這些信息的普通組件來說。即使我們使用一張Hashtable緩存這些信息,也會多一次Hashtable的查詢,這個開銷是否能被接受還需要斟酌。

方案二:加一個新的AddSingletonPart()方法,這樣可以避免方案一的性能問題,對原先已經在運行的代碼也沒有任何影響。該方案的問題是:組件是否是Singleton應該由設計組件的人決定,而不是由使用它的人決定。

方案三:擴展AddPart()方法,加入一個flags參數,用這個參數區分組件對象是否為Singleton。這個方案的優點跟方案二相同,並且給未來擴展flags留下了餘地。但也存在跟方案二一樣的問題:組件是否是Singleton應該由設計組件的人決定,而不是由使用它的人決定。

  1. 無狀態System應該如何實現?

無狀態是為了無副作用,跟函數式編程的理念相同,有幾個跟此相關的概念可以參考:靜態類,工具類,純函數,擴展方法。


0x06. 收尾

有任何關的疑問或建議,歡迎留言探討。


0x07. 參考文獻

  1. wiki: Entity–component–system
  2. 遊戲開發中的ECS 架構概述
  3. 一個無框架的ECS實現(Entity-Component-System
  4. 淺談《守望先鋒》中的 ECS 構架(雲風)
  5. Entitas-CSharp
  6. 遊戲引擎架構
  7. Update Method
  8. Implementing Component-Entity-Systems
  9. Game Programming Patterns: Component
  10. http://entity-systems-wiki.t-machine.org
  11. Entity Systems are the future of MMOG development – Part 2

推薦閱讀:

談談 Vue 業務組件

TAG:遊戲設計 | 設計模式 | 組件 |