search
尋找貓咪~QQ 地點 桃園市桃園區 Taoyuan , Taoyuan

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

今天讀了一篇《守望先鋒》架構設計與網路同步。這是根據 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 所關心了。

在演講中,作者談到了一個根據輸入狀態來決定是不是要把長期不產生輸入的對象踢下線的例子,就是要對象同時具備連接組件、輸入組件等,然後這個 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 產生的操作,並沒有變化。

一旦度過了網路不穩定期,伺服器會通知客戶端已經正常了,這個時候客戶端知道自己壓縮時間導致的領先時長,對應的膨脹放慢時間(降低向伺服器發送操作的頻率)讓狀態回到原點即可。

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 框架對接。



熱門推薦

本文由 yidianzixun 提供 原文連結

寵物協尋 相信 終究能找到回家的路
寫了7763篇文章,獲得2次喜歡
留言回覆
回覆
精彩推薦