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

古玩發表於2020-12-11

轉自雲風的BLOG: https://blog.codingnow.com/2017/06/overwatch_ecs.html

今天讀了一篇 《守望先鋒》架構設計與網路同步 。這是根據 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 產生的操作,並沒有變化。

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

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

相關文章