ECS從本質上來說是一種設計模式.而不是某個語言的特性.該系列文章主要是探索ECS在C#中實現時遇到的各種糾結的問題與方案.同時設計一個unity為前端 .net core做服務器的分布式開發框架.但是水平很菜,難免有錯.望批評指正,與君共勉.
ECS是什么
ECS是Entity Component System的縮寫,實例由組件聚合而成.實例本身不包含任何數據,全部的數據都來自它所擁有的組件.也就是說實例有什么數據,取決它有什么組件.系統則是功能的實現.可以理解為系統關注某些組件,當組件存在時.系統將會進行某些操作.
上面的解讀來自各種能百度到的文章上講的.但是我想在這里用一個實際開發的角度去看待ECS.
先舉個例子:有一件物品在地上,玩家A拾取這個物品.
按照ECS的做法會給物品掛上一個"被拾取"的組件.然后某個系統會處理這個組件.他會把所有帶着這個組件的物品,根據組件上面描述的拾取者,將這個物品移動至該拾取者的背包中.這里系統關注的是"被拾取"組件,但是他同時會使用其他組件,比如這里的背包組件.任何一個持有"被拾取"組件的實例,都會被那個系統給處理移動.如果不加控制,你甚至可以將一個NPC或者玩家掛上這個組件,結果就是玩家變成了寵物小精靈.(這個功能可以有)這顯然是不合理.所以你可以將那個物品掛上一個"可以被拾取"的組件,這樣在掛組件之前可以判斷是否可以拾取.
通過組件於實例的分離.使得實例在持有不同的組件時,會有不同的功能.同時由於相同的組件在同一容器內存儲,這使得他們的內存地址是連續的.這樣在系統更新某組組件時,會有非常好的CPU緩存命中率.這也是Unity目前主推ECS的一個重要原因.組件分離會使得每個組件所包含的數據很少.每次系統執行更新時,所要遍歷的內存也會少的很多.加上上面說的連續的內存排列,性能會有顯著的提升.
好啦,現在看起來一切都那么美好.通過組件的分離,系統的獨立.使得你可以給某個實例掛上某個組件就可以實現某個功能.但是....實際上並沒有想象中的那么美好.
ECS可以實現么
上面把ECS說的那么美好,那我們可以用C#實現這樣的效果么.我們先一一的分析一下.
Cpu Cache
這是ECS模型最顯著的一個優勢.但是事實上,對於業務上的開發.你很難設計CPU Cache友好的代碼.因為我們在系統中處理的邏輯通常都大量使用了其他組件,這時CPU在加載數據的時候就會重新尋找其他組件的內存數據.於是Cache就被破壞了.而且.更重要的是.我們使用C#做開發.那么做到內存連續分配與存儲就變得更麻煩.
想讓內存變的連續存儲我們首先想到的是結構體.如果采用結構體作為組件,那么標識一個組件就不能用一個抽象類繼承,只能用一個接口來繼承.實際上Unity也是這么做的.選了結構體之后會帶來超級多的問題,這讓你在設計一個ECS框架的時候捉襟見肘.后面我們在設計的時候會一一討論.而且你在使用組件的時候,組件的屬性也不能是引用類型的.否則Cpu會跳轉到引用的地址上去尋找數據.同樣破壞cache.
到了這里我們該怎么辦.兩個方向,第一.努力去按照cache友好去設計框架與代碼.這里就難免的要直接去處理內存,否則很大值類型在使用的時候會拷貝傳遞引起性能上的開銷.第二,為了方便設計於使用.放棄掉cache友好.
在我實際的使用過程中發現.想要做到完全Cache友好,難度高到基本做不到.能覆蓋的程度也很難衡量.只有在有大量單位重復做同樣的動作時,才會覆蓋一點.而且即使是覆蓋了,代碼編寫上也非常麻煩.時刻都要注意內存排列的問題.這里編碼設計的付出,與性能提升一點點的回報完全不能正比.所以我們這里就不在兼顧cache友好.(其實Cache友好要做的工作非常大,后面設計的時候就能逐一體現出來)
業務變的更清晰
當我們揮淚砍掉一個特性之后,在回來看上面的那個例子.我們已經注意到了,在使用實例時.實例的多態是來自於不同的組件聚合而成.想讓業務解耦,系統復用.則組件必須要切的特別細.而且,更重要的是"一個系統最好只使用一個組件".假設我們按照標准的ECS模型去實現上面的例子,那么簡單的看應該是這樣.一個"可以被拾取"組件用來標記某個實例可以被拾取,一個"被拾取"組件標記一個實例已經被拾取了.如有有其他人去拾取,則會失敗.返回XXX已經拾取了該物品.一個"背包"組件,內部有一個物品容器.可以接收一個標記為物品的實例.
我們粗略的一看這里已經出現了非常多的組件了,而且每個動作都會有相關的系統實現.實際開發過程中.一個非ECS的業務,比如說有1W行代碼,使用ECS來實現要1.3W行左右,類的數量會顯著膨脹,每個類(組件,系統)的代碼都很少.這會產生兩個極端.
1.每個功能都會實現的很干凈.錯誤會局限在一個很小的范圍內,對擴展跟修改超級友好.復用效率很高.
2.因為類的膨脹導致管理不便,這個膨脹的程度很高.系統功能越單一,則組件就越多,結果就是類就更多.有冗余代碼和運算.因為系統的獨立性,導致某些相關的運算要在不同的系統中重復實現.雖然我們可以抬杠說抽出重復部分做成方法來復用,但是狀態運算的中間值其他系統需要時該怎么處理.采用一個組件來傳遞數據?這部分其實跟微服務的概念一樣.在開發某個系統的時候,你不能讓他依賴另外一個系統,否則就不是一個獨立的系統了.
就拿上面的例子來看.一個"可以被拾取"的組件標識一個實例可以被拾取,但是要如何標識他可以被誰拾取呢?首先我們可以添加一個屬性,用來標示可以被誰來拾取(比如player,Npc).但是同樣我們也可以設計不同的拾取權限組件.比如"可以被Player拾取","可以被Npc拾取"的組件.說實話他們兩個的效果基本沒有區別.雖然你可以說如果分離了,他們可以獨立向下演化下去,各自又會有自己獨立的屬性.但是實際上這么設計下去很容易就過度了.設計組件的時候都是獨立的組件,到了最后發現其實他們並沒有獨立的必要.因為如果產生了不同的屬性時,可以設計不同的組件來補充.
實際上的"效果"
根據上面舉得情景來看.設計過多的組件會導致類的極具膨脹.某一個流程的實現要不斷的掛載移除數個甚至十數個組件.而且更坑的是,這些組件的基本上都是按照這個順序掛載的.雖然看起來代碼上並不耦合,但是使用起來他們從來都是耦合使用的.在我使用的過程中,為了解決這個問題.我引入一個叫做"模塊"的概念.一個模塊組織若干個組件與對應的系統.這樣就不會出現在某處忘記掛載某個組件或者掛錯某個組件(因為組件的名字都非常相似).后來我想要么就不要組件了吧,都用模塊來代替.哪怕一個模塊里只有一個組件.當我剛想着手改造的時候發現,那我把模塊直接叫做組件不就好了么,於是又回到原點.之所以出現這種情況,是因為組件過於松散了.有了教訓之后就開始大開大合,大量的組件合並到一起.帶來的后果就是大量的系統也要合並到一起.這就離CPU Cache越來越遠了.這也是后面決定砍掉Cache友好的重要理由.
扯了這么多,只是想說明幾個問題.因為ECS是反OOP的,因為我經驗不足技術很菜,所以設計起來很容易過耦合或者過松散.在就是很難保證組件的"純凈性",后面在設計的時候會面對這個棘手的問題.在這么多問題存在的情況下,我沒有辦法在兼顧開發速度的同時,又那么兼顧ECS的設計模式.所以只能設計一個符合在.net平台下的,看起來很像ECS的框架.(其實我還是覺得ECS最核心的是EC)
后面我們就逐一的開始設計.