從《守望先鋒》學習關於ECS的概述

南水之源發表於2024-06-07

原文連線:

《守望先鋒》架構設計和網路同步

這次的分享是關於《守望先鋒》(譯註:下文統一簡稱為Overwatch)遊戲架構設計和網路部分。

這次分享的一些技術,是用來降低不停增長的程式碼庫的複雜度(譯註,程式碼複雜度的概念需要讀者自行查閱)。為了達到這個目的我們遵循了一套嚴謹的架構。最後會透過討論網路同步(netcode)這個本質很複雜的問題,來說明具體如何管理複雜性。

Overwatch是一個近未來世界觀的線上團隊英雄射擊遊戲,它的主要是特點是英雄的多樣性, 每個英雄都有自己的獨門絕技。

Overwatch使用了一個叫做“實體元件系統”的架構,接下來我會簡稱它為ECS。

ECS不同於一些現成引擎中很流行的那種元件模型,而且與90年代後期到21世紀早期的經典Actor模式區別更大。我們團隊對這些架構都有多年的經驗,所以我們選擇用ECS有點是“這山望著那山高”的意味。不過我們事先製作了一個原型,所以這個決定並不是一時衝動。

開發了3年多以後,我們才發現,原來ECS架構可以管理快速增長的程式碼複雜性。雖然我很樂意分享ECS的優點,但是要知道,我今天所講的一切其實都是事後諸葛亮 。

ECS架構概述

ECS架構看起來就是這樣子的。先有個World,它是系統(譯註,這裡的系統指的是ECS中的S,不是一般意義上的系統,為了方便閱讀,下文統稱System)和實體(Entity)的集合。而實體就是一個ID,這個ID對應了元件(Component)的集合。元件用來儲存遊戲狀態並且沒有任何的行為(Behavior)。System有行為但是沒有狀態。

這聽起來可能挺讓人驚訝的,因為元件沒有函式而System沒有任何欄位。

ECS引擎用到的System和元件

圖的左手邊是以輪詢順序排列的System列表,右邊是不同實體擁有的元件。在左邊選擇不同的System以後,就像彈鋼琴一樣,所有對應的元件會在右邊高亮顯示,我們管這叫元件元組(譯註,元組tuple,從後文來看,主要作用就是可以呼叫Sibling函式來獲取同一個元組內的元件,有點虛擬分組的意思)。

System遍歷檢查所有元組,並在其狀態(State)上執行一些操作(也就是行為Behavior)。記住元件不包含任何函式,它的狀態都是裸儲存的。

絕大多數的重要System都關注了不止一個元件,如你所見,這裡的Transform元件就被很多System用到。

來自原型引擎裡的一個System輪詢(tick)的例子

這個是物理System的輪詢函式,非常直截了當,就是一個內部物理引擎的定時更新。物理引擎可能是Box2d或者是Domino(暴雪自有物理引擎)。執行完物理世界的模擬以後,就遍歷元組集合。用DynamicPhysicsComponent元件裡儲存的proxy來取到底層的物理表示,並把它複製給Transform元件和Contact元件(譯註:碰撞元件,後文會大量用到)。

System不知道實體到底是什麼,它只關心元件集合的小切片(slice,譯註:可以理解為特定子集合),然後在這個切片上執行一組行為。有些實體有多達30個元件,而有些只有2、3個,System不關心數量,它只關心執行操作行為的元件的子集。

像這個原型引擎裡的例子,(指著上圖7中)這個是玩家角色實體,可以做出很多很酷的行為,右邊這些是玩家能夠發射的子彈實體。

每個System在執行時,不知道也不關心這些實體是什麼,它們只是在實體相關元件的子集上執行操作而已。

Overwatch裡的(ECS架構的)實現,就是這樣子的。

EntityAdmin是個World,儲存了一個所有System的集合,和一個所有實體的雜湊表。表鍵是實體的ID。ID是個32位無符號整形數,用來在實體管理器(Entity Array)上唯一標識這個實體。另一方面,每個實體也都存了這個實體ID和資源控制代碼(resource handle),後者是個可選欄位,指向了實體對應的Asset資源(譯註:這需要依賴暴雪的另一套專門的Asset管理系統),資源定義了實體。

元件Component是個基類,有幾百個子類。每個子類元件都含有在System上執行Behavior時所需的成員變數。在這裡多型唯一的用處就是過載Create和析構(Destructor)之類的生命週期管理函式。而其他能被繼承元件類例項直接使用的,就只有一些用來方便地訪問內部狀態的helper函式了。但這些helper函式不是行為(譯註:這裡強調是為了遵循前面提到的原則:元件沒有行為),只是簡單的訪問器。

EntityAdmin的結尾部分會呼叫所有System的Update。每個System都會做一些工作。上圖9就是我們的使用方式,我們沒有在固定的元組元件集合上執行操作,而是選擇了一些基礎元件來遍歷,然後再由相應的行為去呼叫其他兄弟元件。所以你可以看到這裡的操作只針對那些含有Derp和Herp元件的實體的元組執行。

Overwatch客戶端的System和元件列表

這裡有大概46不同的System和103個元件。這一頁的炫酷動畫是用來吸引你們看的(眾笑)。

然後是伺服器

你可以看到有些System執行需要很多元件,而有些System僅僅需要幾個。理想情況下,我們儘量確保每個System都依賴很多元件去執行。把他們當成純函式(譯註,pure function,無副作用的函式),而不改變(mutating)它們的狀態,就可以做到這一點。我們的確有少量的System需要改變元件狀態,這種情況下它們必須自己管理複雜性。

下面是個真實的System程式碼

這個System是用來管理玩家連線的,它負責我們所有遊戲伺服器上的強制下線(譯註,AFK, Away From Keyboard,表示長時間沒操作而被認為離線)功能。

這個System遍歷所有的Connection元件(譯註:這裡不太合適直接翻譯成“連線”),Connection元件用來管理伺服器上的玩家網路連線,是掛在代表玩家的實體上的。它可以是正在進行比賽的玩家、觀戰者或者其他玩家控制的角色。System不知道也不關心這些細節,它的職責就是強制下線。

每一個Connection元件的元組包含了輸入流(InputStream)和Stats元件(譯註:看起來是用來統計戰鬥資訊的)。我們從輸入流元件讀入你的操作,來確保你必須做點什麼事情,例如鍵盤按鍵;並從Stats元件讀取你在某種程度上對遊戲的貢獻。

你只要做這些操作就會不停重置AFK定時器,否則的話,我們就會透過儲存在Connection元件上的網路連線控制代碼發訊息給你的客戶端,踢你下線。

System上執行的實體必須擁有完整的元組才能使得這些行為能夠正常工作。像我們遊戲裡的機器人實體就沒有Connection元件和輸入流元件,只有一個Stats元件,所以它就不會受到強制下線功能的影響。System的行為依賴於完整集合的“切片”。坦率來說,我們也確實沒必要浪費資源去讓強制機器人下線。

為什麼不能直接用傳統物件導向程式設計模型?

上面System的更新行為會帶來了一個疑問:為什麼不能使用傳統的物件導向程式設計(OOP)的元件模型呢?例如在Connection元件裡過載Update函式,不停地跟蹤檢測AFK?

答案是,因為Connection元件會同時被多個行為所使用,包括:AFK檢查;能接收網路廣播訊息的已連線玩家列表;儲存包括玩家名稱在內的狀態;儲存玩家已解鎖成就之類的狀態。所以(如果用傳統OOP方式的話)具體哪個行為應該放在元件的Update中呼叫?其餘部分又應該放在哪裡?

傳統OOP中,一個類既是行為又是資料,但是Connection元件不是行為,它就只是狀態。Connection完全不符合OOP中的物件的概念,它在不同的System中、不同的時機下,意味著完全不同的事情。

行為和狀態分離的優勢

想象一下你家前院盛開的櫻桃樹吧,從主觀上講,這些樹對於你、你們小區業委會主席、園丁、一隻鳥、房產稅官員和白蟻而言都是完全不同的。從描述這些樹的狀態上,不同的觀察者會看見不同的行為。樹是一個被不同的觀察者區別對待的主體(subject)。

類比來說,玩家實體,或者更準確地說,Connection元件,就是一個被不同System區別對待的主體。我們之前討論過的管理玩家連線的System,把Connection元件視為AFK踢下線的主體;連線實用程式(ConnectUtility)則把Connection元件看作是廣播玩家網路訊息的主體;在客戶端上,使用者介面System則把Connection元件當做記分板上帶有玩家名字的彈出式UI元素主體。

Behavior為什麼要這麼搞?結果看來,根據主體視角區分所有Behavior,這樣來描述一棵樹的全部行為會更容易,這個道理同樣也適用於遊戲物件(game objects)。

新的問題

然而隨著這個工業級強度的ECS架構的實現,我們遇到了新的問題。

首先我們糾結於之前定下的規矩:元件不能有函式;System不能有狀態。顯而易見地,System應該可以有一些狀態的,對吧?一些從其他非ECS架構匯入的遺留System都有成員變數,這有什麼問題嗎?舉個例子,InputSystem, 你可以把玩家輸入資訊儲存在InputSystem裡,而其他System如果也需要感知按鍵是否被按下,只需要一個指向InputSystem的指標就能實現。

在單個元件裡儲存一個全域性變數看起來很很愚蠢,因為你開發一個新的元件型別,不可能只例項化一次(譯註:這裡的意思是,如果例項化了多次,就會有多份全域性變數的複製,明顯不合理),這一點無需證明。元件通常都是按照我們之前看見過的那種方式(譯註:指的是透過ComponentItr<>函式模板那種方式)來迭代訪問,如果某個元件在整個遊戲裡只有一個例項,那這樣訪問就會看起來比較怪異了。

無論如何,這種方式撐了一陣子。我們在System裡儲存了一次性(one-off)的狀態資料,然後提供了一個全域性訪問方式。從圖16可以看到整個訪問過程(譯註:重點是g_game->m_inputSystem這一行)。

如果一個System可以呼叫另外一個System的話,對於編譯時間來說就不太友好了,因為System需要互相包含(include)。假定我現在正在重構InputSystem,想移動一些函式,修改標頭檔案(譯註:Client/System/Input/InputSystem.h),那麼所有依賴這個標頭檔案去獲取輸入狀態的System都需要被重新編譯,這很煩人,還會有大量的耦合,因為System之間互相暴露了內部行為的實現。(譯註:轉載不註明出處,真的大丈夫嗎?還把譯者的名字都刪除!宣告:這篇文章是本人kevinan應GAD要求而翻譯!)

從圖16最下面可以看見我們有個PostBuildPlayerCommand函式,這個函式是InputSystem在這裡的主要價值。如果我想在這個函式里增加一些新功能,那麼CommandSystem就需要根據玩家的輸入,填充一些額外的結構體資訊發給伺服器。那麼我這個新功能應該加到CommandSystem裡還是PostBuildPlayerCommand函式里呢?我正在System之間互相暴露內部實現嗎?

隨著系統的增長,選擇在何處新增新的行為程式碼變得模稜兩可。上面CommandSystem的行為填充了一些結構體,為什麼要混在一起?又為什麼要放到這裡而不是別處?

無論如何,我們就這樣湊合了好一陣子,直到死亡回放(Killcam)需求的出現。

死亡回放系統帶來的重構-Singleton元件

為了實現Killcam,我們會有兩個不同的、並行的遊戲環境,一個用來進行實時遊戲過程渲染,一個用來專門做Killcam。我接下來會展示它們是如何實現的。

首先,也很直接,我會新增第二個全新的ECS World,現在就有兩個World了,一個是liveGame(正常遊戲),一個是replayGame用來實現回放(Replay)。

回放(Replay)的工作方式是這樣的,伺服器會下發大概8到12秒左右的網路遊戲資料,接著客戶端翻轉World,開始渲染replayAdmin這個World的資訊到玩家螢幕上。然後轉發網路遊戲資料給replayAdmin,假裝這些資料真的是來自網路的。此時,所有的System,所有的元件,所有的行為都不知道它們並沒有被預測(predict,譯註:後面才講到的同步技術),它們以為客戶端就是實時執行在網路上的,像正常遊戲過程一樣。

聽起來很酷吧?如果有人想要了解更多關於回放的技術,我建議你們明天去聽一下Phil Orwig的分享,也是在這個房間,上午11點整。

無論如何,到現在我們已經知道的是:首先,所有需要全域性訪問System的呼叫點(call sites)會突然出錯(譯註:Tim思維太跳躍了,突然話鋒一轉,完全跟不上);另外,不再只有唯一一個全域性EntityAdmin了,現在有兩個;System A無法直接訪問全域性System B,不知怎地,只能透過共享的EntityAdmin來訪問了,這樣很繞。

在Killcam之後,我們花了很長時間來回顧我們的程式設計模式的缺陷,包括:怪異的訪問模式;編譯週期太長;最危險的是內部系統的耦合。看起來我們有大麻煩了。

針對這些問題的最終解決方案,依賴於這樣一個事實:開發一個只有唯一例項的元件其實沒什麼不對!根據這個原則,我們實現了一個單例(Singleton)元件。

這些元件屬於單一的匿名實體,可以透過EntityAdmin直接訪問。我們把System中的大部分狀態都移到了單例中。

這裡我要提一句,只需要被一個System訪問的狀態其實是很罕見的。後來在開發一個新System的過程中我們保持了這個習慣,如果發現這個系統需要依賴一些狀態。就做一個單例來儲存,幾乎每一次都會發現其他一些System也同樣需要這些狀態,所以這裡其實已經提前解決了前面架構裡的耦合問題。

下面是一個單例輸入的例子。

全部按鍵資訊都存在一個單例裡面,只是我們把它從InputSystem中移出來了。任何System如果想知道按鍵是否按下,只需要隨便拿一個元件來詢問(那個單例)就行了。這樣做以後,一些很麻煩的耦合問題消失了,我們也更加遵循ECS的架構哲學了:System沒有狀態;元件不帶行為。

按鍵並不是行為,掌管本地玩家移動的Movement System裡有一個行為,用這個單例來預測本地玩家的移動。而MovementStateSystem裡有個行為是把這些按鍵資訊打包發到伺服器(譯註:按鍵對於不同的System就不是不同的主體)。

結果發現,單例模式的使用非常普遍,我們整個遊戲裡的40%元件都是單例的。

一旦我們把某些System狀態移到單例中,會把共享的System函式分解成Utility(實用)函式,這些函式需要在那些單例上執行,這又有點耦合了,我們接下來會詳細討論。

改造後如圖22,InputSystem依然存在(譯註:然而並沒有看到InputSystem在哪裡),它負責從作業系統讀取輸入操作,填充SingletonInput的值,然後下游的其他System就可以得到同樣的Input去做它們想做的。

像按鍵對映之類的事情就可以在單例裡實現,就與CommandSystem解耦了。

我們把PostBuildPlayerCommand函式也挪到了CommandSysem裡,本應如此,現在可以保證所有對玩家輸入的命令(PlayerCommand)的修改都能且僅能在此處進行了。這些玩家命令是很重要的資料結構,將來會在網路上同步並用來模擬遊戲過程。

在引入單例元件時,我們還不知道,我們其實正在打造的是一個解耦合、降低複雜度的開發模式。在這個例子中,CommandSystem是唯一一處能夠產生與玩家輸入命令相關副作用的地方(譯註:sideeffect,指當呼叫函式時,除了返回函式值之外,還對主呼叫函式產生附加影響,例如修改全域性變數了)。

每個程式設計師都能輕易地瞭解玩家命令的變化,因為在一次System更新的同一時刻,只有這一處程式碼有可能產生變化。如果想新增針對玩家命令的修改程式碼,那也很明朗,只能在這個原始檔中改,所有的模稜兩可都消失了。

共享行為-Utility函式

現在討論另外一個問題,與共享行為(sharedbehavior)有關。

共享行為一般出現在同一行為被多個System用到的時候。

有時,同一個主體的兩個觀察者,會對同一個行為感興趣。回到前面櫻花樹的例子,你的小區業委會主席和園丁,可能都想知道這棵樹會在春天到來的時候,掉落多少葉子。

根據這個輸出可以做不同的處理,至少主席可能會衝你大喊大叫,園丁會老老實實回去幹活,但是這裡的行為是相同的。

舉個例子,大量程式碼都會關心“敵對關係”,例如,實體A與實體B互相敵對嗎?敵對關係是由3個可選元件共同決定的:filter bits,pet master和pet。filter bits儲存隊伍編號(team index);pet master儲存了它所擁有全部pet的唯一鍵;pet一般用於像託比昂的炮臺之類。

如果2個實體都沒有filter bits,那麼它們就不是敵對的。所以對於兩扇門來說,它們就不是敵對的,因為它們的filter bits元件沒有隊伍編號。

如果它們(譯註:2個實體)都在同一個隊伍,那自然就不是敵對的,這很容易理解。

如果它們分別屬於永遠敵對的2個隊伍,它們會同時檢查自己身上和對方身上的pet master元件,確保每個pet都和對方是敵對關係。這也解決了一個問題:如果你跟每個人都是敵對的,那麼當你建造一個炮臺時,炮臺會立馬攻擊你(譯註:完全沒理解為什麼會這樣)。確實會的,這是個bug,我們修復了。(眾笑)

如果你想檢查一枚飛行中的炮彈的敵對關係,只需要回溯檢查射出這枚炮彈的開火者就行了,很簡單。

這個例子的實現,其實就是個函式呼叫,函式名是CombatUtilityIsHostile,它接受2個實體作為引數,並返回true或者false來代表它們是否敵對。無數System都呼叫了這個函式。

圖25中就是呼叫了這個函式的System,但是如你所見,只用到了3個元件,少得可憐,而且這3個元件對它們都是隻讀的。更重要的是,它們是純資料,而且這些System絕不會修改裡面的資料,僅僅是讀。

再舉一個用到這個函式的例子。

作為一個例子,當用到共享行為的Utility函式時我們採用了不同的規則。

如果你想在多處呼叫一個Utility函式,那麼這個函式就應該依賴很少的元件,而且不應該帶副作用或者很少的副作用。如果你的Utility函式依賴很多元件,那就試著限制呼叫點的數量。

我們這裡的例子叫做CharacterMoveUtil,這個函式用來在遊戲模擬過程中的每個tick裡移動玩家位置。有兩處呼叫點,一處是在伺服器上模擬執行玩家的輸入命令,另一處是在客戶端上預測玩家的輸入。

簡化共享行為

我們繼續用Utility函式替換 System間的函式呼叫,並把狀態從System移到單例元件中。

如果你打算用一個共享的Utility函式替換System間的函式呼叫,是不可能自動地(magically)避免複雜性的,幾乎都得做語句級的調整。

正如你可以把副作用都隱藏在那些公開訪問的System函式後面一樣,你也可以在Utility函式後面做同樣的事。

如果你需要從好幾處呼叫那些Utility函式,就會在整個遊戲迴圈中引入很多嚴重的副作用。雖然是在函式呼叫後面發生的,看起來沒那麼明顯,但這也是相當可怕的耦合。

如果本次分享只讓你學到一點的話,那最好是:如果只有一個呼叫點,那麼行為的複雜性就會很低,因為所有的副作用都限定到函式呼叫發生的地方了。

下面瀏覽一下我們用來減少這類耦合的技術。

延遲執行

當你發現有些行為可能產生嚴重的副作用,又必須執行時,先問問你自己:這些程式碼,是必須現在就執行嗎?

好的單例元件可以透過“推遲”(Deferment)來解決System間耦合的問題。“推遲”儲存了行為所需狀態,然後把副作用延後到當前幀裡更好的時機再執行。

例如,程式碼裡有好多呼叫點都要生成一個碰撞特效(impact effects)。

包括hitscan(譯註:直射,沒有飛行時間)子彈;帶飛行時間的可爆炸拋射物;查裡婭的粒子光束,光束長得就像牆壁裂縫,而且在開火時需要保持接觸目標;另外還有噴塗。

建立碰撞特效的副作用很大,因為你需要在螢幕上建立一個新的實體,這個實體可能間接地影響到生命週期、執行緒、場景管理和資源管理。

碰撞特效的生命週期,需要在螢幕渲染之前就開始,這意味著它們不需要在遊戲模擬的中途顯現,在不同的呼叫點都是如此。

下圖30是用來建立碰撞特效的一小部分程式碼。基於Transform(譯註:變形,包括位移旋轉和縮放)、碰撞型別、材質結構資料來做碰撞計算,而且還呼叫了LOD、場景管理、優先順序管理等,最終生成了所需的特效。

這些程式碼確保了像彈孔、焦痕持久特效不會很奇怪的疊在一起。例如,你用獵空的槍去射擊一面牆,留下了一堆麻點,然後法老之鷹發出一枚火箭彈,在麻點上面造成了一個大面積焦痕。你肯定想刪了那些麻點,要不然看起來會很醜,像是那種深度衝突(Z-Fighting)引起的閃爍。我可不想在到處去執行那個刪除操作,最好能在一處搞定。

我得修改程式碼了,但是看上去好多啊,呼叫點一大堆,改完了以後每一處都需要測試。而且以後英雄越來越多,每個人都需要新的特效。然後我就到處複製貼上這個函式的呼叫,沒什麼大不了的,不就是個函式呼叫嘛,又不是什麼噩夢。(眾笑)

其實這樣做以後,會在每個呼叫點都產生副作用的。程式設計師就得花費更多腦力來記住這段程式碼是如何運作的,這就是程式碼複雜度所在,肯定是應該避免的。

於是我們有了Contact單例。

它包含了一個未決的碰撞記錄的陣列,每個記錄都有足夠的資訊,來在本幀的晚些時候建立那個特效。如果你想要生成一個特效的時候,只需要新增一條新記錄並填充資料就可以了。等執行到幀的後期,進行場景更新和準備渲染的時候,ResolveContactSystem會遍歷陣列,根據LOD規則生成特效並互相疊加。這樣的話,即使有嚴重的副作用,在每一幀也只是發生在一個呼叫點而已。

除了降低複雜度以外,“推遲”方案還有很多其他優點。資料和指令都快取在本地,可以帶來效能提升;你可以針對特效做效能預算了,例如你有12個D.VA同時在射牆,她們會帶來數百個特效,你不用立即建立全部這些特效,你可以僅僅建立自己操縱的D.VA的特效就可以了,其他特效可以在後面的運算過程中分攤開來,平滑效能毛刺。這樣做有很多好處,真的,你現在可以實現一些複雜的邏輯了。即使ResolveContactSystem需要執行多執行緒協作,來確定單個粒子效果的朝向, 現在也很容易做。“推遲”技術真的很酷。

Utility函式,單例,推遲,這些都只是我們過去3年時間建立ECS架構的一小部分模式。除了限制System中不能有狀態,元件裡不能有行為以外,這些技術也規定了我們在Overwatch中如何解決問題。

遵守這些限制意味著你要用很多奇技淫巧來解決問題。不過,這些技術最終造就了一個可持續維護的、解耦合的、簡潔的程式碼系統。它限制了你,它把你帶到坑裡,但這是個“成功之坑”。

學習了這些之後呢,咱們來聊聊真正的難題之一,以及ECS是如何簡化它的。

網路同步

作為gameplay(遊戲玩法,機制)工程師,我們解決過的最重要的問題就是網路同步(netcode)。

這裡先說下目標,是要開發一款快速響應(responsive)的網路對戰動作遊戲。為了實現快速響應,就必須針對玩家的操作做預測(predict,也可以說是預表現)。如果每個操作都要等伺服器回包的話,就不可能有高響應性了。儘管因為一些混蛋玩家作弊所以不能信任客戶端,但是已經20年了,這條FPS遊戲真理沒變過。詳見原影片的 22:50 - 23:16 部分

遊戲中有快速響應需求的操作包括:移動,技能,就我們而言還有帶技能的武器,以及命中判定(hit registration)。

這裡所有的操作都有統一的原則:玩家按下按鍵後必須立即能夠看到響應。即使網路延遲很高時也必須是如此。

像我這頁PPT中演示的那樣,ping值已經250ms了,我所有的操作也都是立即得到反饋的,“看上去”很完美,一點延遲都沒有。

然而呢,帶預測的客戶端,伺服器的驗證和網路延遲就會帶來副作用:預測錯誤(misprediction,或者說預測失敗)了。預測錯誤的主要症狀就一點,會使得你沒能成功執行“你認為你已經做出的”操作。

問題提出

雖然伺服器需要糾正你的操作,但代價並不會是操作延遲。我們會用”確定性”(Determinism)來減少預測錯誤發生的機率,下面是具體的做法。

前提條件不變,PING值還是250毫秒。我認為我跳起來了,但是伺服器不這麼看,我被猛拉回原地,而且被凍住了(冰凍是英雄Mei的技能之一)。這裡(原影片23:30 - 23:50)你甚至可以看到整個預測的工作過程。預測過程開始時,試圖把我們移到空中,甚至大猩猩跳躍技能的CD都已經進入冷卻了,這是對的,我們不希望預測準確率僅僅是十之八九。所以我們希望儘可能的快速響應,

如果你碰巧在斯里蘭卡玩這個遊戲,而且又被Mei凍住了,那麼就有可能會預測錯誤。

下面我會首先給出一些準則,然後討論一下這個嶄新的技術是如何利用ECS來減少複雜度的。

這裡不會涉及到通用的資料複製技術、遠端實體插值(remote entity interpolation)或者是向後緩和(backwardsreconciliation)技術細節。

我們完全是站在巨人的肩膀上,使用了一些其他文獻中提過的技術而已。後面的幻燈片會假定大家對那些技術都已經很熟悉了。

確定性(Determinism)

確定性模擬技術依賴於時鐘的同步,固定的更新週期和量化。伺服器和客戶端都執行在這個保持同步的時鐘和量化值之上。時間被量化成command frame,我們稱之為“命令幀”。每個命令幀都是固定的16毫秒,不過在電競比賽時是7毫秒。

模擬過程的頻率是固定的,所以需要把計算機時鐘迴圈轉換為固定的命令幀序號。我們使用了一個迴圈累加器來處理幀號的增長。

在我們的ECS框架內,任何需要進行預表現、或者基於玩家的輸入模擬結果的System,都不會使用Update,而是用UpdateFixed。UpdateFixed會在每個固定的命令幀呼叫。

假定輸出流是穩定的,那麼客戶端的始終總是會超前於伺服器的,超前了大概半個RTT加上一個快取幀的時長。這裡的RTT就是PING值。上圖39的例子中,我們的RTT是160毫秒,一半就是80毫秒,再加上1個快取幀時長(上圖中為1幀),我們每幀是16毫秒,全加起來就是客戶端相對於伺服器的提前量。

圖中的垂直線代表每一個處理中的幀。客戶端開始模擬並把第19幀的輸入上報給伺服器,過一段時間(基本上是半個RTT加上緩衝時間)以後,伺服器才開始模擬這一幀。這就是我為什麼要說客戶端永遠是領先於伺服器的。

正因為客戶端是一股腦的儘快接受玩家輸入,儘可能地貼近現在時刻,如果還需要等待伺服器回包才能響應的話,那看起來就太慢了,會讓遊戲變得卡頓。圖39中的緩衝區,你肯定希望儘可能的小(譯註:緩衝越小,模擬時就越接近當前時刻),順便說一句,遊戲執行的頻率是60赫茲,我這裡播放動畫的速度是正常速度的百分之一(譯註:這也是為了讓觀眾看得更清晰、明白)。

客戶端的預測System讀取當前輸入,然後模擬獵空的移動過程。我這裡是用遊戲搖桿來表示獵空的輸入操作並上報的。這裡的(第14幀)獵空是我當前時刻模擬出來的運動狀態,經過完整的RTT加上緩衝事件,最終獵空會從伺服器上回到客戶端(譯註:這裡最好結合演講影片,靜態的文章無法表達到位)。這裡回來的是經過伺服器驗證的運動狀態快照。伺服器模擬權威帶來的副作用就是驗證需要額外的半個RTT時間才能回到客戶端。

那麼這裡客戶端為什麼要用一個環形緩衝(ring buffer)來記錄歷史運動軌跡呢?這是為了方便與伺服器返回的結果進行對比。經過比較,如果與伺服器模擬結果相同,那麼客戶端會開開心心地繼續處理下一個輸入。如果結果不一致,那就是一個“預測錯誤”,這時就需要“和解”(reconcile)了。

如果想簡單,那就直接用伺服器下發的結果覆蓋客戶端就行了,但是這個結果已經是“舊”(相對於當前時刻的輸入來講)的了,因為伺服器的回包一般都是幾百毫秒之前的了。

除了上面那個環形緩衝以外,我們還有另一個環形緩衝用來儲存玩家的輸入操作。因為處理移動的程式碼是確定性的,一旦玩家開始進入他想要進入到移動狀態,想要重現這個過程也是很容易的。所以這裡我們的處理方式就是,一旦從伺服器回包發現預測失敗,我們把你的全部輸入都重播一遍直至追上當前時刻。如下圖41中的第17幀所示,客戶端認為獵空正在跑路,而伺服器指出,你已經被暈住了,有可能是受到了麥克雷的閃光彈的攻擊。

接下來的流程是,當客戶端收到描述角色狀態的資料包時,我們基本上就得把移動狀態及時恢復到最近一次經過伺服器驗證過狀態上去,而且必須重新計算之後所有的輸入操作,直至追上當前時刻(第25幀)。

現在客戶端進行到第27幀(上圖)了,這時我們收到了伺服器上第17幀的回包。一旦重新同步(譯註:注意下圖41中客戶端獵空的狀態全都更正為“暈”了)以後,就相當於回退到了“幀同步”(lockstep)演算法了。

我們肯定知道我們到底被暈了多久。

到了下圖第33幀以後,客戶端就知道已經不再是暈住的狀態了,而伺服器上也正在模擬相同的情況。不再有奇怪的同步追趕問題了。一旦進入這個移動狀態,就可以重發玩家當前時刻的操作輸入了。

然而,客戶端網路並不保證如此穩定,時有丟包發生。我們遊戲裡的輸入都是透過定製化的可靠UDP實現。所以客戶端的輸入包常常無法到達伺服器,也就是丟包。伺服器又試圖保持了一個小小的、儲存未模擬輸入的緩衝區,但是讓它儘量的小,以保證遊戲操作的流暢。

一旦這個緩衝區是空的,伺服器只能根據你最後一次輸入去“猜測”。等到真正的輸入到達時,它會試著“緩和”,確保不會弄丟你的任何操作,但是也會有預測錯誤。

下面是見證奇蹟的時刻。

上圖可以看到,已經丟了一些來自客戶端的包,伺服器意識到以後,就會複製先前的輸入操作來就行預測,一邊祈禱希望預測正確,一邊發包告訴客戶端:“嘿哥們,丟包了,不太對勁哦”。接下來發生的就更奇怪的了,客戶端會進行時間膨脹,比約定的幀率更快地進行模擬。

這個例子裡,約定好的幀速是16毫秒,客戶端就會假裝現在幀速是15.2毫秒,它想要更加提前。結果就是,這些輸入來的越來越快。伺服器上緩衝區也會跟著變大,這就是為了在儘量不浪費的情況下,度過(丟包的)難關。

這種技術運轉良好,尤其是在經常抖動的網際網路環境下,丟包和PING都不穩定。即使你是在國際空間站裡玩這個遊戲,也是可以的。所以我想這個方案真的很NB。

現在,各位都記個筆記吧,這裡收到訊息,現在開始放大時間刻度,注意我們是真的加速輪詢了,你可以看見圖中右邊的坡越來越平坦了。它比以前更加快速地上報輸入。同時伺服器上的緩衝也越來越大了,可以容忍更多地丟包,如果真的發生丟包也有可能在緩衝期間補上。

如果這個過程持續發生,那目標就會是是不要超過承受極限,並透過輸入冗餘來使得預測錯誤最小化。

溫馨提示:(原影片的 30:50 - 31:56 體現了客戶端時間膨脹和服務端緩衝區變化全過程)

早些時候我有提到過,伺服器一旦飢餓,就會複製最後一次輸入操作,對吧?一旦客戶端趕上來了,就不會再複製輸入了,這樣會有因為丟包而被忽略的風險。解決方法是,客戶端維持一個輸入操作的滑動視窗。這項技術從《雷神世界》開始就有了。

我們不是僅僅傳送當前第19幀的輸入,而是把從最後一次被伺服器確認的運動狀態到現在的全部輸入都傳送過去。上面的例子可以看出,最後一次從伺服器來的確認是第4幀。而我們剛剛模擬到了第19幀。我們會把每一幀的每一個輸入都打包成為一個資料包。玩家一般頂多每1/60秒才會有一次操作,所以壓縮後資料量其實不大。一般你按住“向前”按鈕之前,很可能是已經在“前進”了。

結果就是,即使發生丟包,下一個資料包到達時依然會有全部的輸入操作,這會在你真正模擬以前,就填充上所有因為丟包而出現的空洞。所以這個反饋迴圈的過程和可增長的緩衝區大小,以及滑動視窗,使得你不會因為丟包而損失什麼。所以即使丟包也不會出現預測錯誤。

接下來會再次給你展示動畫過程,這一次是雙倍速,是正常速度的1/50了。

這裡有全部不穩定因素:網路PING值抖動,有丟包,客戶端時間刻度放大,輸入視窗填充了全部漏洞,有預測失敗,有伺服器糾正。我們它們都合在一起播放給你看。

戰鬥系統相關

接下來的議題,我不想講太多細節,因為這是Dan Reid的分享的主題(譯註,已經翻譯就是《守望先鋒》中網路指令碼化的武器和技能系統一文),因為這是開幕式的一部分,所以強烈推薦各位聽一下,真的很棒。還是在這個房間,我講完了就開始。

所有的技能都是用暴雪自有指令式指令碼語言State開發的。指令碼系統的一大優點就是它可以在前後穿越時空。在客戶端預測,然後伺服器驗證,就像之前的例子裡面的移動操作,我們可以把你回滾然後重播所有輸入。技能也使用了與移動相同的前後滾原則,先回退到最後一次經過驗證的快照的狀態,然後重播輸入直到當前時刻。

大家肯定還記得這個例子,就是獵空被暈導致的伺服器糾正過程,技能的處理過程是相同的。客戶端和伺服器都會模擬技能執行的確定性過程,客戶端領先於伺服器,所以一般是客戶端先模擬,伺服器稍後跟進。客戶端處理預測錯誤的方式是,先根據伺服器快照回滾,然後再前滾(roll forth),就像這樣幻燈演示的動畫過程那樣。這裡演示的是死神的幽靈形態。圖45中的這些方塊(譯註:State中的State)代表了幽靈形態,有了這些方塊我就可以很自信的播放很酷的特效和動畫了。

幽靈形態結束後就會關閉這些方塊。在同一幀中這些小動畫會展示出State的關閉過程。緊接著就是幽靈形態的出現,不久以後我們就會得到來自伺服器的訊息:“嗨,我預測的幽靈形態的過程已經告訴你了,所以你趕緊倒退回去,把這些State都開啟,然後咱們再重新模擬全部輸入,把這些State都關了”。這基本上就是每次伺服器下發更新時回滾和前滾的過程了。

能預測移動很酷,這意味著可以預測每個技能,我們也確實這樣做了,同樣,對於武器或者其他的模組,我們也可以這麼做。

命中判定的預測和確認

現在討論一下命中判定的預測和確認。

ECS處理這個其實很方便,還記得嗎,實體如果擁有行為所需的元件元組,它就會是這個行為的主體。如果你的實體是敵對的(還記得我們之前講的敵對性檢查吧)而且你有一個ModifyHealthQueue元件,你就可以被別的玩家擊中,這都受制於“命中判定”。

這兩個元件,一個是用來檢查敵對性的,一個是ModifyHealthQueue。ModifyHealthQueue是伺服器記錄的你身上的全部傷害和治療。與單例Contact類似,也是延遲計算的,而且有多個呼叫點,這就是最大的副作用。延遲計算是因為不想在拋射物模擬途中,立即生成一大堆特效,我們選擇延後。

順便說一句,傷害,也完全不會在客戶端預測,因為它們全都是騙子。

然而命中判定卻是在客戶端處理的。所以,如果你有一個MovementState元件,而且是一個不會被本地玩家操縱的remote物件,那你會被運動 System經過插值(interpolate)運算來重新定位。標準插值是發生在最後一次收到的兩個MovementState之間的,這項技術自從《Quake》時代就有了。

System根本不在乎你是一個移動平臺、炮臺、門還是法老之鷹,你只需要擁有一個MovementState元件就夠了,MovementState元件還要負責儲存環形緩衝區,還記得環形緩衝嘛?之前用來儲存那些獵空小人的位置的。

有了MovementState元件,伺服器在計算命中以前,就會把你回滾到攻擊者上報時你所在的那一幀,這就是向後緩和(backwards reconcilation)。這個回滾過程與ModifyHealthQueue無關,只是為了判斷是否擊中目標,當判定擊中時ModifyHealthQueue才開始工作,來決定了是否接受傷害。我們還需要倒回門、平臺、車的狀態,如果子彈被擋住了的話,就無所謂了。一般來說如果你是敵對的,而且有MovementState元件,你就會被倒回,而且可能會受傷。

被倒回(rewind)是被一組Utility函式操縱的行為;而受傷是MovementState元件被延遲處理時發生的另外一個行為。這兩種行為獨立開來,各自發生在各自的元件切片上。

射擊過程有點抽象,我這裡會分解一下。

圖47中的框是每一個實體的邏輯邊界(bounding volumes),可能有些不太明顯,圖片中央往左一點有一個茶色透明的框,就是邏輯邊界。邏輯邊界基本上就是代表了這個源氏的實時快照的並集。所以源氏周圍的邏輯邊界就代表了過去半秒鐘這個角色的全部運動(的最大範圍)。如果我現在沿著準星方向射擊,在倒回這個角色以前,會首先與這個邊界相交,因為基於我的PING值,它有可能在邊界內的任意一處位置。

這個例子裡,如果我沿著這個方向射擊,那隻需要單獨倒回安娜即可,因為子彈只和她的邊界相交了。不需要同時倒回大錘和他的能量盾或者車,以及後面的門。

射擊如同移動一樣,也可能會有預測失敗。

這裡的綠色人偶是死神的客戶端視角,黃色是伺服器視角。這些綠色的小點點是客戶端認為它的子彈擊中的位置。可以看見綠色的細線是子彈經過的路徑,但伺服器在校驗的時候,這個藍紫色的半球才代表實際命中的位置。

這完全是個人為製造的例子,確定型模擬過程是很可靠的,為了重現射擊過程中的預測失敗,我把我的丟包率設定為60%,然後足足射了這個混蛋20分鐘才成功重現(眾笑)。

這裡我還得提一句,模擬過程如此精確,要歸功於我們的QA團隊的同事。他們從不接受“NO”作為答案,而且因為市面上其他遊戲都不會把命中判定的預測精確度做到這個水平,所以我們的QA小夥伴們根本不相信我,也不在乎我。只是不停地提bug單,而且是越來越多的bug單,而每一次當我們去檢查是否真的有bug時,結果是每次都真的有。這裡要對他們表示深深的感謝,有了他們的工作才使得我們能做出如此偉大的產品。

如果你的PING值特別高,命中判定就會失效。

一旦PING值超過220毫秒,我們就會延後一些命中效果,也不會再去預測了,直接等伺服器回包確認。之所以這麼做的原因是,客戶端上本來就做了外插值(extrapolate),不想把目標倒回那麼遠。不想讓受害者覺得他們拼命跑到牆後面找掩護,結果還是被回拉、受傷。所以加了一層保護。這倒回外插後一段時間內的行為。下面的影片會演示這個過程(譯註:強烈建議看影片39:40 - 40:40)。

PING為0的時候,對彈道碰撞做了預測,而擊中點和血條沒有預測,要等伺服器回包才渲染。

當PING達到300毫秒的時候,碰撞都不預測了,因為射擊目標正在做快讀的外插,他實際上根本沒在這裡,這裡我們用了DR(Dead Reckoning)導航推測演算法,雖然很接近,但是他真沒在那裡。死神左右來回晃動時就會出現這種情況,外插時完全無法正確預測。這裡我們不會照顧你的感受,你的網路太差了。

最後這個影片,PING達到1秒的時候,尤為明顯。死神的移動方式不變,還會有外插。順便提一句,甚至PING已經是1秒鐘那麼慢了,客戶端的所有操作都還是能夠立即預測、響應的,只不過大部分都是錯的而已。其實我應該放大招的(午時已到),肯定能弄死他。

下面講下其他預測失敗的例子,PING值還是不怎麼好,150毫秒。這種條件下,無論何時遇到運動預測失敗,都會錯誤的預測命中。下面用慢動作展現一下。

看,都已經飆血了,但是卻沒看見血條,也沒看見彈坑,所以對於彈道碰撞的預測來講就是錯誤的。伺服器拒絕了,這不是一次合法的命中。碰撞效果預測失敗的原因就是“冰牆”立起來了。你“以為”自己開火時還站在地上,但是伺服器模擬時,你已經被冰牆升到了空中,就是這個行為導致預測失敗的。

當我們修復這些微小的命中預測錯誤時,發現大部分情況都能透過與伺服器就位置問題達成一致來消除,所以我們花了很多時間來對齊位置。

下面是與運動相關的預測失敗的例子,同時也與遊戲玩法有關。

PING值還是150毫秒,你想射中這個死神,但是他處於幽靈形態,箭頭碰到他時,客戶端會預測說應該有血飈出來,但沒有彈坑(hit pit),也沒有血條,我們根本沒擊中他,因為它已經先進入幽靈狀態了。

總結

ECS簡化了網路同步問題。網路同步程式碼中用到的System,知道自己何時被用於玩家身上,很簡單直接,基本上如果一個實體被一個帶有Connection元件的東西控制了,它就是一個玩家。

System也知道哪些目標需要被倒回到進攻者時刻的那一幀上,任何包含MovementState元件的實體都會被倒回。

實體與元件之間的內在關聯主要行為是MovementState可以在時間線上被取消。

上圖52是System和元件的全景圖,其中只有少數幾個與網路同步行為有關。而這就是我們已知最複雜的問題了。System中有兩個是NetworkEvent和NetworkMessage,是網路同步模組的核心組成部分,參與了接收輸入和傳送輸出這樣的典型網路行為。

還有另外幾個System,一隻手就數得過來:InterpolateMovement,Weapons,State,MovementState,我特別想刪了MovementState,因為我不喜歡它。所以呢,實際上網路同步模組中,只有3個System是與gameplay有關的,其中用到的元件就是右邊高亮列出的,也只有元件對於網路同步模組是隻讀的。真正修改了資料的就是像ModifyHealthQueue,因為對敵人造成的傷害是真實的。

現在回頭看一下,用了ECS這麼多年後,都學到了哪些知識與心得。

我有點希望System和Utility都能回到最早那個ECS操作元祖的權威例程的用法,做法有點特殊,我們只遍歷一個元件就夠了,再透過它訪問所有兄弟元件。對於真正複雜的元件訪問元組模型,你必須知道確切的訪問物件才行。如果有個行為需要一個含有40個元件的元組,那可能是因為你的系統設計過於複雜了,元組之間有衝突。

元組另一個很酷的副作用是,你掌握了關於什麼System能訪問什麼狀態的先驗知識,那麼回到我們用到元組的那個原型引擎當中,就可以知道2或3個System可以操作不同的元件集合。因為根據元組的定義就可以知道他們的用途。這裡設計的非常容易擴充套件。就像之前那個彈鋼琴的動畫一樣,不過可以看到多個System同時點亮,只因為它們操縱的元件集合是不同的。

由於已經知道元件讀寫的優先順序,System的輪詢可以做到多執行緒處理gameplay程式碼。這裡要提一句,Transform元件依然很受歡迎,但只有為數不多的幾個System會真正修改它,大部分System都是對它只讀。所以當你定義元組時,可以把元件標記上“只讀”屬性,這就意味著,即使有多個System都操作對該元件,但都是隻讀,可以並行處理。

實體生命週期管理需要一些技巧,尤其是在一幀的中間建立出來的那些。在早期,我們推遲了建立和銷燬行為,當你說“嘿我想要建立一個實體時”,實際上是在那一幀結束時才完成的。事實證明,推遲銷燬一點問題都沒有,而推遲建立卻有一大堆副作用。尤其是當你在System A 中申請建立一個新的實體,然後在System B中使用,這時如果你推遲了建立過程,你就要隔一幀才能使用。

這有點不爽。這也增加了很多內部複雜性(譯註:看到這裡,複雜性都是一些潛規則,需要花腦力去記住的hardcode),我們想修改掉這部分程式碼,使它可以在一幀的中途建立好,這樣就可以馬上使用了。

我們在遊戲釋出之後才做了這些改動,實在很恐怖。這個補丁打在了1.2或者1.3版本,上線那天晚上我都是通宵的。

我們大概花了1年半的時間來制定ECS的使用準則,就像之前那個權威的例子,但是我們需要改造一些現有的程式碼使之能夠適應新的架構。這些準則包括:

  • 元件沒有函式;
  • System沒有狀態;
  • 共享程式碼要放到Utils裡;
  • 元件裡複雜的副作用要透過佇列的方式推遲處理,尤其是單例元件;
  • System不能呼叫其他System的函式,即使是我們自己的取名System也不行,這個System幾年之前暴雪分享過的。

仍然有大量程式碼不符合這個規範,所以它們是複雜度和維護工作的主要來源,就一點也不奇怪了。透過檢視程式碼變更數量或者說bug數量,你就能發現這一點。

所以,如果你有什麼遺留程式碼而且無法融入ECS規範的話,就絕對不應該使用。保持子系統整潔,不用建立任何代理元件去對它們進行封裝。

不同的系統設計是用來解決問題的不同方法。

ECS是一個整合大量System的工具,不合適的系統設計原則就不應該被採用。

ECS的設計目的是用來把大量的模組進行整合並解耦,很多 System及其依賴的元件都是冰山形狀的。

冰山型元件對其他ECS的System暴露的表面很小,但它們內部其實有大量的狀態、代理或者資料結構是ECS層無法訪問的。

線上程模型中這些冰山的體型相當明顯,大部分ECS的工作,例如更新System,都是發生在主執行緒(圖58頂部)上的。我們也用到了大量的多執行緒技術,像fork和join。這個例子裡,有角色發射了大量的拋射物,然後指令碼System說我們需要生成一些拋射物,就建立了幾個工作執行緒來幹活。還有這裡是ResolvedContactSystem想要建立一些碰撞特效,這裡花費了幾個工作執行緒去做這項工作。

拋射物模擬的幕後工作已經被隔離,而且對上層ECS是不可見的,這樣很好。

另外一個很酷的例子就是AIPetDataSystem,很好的應用了fork和join模式,在ECS層面,只有一點點耦合,可能是說“嗨,這是一扇可破壞的門,你可能需要在這些區域重建路徑”,但是幕後工作其實很多,像獲取所有三角形,渲染並裁減,這些都與ECS無關,我們也不應該把ECS置於那些問題領域,應該自己想辦法。

這裡的影片演示的是PathValidationSystem,路徑(Path)就是全部這些藍色色塊,AI可以行走於其表面上。其實路徑並不只用於AI,也用在很多英雄的技能上。所以就需要在伺服器和客戶端之間對這些路徑進行資料同步。

影片裡的禪亞塔將會破壞這裡的這些物品,你會看見破壞後的物體掉落到表面下方。然後那裡的門會開啟我們會把那些表面粘在一起。PathValidationSystem只需要說:“嗨,三角形有變化”。然後冰山背後就會用全部資料重建路徑。

現在準備結束今天的分享了。

ECS是Overwatch的粘合劑,它很酷,因為它可以幫你用最小的耦合來整合大量分散的系統。如果你打算用ECS定義你的規範,實際上無論你想用什麼架構來快速定義你的規範,應該都是隻有少數程式設計師需要接觸物理系統程式碼、指令碼引擎或者音訊庫。但是每個人都應該能夠用到膠水程式碼,一起整合系統。

實施這些限制,就能夠馬到成功。

事實證明,網路同步真的很複雜,所以必須儘可能的與引擎其餘部分解耦,ECS是解決這個問題的好辦法。

最後在接受提問以前,我想感謝我們團隊成員,尤其是gameplay工程師,大家花了3年時間創造瞭如此美妙的藝術品。我們共同努力,建立原則,架構不斷進化,結果也是有目共睹的。

相關文章