1. 程式人生 > >《守望先鋒》中網路指令碼化的武器和技能系統

《守望先鋒》中網路指令碼化的武器和技能系統

https://www.gameres.com/751979.html

《守望先鋒》中網路指令碼化的武器和技能系統

釋出者: 一元 | 釋出時間: 2017-5-24 10:36| 評論數: 4

遊戲程式
平臺型別:  
程式設計:  
程式語言:  
引擎/SDK:  
  在GDC2017【Networking Scripted Weapons and Abilities in Overwatch】的分享會上,來自暴雪的Dan Reed介紹了《守望先鋒》中網路化的指令碼和工具相關技術。一起來看看吧。
 

 


  嗨,大家好,我叫 Dan Reed, 是暴雪娛樂的遊戲工程師(gameplay engineer,譯註:遊戲機制工程師,或者遊戲工程師,都可以),今天主要跟大家分享《守望先鋒》(後面統一用Overwatch表示)中的網路指令碼化的武器和技能系統。

  那麼這裡先簡單介紹下我在Overwatch中的主要工作。

 


  包括:

  Statescript腳本系統,這也是今天我們要講到的;

  拋射物和單局遊戲模式;

  同時我也參與了一些特殊武器、技能和運動系統的設計;

  觀戰模式;

  一些UI也是我做的;

  再有就是一些我自己都不記得的工作了。

  概覽(譯註:這種黑體頂頭格式用於每一頁幻燈片上方,提領下文)

 


  Overwatch實現了一套自己的腳本系統來編寫包括武器和技能在內的高層邏輯, 這套暴雪自有(proprietary)的腳本系統叫做Statescript。

  今天分享的內容包括關於Statescript的“為什麼” 、“是什麼”以及“如何做到的”。為什麼我們決定實現這麼特殊的一套系統,Statescript到底是什麼?以及它背後的技術細節,這部分大約會耗時15分鐘。

  另外會討論網路通訊需求及解決方案,包括腳本系統在Internet環境下遇到的那些限制,以及我們是怎麼應對的,約30分鐘。

  然後會分析一下這種方法的好處和挑戰,這部分大約5分鐘。

  最後宣告一下本次分享不會包括的內容: 拋射物、命中檢測以及一些特殊技能的實現。不是說這些不重要,這些都是很棒的特性值得作為獨立的議題來討論,只是超出了今天的分享範圍 。

  為什麼要開發Statescript

 


  我們需要給“非程式設計師”提供開發上層邏輯的能力,因為我們知道需要建立大量的遊戲邏輯,又不希望每個需求都要靠程式設計師手動編寫解決方案。

  我們希望這個解決方案允許使用者“定義”新的遊戲狀態,而不僅僅是“響應”這些狀態。一般典型的遊戲腳本系統都有一個相當不透明的遊戲模擬過程,其中指令碼也能編寫邏輯以響應事先定義好的事件,通過使用者自己定義變數、函式呼叫來微調,執行的結果最後都會消失回到黑盒狀態。而我們更需要的是一個形式化的、明確的方式,使得指令碼開發者(譯註:scripter,下面統一用開發者)對狀態和狀態轉移能直接地、完全地掌控。

  我們想要模組化的程式碼儘可能多地被複用,我們不會把一個特性(feature,也可譯作功能)需求看作是一組垂直功能的堆疊,而是會去設計並實現那些這個特性所需的基礎功能元件。

  我們需要一個無痛的、穩定的方式來實現一個能夠通過網路同步的狀態機。手寫這些程式碼費時費力而且容易出錯,所以,最好讓計算機來替你完成這些工作。

  另外這個方案需要能夠與專案引擎的其餘部分協同工作,我們也對比了很多第三方指令碼引擎,但是最終還是決定自己去開發一套能嵌入到我們的遊戲引擎中的指令碼語言,以得到最好的結果。

  Statescript 是什麼?

 


  Statescript是一個視覺化的指令碼語言;每一個指令碼都是一組互相連線的節點(node)形成的圖(graph),代表了一段遊戲邏輯的實現;這裡舉幾個指令碼的例子:獵空的“閃回”技能,盧西奧附近隊友受到的加速、治療buff,所有英雄都有的UI控制元件等。
 


  當一個指令碼執行時,它會建立一個執行時物件,這裡稱之為指令碼例項(instance),每一個例項都被一個實體(entity,不懂的同學可以參考另一篇分享:Overwatch Gameplay Architecture and Netcode)所擁有,例如每個“英雄”都是一個“實體”。如果你聽過Tim Ford的分享,你肯定知道這是什麼。

  實體上的指令碼例項可以被動態地新增和刪除,例如,無論何時你被麥克雷的閃光彈暈到,一段能夠阻止你移動、瞄準行為的指令碼例項就會動態加在你的身上,並且在一段時間內起作用,直到它被移除。

  同一個實體上可以同時運行同一指令碼的多個例項。

  現在開始聊一下Statescript 節點(node)

 


  在所有的節點中,首先我們有入口(Entry),Entry是指令碼執行的起始點,它的作用很基礎,就是在指令碼開始執行時,觸發一個脈衝給到它的輸出(Output),當然也有好多其他型別的Entry會等待特定的訊息(Message)才觸發。

  然後是條件(Condition),Condition會影響指令碼執行流程,上圖中的布林Condition僅僅基於一些表示式的結果來輸出“真”或者“假”。

  接下來是動作(Action),Action基本上就是C++函式呼叫,這些函式在觸發輸出以前,會做一些立即完成的工作,像這個SetVar就是目前最常用的一個Action。

  最後是狀態(State),State代表一些正在進行中的工作。一個State一直是處於未啟用(Inactive)狀態,直到它的Begin插頭(Plug,可譯作介面,但是會有概念混淆)上收到脈衝訊號,它才啟用自己。然後State就會一直保持在這個啟用狀態中,直到它自己決定關閉(Deactivate),或者是因為外部原因而被動結束。

  在這背後,每個State型別都是一個帶有一堆虛擬函式的C++類(class),這些虛擬函式提供了一系列介面,包括OnActivate(啟用)、OnDeactivate(關閉)、OnTick(輪詢)、OnDependencyChange()等。這裡面最重要的部分是他們都代表某種持續性的行為(behavior),而且這些行為都會在持續一段時間後停止。這個WaitState很簡單,就像它的名字所描述的:“等待3秒鐘就結束”。

  (譯註:所有的節點型別,為了避免誤解,後面統一用英文單詞)

  變數(Variables)

 


  Statescript提供了大量的變數,包括“例項變數”和“所有者變數”來存放數值。

  每個例項都有隻屬於自己的一堆變數,叫例項變數。

  而例項所屬的實體(譯註:就是例項的“所有者”),一般也含有一堆共享變數。上圖中,執行在獵空的脈衝槍指令碼上的子彈(Ammo)和彈夾(Clip)變數,就是這個指令碼的私有變數,但“AbilityLock”變數卻可以被獵空英雄實體的所有Statescript例項共享,這就是“所有者變數”。

  一個變數既可以是單個的基本型別,也可以基本型別的陣列。對於大部分需求來說,這已經足夠了,但是至少還有一些時候,我們希望能支援巢狀結構體(nested struct)和集合(bags),我們將來會考慮實現這個功能。

  變數可以是“state-defined”(狀態定義)的,它們當前的值是根據當前的StatescriptState來確定的,所以基本上可以通過詢問State來得到一個變數的值。

  屬性Properties

 


  Statescript節點的行為是根據屬性定義的;從上圖右邊部分中能都看到,開發者可以從事先配置好的變數(Config Vars, 譯註:翻譯成配置引數比較好)列表裡選擇需要的變數來給每個“屬性”賦值;

  Config Vars可以包含巢狀的屬性,例如圖中右上方有個“HeadPosition”配置變數裡就有一個巢狀屬性,你可以從另外一個Config Vars裡選擇,哪個實體會被賦予這個位置屬性,在這裡例子裡就是此指令碼的所有者實體。

  每一個Config Vars型別都是通過C++中的一個函式來實現的,這個函式可以把這些變數的值返回給這些指令碼。下圖是一些Config Vars的例子:

 


  常見Config Vars有:字面型別,變數,Utilities(基本就是一些C++函式)和表示式。表示式除了能做一些“foo是不是大於3”的無聊事情以外,還能夠引用巢狀Config Vars列表,以支援更復雜的邏輯,例如:“源實體位置和目標實體位置之間的離是否大於3”。

  其他Statescript功能

  大部分其他功能今天沒時間講了,但是有幾個我認為值得一提的還是想拿出來說一下。第一個是Subgraph(譯註:子圖,指的是每個節點還可以包含一個圖)。

 


  每一個State都有一個Subgraph的輸出,在State啟用時就會產生脈衝,而在State關閉(Deactivated)時,所有Subgraph中的State也會隨之關閉。有些State會包含其他型別的Subgraph插頭,會在特定的時刻啟用或者關閉State。

  上圖中的Boolean Switch State就是個很常見的例子,它有1個TrueSubgraph和1個False Subgraph,會在條件達到時啟用,條件未達到時關閉。

  接下來是容器“Containers”

 


  我們有不同的Containers變種。灰色邊框的是最基本型別的Container,幾乎沒怎麼組織,不會影響Behavior(行為);紅色邊框的Container定義了哪些State是Subgraph的一部分,否則的話Subgraph只會跳轉一次State;藍色邊框的Container是客戶端專用的;紫色的是Server端專用的。這些可以在必要的時候,在客戶端和Server端生成不同功能的Behavior。

  在我講解第一個真實的指令碼例子以前,我想簡要的介紹一下兩個重要的Statescript Theme(主題)。

  第一個Theme是“生命週期保障”(lifetime guarantees)。

 


  簡單來說,也就是State的自我清理。在一個State關閉時,它的邏輯behavior執行完成,所以需要停止播放動畫,清除它擁有的全部特效,重置所有改變過的變數,並關閉它啟用的Subgraph,等等。

  一個例項被刪除時會關閉所有狀態。

  一個實體銷燬時它會刪除所有例項。

  遊戲結束時它會銷燬所有實體。

  這些都是顯而易見的,但是當整個class都有bug時,開發者也不用擔心,因為每個State的合約都是:如果需要清理,它自己必須實現完整的OnDeactivate介面。

  另外一個Theme叫Logic Style(程式正規化)。

 


  Statescript既支援指令式(Imperative)指令碼:先做這個,再檢查那個,再做那個;也支援宣告式(Declarative)指令碼,無論何時告訴電腦做什麼,它就做什麼,能做到這一點的部分原因是因為我們有生命週期管理。

  我們發現針對大型、複雜的需求建模,宣告式指令碼是最明智的選擇。但是指令式也有它自己的一席之地,通常被用在宣告式指令碼的指令樹的葉子節點上。

  這就引出了我們第一個Statescript例項

 

 


  “死神”本來不能用右鍵開火,所以現在讓我們賦予他一個新的技能,流程如下:玩家按住右鍵1秒鐘,攝像機就切入第三人稱視角,代表技能現在已經開始準備,然後玩家釋放按鍵,死神就被髮射到半空中。注意,如果玩家按住右鍵少於1秒鐘的話,什麼都不會發生。

  現在我們在編輯器裡來搞定這個技能。

 


  先增加一個Entry,當“死神”出生時,指令碼就可以開始執行了。然後增加一個叫“LogicalButton”的State,當右鍵被按住時觸發一個Subgraph,還有另外一個在右鍵沒有被按住時執行的Subgraph。當右鍵已經被按住了1秒鐘,把“ReadyToLaunch”變數設定為True。然後進入第三人稱視角。
 


  然後呆在這個狀態直到右鍵被釋放。注意:這裡用來演示操作過程的視訊已經被加速到2倍,實際上我是沒辦法弄得這麼快的(眾笑)。
 


  一旦右鍵被釋放,我們立即就會去檢查ReadyToLaunch是否為True,如果按住右鍵足夠長時間的話,那它就一定是True。而且如果我們真的這麼做了,就一定能把自己發射到空中。

  在那之前我先把ReadyToLaunch設定為False。

  正如你所見到的,這個指令碼例子混合了一個宣告式風格:這個行為當且僅當按鍵被按下時才啟用,和一個指令式風格:等待一秒鐘,把變數ReadyToLaunch設為True,然後進入第三人稱視角。

  然後來測試這個新的技能。

 


  一切正如我們所期望的那樣:右鍵按下,1秒鐘以後,ReadyToLaunch變成True,然後進入第三人稱視角,右鍵擡起,我被升到空中,同時ReadyToLaunch變成False。如果我只是輕輕點一下按鍵,則什麼都沒有發生。我至少要按住右鍵1秒鐘才能進入準備發射狀態,並進入第三人稱視角。

  下面來做一個更加複雜的指令碼。這是“獵空”的脈衝槍,嗯,這裡我沒時間講解所有關於它如何運作的細節,但是你也能看出同樣的原則在起作用,宣告式指令碼:這個為True的時候,這些事一定會發生;以及指令式指令碼:先做這個事情,接著等待1秒鐘然後做其他事。

 


  在我們進入到網路部分以前,再花5分鐘的時間來快速地過一遍整個Statescript系統是如何用C++實現的。
 


  核心執行時(Core Runtime)
 


  整個Overwatch的計時器都是基於整數的Command Frames(命令幀,也可譯作指令幀,代表伺服器下發到客戶端的資料單位)的,所以Statescript也利用了這個特性。

  每一幀是16毫秒,一秒鐘剛好60幀;

  每個實體都需要掛載一個Statescript元件才能執行指令碼。假如你錯過了之前那個很重要的分享(Overwatch Gameplay Architecture and Netcode), 那我告訴你,實體,以及Overwatch是建造在一系列元件之上的,這些元件允許系統可以執行特定的操作,這一切就是“實體元件系統模型”,簡稱ESC。

 


  Statescript元件包含了所有在一個實體上執行指令碼所必需的資料,會簡單瀏覽一遍。

  客戶端上會有內部命令幀(Internal Command Frame),這個內部命令幀與當前正在模擬的來自Server的命令幀有所區別,後面會詳細講到。

  我們有一個Statescript例項陣列和一堆所有者變數,還有同步管理器(sync manager),後面會深入講。

  每一個Statescript例項都是在指令碼開始、停止時動態分配的;都有唯一的例項ID用來做網路序列化;它還有一個指向Stu(譯註:結構化資料的縮寫,後面還會提到) Graph Asset資源的指標,Stu Graph物件裡都是靜態資料,不會在執行時改變;還有一個Statescript State陣列,State是多型的,在指令碼中首次用到時,通過一個工廠方法建立,然後就一直存在直到指令碼被銷燬。

  這裡有一個未來事件Event的列表,這些都是準備好在將來的某個時刻在某個State或者是例項自己身上執行的。事件經常在與自己入佇列時相同的命令幀上被觸發,有時候會帶有權重在未來觸發。

  另外每個例項上都會有一堆例項變數。

  順便說一句,這只是資料的粗略描述,真正深入到一個執行時的Statescript裡, 會看到更多標誌(flags)、快取物件列表(cached list)來優化效能。我上面列出的僅是一些最重要的資料,而且與我後面講到的內容會有關聯。

  States(狀態)

 


  Statescript的State基類提供了一些實用函式,例如“訪問屬性資料”、“事件排程”和“註冊輪詢回撥(registering forticking)”。

  這個基類還提供了一些虛擬函式留給派生類去實現,所以我們就有了OnActivate,OnDeactivate,OnTimerEvent,OnFrameTick這些介面,如果State註冊了輪詢回撥,那這些介面會在每個命令幀被呼叫到。

  GetStateDefinedValue這個函式允許State給一個特定的變數提供一個on-demand值;

  OnInternalDependencyChanged,接下來會馬上講到;

  最後三個虛擬函式是用來隱藏網路抖動的,稍後也會講到。

  所有的State都有一個StatescriptDependencyListener

 


  它是一個指向StatescriptDependencyProvider的指標陣列,反過來,每一個Provider也都有一個指回Listener的指標陣列,這就形成了一個多對多的關係。

  Providers可以依賴於Statescript內部的變數,也可以依賴於那些Statescript以外的,被State依賴的物件。

  執行的時候,Listener是在某些需要特定Providers的屬性第一次被計算的時候懶載入的。所以,如果一個屬性請求查詢某些實體的Health(血量),State的Listener就會獲得一個指向那個實體的Health元件的Provider的指標,顯然,這個Provider也會同時指回Listener。

  Provider變化時,會在所有Listening(譯註:Listening的意思是與Provider互相指向)的State上呼叫OnInternalDependencyChanged。這是一個很重要的優化點,因為它意味著State不需要進行輪詢(Pull)檢查值是否變化,而是會收到通知。

  變數Variables

 


  StatescriptVarBags包含了一個指向StatescriptVars指標的字典表,這些StatescriptVars是在第一次使用到時才分配的。

  字典每個成員的key都是一個16位ID,對映到我們的Asset庫中某個已註冊的asset(譯註:這裡需要了解暴雪的Asset管理系統)。

  StatescriptVar可以是以下2種類型的任意1種:基本型別和基本型別的陣列。每個基本型別都是一個128位(bit)長的聯合體(Union),可以存下整形、動態陣列、字串指標等;

  StatescriptVar本身也是DepenencyProvider,有任何變化時都可以通知State。

  StatescriptVar也可以引用一個Statescript的State,可以獲取到State的當前值。所以如果你想知道一個變數的值,只需要呼叫GetState即可獲取當前引用的State上該變數的state-defined值。關於這一點,最常見的用法是ChaseVar State,這個State可以持續追蹤變數的值變化。

  繼續其他議題以前,說兩句關於結構化資料Stu

 


  Overwatch中的很多資源(assets)都是用一種我們稱之為結構化資料的格式定義的,簡稱Stu。 這裡會有一個步驟來把這些.stu檔案編譯成程式碼,我們的編輯器editor、資源編譯器complier和執行時runtime都能夠理解並使用這些程式碼。對類(class )型別和資料成員新增屬性、反射也是支援的。這些屬性對於Statescript編輯器和資源編譯器(後面我會講到)都是很有用的。

  現在可以討論Wait State Data Schema了

 


  這個例子裡,不好意思,“在”這個例子裡,有一個關於Wait State的結構化資料的定義,這裡提醒一下,這個不是C++程式碼,而是Stu標記語言。Stu標記是用來生成描述這些資料物件的class的。

  現在看下這個Stu class的第一個成員,它只有一個屬性(property),就是m_timeout持續時間,代表這個Wait State的超時結束 時間。

  它上方的Constraint標籤告訴編輯器,把這個屬性的下拉選擇內容限制為那些能夠提供數值型結果的ConfigVar,可以是整形或者浮點型。

  在底部我們還添加了2個插頭(plug),一個是用來在State被提早撤銷時觸發,另外一個是在等待結束時觸發。

  下面是Wait State的C++執行時

 


  你可以看出它是繼承自StatescriptState基類的。

  頂部的巨集定義DECLARE_STATESCRIPT_RTTI用來設定一些執行時型別資訊(RTTI)。這個類的大部分程式碼都是關於過載函式OnActivate的。

  首先我們定義了一個指向Stu物件的指標,Stu物件包含了這個State所需的資料,這些資料需要在編輯器裡填充。

  Stu物件的類是在上一頁幻燈片中定義的。

  然後我們呼叫了函式GetFloat,並把timeout ConfigVar作為引數傳遞給它。這樣就能得到用來傳遞給EnqueueFinishStateEvent函式的“秒”數。經過這個時間以後,State就會觸發它的Finish插頭(m_onFinishPlug),然後進入關閉狀態。

  接下來又是2個巨集定義,用來保證Abort和Finish這兩個插頭能夠在期望的時間內觸發。

  最後一行還是巨集定義,是用來把執行時型別和Stu結構化資料型別關聯起來,這樣的話,Statescript系統在程式碼執行到這個階段時,就知道用哪個class來初始化。

  顯然我沒有任何一個例子可以用來說明Actions,Conditions和ConfigVars是如何實現的,但是你們可以稍微把他們想象成State的更簡化版本,他們每一個都有且僅有一個被呼叫的函式,而且他們的執行時版本不包含任何資料,在指令碼執行時也不需要例項化任何東西,所以更簡單。

  以上就是關於Statescript的簡單介紹了。

  現在是時候來說明如何用Statescript來做一個網路遊戲了

 


  我們的第一個需求是“可用性”
 


  它不能干擾使用者,並且抽象了全部的網路通訊細節 。最早的時候。我們不想區分伺服器和客戶端指令碼,這種恐懼來自於,即使聽起來很簡單的Behavious行為,實現起來也需要大量額外的指令碼來同步資料,寫這樣的程式碼很乏味也容易出錯。我們的遊戲開發團隊對於那些本應由計算機完成的工作容忍度是很低的,所以很自然地也把這個原則應用到了Statescript網路版中。
 


  結果就是我們可以在伺服器和客戶端運行同樣的指令碼。我們發現其實也給開發者提供在必要時分離的指令碼行為,但是這樣做的機會不多。

  響應性

 


  必須能夠適應快速響應的遊戲。這意味著無論延遲有多高,玩家的操作必須能夠立即有響應。這一點無需多言,否則的話,假設你開了一槍、用了一下技能或者開始衝刺,然後等待伺服器回包才能收到視覺上的反饋,你一定會覺得這遊戲遜斃了。

  安全性

 


  安全性是必須的,我們必須防止玩家通過傳送惡意資料包來影響其他玩家的行為,沒有人喜歡作弊者。

  效率

 


  它必須足夠高效,允許遊戲在弱網路環境中正常進行。因為Overwatch需要執行在全世界的網路上,這就意味著有時必須面對“高延遲”、“丟包”等網路問題。

  無縫

 


  它必須是無縫的,能夠最小化那些可察覺的、來自網路的影響。最開始我們只是想著在遇到問題時能夠有辦法處理就好了,但是當我們實現了越來越多的新節點型別(node types,就是上文中提到的state,action,condition等等)以後,清晰地感覺到,我們需要一個更加正規的方法,來處理那些因為使用特定武器和技能時遇到的肉眼可見的,醜陋的拉扯、卡頓問題。

  網路同步解決方案

 


  那現在來講一下我們是如何滿足這些需求的。首先讓我們來澄清一下,對於一個特定的Statescript例項,“網路同步”意味著什麼。
 


  經過同步化以後,伺服器和客戶端可以在使用邏輯上相同的例項。就是說,因為無需關注網路細節,大家可以公平地討論伺服器和客戶端都在模擬(simulate,譯註:後面會多次提到,這裡採用的翻譯是模擬,用在本文裡有執行、執行遊戲邏輯程式碼的含義)的同一個邏輯例項。

  同步的結果是最終一致的,所以無論客戶端做過什麼樣的預表現(Prediction,譯註:翻譯成預測、預演、預表現都可以),無論發生什麼樣的網路異常,伺服器和客戶端都能修正並最終回到彼此一致的狀態。

  另外還有非同步的例項,這些例項依然可以收到來自同步化例項的訊息,也可以從同步化例項讀取變數,但除此以外,他們的內部邏輯又是完全獨立的。

  下面是一些同步化、非同步化指令碼的例子

 


  對於同步化的例項,我們有武器、技能、表情、單局遊戲模式和地圖實體(大門、血包等) 。

  對於非同步化的例項,我們有選單、英雄收藏品、單局結束流程和音樂。

  再說一次,正因為指令碼中可以在例項之間傳送訊息,甚至是同步化例項和非同步化例項之間,所以我們可以做到讓單局遊戲模式例項控制音樂例項來播放不同的音樂。

  在我們更加深入網路部分以前,關於例項,還有最後一個定義

 


  任何一個給定的客戶端上, 任何一個網路化的、可以被玩家直接控制的實體,例如:你可能正在玩獵空或者源氏,我們把這個實體和它身上的Statescript例項叫做該客戶端上的local,所有其他的網路化實體都叫該客戶端上的remote。

  注意local實體並不是必須的,例如當播放死亡回放時,或者當前遊戲內玩家沒有任何可以操作的物件時,這時並沒有local實體,你僅僅是在觀看已經發生的一切。

  伺服器會跟蹤記錄哪些實體對於哪些客戶端是local的。

  現在開始討論一下伺服器權威

 


  網路版Statescript 就是伺服器權威的,這意味著伺服器對於所有發生的事情,具有最終裁決權。通訊通常是從伺服器到客戶端單向進行的,唯一的從客戶端到伺服器的通訊就是按鍵輸入和瞄準。

  接著簡單說一下從客戶端到伺服器的輸入操作

 


  如果你聽過Tim的分享,你肯定已經看過這個流程圖了,而且是更加細節的。

  注意:這裡的水平軸是現實世界的時間。首先,伺服器下發一次更新,這是它處理過的最新的一個命令幀,在這個例子裡,幀號是100。客戶端收到以後,發現為了讓自己可以對伺服器正在發生的事情有影響,它的輸入必須及時到達伺服器以被正確處理。這就意味著它不能僅僅把輸入操作作為100幀的回包發給伺服器,因為伺服器上的時間會一直流逝。所以它需要把輸入作為未來的某個時刻發給伺服器。但是應該有多“超前”呢?

  伺服器和客戶端形成了一個反饋環,伺服器會分析命令幀到達時有多提前或者延後,然後通知客戶端這些計算後的往返時延,簡稱

  RTT(round-triptime),所以這個例子裡,假如客戶端想要傳送針對100幀加上RTT的時延的回包,那就是105幀,因而也就能及時到達伺服器並處理。

  在實踐中,我們實際上是在網路條件的基礎上,再超前一點點。例如,如果你的RTT頻繁變化,我們的補償就會再超前一點點來確保輸入及時到達伺服器。

  本來我們應該再回頭講講客戶端的,但是現在我們已經知道伺服器如何從客戶端獲取輸入,那麼我們可以更深入瞭解伺服器的同步響應性。

  簡要概覽

 


  首先伺服器從客戶端收集當前命令幀的所有實體的操作,然後我們在所有的實體上執行這個命令幀,並把所有發生的變化儲存在StatescriptDeltas中,最後把這些Delta(直譯為“變化”,這裡不做翻譯了直接用Delta表示)發給所有的客戶端。

  我們講講StatescriptDeltas

 


  如果你還能記起早前講過的,Statescript元件都包含一個Sync Manager,用來在伺服器和客戶端之間對實體保持同步。在伺服器端,Sync Manager持續追蹤一個StatescriptDeltas的陣列,這些Delta代表了實體在一個特定命令幀上經歷的變化。注意,我們只在那些有變化的幀上建立Delta物件,最後來看,這部分比例很小,因為大部分時候對於一個實體來說很少發生變化。

  現在過一遍StatescriptDeltas的資料結構,首先我們有命令幀,注意我們的Delta代表是一個實體在命令幀開始和結束之間的那些變化;我們還有一個包含所有發生變化且已經同步了的例項的陣列,對於這個陣列的每一個成員,都有這些屬性:Instance ID;建立/銷燬標誌;以及所有發生變化的例項變數(Variable)陣列,對於每個例項變數都有一個ID欄位,對於陣列型別例項變數,我們有一個欄位代表“發生變化的陣列下標範圍”,通過追蹤記錄這個範圍,我們可以避免傳輸整個陣列;還有一個數組記錄了所有發生變化的State的索引;再有一個數組記錄了所有執行過的Action的索引;最後還是一個數組,記錄了在一個給定命令幀上,發生過變化的所有者變數(Owner Variables)。

  每一個StatescriptDeltas在所有客戶端都確認收到對應的命令幀前會一直儲存在伺服器,確認後就沒必要在儲存了,可以很安全地刪除它。

 


  現在我們已經知道發生了哪些變化,但是到底應該把哪些變化傳送給誰呢?這就是StatescriptGhosts的用處所在了。
 


  StatescriptGhosts跟蹤記錄每個客戶端對於伺服器上的每一個實體的資訊瞭解程度。現在看一下它的資料結構:客戶端編號;最後一次確認的命令幀編號,證實客戶端確實擁有了現在這個及之前命令幀的全部資訊;一個指標陣列,指向外部的StatescriptPackets資料包,這裡的“外部”的意思是,我們已經發送了資料包但是還沒有得到對方是否收到的答覆。注意,當一個數據包被客戶端確認接收(簡稱Ack),或者超時未接收表示發生丟包(簡稱Nack),Overwatch的網路底層會分別通知每一個系統模組,也包括Statescript系統。我們利用這個特性來維護StatescriptGhost物件:一旦我們得到某個資料包的Ack或者Nack,我們就把它從外部資料包列表中移除。

  客戶端斷開連線以後,StatescriptGhosts才會銷燬。

  現在學習一下StatescriptPacket

 


  還是先看資料結構:一個Local/Remote的標誌,根據牽涉到的實體相對於接受者是否為Local,包資料格式會有所不同;命令幀範圍起始和結束編號;最重要的payload(直譯為有效載荷,指協議外的有效資料)欄位,代表要傳輸的實際內容,為了生成這個payload,我們建立了一個命令幀範圍內全部StatescriptDeltas的並集,這裡的並集就是數學上的概念,基本上我們需要知道命令幀範圍內的全部變化。然後我們對這個並集中引用到的所有物件的值進行序列化。

  如果命令幀範圍是從0開始,那它肯定是一個剛剛建立連線的客戶端,那就僅僅需要傳送全部物件的“當前值”即可,我們把這叫做全量更新(full update),這種情況下完全不需要關心Delta。

  資料包在傳送後會暫存。另外在命令幀範圍相同,Local/Remote標誌也相同的情況下,資料包可以重複利用。這是一個優化點:不需要花時間重新建立完全相同的payload了。

  與StatescriptDeltas的工作方式類似,一個數據包也是會一直儲存,直到所有客戶端都已經確認收到其中的“結束幀”。

 


  伺服器同步總結
 


  StatescriptDeltas跟蹤記錄實體的最近的變化情況;StatescriptGhosts跟蹤記錄哪個客戶端對於哪個StatescriptDeltas瞭解多少;StatescriptPackets是可重用的有效資料payload,繫結到客戶端,對應於一個或者多個StatescriptDeltas。

  下面是個Demo,用來演示某個具體實體的網路同步流程

 


  在頂部的時間線(timeline)上,能看見2個不同的Delta,對應於期間 Statescript實體發生過變化的命令幀。第一個Delta發生在100幀,我們立即建立了一個數據包並下發到客戶端。經過一段時間後,在103幀上,這個實體產生了另外一次Delta。由於之前的資料包還在傳輸過程中,沒必要重傳,所以我們建立了只包含103幀Delta的資料包並下發。

  等到第106幀的時候,伺服器發現出問題了:它可能不會收到100幀的資料包的確認訊息了。這種情況下伺服器就要做決定了:重發哪些包呢?它至少必須重發100的包,但是是否重發103,現在決定還為時過早。

  在這個案例中,我們最終決定多走一步,還是傳送100和103兩個幀包的並集,避免因為103幀也發生丟包而引發的問題。但這就意味著客戶端可能收到兩次103包,如你所見確實發生了。如果說冗餘可以幫助一個客戶端更快地從一連串的丟包中恢復過來的話,那它就完全是值得的。

  客戶端也懂得這種重複是伺服器的策略之一,所以它不會處理第一個103包, 因為這樣做不但會導致錯誤的執行狀態(illegal simulationstate,只有103的變化,缺失了100的變化,這種狀態在伺服器上根本不存在),而且也沒必要(後面無論如何都還會收到一次包含103包的合集,已經是最新的了,根本不需要第一個103包 )。

  最後回到伺服器端,收到了來自客戶端關於2個數據包(譯者注:一個是103的,一個是100和103合集的)的確認收到資訊。事實上收到第一個確認包並不會對伺服器有任何幫助,因為僅僅能夠知道100包還在路上;第二個確認包則會讓伺服器很開心了,因為它知道100和103都確實被客戶端收到了,一切都很順利。

  現在換回到客戶端

 


  客戶端當前Local實體在模擬(執行)時會快取按鍵輸入和預表現。正如你還能記得起來的那樣,Local實體是執行在一個相對於下行包更加未來的時間線上的,我們發給伺服器的上行包會在伺服器處理該命令幀之前到達。Local實體跑在未來,所以它用預表現來儲存未確認的操作。

  當收到一個來自伺服器的StatescriptPacket時,首先發送一個確認收到的Ack資訊,如果是超時冗餘或者亂序的包,就整個忽略掉。正如之前的幻燈片中展示的例子那樣。

  如果StatescriptPacket是Remote,就直接複製包資料就行了。

  否則,如果是Local,首先回滾所有已經執行的預表現,複製資料,然後使用之前快取的輸入,重新模擬執行到當前時刻,我們有時候管這個過程叫前滾(Roll forth)。這裡要注意,執行前滾時,儘管我們使用了之前快取的輸入和瞄準操作,但新的預表現又需要被加進來。 另外,整個回滾、前滾過程都是實時發生在同一幀,玩家是發現不了的 。

  我們確實需要給Statescript  State和Action新增一些實用函式來保持這個過程是無縫的,我等下就會再詳細講講這個。

  收到一個Remote包的處理過程

 


  從上圖中可以看到,客戶端有一個Remote實體,從伺服器收到幾個StatescriptPackets以後,接受這些更新(Update),就這麼簡單。

  注意,在大多數情況下,Remote Statescript例項既不觸發節點(Node)間的link,也不處理事件,他們僅僅是輪詢(Tick)那些需要重新整理的State。在這個例子裡,他們都是“啞”的,依賴伺服器告訴他們所有的事情。這裡唯一的例外是Client專有的Subgraph,只要擁有這個Subgraph的State認為它是啟用的,它就會一直全量地模擬執行。

  收到一個預表現包的處理過程

 


  上圖顯示了客戶端的一個Local實體,進行一次預表現,並收到了一個StatescriptPacket回包,圖中的灰色條代表一個按鍵被按住不放,灰色虛線是客戶端把這個輸入發回給伺服器,哦對不起,是發給伺服器。

  可以看見客戶端在100幀做了一些預表現行為,來響應玩家按鍵。伺服器上也是在同一幀執行同樣的過程,然後下發一個StatescriptPackets。類似的事情也發生在103幀。

  等到105幀的時候,客戶端收到一個描述活動的100幀回包,所以它回滾所有在103和100幀做過的預表現,圖中用洋紅色表示的,直接丟棄它們。然後複製伺服器版本的100幀資料,圖中是用青色表示的。然後重新執行從101到105幀的全部過程(雖然作者沒說明,但明顯是綠色表示的),這個過程中重新構造了103幀。

  注意這裡的ICS代表內部命令幀(Internal Command Frame),這是Statescript系統當前正在模擬的幀。

  最後當客戶端收到來自伺服器的第二個活動時,我們會在108幀得到一些類似的過程。

  收到預測錯誤的包如何處理

 


  在這個例子裡, 客戶端發生了一些沒做預表現的事情,所以也無法進行回滾操作。引起這些的原因可能是外部的,例如被“眩暈”或者被“擊殺”;假如在103幀客戶端做了預表現,執行了一些操作但是伺服器上並沒有做,有可能是因為另外一個外部原因阻止伺服器這樣做了。一旦客戶端意識到它在103幀上做的預表現永遠收不到確認回包了它就會回滾,然後從104幀開始重新模擬到現在。

  現在回頭看看這些同步是如何作用於咱們剛剛給死神新增加的右鍵技能上的。

 


  (譯註:下面很長一段時間都是動態演示過程,最好結合視訊,僅僅靠幻燈片是比較難以理解的)

  現在按住右鍵,等待,切換到第三人稱,釋放,跳到空中。現在請把注意力放到螢幕右邊的垂直方向的條上,這是Statescript偵錯程式的時間線。我現在暫時停止收集資料,並回滾時間到過去來看看發生了什麼事情。

  螢幕左上角,你可以看見View:Server字樣,說明現在顯示的內容是伺服器上發生過的事情,接下來我們開始對整個命令幀單步除錯,當我放開右鍵的時候,可以看到下面的Subgraph關閉了,包括Camera 3P這個State也是,然後就能看見bool condition的ReadyToLaunch變成True了。然後我們執行這個MovementMod Action,就會把我發射到空中。最後ReadyToLaunch會被設定為False。

  現在來看一下客戶端都發生了什麼。

 


  還是螢幕左上角,切換View到Client。我們還是單步跟蹤發射技能的模擬預表現,可以看見時間線是綠色的。如果你觀察時間線上游標旁邊,可以看到CF字樣,CF代表命令幀(Command Frame)。這就是死神這個實體當前正在進行模擬的一幀,ICF代表內部命令幀(Internal CommandFrame),這是Statescript系統正在進行模擬的一幀。那麼現在,因為我們已經執行一次預表現,這兩個值(CF和ICF)是相同的,但是當我們前進幾幀以後再看看會發生什麼?游標進入洋紅色區域,這就意味著我們從伺服器收到了一個StatescriptPacket而且正在執行回滾。你會注意到現在ICF剛好在我們第一次做預表現的那一幀上。回滾完成以後,我們實際上已經處於更早的命令幀的開始階段上了。

  接下來我們會進入青色區域,複製操作開始了。注意,複製不需要跟隨links。為了節省頻寬,儘量做到最小化:設定變數然後更新State。

  如果你很好奇為什麼這些Action沒有被複制,那是因為如果執行復制的話,SetVar和MovementMod這兩個Action會冗餘。前者是因為其中的變數已經被複制過了;後者是因為它會執行自己的複製操作。關於這些優化我會再多講一些。

  在現在的情形下,我們需要模擬回到當前,這就需要執行“前滾”。但是因為什麼都沒做,偵錯程式什麼也沒記錄,這就是為什麼看起來它好像不見了,但是我們肯定會確保回到現在的。現在可以看到命令幀和內部命令幀完全相同。

  那麼,難道回滾和前滾不會使得程式設計師開發新節點(Node)型別變得更困難嗎?畢竟誰也不想僅僅就是因為從伺服器收到了一個包,就得重新開始播放動畫或者重複播放一段聲音,或者生成額外的粒子特效!

 


  答案是:是的,它的確使得開發變難了。儘管Statescript很大程度上把開發者從網路細節下保護起來了,C++程式設計師還是偶爾不得不處理這種問題。為了幫助改善,State提供了很多實用函式,例如每個State的啟用和關閉都有一個Reason引數,Reason可以是“伺服器回滾複製”、“實體被銷燬”等。State還提供了一些函式來幫助瞭解模擬過程當前處於哪個階段,例如:“訪問某一幀的某個State的所有活動和關閉資訊” 。

  然後我們還有OnBecomeActiveThisTick和OnBecomeInactiveThisTick,在一幀的最後,如果你的State的啟用狀態與這一幀開始時不同,這兩個函式就會被客戶端的Sync Manager在你的State上呼叫。當你的State僅僅處理輸出(例如特效或者聲音或者UI)而且不需要自己反饋結果給到Statescript去模擬時,這就會很有用。這種情況下,完全不用擔心OnActivate和OnDeactivate的實現, 只要等到一幀的最後對這些做響應就行了,這些可以幫助在回滾和前滾場景下避免因為狀態關閉開啟時帶來干擾(pops)和額外影響。

  最後我們還有2個函式 PutUpdate和GetUpdate,用來從伺服器向客戶端傳輸State的資料,雖然很有用,但是這種函式寫起來很乏味又容易出錯,我們應該能夠做到更好,後面會繼續講。

  Action也有一些實用(Utilities)函式,可以執行單獨的回滾和訪問臨時回滾儲存。這裡需要有儲存是因為Action都是單例(Singleton)物件沒有自己的儲存區。然而我們是需要存點東西的,來避免在複製或者前滾期間播放聲音。這是對於整個Action的無狀態原則的一種破壞,不怎麼理想,但是看起來是值得讓步的。

  幸運的是,我們不需要經常寫這類可預測(predictable)的Action。

 


  即使有了這些實用函式,我沒還是覺得編寫同步化的State有點困難。所以我們又想了另外一個辦法。
 


  我們沒有用PutUpdate和GetUpdate,而是用了結構化映象資料庫自動從伺服器到客戶端複製資料,自動處理回滾。有了這個以後,程式設計師從此不再需要手動編寫傳輸State資料的程式碼,實現起來更快了,bug也少了。

  更好的是,程式設計師甚至都不需要編寫定製化的邏輯來處理回滾時State的內部資料了。

  現在來看另外一個例子:獵空開槍時的回滾和前滾

 


  這裡可以看到WeaponVolley這個State,在我們做本地預表現時,忽略掉了所有單次的射擊(譯註:這個忽略過程一定要配合視訊來理解)。

  這裡開始回滾,因為收到伺服器回包了。青色的這些是資料複製。然後這是伺服器檢視。最終我們模擬回到了現在。注意看,儘管WeaponVolly State在全力更新每個內部命令幀回到現在,它又是如何還能夠重新處理那些已經忽略的單次射擊呢?那是因為“拋射物”型別的子彈的模擬和同步都是由一個外部系統處理的,回滾的處理方式和Statescript是不同的。而WeaponVolly State需要知道這一點。(譯註:這裡沒太弄清作者意圖:獵空的手槍明顯是hitscan型別的,這裡提到projectiles拋射物型別,僅僅是用來對比嘛?)

  雖然Statescript提供了實用函式和功能來幫助State很好地處理回滾前滾場景,但最終能否處理正確還是依賴於每一個State自己。

  最後,當處理重新模擬和前滾回到現在時,清楚地知道每個正在處理的命令幀的哪些歷史資料是精準的,就比較重要了。

 


  對於歷史資料,包括Local實體的全部變數和狀態,這很容易理解,畢竟我們正在處理的就是這些;還有按鍵輸入和瞄準;以及所有實體的位置和姿態,位置對於技能系統來說尤其重要,因為技能施放成功或失敗很依賴實體的相對位置。

  值得注意的是,伺服器也有關於位置和姿態的歷史資料,而且它還知道我們的RTT時間,所以它在執行模擬期間,是可以獲取當時客戶端位置和姿態的確切值的。

  在伺服器和客戶端同時記錄這些資料,對於避免預測錯誤是至關重要的。

  我們還有些不需要歷史資料的,包括Remote實體的變數和狀態;其他實體元件(例如血量和過濾器)的資料。

  最後發現,只訪問這些資料的最新、最全版本是ok的,因為不像位置和姿態資訊,伺服器是不會對它進行倒帶(Rewind)的。無論如何,在重新模擬期間,換個方法訪問資料的歷史版本反而讓我們更容易錯誤預測。

  現在總結下我們都是怎麼做的

 


  首先需要我們有足夠的可用性,開發者不用關注網路細節。

  我們還有響應性,武器和技能的立即對玩家的輸入做出預表現,然後再根據伺服器的更新資訊來回滾到正確狀態。

  安全性也有保障,因為唯一需要發給伺服器的就只有按鍵和瞄準,欺騙伺服器權威性是不可能的。

  說到無縫,核心系統和Sync Manager(同步管理器)提供了多種方法來幫助工程師實現無縫的回滾、複製和前滾,在同一幀內可以全部完成。

  現在就只剩下“高效率”這一條還沒有實現了。其實在討論StatescriptDeltas、StatescriptGhosts和StatescriptPackets的時候已經覆蓋到了一點點,但是還是有一些需要講的。

  再快速概覽一下

 


  StatescriptDeltas、Deltas、Ghosts能夠容忍丟包並能夠從中恢復,而無需重發所有的中間狀態的資料包,如果你還能記起來的話,這都是使用並集(Union)來包含多個Deltas帶來的好處。

  實體概覽

 


  為了使得頻寬佔用儘可能的低,Statescript在編譯指令碼期間,會自動分析並發現“同步”需求。對於Local實體,Statescript必須打包所有的東西,這樣預表現就會很精確。下發給客戶端的時候,任何內容都不能忽略,因為我們假定其中所有得都是為了做到精確模擬所必需的。

  現實中,我們或許還可以做得更多,例如找出哪些State變數和Action僅僅影響伺服器,但是寫個演算法來證明這一點的話,編譯過程就會變得複雜。

  另一方面,針對Remote物件進行優化對我們來說是更重要的,至少對於英雄來說,