《守望先鋒》架構設計與網絡同步 -- GDC2017 精品分享實錄

Overwatch Gameplay Architecture and Netcode

Timothy Ford

Lead Gameplay Engineer

Blizzard Entertainment

翻譯:kevinan

 

在GDC2017【Overwatch Gameplay Architecture andNetcode 】的分享會上,來自暴雪的Tim Ford介紹了《守望先鋒》遊戲架構和網絡同步的設計。一起來看看吧。

《守望先鋒》架構設計與網絡同步  -- GDC2017 精品分享實錄

《守望先鋒》架構設計與網絡同步  -- GDC2017 精品分享實錄

《守望先鋒》架構設計與網絡同步  -- GDC2017 精品分享實錄

          哈嘍,大家好,這次的分享是關於《守望先鋒》(譯註:下文統一簡稱爲Overwatch)遊戲架構設計和網絡部分。老規矩,手機調成靜音;離開時記得填寫調查問卷;換下半藏,趕緊推車!(衆笑)

          我是Tim Ford,是暴雪公司Overwatch開發團隊老大。自從2013年夏季項目啓動以來就在這個團隊了。在那之前,我在《Titan》項目組,不過這次分享跟Titan沒有半毛錢關係。(衆笑)

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

 

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

《守望先鋒》架構設計與網絡同步  -- GDC2017 精品分享實錄

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

《守望先鋒》架構設計與網絡同步  -- GDC2017 精品分享實錄

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

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

 

ECS架構概述

《守望先鋒》架構設計與網絡同步  -- GDC2017 精品分享實錄

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

 

這聽起來可能挺讓人驚訝的,因爲組件沒有函數而System沒有任何字段。

 

 


 

ECS引擎用到的System和組件

《守望先鋒》架構設計與網絡同步  -- GDC2017 精品分享實錄

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

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

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


 

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

《守望先鋒》架構設計與網絡同步  -- GDC2017 精品分享實錄

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

《守望先鋒》架構設計與網絡同步  -- GDC2017 精品分享實錄

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

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

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

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

《守望先鋒》架構設計與網絡同步  -- GDC2017 精品分享實錄

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

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

《守望先鋒》架構設計與網絡同步  -- GDC2017 精品分享實錄

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

Overwatch客戶端的System和組件列表

《守望先鋒》架構設計與網絡同步  -- GDC2017 精品分享實錄

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

然後是服務器

《守望先鋒》架構設計與網絡同步  -- GDC2017 精品分享實錄

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

下面是個真實的System代碼

《守望先鋒》架構設計與網絡同步  -- GDC2017 精品分享實錄

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

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

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

你只要做這些操作就會不停重置AFK定時器,否則的話,我們就會通過存儲在Connection組件上的網絡連接句柄發消息給你的客戶端,踢你下線。

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

 

爲什麼不能直接用傳統面向對象編程模型?

《守望先鋒》架構設計與網絡同步  -- GDC2017 精品分享實錄

上面System的更新行爲會帶來了一個疑問:爲什麼不能使用傳統的面向對象編程(OOP)的組件模型呢?例如在Connection組件裏重載Update函數,不停地跟蹤檢測AFK?

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

傳統OOP中,一個類既是行爲又是數據,但是Connection組件不是行爲,它就只是狀態。Connection完全不符合OOP中的對象的概念,它在不同的System中、不同的時機下,意味着完全不同的事情。

 

那麼把行爲和狀態區分開,又有什麼理論上的優勢(conceptual advantages)呢?

《守望先鋒》架構設計與網絡同步  -- GDC2017 精品分享實錄

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

《守望先鋒》架構設計與網絡同步  -- GDC2017 精品分享實錄

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

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

 

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

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

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

《守望先鋒》架構設計與網絡同步  -- GDC2017 精品分享實錄

無論如何,這種方式撐了一陣子。我們在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)需求的出現。

《守望先鋒》架構設計與網絡同步  -- GDC2017 精品分享實錄 

《守望先鋒》架構設計與網絡同步  -- GDC2017 精品分享實錄

 

《守望先鋒》架構設計與網絡同步  -- GDC2017 精品分享實錄

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

 

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

《守望先鋒》架構設計與網絡同步  -- GDC2017 精品分享實錄

         

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

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

 

《守望先鋒》架構設計與網絡同步  -- GDC2017 精品分享實錄

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

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

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

《守望先鋒》架構設計與網絡同步  -- GDC2017 精品分享實錄

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

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

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

《守望先鋒》架構設計與網絡同步  -- GDC2017 精品分享實錄

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

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

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

《守望先鋒》架構設計與網絡同步  -- GDC2017 精品分享實錄

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

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

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

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

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

每個程序員都能輕易地瞭解玩家命令的變化,因爲在一次System更新的同一時刻,只有這一處代碼有可能產生變化。如果想添加針對玩家命令的修改代碼,那也很明朗,只能在這個源文件中改,所有的模棱兩可都消失了。

 

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

《守望先鋒》架構設計與網絡同步  -- GDC2017 精品分享實錄

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

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

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

《守望先鋒》架構設計與網絡同步  -- GDC2017 精品分享實錄

舉個例子,大量代碼都會關心“敵對關係”,例如,實體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都調用了這個函數。

《守望先鋒》架構設計與網絡同步  -- GDC2017 精品分享實錄

圖25中就是調用了這個函數的System,但是如你所見,只用到了3個組件,少得可憐,而且這3個組件對它們都是隻讀的。更重要的是,它們是純數據,而且這些System絕不會修改裏面的數據,僅僅是讀。

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

《守望先鋒》架構設計與網絡同步  -- GDC2017 精品分享實錄

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

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

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

 

《守望先鋒》架構設計與網絡同步  -- GDC2017 精品分享實錄

          我們繼續用Utility函數替換 System間的函數調用,並把狀態從System移到單例組件中。

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

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

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

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

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

《守望先鋒》架構設計與網絡同步  -- GDC2017 精品分享實錄

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

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

 

例如,代碼裏有好多調用點都要生成一個碰撞特效(impact effects)。

《守望先鋒》架構設計與網絡同步  -- GDC2017 精品分享實錄

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

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

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

下圖30是用來創建碰撞特效的一小部分代碼。基於Transform(譯註:變形,包括位移旋轉和縮放)、碰撞類型、材質結構數據來做碰撞計算,而且還調用了LOD、場景管理、優先級管理等,最終生成了所需的特效。

 

 《守望先鋒》架構設計與網絡同步  -- GDC2017 精品分享實錄

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

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

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

          於是我們有了Contact單例。

《守望先鋒》架構設計與網絡同步  -- GDC2017 精品分享實錄

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

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

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

 

《守望先鋒》架構設計與網絡同步  -- GDC2017 精品分享實錄

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

 

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

《守望先鋒》架構設計與網絡同步  -- GDC2017 精品分享實錄

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

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

《守望先鋒》架構設計與網絡同步  -- GDC2017 精品分享實錄

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

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

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

         

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

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

《守望先鋒》架構設計與網絡同步  -- GDC2017 精品分享實錄

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

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

《守望先鋒》架構設計與網絡同步  -- GDC2017 精品分享實錄

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

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

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

 

確定性(Determinism)

《守望先鋒》架構設計與網絡同步  -- GDC2017 精品分享實錄

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

《守望先鋒》架構設計與網絡同步  -- GDC2017 精品分享實錄

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

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

《守望先鋒》架構設計與網絡同步  -- GDC2017 精品分享實錄

          假定輸出流是穩定的,那麼客戶端的始終總是會超前於服務器的,超前了大概半個RTT加上一個緩存幀的時長。這裏的RTT是PING值加上邏輯處理的時間。上圖39的例子中,我們的RTT是160毫秒,一半就是80毫秒,再加上1幀,我們每幀是16毫秒,全加起來就是客戶端相對於服務器的提前量。

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

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

《守望先鋒》架構設計與網絡同步  -- GDC2017 精品分享實錄

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

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

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

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

《守望先鋒》架構設計與網絡同步  -- GDC2017 精品分享實錄

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

《守望先鋒》架構設計與網絡同步  -- GDC2017 精品分享實錄

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

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

《守望先鋒》架構設計與網絡同步  -- GDC2017 精品分享實錄

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

《守望先鋒》架構設計與網絡同步  -- GDC2017 精品分享實錄

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

《守望先鋒》架構設計與網絡同步  -- GDC2017 精品分享實錄

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

          下面是見證奇蹟的時刻。

《守望先鋒》架構設計與網絡同步  -- GDC2017 精品分享實錄

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

 

《守望先鋒》架構設計與網絡同步  -- GDC2017 精品分享實錄

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

 

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

 

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

《守望先鋒》架構設計與網絡同步  -- GDC2017 精品分享實錄

          一旦服務器發現,你現在的網絡恢復健康了,它就會發消息給你說:“嘿哥們,現在沒事了”。而客戶端會做相反的事情:它會縮小時間刻度,以更慢的速度發包。同時服務器會減小緩衝區的尺寸。

         

《守望先鋒》架構設計與網絡同步  -- GDC2017 精品分享實錄

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

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

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

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

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

 

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

 

          接下來的議題,我不想講太多細節,因爲這是Dan Reid的分享的主題(譯註,已經翻譯),因爲這是開幕式的一部分,所以強烈推薦各位聽一下,真的很棒。還是在這個房間,我講完了就開始。

《守望先鋒》架構設計與網絡同步  -- GDC2017 精品分享實錄

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

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

 

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

 

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

 

 

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

《守望先鋒》架構設計與網絡同步  -- GDC2017 精品分享實錄

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

 

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

 

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

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

 

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

 

有了MovementState組件,服務器在計算命中以前,就會把你回滾到攻擊者上報時你所在的那一幀,這就是向後緩和(backwards reconcilation)。這一切都與ModifyHealthQueue組件正交, ModifyHealthQueue組件決定了是否接受傷害。我們還需要倒回門、平臺、車的狀態,如果子彈被擋住了的話,就無所謂了。一般來說如果你是敵對的,而且有MovementState組件,你就會被倒回,而且可能會受傷。

 

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

 

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

《守望先鋒》架構設計與網絡同步  -- GDC2017 精品分享實錄

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

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

 

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

《守望先鋒》架構設計與網絡同步  -- GDC2017 精品分享實錄

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

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

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

 


 

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

《守望先鋒》架構設計與網絡同步  -- GDC2017 精品分享實錄

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

 

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

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

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

         

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

《守望先鋒》架構設計與網絡同步  -- GDC2017 精品分享實錄

 

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

 

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

         

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

《守望先鋒》架構設計與網絡同步  -- GDC2017 精品分享實錄

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

 

這種例子裏,雖然大部分時間都會優先滿足進攻者,但除非受害者做了什麼事情緩和(mitigate)了這次進攻。在這個例子裏,死神的幽靈形態會給他3秒鐘的無敵時間。無論如何,我們沒有真的打到死神。

讓我從哲學角度想象一下,你就是那個死神,你進入了幽靈狀態,但事實上服務器很可能會讓你播放所有特效,讓後讓你死掉,因爲你不可能如此快速進入那個狀態。


 

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

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

 

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

 

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

 

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

《守望先鋒》架構設計與網絡同步  -- GDC2017 精品分享實錄

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

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

《守望先鋒》架構設計與網絡同步  -- GDC2017 精品分享實錄

 

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

《守望先鋒》架構設計與網絡同步  -- GDC2017 精品分享實錄

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

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

《守望先鋒》架構設計與網絡同步  -- GDC2017 精品分享實錄

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

 

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

《守望先鋒》架構設計與網絡同步  -- GDC2017 精品分享實錄

我們大概花了1年半的時間來制定ECS的使用準則,就像之前那個權威的例子,但是我們需要改造一些現有的代碼使之能夠適應新的架構。這些準則包括:組件沒有函數;System沒有狀態;共享代碼要放到Utils裏;組件裏複雜的副作用要通過隊列的方式推遲處理,尤其是單例組件;System不能調用其他System的函數,即使是我們自己的取名System也不行,這個System幾年之前暴雪分享過的。

 

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

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

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

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

 

《守望先鋒》架構設計與網絡同步  -- GDC2017 精品分享實錄

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

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

 

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

 

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

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

《守望先鋒》架構設計與網絡同步  -- GDC2017 精品分享實錄

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

 

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

《守望先鋒》架構設計與網絡同步  -- GDC2017 精品分享實錄

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

 

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

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

 

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

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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章