所有Unreal網遊開發者都應該看的文章:使用虛幻引擎4年,再談UE的網路架構

Jerish發表於2020-06-19
1.png

文章轉自公眾號“遊戲開發那些事”作者 Jerish

這是【所有Unreal網遊開發者】都應該看的一篇技術文章。

我從16年開始接觸unreal,到如今已經4年了。最近看了不少關於網路同步的論文和書籍,總算是理解了Doom和Quake這種古董級遊戲的發展歷史,對其網路架構也有了更深一層的認識。這次想根據自己的工作和學習經驗,以一個全域性的視角來重新回顧一下虛幻的網路模組,並總結一些我們常見的問題,相信對UE同步細節模糊不清的你看完後一定會醍醐灌頂。

開始前,我先給初學者一個建議。如果你打算看UE4的同步原始碼,最好先大致閱讀一遍這本書——《網路多人遊戲架構與程式設計》,裡面基本涵蓋了UE4同步框架的大部分內容,可以讓你少走不少彎路。

2.png

下面進入正題:

網路同步,就是使各個客戶端上的角色表現保持一致,屬於遊戲引擎的高階功能,所以一般我們都將其歸於Gameplay模組當中。不過具體的實現方案其實會深刻影響到底層的網路架構(甚至是整個遊戲架構),我們既要決定通過哪種網路協議來完成,又要決定遊戲各個模組的迴圈執行順序,其實已經不單單是“Gameplay”層面的東西了。

虛幻引擎屬於標準的CS架構(經過無數次改版的),內建狀態同步功能,其同步頻率與遊戲的幀率相同,屬於變長步更新。由於幀率完全受CPU、GPU效能的影響,所以網路同步的頻率與整個專案的效能息息相關。不過,有一點我們要認識到,unreal已經是儘可能的按照自己最快的速度進行資料的傳送與接收了,只要我們做好各方面的效能優化即可。

一、RPC與屬性同步

在Unreal裡面,同步有兩種手段,即RPC與屬性同步(很多伺服器引擎都是如此)。與其說RPC是同步手段,不如說他是一種傳輸資料的方式,好處就是可以直接通過類的函式形式書寫,方便理解。同時不需要你直接寫Socket,也不需要你處理封包和拆包。在計算機網路的概念裡面,RPC叫做“遠端過程呼叫”,本質上就是一種傳遞資料的手段,而其實現方式既可以是應用層的Http,也可以是傳輸層的TCP/UDP。在虛幻裡面,由於很多遊戲的同步(比如FPS)對網路延遲要求比較苛刻,所以我們放棄了需要三次握手的TCP而改用UDP(更不可能考慮HTTP了)。RPC既可以標記為可靠,也可以標記為不可靠。可靠的RPC最終一定會到達目標終端,但不可靠的RPC除了在網路擁擠的環境下丟失,也可能在引擎限流的情況下被提前攔住。RPC本身並不是一個可以持續存在的物件,我們只能通過RPC引數“一次性”的將資料從一端傳送到另一端,所以每個RPC呼叫只能“只執行一次”(可以理解為生命週期只有瞬間的)。如果RPC訊息從網路中丟失,那麼他就會永久的丟失(不可靠的RPC),所以並不適合遊戲世界各種物件的狀態恢復,必須要結合可以保持物件狀態的屬性才行。此外,UE4裡面RPC並不支援回撥,所有RPC函式的返回型別都是void。

屬性同步,本質上屬於一個比較上層的功能特性,是以每個物件為單位處理的(不支援更細粒度的同步,但理論上可以通過條件屬性做部分調整,詳見AACtor::PreReplicate)。unreal的伺服器會按照一定頻率的去執行同步物件屬性的資料傳送和接收,同時處理回撥函式。屬性同步的產生是為了維持物件的狀態,是一個從概念上非常貼近“同步”二字的功能,一旦伺服器上的同步屬性發生了變化,就一定會傳送給客戶端(注意:屬性同步只是伺服器向客戶端的同步,不存在客戶端向伺服器流通),也許中間會丟包會延遲(actor首次同步時是reliable的),但是其內建的機制會保證屬性的值最終送達到客戶端。借用一句經典的話來說就是,同步資料也許會遲到,但是永遠不會缺席。

無論是RPC,還是屬性同步,你會發現他都是基於UObject的,或者更確切的講都是基於Actor的(以及其附屬元件)。因為這兩種功能一個是利用類中的函式,另一個是利用類物件的屬性,他們都需要與某一個具體的物件作為媒介,而在UE的架構中,設計都是物件導向的,每個Actor都可以理解為現實世界的物件。既然是基於Actor的,那麼整個同步就與GamePlay框架緊密相連。由於我們在傳送同步資料的時候需要知道這個資料應該發向哪個客戶端,而客戶端與伺服器的連結資訊(IP等)又在Playercontroller裡面,所以同步的邏輯與playercontroller密切相關。很多剛接觸unreal的朋友經常會遇到RPC資料發不出去或者收不到的問題,就是沒有認識到playercontroller其實是包含客戶端與伺服器的連線資訊的。最典型的,假如你有伺服器上連著10個玩家客戶端,伺服器上有一輛車,讓他執行Client RPC,他怎麼知道發給哪個客戶端?當然是通過這個車找到控制他的playercontroller,然後找到對應客戶端的IP,如果這個車不被任何客戶端控制,那他就不知道要發給誰。

當然,RPC與屬性同步的實現原理不同也決定了他們有很多差異。由於屬性同步是跟著每一個例項物件走的,所以不存在“隨用隨發”。也就是說,屬性同步需要在每幀特定的時機通過統一的引擎介面寫到傳送快取(sendbuffer)裡面。這樣帶來的問題就是,你在同一幀裡面修改的屬性只有最後的那個值會傳到客戶端那裡,進而導致你的回撥函式也只會執行一次。而RPC不同,每次執行時都會立刻將資料塞到傳送快取裡面,從而保證不會丟失任何一次RPC的呼叫(假如RPC是可靠的)。

3.png

另外,這裡面還有一個深坑,就是關於Actor以及Component的同步順序問題。一個物件的同步首先要給客戶端上的物件與伺服器上的物件建立關聯,這樣伺服器的A變化了才會告訴客戶端上的A也去變化。但是A是一個物件,物件也是需要同步的,一個場景裡面有那麼多的物件,同步肯定是按順序的來的。這樣就會經常出現A的物件裡面有很多指向B物件的同步指標屬性,但是A物件出現的時候B還沒同步過來,所以在A的Beginplay裡面訪問B是不行的。那麼如何解決這個問題?答案是用屬性回撥,一旦執行了屬性回撥,就可以確保A的B指標是存在的。不過,屬性回撥並不能解決所有問題。假如B物件還有C物件的指標,回撥的時候C還沒同步過來,你想用B去訪問C發現又是空指標。這問題目前在現在的虛幻引擎裡面還沒有完美的解決方案,所以我們要儘可能的避免這種情況(我本人正在嘗試實現一些可行的方法)。類似引發的更細節的問題還有很多,後面我會列舉一些。

4.png


二、移動同步

兩種同步手段已經介紹完畢,我們現在把視角鎖定在網路同步的解決方案上。遊戲中同步本質上是同步客戶端之間的表現,而RPC與屬性同步都只是資料上的同步,我們需要將其與畫面表現結合起來。畫面表現說白了就是物體的顯示與隱藏、動畫、位置等,其中位置同步就是最複雜的一項,因為遊戲中的角色可能是每幀都在移動的,移動元件(movementcomponent)就是為了解決這個問題而誕生的。

移動元件很複雜,他需要考慮到各種情況的延遲、抖動,需要解決不同客戶端不同角色的流暢性問題,需要實現各種插值手段。在網路同步中,始終存在三種形式的角色,分別是本地玩家控制的、伺服器控制的以及其他玩家控制的,在unreal中分別對應著Autonomous、Authority與Simulate。這三種型別的存在本質上代表著角色的控制者是誰(哪個端可以直接通過命令操作他),而從另一個角度講這種分類其實是代表著玩家的操作是否有網路延遲以及延遲的大小。對於本地控制的Autonomous角色,他可以在本地直接響應你的操作,如果想把操作發給伺服器,則需要經歷一個client——server的延遲,而伺服器想把這個操作同步給其他客戶端又需要一個server——client的延遲。

5.png

同步中最難的其實就是如何有效的對抗這種延遲。所以,會誕生諸如延遲補償這種同步策略,即伺服器收到其他客戶端射擊訊息的時候將本地的所有角色回滾到【當前時間 - 網路延遲時間】時的位置再進行訊息的處理和計算。(UE4預設引擎裡面沒有這種操作,虛幻競技場裡面有。如下圖)。

6.png
紅色是當前端的具體位置,黃色是回滾預測的位置

移動元件本地客戶端到伺服器採用的是不可靠的RPC,而伺服器到其他客戶端採用的是屬性同步。為什麼使用RPC?因為客戶端向伺服器傳送訊息只能通過RPC,屬性同步只是用來伺服器同步給客戶端用的。unreal在同步位置時記錄了各個客戶端以及伺服器的時間戳,通過位置buffer快取、每幀不停的傳送位置、判斷時間戳調整位置與回滾等操作實現比較理想的效果,本質上守望先鋒的幀同步(嚴謹點來說是借用幀同步的命令幀)+狀態同步是相同的(詳見:守望先鋒架構與網路同步)[1]。不過虛幻並沒有採用ECS,並不能在架構上很好的支援所有邏輯的回滾。網路同步發展至今,其實基本已經成型。從早期的Lockstep到指令流水線化再到預測回滾TimeWarp,大體的同步優化手段都是這些,現在的趨勢就是狀態同步與幀同步裡的各種機制互相借鑑互相促進。除了移動同步,其他的諸如動作同步和隱藏顯示我們一般要求不那麼苛刻,因為他們不需要每幀都做處理,一般採用RPC做一次性的通知修改就可以了。關於同步,還有一個大家平時不是很在意的細節,那就是同步頻率。前面提到了UE4會按照儘可能快的速度去傳送同步資料,如果客戶端的效能非常好幀數非常高,那麼一幀就會產生非常多的移動RPC。理論上來說,如果沒有丟包的話,即使伺服器幀率很低,伺服器也會按照客戶端發來資料逐個模擬,最後兩端結果相同,仍然是流暢的。但是,如果中間丟失了部分移動的RPC(引擎內部就會對傳送進行限流),就可能造成伺服器計算結果與客戶端不同進而不斷拉回客戶端,造成卡頓。

總的來說,RPC與屬性同步有些場景是可以相互替代的。對於簡單且實時性要求不高的使用RPC就可以,而對於需要伺服器實時保有主控權且持續性同步的狀態我們就可以使用屬性同步。屬性同步本身已經做了優化消耗沒有那麼大,你可以通過各種條件來設定他的同步規則。但是注意,量變產生質變,如果不加節制的全部使用屬性同步,那麼actor(以及屬性)遍歷的開銷與會相當可觀,所以還是合理的使用還是非常重要的。這塊理論上有很多可以優化的地方,比如Actor可以設定同步的範圍(類似AOI),距離玩家很遠的物件不需要同步;Actor可以根據一些規則關閉對某些客戶端的屬性複製功能(Dormancy),同時關閉ActorChannel並從NetConnection裡移除;採用replicationgraph對空間進行劃分,剔除相關性不強的物件從而減少頻寬的佔用(但是這個方案只適合大世界型別的遊戲)。理論上,我們還可以新增更多的優化方式以及更細的粒度來進行調整,不過具體方案就要根據遊戲型別來靈活處理了。

7.jpg
Replicationgraph示意,每個寶箱被放置到他所影響的所有格子裡面。玩家只有進入這些格子裡面才會收到寶箱的同步資訊

三、回放系統

回放看起來是個很高大上的功能,但其實早在上世紀90年代就隨著Lockstep演算法一起誕生了。UE4內建了一套Demonetdriver系統來處理回放和錄製,但由於採用的是狀態同步而不是幀同步,所以實現起來比較複雜。基本思路就是在本地建立一個虛擬的伺服器,錄製的時候本地當成一個伺服器,回放的時候本地又當做一個客戶端。在遊戲進行的時候,本地開始錄製並把回放相關的資料序列化到資料流裡面(可以是記憶體、磁碟或者是網路包),播放的時候再去對應的資料流裡面讀出來。雖然框架是有的,但還處於一個未完成的階段,用起來坑也是相當的多(比如過期的多播事件在回放中不會被執行到)。對於死亡回放以及精彩鏡頭這種實時切換的需求,涉及到的邏輯要更復雜一些(比如真實世界和回放世界的切換與隱藏),這塊有時間我會再寫文章來仔細講講。

8.png
官方射擊遊戲Demo——ShooterGame中就含有一個簡單的回放演示功能

四、網路框架

說完了上層的網路同步,再簡單談談底層。虛幻引擎誕生於90年代,也肯定參考了很多其他遊戲的設計,比如“雷神之錘(Quake)”,“星際圍攻:部落(Tribe)”等。當時Quake是最早一批採用基於“CS架構狀態同步”的遊戲,而Tribe將模組進行拆分和封裝,是第一個構建了比較完善的網路同步架構的遊戲。UE4的架構與Tribe很像,通過NetDriver + NetConnection + Channel + Actor/Uobject抽象分層實現了目前的同步方式。很多人總是抱怨虛幻引擎把底層搞得太複雜,但這其實有很多歷史原因以及技術上的權衡,官方團隊在過去的20年裡肯定也無數次地思考過這種問題,這裡也不過多贅述。總之,從網路層面上說,UE4高度耦合的網路框架不適合幀同步(這裡指lockstep),同時也很難改造成ECS架構。不過,我個人也同樣覺得很多遊戲沒必要非要追求幀同步,兩種同步開發各有各的坑,真做起來遊戲其實都沒那麼簡單(也許踩UE官方的坑可能會讓你更不爽一點,畢竟不是自己寫的)。

關於網路協議,遊戲界經過大量的測試很早就公認——對於高頻同步的遊戲,使用UDP同步的效果要好於TCP。因此,unreal使用的就是UDP協議,但是為了保證資料的可靠性,需要在上層封裝一個可靠的UDP,也就是NetDriver + NetConnection + Channel那一套。裡面的邏輯很複雜而且涉及到很多模組,確實有一些冗餘。此外,雖說是可靠的,但是在屬性同步和RPC的處理方式上並不相同,屬性同步只保證最後的資料是可靠的,中間的結果可能會丟失,而RPC則可以保證訊息一定按序送達。針對其內建的RUDP的重發機制,UE其實已經做過很多次的優化和調整了,之前任何的丟包和亂序都會立刻觸發重發,4.24裡面已經新增了迴圈佇列來收包矯正收包的次序,一定程度上減少了不必要的重傳。訊息的接收和傳送預設還是在主執行緒處理的(我們可以決定是否啟用多執行緒),由於UDP不需要監聽多個Socket而且針對收包採用多執行緒意義也不大,所以也沒有采用iocp或者其他非同步IO的方式。在虛幻引擎中,網路包的更新順序是“收資料——邏輯更新——發資料”,但並不是所有的同步更新邏輯都在收包的時候做,UObject型別同步屬性的更新可能就是在發包前更新的(這塊是一個坑,要注意),具體可以參考我的文章“【《Exploring in UE4》網路同步原理深入(下)】[2]” 中的第五部分第8小節。

最後,我們再總結一些在同步中經常會遇到的問題。

Client的RPC並不能保證一定在客戶端執行。在伺服器上,如果有一個沒有connection資訊的actor(比如不是同步的,完全由AI控制的。或者說他的remote role等於none),那麼他的clientRPC只會在自己的客戶端上面執行。最後可能造成的後果就是函式呼叫棧的無限迴圈進而崩潰。

beginplay在客戶端伺服器都會執行,如果在beginplay執行另外一個actor的生成。可能會觸發客戶端和伺服器都生成一遍自己的actor,結果客戶端存在了兩個Actor(一個自己生成的,一個伺服器生產的)。之後在呼叫RPC的時候很可能會出現RPC執行失敗,因為本地生成的Actor沒有任何connection資訊。

客戶端上物件的Beginplay是可能執行多次。在unreal中,如果一個actor是伺服器建立並同步給客戶端,那麼伺服器可以隨時關閉這個物件的同步。一旦這個物件距離玩家角色非常遠或者伺服器主動關閉同步,客戶端上的物件就會被刪除掉。後期如果玩家又靠近了這個物件,那麼就會重新同步到客戶端,再執行一次Beginplay。這樣某些資料進行兩次初始化,可能不是我們想要的。

我們經常會遇到“遊戲狀態恢復”的場景,比如網路遊戲中的斷線重連。然後你就可能會遇到一些物件在重連後狀態不對,因為很多物件的變化是通過RPC去做的,RPC是一次性的。當你重連後,RPC不會再執行一次,所以客戶端重連的狀態與伺服器其實是不同的。這時候需要使用屬性同步來解決問題,但是屬性回撥在斷線重連的時候你也並不一定想執行,所以要重新審視一下回撥函式裡面的內容。

不要把隨時可能被destroyed的物件傳進RPC的引數裡面,RPC引數裡面沒有判斷物件是否是合法的。如果傳遞的過程中物件被destroy掉,後續可能觸發序列化找不到NETGUID的相關崩潰。

一般情況下,onrep回撥的執行順序在同一個character內是嚴格按照屬性的宣告順序的,不同actor無法保證先後

一般回撥會調到的函式,要注意裡面有沒有判空return的情況,這個時候其他actor的指標是有可能為空的。

一個UObject指標型別的陣列屬性,可能會觸發多次回撥,最後一次可以確保所有指標都有值。

屬性回撥執行的前提是客戶端與伺服器的值不同,如果你本地先修改一個值,然後伺服器修改的與客戶端相同,那麼是不會觸發回撥的。也可以採用DOREPLIFETIME_CONDITION_NOTIFY( AActor, XXX, COND_Custom, REPNOTIFY_Always ); 巨集來強制客戶端執行

一般來說當Actor與PC解綁後,Actor就無法保證RPC的執行了。這種情況往往發生在角色死亡後執行unpossess時,所以在這時應該注意RPC的執行情況。

如果屬性沒有同步到客戶端或者不執行回撥,注意一下是否使用了自定義的條件屬性

所有設定定時器來判斷同步屬性是否收到的邏輯都是不規範的,一旦伺服器或者客戶端變卡(一開始沒有表現,但是隨著遊戲內容的增加可能出現各種詭異的bug)就可能導致資訊丟失

注:

[1]守望先鋒架構與網路同步
https://gameinstitute.qq.com/community/detail/114516
[2]《Exploring in UE4》網路同步原理深入(下)
https://zhuanlan.zhihu.com/p/55596030


相關文章