淺談《守望先鋒》中的 ECS 構架

https://blog.codingnow.com/2017/06/overwatch_ecs.htmlhtml

今天讀了一篇 《守望先鋒》架構設計與網絡同步 。這是根據 GDC 2017 上的演講 Overwatch Gameplay Architecture and Netcode 視頻翻譯而來的,因此並無原文。因爲是個一小時的演講,不可能講得面面俱到,因此理解起來有些困難,我反覆讀了三遍,而後把英文視頻找來(訂閱 GDC Vault 能夠看,有版權)看了一遍,大體理解了 ECS 這個框架。寫這篇 Blog 記錄一下我對 ECS 的理解,結合我本身這些年作遊戲開發的經驗,可能並不是等價於原演講中的思想。數組

Entity Component System (ECS) 是一個 gameplay 層面的框架,它是創建在渲染引擎、物理引擎之上的,主要解決的問題是如何創建一個模型來處理遊戲對象 (Game Object) 的更新操做。服務器

傳統的不少遊戲引擎是基於面向對象來設計的,遊戲中的東西都是對象,每一個對象有一個叫作 Update 的方法,框架遍歷全部的對象,依次調用其 Update 方法。有些引擎甚至定義了多種 Update 方法,在同一幀的不一樣時機去調用。網絡

這麼作實際上是有極大的缺陷的,我相信不少作過遊戲開發的程序都會有這種體會。由於遊戲對象實際上是由不少部分聚合而成,引擎的功能模塊不少,不一樣的模塊關注的部分每每互不相關。好比渲染模塊並不關心網絡鏈接、遊戲業務處理不關心玩家的名字、用的什麼模型。從天然意義上說,把遊戲對象的屬性聚合在一塊兒成爲一個對象是很天然的事情,對於這個對象的生命期管理也是最合理的方式。但對於不一樣的業務模塊來講,針對聚合在一塊兒的對象作處理,把處理方法綁定在對象身上就不那麼天然了。這會致使模塊的內聚性不好、模塊間也會出現沒必要要的耦合。數據結構

我以爲守望先鋒之因此要設計一個新的框架來解決這個問題,是由於他們面對的問題複雜度可能到了一個更高的程度:好比如何用預測技術作更準確的網絡同步。網絡同步只關心不多的對象屬性,不必在設計同步模塊時牽扯過多沒必要要的東西。爲了準確,須要讓客戶端和服務器跑同一套代碼,而服務器並不須要作顯示,因此要比較容易的去掉顯示系統;客戶端和服務器也不徹底是一樣的邏輯,須要共享一部分系統,而在另外一部分上根據分別實現……多線程

總的來講、須要想一個辦法拆分複雜問題,把問題聚焦到一個較小的集合,提升每一個子任務的內聚性。架構

ECS 的 E ,也就是 Entity ,能夠說就是傳統引擎中的 Game Object 。但在這個系統下,它僅僅是 C/Component 的組合。它的意義在於生命期管理,這裏是用 32bit ID 而不是指針來表示的,另外附着了渲染用到的資源 ID 。由於僅負責生命期管理,而不設計調用其上的方法,用整數 ID 更健壯。整數 ID 更容易指代一個無效的對象,而指針就很難作到。框架

C 和 S 是這個框架的核心。System 系統,也就是我上面提到的模塊。對於遊戲來講,每一個模塊應該專一於幹好一件事,而每件事要麼是做用於遊戲世界裏同類的一組對象的每單個個體的,要麼是關心這類對象的某種特定的交互行爲。好比碰撞系統,就只關心對象的體積和位置,不關心對象的名字,鏈接狀態,音效、敵對關係等。它也不必定關心遊戲世界中的全部對象,好比關心那些不參與碰撞的裝飾物。因此對每一個子系統來講,篩選出系統關心的對象子集以及只給它展現它所關心的數據就是框架的責任了。函數

在 ECS 框架中,把每一個可能單獨使用的對象屬性概括爲一個個 Component ,好比對象的名字就是一個 Component ,對象的位置狀態是另外一個 Component 。每一個 Entity 是由多個 Component 組合而成,共享一個生命期;而 Component 之間能夠組合在一塊兒做爲 System 篩選的標準。咱們在開發的時候,能夠定義一個 System 關心某一個固定 Component 的組合;那麼框架就會把遊戲世界中知足有這個組合的 Entity 都篩選出來供這個 System 遍歷,若是一個 Entity 只具有這組 Component 中的一部分,就不會進入這個篩選集合,也就不被這個 System 所關心了。post

在演講中,做者談到了一個根據輸入狀態來決定是否是要把長期不產生輸入的對象踢下線的例子,就是要對象同時具有鏈接組件、輸入組件等,而後這個 AFK 處理系統遍歷全部符合要求的對象,根據最近輸入事件產生的時間,把長期沒有輸入事件的對象通知下線;他特別說到,AI 控制的機器人,因爲沒有鏈接組件,雖然具有狀態組件,但不知足 AFK 系統要求的完整組件組的要求,就根本不會遍歷到,也就不用在其上面浪費計算資源了。我認爲這是 ECS 相對傳統對象 Update 模型的一點優點;用傳統方法的話,極可能須要寫一個空的 Update 函數。

遊戲的業務循環就是在調用不少不一樣的系統,每一個系統本身遍歷本身感興趣的對象,只有預約義的組件部分能夠被子系統感知到,這樣每一個系統就能具有很強的內聚性。注意、這和傳統的面向對象或是 Actor 模型是大相徑庭的。OO 或 Actor 強調的是對象自身處理自身的業務,而後框架去管理對象的集合,負責用消息驅動它們。而在 ECS 中,每一個系統關注的是不一樣的對象集合,它處理的對象中有共性的切片。這是很符合守望先鋒這種 MOBA 類遊戲的。這類遊戲關注的是對象間的關係,好比 A 攻擊了 B 對 B 形成了傷害,這件事情是在 A 和 B 之間發生的,在傳統模型中,你會糾結於傷害計算到底在 A 對象的方法中完成仍是在 B 的方法中完成。而在 ECS 中不須要糾結,由於它能夠在傷害計算這個 System 中完成,這個 System 關注的是全部對象中,和傷害的產生有關的那一小部分數據的集合。

ECS 的設計就是爲了管理複雜度,它提供的指導方案就是 Component 是純數據組合,沒有任何操做這個數據的方法;而 System 是純方法組合,它本身沒有內部狀態。它要麼作成無反作用的純函數,根據它所能見到的對象 Component 組合計算出某種結果;要麼用來更新特定 Component 的狀態。System 之間也不須要相互調用(減小耦合),是由遊戲世界(外部框架)來驅動若干 System 的。若是知足了這些前提條件,每一個 System 均可以獨立開發,它只須要遍歷給框架提供給它的組件集合,作出正確的處理,更新組件狀態就夠了。編寫 Gameplay 的人更像是在用膠水粘合這些 System ,他只要清楚每一個 System 到底作了什麼,操做自己對哪些 Component 形成了影響,正確的書寫 System 的更新次序就能夠了。一個 System 對大多數 Component 是隻讀的,只對少許 Component 是會改寫的,這個能夠預先定義清楚,有了這個知識,一是容易管理複雜度,二是給並行處理留下了優化空間。

在演講中談到了開發團隊對 ECS 的設計認知也是逐步演進的。

好比在一開始,他們認爲 Component 就是大量有某種同類 Entity 屬性的集合的篩選器。ECS 框架輔助這個篩選過程,每一個 System 模塊都用 for each 的方式迭代相關的 Entity 中對象的組件。以後他們發現,其實對於每一個遊戲對象集合體來講,一類 Component 能夠也應該只有一個。好比存放玩家鍵盤輸入的 Component ,就沒有多個。不少 System 都須要去讀這個惟一的 Component 內的狀態(哪些按鈕被按下了),能夠安排一個 System 來更新這個 Component 。原文把這種 Component 成爲 Singleton Component ,我認爲這個東西和一開始 ECS 想解決的問題仍是有一些差異的:不一樣種類的 Entity 分別擁有同類的屬性組,框架負責管理同類集合。咱們的確仍是能夠建立一個叫作玩家鍵盤的 Entity 加到遊戲世界中,這個 Entity 是由鍵盤組件構成。可是咱們徹底沒必要迭代玩家鍵盤這個 Entity 集合,由於它確定只有一個,直接把這個對象放在遊戲世界中便可。但把它放在 System 中就不是一個好設計了。由於它破壞了 System 無狀態的設計原則,並且也不支持多個遊戲世界:在原文中舉了個例子,實際遊戲和遊戲回放就是兩個不一樣的遊戲世界,不一樣的遊戲世界意味着不一樣的業務流程的組合,須要用不一樣的方式粘合已經開發好的 System 。把遊戲鍵盤狀態這種狀態內置在特定的 System 中就是不合適的了。從這個角度來講 ECS 的本質仍是數據 C 和操做 S 分離。而操做 S 並不侷限於對同類組件集合的管理,也但是是針對單個組件。做者本身也說,最終有 40% 的組件就是單件。

單件自己其實就和傳統面向對象模型差很少了。可是數據和方法分離仍是頗有意義。咱們在用面向對象模式作開發的時候也會碰到一個對象有幾個不一樣的方法,某些方法關注這部分狀態、另外一些方法關注另外一部分狀態,還有一些方法關注前面幾組狀態的集合。這裏的方法就是 ECS 中的系統、狀態就是組件。將數據和方法分離能夠將不一樣的方法解耦。若是用傳統的 C++ 的面向對象模式,極可能須要用多繼承、組合轉發等等複雜的語法手段。


演講後面還提到了一些 ECS 模式下處理一些複雜問題的常見手法。

Component 沒有方法,而 System 則沒有狀態,只是對定義好的 Component 狀態的加工過程。而許多 System 中極可能會處理同一類問題,涉及的 Component 類型是相同的。若是這個有共性的問題只涉及一個 Entity ,那麼直觀的方法是設計一個 System ,迭代,逐個把結果計算出來,存爲 Component 的狀態,別的 System 能夠在後續把這個結果做爲一個狀態讀出來就能夠了。

但若是這個行爲涉及多個 Entity ,好比在不一樣的 System 中,都須要查詢兩個 Entity 的敵對關係。咱們不可能用一個 System 計算出全部 Entity 間的敵對關係,這樣必然產生了大量沒必要要的計算;又或者這個行爲並不想額外修改 Component 的狀態,但願對它保持無反作用,好比我想持續模擬一個對象隨時間流逝的位置變化,就不能用一個 System 計算好,再從另外一個 System 讀出來。

這樣,就引入了 Utility 函數的概念,來作上面這種類型的操做,再把 Utility 函數共享給不一樣的 System 調用。爲了下降系統複雜度,就要求要麼這種函數是無反作用的,隨便怎麼調用都沒問題,好比上面查詢敵對關係的例子;要麼就限制調用這種函數的地方,僅在不多的地方調用,由調用者當心的保證反作用的影響,好比上面那個持續位置變化的過程。

若是產生狀態改變這種反作用的行爲必須存在時,又在不少 System 中都會觸發,那麼爲了減小調用的地方,就須要把真正產生反作用的點集中在一處了。這個技巧就是推遲行爲的發生時機。就是把行爲發生時須要的狀態保存起來,放在隊列裏,由一個單獨的 System 在獨立的環節集中處理它們。

例如不一樣的射擊行爲均可能建立出新的對象、破壞場景、影響已有對象的狀態。在同一面牆上留下不一樣的彈孔,不須要堆疊在一塊兒,而只須要保留最後一個,刪除前面的。咱們能夠把讓不一樣的 System 觸發這些對象建立、刪除的行爲,但並不真正去作。集中在一塊兒推遲到當前幀的末尾或下一幀的開頭來作。這樣就儘可能保證了多數 System 工做的時候,對大多數組件來講是無反作用的,而把嚴重反作用的行爲集中在單點當心處理。


ECS 要解決的最複雜,最核心的問題,或許仍是網絡同步。我認爲這也是設計一個狀態和行爲嚴格分離的框架的主要動機。由於一個好的網絡同步系統必須實現預測、有預測就有預測失敗的狀況,發生後要解決衝突,回滾狀態是必須支持的。而狀態回滾還包括了只回滾部分狀態,而不能簡單回滾整個世界。

我在去年其實在本 blog 中談過這個問題 。個人觀點是,狀態的單獨保存是很是重要的。在 ECS 模型中,C 是純數據,因此很是方便作快照和回滾。Entity 的組件分離,也適合作關鍵狀態的記錄。去年和一個同事一塊兒作了一個射擊類的 MOBA demo ,最終的實現方案就是把遊戲對象的位置(移動)狀態,和射擊狀態專門抽出來實現預測同步,效果很是不錯。

這個演講其實並無談及預測和同步的具體技術,而是談 ECS 怎麼幫助下降利用這些技術的實現複雜度。同時也說起了一些有趣的細節。

好比說,ECS 規定每一個須要根據輸入表現的 System 都提供了一個 UpdateFixed 函數。守望先鋒的同步邏輯是基於 60fps 的,因此這個 UpdateFixed 函數會每 16ms 調用一次,專門用於計算這個邏輯幀的狀態。服務器會根據玩家延遲,稍微推遲一點時間,比客戶端晚一些調用 UpdateFixed 。在我去年談同步的 blog 中也說過,玩家其實不關心各個客戶端和服務器是否是時刻上絕對一致(絕對一致是不可能作到的),而關心的是,不一樣客戶端和服務器是否是展示了相同的過程。就像直播電影,不一樣的位置早點播放和晚點播放,你們看到的內容是一致的就夠了,是否是同時在觀看並不重要。

可是,遊戲和電影不同的地方是,玩家本身的操做影響了電影的情節。咱們須要在服務器仲裁玩家的輸入對世界的影響。玩家須要告知服務器的是,我這個操做是在電影開場的幾分幾秒下達的,服務器按這個時刻,把操做插入到世界的進程中。若是客戶端等待服務器回傳操做結果那就實在是太卡了,因此客戶端要在操做下達後本身模擬後果。若是操做不被打斷,其實客戶端模擬的結果和服務器仲裁後的結果是同樣的,這樣服務器在回傳後告之客戶端過去某個時間點的對象的狀態,其實和當初客戶端模擬的其實就是一致的,這種狀況下,客戶端就開開心心繼續往前跑就行了。

只有在預測操做時,好比玩家一直在向前跑,可是服務器那裏感知到另外一個玩家對他釋放了一個冰凍,將他頂在原地。這樣,服務器回傳給玩家的位置數據:他在某時刻停留在某地就和當初他本身預測的那個時刻的位置不一樣。產生這種預測失敗後,客戶端就須要本身調節。有 ECS 的幫助,狀態回滾到發生分歧的版本,考慮到服務器回傳的結果和新瞭解到的世界變化,從新將以後一段時間的操做從新做用到那一刻的狀態上,作起來就相對簡單了。

對於服務器來講,它默認客戶端會持續不斷的以固定週期向它推送新的操做。正如前面所說,服務器的時刻是有意比客戶端延後的,這樣,它並不是馬上處理客戶端來的輸入,而是把輸入先放在一個緩衝區裏,而後按和客戶端固定的週期 ( 60fps ) 從緩衝區裏取。因爲有這個小的緩衝區的存在,輕微的網絡波動(每一個網絡包送達的路程時間不徹底一致)是徹底沒有影響的。但若是網絡不穩定,就會出現到時間了客戶端的操做尚未送到。這個時候,服務器也會嘗試預測一下客戶端發生了什麼。等真的操做包到達後,比對一下和本身的預測值有什麼不一樣,基於過去那個產生分歧的預測產生的狀態和實際上傳的操做計算出下一個狀態。

同時,這個時候服務器會意識到網絡狀態很差,它主動通知客戶端說,網絡不太對勁,這個時候的你們遵循的協議就比較有趣了。那就是客戶端獲得這個消息就開始作時間壓縮,用更高的頻率來跑遊戲,從 60fps 提升到 65fps ,玩家會在感覺到輕微的加速,結果就是客戶端用更高的頻率產生新的輸入:從 16 ms 一次變成了 15.2 ms 一次。也就是說,短期內,客戶端的時刻更加領先服務器了,且越領先越多。這樣,服務器的預讀隊列就能更多的接收到將來將發生的操做,遇到到點殊不知道客戶端輸入的可能性就變少了。可是總流量並無增長,由於假設一局遊戲由一萬個 tick 組成,不管客戶端怎麼壓縮時間,提早時刻,總的數據仍是一萬個 tick 產生的操做,並無變化。

一旦度過了網絡不穩按期,服務器會通知客戶端已經正常了,這個時候客戶端知道本身壓縮時間致使的領先時長,對應的膨脹放慢時間(下降向服務器發送操做的頻率)讓狀態回到原點便可。

btw, 守望先鋒 是基於 UDP 通信的,從演講介紹看,對於 UDP 可能丟包的這個問題,他們處理的簡單粗暴:客戶端每次都將沒有通過服務器確認的包打包在一塊兒發送。因爲每一個邏輯幀的操做不多,打包在一塊兒也不會超過 MTU 限制。

ECS 在這個過程當中真正發生威力的地方是在預測錯誤後糾正錯誤的階段。一旦須要糾正過去發生的錯誤,就須要回滾、從新執行指令。移動、射擊這些都屬於常規的設定,比較容易作回滾從新執行;技能自己是基於暴雪開發的 Statescript 的,經過它來達到一樣的效果。ECS 的威力在於,把這些元素用 Component 分離了,能夠單獨處理。

好比說射擊命中斷定,就是一個單獨的系統,它基於被斷定對象都有一個叫作 ModifyHealthQueue 的組件。這個組件裏記錄的是 Entity 身上收到的全部傷害和治療效果。這個組件能夠用於 Entity 的篩選器,沒有這個組件的對象不會受到傷害,也就不須要參與命中斷定。真正影響命中斷定的是 MovementState 組件,它也參與了命中斷定這個系統的篩選,並真正參與了運算。命中斷定在查詢了敵對關係後從 MovementState 中獲取應該比對的對象的位置,來預測它是否被命中(可能須要播放對應的動畫)。可是傷害計算,也就是 ModifyHealthQueue 裏的數據是隻能在服務器填寫並推送給客戶端的。

MovementState 會由於須要糾正錯誤預測而被回退,同時還有一些非 MovementState 的狀態也會回退,好比門的狀態、平臺的狀態等等。這個回退是 Utility 函數的行爲,它可能會影響受擊的表現,而受傷則是另外一種固定行爲(服務器肯定的推送)的後果。他們發生在 Entity 的不一樣組件切片上,就能夠正交分離。

射擊預測和糾正能夠利用對象的活動區域來減小斷定計算量。若是能老是計算保持當前對象在過去一段時間的最大移動範圍(即過去一段時間的包圍盒的並集),那麼當須要作一個以前發生的射擊命中斷定時,就只須要把射擊彈道和當前全部對象的檢測區域比較,只有相交才作進一步檢測:回退相關對象到射擊發生的時刻,作嚴格的命中校驗。若是當初預測的命中結果和如今覈驗的一致就無所謂了,不須要修正結果(若是命中了,具體打中在哪不重要;若是未命中,也無論子彈射到哪裏去了)。

若是 ping 值很高,客戶端作命中預測每每是沒有什麼意義的,徒增計算量。因此在 Ping 超過 220ms 後,客戶端就再也不提早預測命中事件,直接等服務器回傳。

ECS 框架在這件事上能夠作到只去回滾和重算相關的 Component ,一個 System 知道哪些 Entity 纔是它真正關心的,該怎麼回退它所關心的東西。這樣開發的複雜度就減小了。遊戲自己是複雜的,可是和網絡同步相關的影響到遊戲業務的 System 卻不多,並且參與的 Component 幾乎都是隻讀的。這樣咱們就儘量的把這個複雜的問題和引擎其它部分解耦。


ECS 是個不錯的框架,可是須要遵循必定的規範才能起到他應有的效果:減小大量系統間的耦合度。但並不是全部的問題都適合遵循 ECS 的規範來開發,尤爲是一些舊有的模塊,很難作到把數據結構按 Component 得規範暴露出來,並把狀態改變的方法集成到獨立的 System 中去。這個時候就應該作一些封裝的工做。好比說有些系統本來就利用了多線程模型做並行優化,因此咱們須要把這些已經作好的工做隔離在 ECS 框架以外,僅僅暴露一些接口和 ECS 框架對接。

相關文章
相關標籤/搜索