上一篇我們已經介紹瞭最基礎的Unreal的Gameplay框架(UE5 新項目Gameplay框架設計(以Lyra為例)),其中多次提到瞭服務端和客戶端的概念,比如GameMode就存在於服務端,而GameState雖然存在於服務端,卻可以同步到客戶端。但從GameState的各種代碼中,都沒有看到跟數據/網絡同步相關的邏輯,那麼這一切是如何發生的呢?

關於Unreal的網絡模塊,應該是當前能檢索到的信息最豐富的模塊之一瞭,其他的還有GAS,動畫系統等。比如我這裡就找到瞭一些比較好的,從不同視角來介紹Unreal網絡模塊的高質量好文章(當然好的文章不止這些哈,知乎隨便搜索“UE網絡同步”就能看到很多很多)。

  • 都會|差值_UE4 UDP是如何進行可靠傳輸的
  • UE4網絡模塊分析
  • 《Exploring in UE4》網絡同步原理深入(上)[原理分析]
  • 《Exploring in UE4》網絡同步原理深入(下)[原理分析]
  • UE4網絡同步思考(一)—經典同步方案
  • UE4網絡同步思考(二)—大世界同步方案ReplicationGraph
  • UE4網絡同步-基礎流程

如果都能結合代碼吃透的話,那麼UE的網絡同步基本就能比較熟練的掌握瞭。話說回來,雖然已經有瞭這麼多前人的高質量的文章,我還是想要加上一些我自己的理解,也給大傢提供多一個的視角來看Unreal的網絡同步。我在構思這篇文章的時候,思考瞭兩種視角模式:

  • 一種是以最基礎的Actor為切入點,不斷增加它的能力來達到網絡同步的目標,並基於此來不斷引入更上一層的設計和相關的同步框架。但Actor本身是Unreal常用的基礎單位,除瞭網絡同步之外還承擔非常多的其他職能和作用,再加上以結果去推測原因有點站不住腳。
  • 第二種是以宏觀的網絡同步需求為切入點,從最頂層的需求來分析和解析Unreal的對應實現,不僅可以瞭解Unreal框架的設計原因,還能不斷的解決實際開發中遇到的各種問題,但缺點就是沒有辦法一次性完整的介紹某個框架或者某個組件的整體實現細節,甚至某個類可能會在不同的需求下反復提及,會有分裂感。

結合前面大傢的介紹思路都會偏向於先從底層開始,這裡我就從宏觀切入,盡量把一個類的用途一次講完。

1 UE服務器介紹

網絡遊戲自然會有網絡同步的需求,而同步的主要內容則是各種對象的屬性和RPC。在過去的遊戲開發中,跟客戶端打交道的大部分是業務服務器和戰鬥服務器。前者用來存儲和同步玩傢的各種數據,後者則大多同步玩傢的各種操作來達到多人聯機暢玩的目的。

標題裡我們提到的同構服務器和異構服務器框架則是指服務端的執行邏輯和客戶端的執行邏輯是否是同一套代碼。這說的可能有點抽象,可以舉個例子,稍微展開說一下。

1.1 同構和異構

一般的開發團隊裡,我們都有服務器開發組和客戶端開發組,兩個組之間使用的框架,代碼,和框架都會有很大的差別。比如服務器註重玩傢數據的處理,註重數據存儲和安全性,而客戶端則註重渲染表現和用戶交互。就一個戰鬥框架來說,客戶端更多的是如何如何做好玩傢體驗和開發配置效率,而服務器則註重玩傢的數據計算。

正是因為不同的業務端的業務重點差異比較大,所以服務器和客戶端通過約定一些協議,再通過網絡來傳遞玩傢的操作請求和執行後的變化。例如玩傢A向BOSS釋放瞭一個魔法飛彈,客戶端要處理的是,玩傢按瞭快捷鍵,快捷鍵綁定瞭哪個技能,目標是誰。將這些信息包裹起來,通過網絡和協議發送給服務器,服務器校驗數據和操作的合法性(比如是不是有CD,是不是距離不夠等等),通過執行技能機制之後,記錄玩傢的狀態變化,記錄BOSS的狀態變化,並將變化通過協議發送給所有參與本場戰鬥的成員。客戶端接收到服務器的狀態變化之後,執行BOSS的血量扣除,播放對應的技能特效等等。

由於客戶端和服務器的執行邏輯完全不一樣,所以邏輯肯定不能通用,那就是服務器和客戶端分別各自實現,我們把這種方式叫做異構服務器:即服務器和客戶端的開發是不一樣的,框架設計不一樣,代碼也不一樣,甚至開發語言都不一樣。

基於異構服務器的概念,我們再去理解同構服務器就簡單瞭:服務器和客戶端用同一套框架和代碼,通過在啟動時指定自己作為服務器還是客戶端即可。

那麼既然服務器和客戶端處理的邏輯不一致,為什麼會有同構服務器的需求呢?答案就是有的遊戲服務器和客戶端邏輯的處理是一致的,甚至不需要專屬服務器都可以完成。比如:魔獸爭霸。

類似於這種對戰類型的遊戲,房主開一個房間,玩傢進入房間,每個人玩傢的邏輯必須得完全一致才能保障遊戲的公平和準確。這就要求作為服務器的這個房主的戰鬥邏輯必須和所有其他人是一致的,那麼同構服務器的需求就是必須的瞭。同時因為服務器的計算邏輯一致,那麼服務器的作用就隻限於轉發玩傢的操作給所有其他玩傢瞭。

上面隻是同構服務器的一種需求場景。再比如現在的一些SLG或者休閑類型的遊戲,戰鬥本身的並不復雜,但對戰鬥的準確性要求較高,又或者需要比較一致的戰鬥回放,用異構服務器實現的代價會比同構服務器大很多。所以我們就需要設計一個同構服務器,讓它在不同端上的使用相同的初始數據得到的結果都一樣,這樣我們就可以先信任客戶端的戰鬥結果,然後將數據復制到一個專屬的驗算服務器來後置的處理作弊情況。

當然如果不想後置處理,先丟到戰鬥服上去算,將結果作為戰報下發,客戶端看回放也是一樣的。這取決於遊戲的玩法策略。相關的同構服務器的框架,可以參考我以前寫過的同構框架:Unity手遊實戰:從0開始SLG——ECS戰鬥(三)邏輯與表現分離Unity手遊實戰:從0開始SLG——ECS戰鬥(四)實戰ECS架構和優化這裡簡單比較一下同構服務器和異構服務器的差異:

  • 異構服務器。可以針對性的開發服務器和客戶端的邏輯,由於是兩個完全獨立的端,可以讓單端專精的開發人員負責開發。框架可以設計的更加精煉,性能更加高效,開發人員的思路也會比較清晰。劣勢則在於開發的人力成本相對較高,需要比較緊密的協作,出錯之後定位問題較麻煩。
  • 同構服務器。由於代碼自身扮演的職能在沒有啟動之前都無法預估,所以在開發過程中要加大量的判斷來決定某條邏輯是作為服務器執行還是客戶端執行。因為客戶端和服務器邏輯糅雜在一起,在開發階段需要開發人員時刻切換自己的身份來實現雙端的邏輯。但好處就是,代碼邏輯是一致的,出錯之後定位問題更簡單一些。

好瞭,之所以要先介紹同構服務器的概念,是因為,Unreal 引擎天生就自帶瞭這一套同構服務器框架。並且它是從最底層的Actor上就實現瞭網絡同步的機制。

1.2 UE中的專屬服務器(Dedicated Server)

Unreal中,一共提供瞭兩種不同的服務器模式,一種是傳統的MMO類型的(Dedicated Server),即服務端邏輯是運行在一個獨立的服務器上,所有的玩傢都需要連接到這個服務器上進行數據交換和邏輯更新;第二種則是房間式的(Listen Server),服務器部署在某個玩傢的客戶端上,該端既承擔自己的客戶端運算邏輯,又需要轉發變化自己端計算的結果給其他的玩傢。

相比於房間式的服務器(LS)來說,專屬服務器(DS)擁有更多的優勢:

  • 因為不需要實例化界面,DS的整體代碼體量和性能都會更好。
  • 因為LS本身“既當選手,又當裁判”會導致其擁有其他客戶端無法比擬的數據權威性和網絡延遲,這對其他玩傢並不公平
  • DS因為沒有本地的客戶端需要處理,所以可以更加專註的完成Gameplay的邏輯,並管理不同玩傢之間的數據,同時能兼顧所有玩傢的公平性。

關於如何部署DS,可以查看官方文檔:設置專用服務器 。但Unreal的服務器框架設計不是沒有缺點,由於其同構性,並且還支持不同的服務器模式,所以它在代碼上是比較臃腫的。也因為它網絡同步的對象是按Actor為單位來管理的,所以其作為服務器本身的承載能力相比於異構的服務器來說要差很多(相對於堡壘之夜,一場百人局的戰鬥來說 還是是綽綽有餘的),同時其網絡流量也會隨著需要同步的Actor數量增多而增多(雖然它本身已經做瞭很多數據合並的邏輯瞭)。

1.3 網絡模塊的初始化

之前的文章我們就介紹過一些概念,比如Gamemode是跑在服務器上的,GameState是跑在服務器上但能同步在客戶端上的,但其實UE也可以制作單機遊戲,也就沒有服務器一說。

所以UE對網絡的初始化判斷是來自於Gamemode中定義是否需要網絡來決定的。在介紹網絡之前,我們需要還是需要先簡述一下UE的場景初始化規則(詳細可以參考:《InsideUE4》GamePlay架構(三)WorldContext,GameInstance,Engine):如果開發者是一位上帝,並且整個宇宙是他玩的一場遊戲,那麼UEngine就是支撐它運行整場遊戲的底層框架。而掌控這場遊戲的類就是GameInstance。World就是宇宙裡一個個的星球,Level就是星球上的一片片大陸(或者國傢),Actor就是大陸上的所有物體的基本組成元素。這些Actor可以通過各自的進化,變成不同的小組件(Component),然後再組合在一起,變成千奇百怪,光怪陸離的世界組成部分。

每個星球(World)有自己不同的生態系統,他們可能差異微小,也可能大相徑庭。上帝以星球(world)為單位創建不同的遊戲規則(GameMode),存儲當前的遊戲狀態(GameState),有一個代理角色(Character),有一個附身看世界的視角(Camera),在加上代理角色的操控器(Controller)。這些加在一起就是針對這個星球(world)的玩法(也叫Gameplay)。

當上帝來到一個星球(world)的時候,它其實是一次星球旅行(WorldTravel)。由於星球之間的獨立性,數據並不互通,所以需要一個量子口袋(WorldContext)來臨時存儲要帶到目標星球(World)去的東西。到瞭這個星球之後,它先翻一翻之前創造星球時候的配置,看看當初創造的時候這個星球的規則是什麼樣的(加載 GameMode),然後從量子口袋(WorldContext)裡找找記錄,看看這個星球是否需要支持其他平行宇宙的上帝朋友一起來玩(決定是否要開啟聯機系統)。如果需要,那就要開啟通信系統,讓它的朋友們能以量子糾纏態(NetDriver)將自己的數據投射到這個星球(World)上。(也可以看看這個,畫的挺有意思:《圖解UE4渲染體系》Part 0 引擎基礎)

那麼現在我們要討論的重點就是這個量子糾纏態的投射:網絡模塊的初始化。

雖然從一個World切換到另外一個World我們調用瞭ServerTravel,但其實它們隻是把下一個World的信息拼裝成瞭一個URL,並存放在WorldContext裡。

當引擎Tick執行的時候,它會先去WorldContext看一下,如果FURL不為空,表示要開啟下一次旅行瞭。然後再查看FURL中的參數,是否攜帶瞭需要初始化網絡的參數需求。下面是一次網絡初始化的調用堆棧,可以看到是從tick進來的。

如果需要初始化網絡,就會創建NetDriver。

1.4 NetDriver的類型

前面鋪墊瞭這麼久,終於來到瞭網絡模塊的門口。但從這個類的命名其實可以看出,它其實是更偏向於底層的連接模塊,Driver這個詞就很明顯,它幹的是網絡驅動層的事,主要是管理各種實際的網絡連接(NetConnections)以及它們之間的數據交換。但就一個NetDriver類型的話,並不能很好的抽離出所有的開發需求,因此根據實際的事情情況,它又分為幾個子類型,分別在不同業務場景下使用:

  • GameNetDriver 。標準的遊戲網絡連接,正常遊戲中都會使用這個模式,但它其實是一個代號,GameNetDriver 的實際Class是可以在引擎配置文件中配置的,大部分平臺下,默認都是UIpNetDriver。
  • DemoNetDriver 。主要用於錄制和回放。
  • BeaconNetDriver 。其他非正常的gameplay可能會用到。
  • Custom。開發者自行定義。(從NetDriver繼承,然後在項目配置中指定為默認的網絡驅動器即可)

我們沿著主線,隻講正常網絡連接,也就是GameNetDriver 。回放可以看看這篇:《Exploring in UE4》Unreal回放系統剖析

2 網絡驅動層

首先這個框架是基於Unreal寫的,所以它有必要支持Unreal常規的反射和全局的config配置。

這些配置都在BaseEngine.ini文件裡,也是常規的Unreal的配置方式。也就是說它是可以通過配置文件來進行初始值的覆蓋的。如果我們自己寫瞭自定義的netDriver的話,可以通過在編輯器開出參數進行默認Class的覆蓋(註意 Config這個特性描述,帶這個配置的參數都是可以在ini文件下配置的)。

2.1 連接管理

作為一個“網絡驅動層”,它最基本的義務就是維持正常的網絡連接,並且管理它們的狀態。由於是同構的關系,它還需要根據自己當前是服務器還是Client來確定當前的網絡連接數量,並確保相關的邏輯沒有問題。那麼這個部分裡最核心的幾個函數為(註意,InitConnect和InitListen在NetDriver裡並沒有實現,它們必須要在派生類中實現):

在第一小節我們已經看到瞭,Uworld初始化的時候會根據是否有“Listen”的命令來決定是否需要創建NetDriver,並執行它的InitListen,這個時候它代表的是服務器。而如果是在客戶端的話,它需要執行InitConnect,但不管是服務器還是客戶端在創建連接的時候,必須要先執行InitBase,它主要是預處理瞭一些命令行,看看是否需要覆蓋當前的InitialConnectTimeout參數和ConnectionTimeout參數。

然後是初始化各種配置和派生類。

註意,這裡UReplicationDriver的用途是優化需要同步的數據,後面我們還會碰到,這裡不展開。接下來會初始化一個奇怪的東西FDDoSDetection。這玩意兒常規的理解是放DDoS流量攻擊的,不過因為UE底層使用的是UDP+可靠性傳輸,而UDP又是DDoS攻擊的目標,所以有這玩意兒好像也不稀奇瞭。

由於InitListen和InitConnect是虛函數,NetDriver本身沒有實現,我們可以往後放一放再說。現在我們知道瞭驅動層的初始化順序,那麼它是如何管理連接的呢?

  • 首先作為客戶端來說,它應該隻會有一個連接,用來連接服務器。
  • 其次作為服務器來說,它會有非常多的連接,用來管理客戶端。

這兩個在類的體現就是ServerConnection和ClientConnections。

另外,如何判斷自己是不是服務端呢?答案就是判定ServerConnection是否為空,因為隻有在客戶端才會初始化ServerConnection,如果它為空表示它不是客戶端而是服務端。

當作為服務端的時候,它的連接眾多,所以它需要一個Map來保存Ip地址和客戶端連接的對應關系,以便區分連接上來的客戶端是否是新的客戶端。

FConnectionMap的定義如下(就不往下一層層展開瞭,這裡看類命名應該就能知道存儲結構和用途瞭):

現在的服務器其實都會有排隊功能,當服務器的容量超載的時候,多餘的人會在服務器外面排隊。這時候,如果有一個已經在遊戲的玩傢突然掉線瞭,那麼他上線重連就不得不從最後一名來時排隊,萬一正在打個副本或者組隊,體驗非常不好。那麼這裡就可以加一個最近失聯的客戶端列表。如果可以的話,我們可以改寫部分邏輯,如果某玩傢短時間掉線,再上線排隊可以直接插入在最前面,以保障玩傢的遊玩體驗。

那麼以上基本的網絡功能結構已經出來瞭,我們有連接的定義瞭(Server,Client的Connection),有連接和初始化的調用接口(Init的幾個函數)瞭,剩下就是給它們配備比較完全的控制和參數瞭。比如初始化連接的超時時間,連接本身的超時時間,作為客戶端時候的tickRate,作為服務器時候的tickRate,掉線多久算最近掉線等等,圍繞Connection建立的各種參數和邏輯處理。

2.2 Channel管理

Channel可以理解為網絡同步的信道。每一個Connection可以視作一個玩傢,但玩傢的數據可能分為很多種,比如某個玩傢的Connection連接狀態需要發送給其他的玩傢,那麼就可以通過一個單獨的Connection類型的信道把數據發出去,而接收端也使用Connection的信道做接收,專人專項處理,讓數據不混雜。在目前UE默認的實現中,隻有3種已經實現的Channel:

  • ControlChannel 。看起來好像是CS之間傳遞信息的信道,但實際上隻發送和接收瞭一些連接相關的信息。
  • VoiceChannel 。 跟語言通話相關的消息信道。
  • ActorChannel 。最基本的Actor信道。也是最常用,最復雜的信道管理瞭。ActorChannel 並不是一個管理集,而是每一個需要進行網絡數據交互的Actor都會有一個Channel信道。也就是說一個UE服務器中,可能會存在成千上萬個Actor信道,這也是Unreal需要重點管理的數據集合。

關於Channel的討論可以放後一些,這裡還是往下說說NetDriver對Channel的管理。

由於Actor的Channel眾多,所以要做一個Pool來重復利用和管理,當然邏輯中也就會包含Actor的復用和入池等。

同樣的,還會有一些跟Channel相關的配置參數和定義,以及Channel的名稱映射之類的輔助邏輯。

整個NetDriver並不會介入實際的Connection和Channel的業務邏輯,它隻負責對它們的初始化和管理。比如創建ClientChannel,實際上還是調用的Connection來執行的。

比如前面提到的ChannelDefinition,就是從配置文件讀取的。比如FChannelDefinition的定義

在ini下的相關配置。

最終成為NetDriver的成員變量,被用作各種初始化參數。

到這裡,NetDriver的核心準備工作已經完成瞭。能區分自己是客戶端還是服務端,能根據配置正確的初始化Connection,能完成Channel的配置,並且輔助Connection來完成Channel的初始化。

2.3 數據收發管理

在NetDriver正式開始搬磚之前,還需要先建立一個握手和監聽邏輯:

其中,StatelessConnectComponent負責對新連接建立握手過程,而ConnectionlessHandler則負責處理網絡數據包。在執行InitListen或者InitConnect的時候就會調用InitConnectionlessHandler來完成Handler的初始化。

那麼接下來就要開始搬磚瞭。NetDriver搬磚的動力來自於 下面4個函數。

其中TickDispatch負責接收網絡數據。如果它接收到一個來自陌生地址的數據就會嘗試進行一個握手的過程,通過瞭之後創建一個新的Connection。如果是已知的Connection,就會把數據轉移給它並開始解包數據。TickFlush則負責整理需要發送的數據,比如針對需要復制的Actor做處理的ServerReplicateActors邏輯。

以及需要遠程調用的邏輯ProcessRemoteFunction。

這兩個邏輯都還是比較復雜的,因為網絡復制在服務端非常復雜。它需要針對不同的Connection篩選不同的需要復制的內容(但對於客戶端來說就比較簡單),所以如果想瞭解細節的可以直接看對應代碼,這裡就跳過瞭。除瞭處理正常的數據之外,還有額外的專門針對音頻數據的處理。因為Unreal會處理《堡壘之夜》這樣的遊戲,所以它的local發送規則是在附近的人。

以上就是NetDriver的核心職能瞭,管理連接,管理Channel信道,收發數據。但作為一個健壯的框架來說,還遠遠不夠。

2.4 命令行

NetDriver繼承自FExec,表示它會接受並處理來自命令行的數據。

如果不是Shipping包的話,它預置瞭以下的命令處理:

不一一介紹,這裡就看第一個Sockets的的命令行,它其實就是序列化瞭當前所有的Connection和Channel信息。

2.5 性能統計

除瞭命令行之外,框架內還會統計一些關鍵指標和部分性能數據。比如客戶端的連接數量,收發網絡包的數量,大小和頻率,音頻的網絡流量等。

在每次的TickFlush的最後會調用UpdateNetworkStats來計算和填充這些數據並更新當前網絡的lag情況。

2.6 調試

一些開發期間的調試信息,比如打印一些關心的數據。

比如在場景裡區分出不同的同步規則的Connection和玩傢等。

對於NetDriver而言,以上就是它的全部職責瞭。作為一個網絡驅動層來說,他核心的任務就是管理連接和信道,負責接收和發送數據(當然這些數據的解析和準備工作都是轉交瞭其他模塊完成的),同時作為一個健壯的框架,它有一些調試信息,能統計自身的狀態和性能數據。又因為是Unreal引擎所支撐的框架,它還需要接受一定的外部配置。整體而言NetDriver比較完美的完成瞭它的使命。

但從設計上來說,個人認為還是有些許的小瑕疵,比如Channel信道應該交給Connection來管理,Actor數據的準備和復制應該放在Connection上。作為一個邏輯驅動層,它往下應該和邏輯傳輸層(TCP/UDP)建立良好的組合關系,往上應該隻關註Connection相關的部分,就好像順豐的營業部一樣,從客戶(Connection)手上拿包裹,然後選擇走海陸空(TCP/UDP)運的方式寄送包裹。至於客戶怎麼打包應該交給客戶自己決定。

2.7 派生類IPNetDriver

前面我們提過,NetDriver本身隻是一個基類,正常遊戲裡的使用的是它的派生類IPNetDriver,這是一個實例化的類。因為派生類是可以在ini裡配置的,所以在高層的邏輯裡,大傢會把它抽象成GameNetDriver。也就是說,ini裡配置GameNetDriver,實際指向IPNetDriver。好處就是,如果你要變化GameNetDriver的配置,隻需要自己重新配置一下就好瞭,不會影響到高層邏輯裡的理解。它的邏輯沒有NetDriver本體那麼復雜,主要做瞭兩件事:

  • 覆寫瞭比較重要的幾個函數,同時實現瞭基類中沒有實現的部分,比如InitListen和InitConnect。
  • 前面我們說過NetDriver應該管理“邏輯傳輸層”,也就是socket,但基類中並沒有實現,所以在IPNetDriver中需要完成。它對基類邏輯的補充和接管體現在下面幾個核心函數的改動上:

首先,在InitBase中,除瞭執行父類的基礎邏輯之外,還初始化瞭整個Socket系統,並且完成綁定。

其中Resolver的類型是FNetDriverAddressResolution,負責實際意義上的IP地址管理。

如果socket創建成功,則創建一個單獨的接收線程用於接收socket數據。到此InitBase的邏輯結束。

接下來是InitListen,這個是作為服務端的時候會調用的,之前在NetDriver基類中沒有實現,現在實現也很簡單,就是調用以下InitBase,成功之後調用Handler的初始化,來完成服務器的連接和握手邏輯的初始化。

而InitConnet則是客戶端才會調用的,它也是需要先調用InitBase來完成基本的網絡模塊初始化,然後創建ServerConnection,並完成Channels的創建。

最後就是數據收發的邏輯瞭,先在TickDispatch裡做具體的數據接收。

數據的發送在基類中已經完成瞭。邏輯過程大概是這樣的,把自己的TickFlush註冊到World的tick中,然後在TickFlush調用FlushHandler,這裡會得到所有Channel信道裡收集好的Packets,然後調用Connection的Tick進行發送。

當然在IPNetDriver裡也進行瞭LowLevelSend的覆寫邏輯。調用瞭Socket進行最後的數據發送。

總結一下,NetDriver制定瞭整個網絡驅動層的工作規范和框架,而IPNetDriver則完成瞭實際的實現。尤其是針對socket的各種初始化和收發邏輯來說,基本幾乎沒有涉及,全部在子類中完成瞭業務落地。