UnrealEngine - 網絡同步之連接篇

1 連接過程 - 握手

傳統的 C/S 架構下,Client 和 Server 通常會建立一條抽象的 Connection,用來進行兩端的通信。
UE 的官方文檔中提供了 Client 連接到 Server 的示例 ,簡單來說分爲如下幾步:

  • 打包構建好 Client 和 Server 進程
  • 啓動 Server 進程,啓動參數爲 ./Binaries/Win64/<PROJECT_NAME>Server.exe -log
  • 啓動 Client 進程,啓動參數爲 ./Binaries/Win64/<PROJECT_NAME>Client.exe 127.0.0.1:7777 -WINDOWED -ResX=800 -ResY=450

默認情況下,專用服務器在 localhost Ip 地址( 127.0.0.1 )的端口 7777 處監聽。可以添加命令行參數 -port=<PORT_NUMBER> ,更改專用服務器的端口。如果要更改服務器正在使用的端口,則還需要更改將客戶端連接到服務器時的端口。

1.1 啓動 Server

Client 連接到 Server 的前提是 Server 啓動完畢,監聽完畢端口,準備好接收連接了。UE 中監聽的核心接口如下:

bool UWorld::Listen( FURL& InURL );

其接口核心參數爲一個 FURL ,UE 中會根據啓動參數和配置等構建一個 FURL,其結構如下 (只展示部分變量):

//URL structure.  
USTRUCT()  
struct  FURL  
{  
   // Optional hostname, i.e. "204.157.115.40" or "unreal.epicgames.com", blank if local.  
   UPROPERTY()  
   FString Host;  
   // Optional host port.  
   UPROPERTY()  
   int32 Port;  
   // Map name, i.e. "SkyCity", default is "Entry".  
   UPROPERTY()  
   FString Map;  
   // Options.  
   UPROPERTY()  
   TArray<FString> Op;  
}

可以看到裏面有關鍵的 Host 和 Port 等信息。
Listen 接口具體做了什麼呢?

  • 通過 UEngine:: CreateNamedNetDriver 創建 NetDriver,主要驅動網絡同步
  • UNetDriver::InitListen 解析 FURL,監聽端口
    網絡相關的流程在這裏開始就交付給了 UNetDriver,顯然它是一個比較重要的網絡管理類,這裏簡單看下其結構

image.png

可以看到主要負責:

  • Server 端初始化監聽端口
  • 初始化連接
  • 管理 UNetConnection,UNetConnection 顯然就是抽象出來的連接
    • 這裏有 ServerConnection 和 ClientConnections,當擁有 ServerConnection 時表示當前是 Client 端,擁有 ClientConnection 時表示當前時 Server 端

同時其派生了不同的類,如:

  • UDemoNetDriver:用來支持遊戲錄像和回放(類似守望先鋒的擊殺回放)
  • UWebSocketNetDriver:用於實現 WebSocket 協議的網絡通信。WebSocket 是一種基於 TCP 的網絡協議,允許在客戶端和服務器之間進行雙向通信,可以實現實時通信和數據傳輸。通過使用 UWebSocketNetDriver,可以在 UE4中使用 WebSocket 協議進行網絡通信
  • UIpNetDriver:用於實現基於 IP(Internet Protocol)的網絡通信
    Server 端完整的綁定端口監聽的流程大致如下:

image.png

可以看到其實和普通的 C++ 創建 TCP C/S 連接類似,最終都是創建一個 Socket 並且 Bind 到指定端口。

1.2 Client 初始化

客戶端啓動之後,也是類似的流程,創建 NetDriver 驅動網絡相關的流程,對比 Server,其多了一個 UPendingNetGame 的對象。UPendingNetGame 類是一個用於處理網絡遊戲連接過程的類。它在客戶端嘗試連接到服務器時創建,並在連接成功或失敗後銷燬。

關於 UPendingNetGame

  1. 用處:
    UPendingNetGame 主要負責處理客戶端與服務器之間的連接流程。主要功能包括:
    a. 處理連接請求:客戶端向服務器發起連接請求時,UPendingNetGame 負責處理這個請求,包括創建套接字連接、發送握手請求等。
    b. 加載關卡:在連接過程中,若服務器需要客戶端加載一個關卡,UPendingNetGame 負責處理這個請求,包括加載關卡資源、同步關卡狀態等。
    c. 狀態同步:在連接過程中,UPendingNetGame 負責與服務器進行狀態同步,包括玩家數據、遊戲規則等。
    d. 錯誤處理:若連接過程中出現錯誤,如超時、被拒絕等,UPendingNetGame 負責處理這些錯誤,通知用戶並做出相應處理

  2. 創建與銷燬:
    a. 創建:當客戶端嘗試連接到服務器時,會創建一個 UPendingNetGame 實例。
    b. 銷燬:當客戶端成功連接到服務器並完成狀態同步後,UPendingNetGame 完成其任務並被銷燬。如果連接過程中出現錯誤,如超時、被拒絕等, UPendingNetGame 也會在處理完錯誤後被銷燬

Client 的初始化流程大致如下:

  • UEngine::Browse 解析 FURL
  • UPendingNetGame::InitNetDriver 初始化網絡驅動
  • UIpNetDriver::InitConnect 初始化連接
    • 創建 UIpNetConnection
    • UIpNetConnection::InitLocalConnection 初始化連接信息
  • 調用 Connection 的 Handler 的 BeginHandshaking 發握手包
    其大致執行堆棧如下:

1.3 Server 收包

Server 端上 PacketHandler 處理的數據包的結構如下:

/**  
 * Represents a view of a received packet, which may be modified to update Data it points to and Data size, as a packet is processed.
 * Should only be stored as a local variable within functions that handle received packets. 
 **/
 struct FReceivedPacketView  
{  
   /** View of packet data, with Num() representing BytesRead - can reassign to point elsewhere, but don't use to modify packet data */  
   TArrayView<const uint8>       Data;  
   /** Receive address for the packet */  
   TSharedPtr<FInternetAddr>  Address;  
   /** Error if receiving a packet failed */  
   ESocketErrors           Error;  
};

1.3.1 收包流程

Server 監聽完端口之後就要處理客戶端發過來的連接請求,由於是 UDPSocket,所以只需要簡單的 Bind + RecvFrom 就能接收數據了。其主流程主要由 NetDriver 的 TickDispatch 驅動,如下:

  • UIpNetDriver::TickDispatch
  • FPacketIterator (UIpNetDriver*) ++,UE 實現了一個 Iterator 遍歷消費 Socket 的 Packet
  • UIpNetDriver::AdvanceCurrentPacket
  • FPacketIterator::ReceiveSinglePacket 迭代器收包
    • UIpNetDriver 中檢查 SocketReceiveThreadRunnable 如果存在這個線程(默認情況下應該是沒開的,這個時候就相當於這個線程的邏輯在 GameThread 跑了),從 SocketReceiveThreadRunnable->ReceiveQueue 這個 Packet 隊列彈出,這裏主要是區分用 GameThread 還是用 SocketReceiveThread 來取包。
      • FReceiveThreadRunnable::Run 本身是生產者,可以將 ReceiveQueue 理解爲一個數據中間件,IpNetDriver 的 TickDispatch 則是消費者,一直消費 ReceiveQueue 的數據
      • ReceiveQueue 在 SocketReceiveThreadRunnable 線程中一直使用 FSocket::RecvFrom(抽象接口,大部分情況下都是爲 FSocketBSD::RecvFrom)接收數據,其底層實現就是使用 recvfrom 這個操作系統接口

image.png

SocketReceiveThreadRunnable 默認是沒有打開的,官方說明如下
// If the cvar is set and the socket subsystem supports it, create the receive thread.
CVarNetIpNetDriverUseReceiveThread.GetValueOnAnyThread() != 0 && SocketSubsystem->IsSocketWaitSupported()

1.3.2 處理客戶端連接

首先 Server 需要檢查這個 Packet 是否已經有連接了,這裏引出一個問題,Server 端是如何管理和查詢 Connection 的?主要是通過解析 Packet 的 Address,在 UNetDriver 中查詢緩存地址映射關係。

// 聲明
class UNetDriver {
	TMap<TSharedRef<const FInternetAddr>, UNetConnection*, FDefaultSetAllocator, FInternetAddrConstKeyMapFuncs<UNetConnection*>> MappedClientConnections;
}
// 使用
const TSharedRef<const FInternetAddr> FromAddr = ReceivedPacket.Address.ToSharedRef();
UNetConnection** Result = MappedClientConnections.Find(FromAddr);

接下來是處理 Packet

  • TickDispatch 正常消費到 Packet 之後,要確定 Packet 該丟給哪一層
  • 由於未建立連接,下一層交由 UIpNetDriver::ProcessConnectionlessPacket
    • PacketHandler::IncomingConnectionless 校驗 Packet 正確性
      • PacketHandler::Incoming_Internal
        • 遍歷 HandlerComponent 對包進行處理
        • StatelessConnectHandlerComponent::IncomingConnectionless 處理無連接的 Packet
          • StatelessConnectHandlerComponent::ParseHandshakePacket 檢查是否爲握手包,根據 Packet 時間戳確定是否是 bInitialConnect
          • 握手包回一個 Challenge 包 StatelessConnectHandlerComponent::SendConnectChallenge
    • StatelessConnectHandlerComponent::HasPassedChallenge 校驗
    • 檢查是否是重連,處理重連邏輯
    • 創建 UIpConnection
    • UIpConnection::InitRemoteConnection 這裏初始化連接,給客戶端發送 NMT_Hello 包,開始正式的握手流程,這裏開始有一個狀態機來驅動連接過程
      • UNetConnection 的 ClientLoginState 初始化爲 EClientLoginState::Type::LoggingIn
    • FNetworkNotify::NotifyAcceptedConnection 通知接收連接
    • UNetDriver::AddClientConnection 添加 UIpConnection

關於 Challenge
Challenge 消息是 Unreal Engine 4(UE4)中的一種網絡消息,用於在客戶端和服務器之間進行身份驗證。在 UE4 中,客戶端和服務器之間的通信是通過一種稱爲 Unreal Network Protocol(簡稱 UNet)的協議實現的。UNet 通過在客戶端和服務器之間發送各種類型的網絡消息來管理通信。

在 UE4 中,當客戶端第一次連接到服務器時,服務器會向客戶端發送一個 Challenge 消息,其中包含一個隨機生成的 Challenge 令牌。客戶端必須將這個 Challenge 令牌使用預共享密鑰(PSK)進行簽名,並將簽名後的結果發送回服務器。服務器會驗證簽名是否正確,如果正確,則表示客戶端是一個合法的用戶,並將向客戶端發送一個 ChallengeAck 消息,其中包含服務器的簽名和一些其他的驗證信息。客戶端必須驗證 ChallengeAck 消息是否正確,並將消息發送回服務器,以便進行最終的身份驗證。

關於 NMT_Hello
可以看到收到客戶端連接包之後,除了回覆正常的 Ack 包之外,會主動給客戶端發送一個 NMT_Hello 包,這裏的 NMT_Hello 是一個枚舉。UE4 中 NMT 開頭的枚舉是指 NetworkMessageTypes,是 Unreal Engine 4(UE4)中用於管理網絡消息類型的一組枚舉。在 UE4 中,網絡消息是通過一種稱爲 Unreal Network Protocol(簡稱 UNet)的協議進行傳輸和管理的。UNet 通過在客戶端和服務器之間發送各種類型的網絡消息來管理通信。

通過接收不同的 NMT 消息,從而在客戶端服務器連接過程中,不同階段執行不同的操作,比如當前收到這個消息應該加載地圖或者創建 PlayerController。

1.4 握手小結

至此大致梳理完了 Client 和 Server 的握手流程:

  • 創建網絡驅動 UNetDriver
  • Server 端 Listen
  • Client 端先創建 UIpConnection 發起連接
  • Server 端接收連接,回覆 ConnectChallenge 包
  • Client 收包,回覆 ChallengeResponse 包
  • Server 回覆 ChallengeAck
  • 握手完畢
    其中重點內容主要有:
  • UNetDriver 是網絡同步核心,用於驅動網絡同步
  • Client 會有一個 UPendingNetGame 在正式連接前驅動握手過程
  • Client 會先創建 Connection,Server 收到後才創建對應的 Connection,Connection 用於收發握手過程中的數據包
  • Server 和 Client 收包底層使用 Connection 的 PacketHandler
  • 握手過程主要利用 PacketHandler 的 HandlerComponent 中的 StatelessConnectHandlerComponent,其負責整個握手過程,此外 PacketHandler 的 HandlerComponent 可以掛載各種組件來支持對數據包的處理,比如 RSA,加密解密等
    雙方完整握手的流程如下:

new.png

1.5 QA

1.5.1 丟包處理

握手過程中顯然有丟包的可能,在 CS 握手過程中,大致發送的 Packet 如下:
image.png

Client 主要發送兩個包,Handshake 和 ChallengeResponse,當 Client 沒有收到迴應時,對應階段在 StatelessConnectHandlerComponent::Tick 都會有一個重發機制。參考代碼如下:

void StatelessConnectHandlerComponent::Tick(float DeltaTime)  
{  
   if (Handler->Mode == Handler::Mode::Client)  
   {  
	   // ... 省略一些代碼
	 if (LastSendTimeDiff > 1.0)  
	 {  
		if (State == Handler::Component::State::UnInitialized)  
		{  
		   NotifyHandshakeBegin();  
		}  
		else if (State == Handler::Component::State::InitializedOnLocal && LastTimestamp != 0.0)  
		{  
		   SendChallengeResponse(LastSecretId, LastTimestamp, LastCookie);  
		}  
	 }  
   }

1.5.2 連接過程用到了哪些關鍵 Class

大致如下:

handshakeuml.png

2 連接過程 - Enter Game

握手完畢後就要準備一些 Gameplay 層的相關操作,比如加載地圖等,Packet 對於應用層還是太底層了,UE 爲此引入了 Bunch 和 Channel 的概念

2.1 Bunch

2.1.1 Bunch 和 Packet 的區別

首先 Bunch 和 Packet 的關係如下:

  1. Bunch:Bunch是UE4中的一個基本網絡數據單位。它可以被看作是一組數據的集合,這些數據代表了某個特定時刻的遊戲狀態變化。Bunch充當了一種中介,將遊戲的狀態信息打包成可以在網絡上發送和接收的格式。它包含了一些關於對象、事件和屬性的信息,以及一些控制網絡通信的元數據。
  2. Packet:Packet是一個更大的網絡數據單位,用於在網絡上實際傳輸數據。一個Packet通常包含多個Bunch,以及其他一些網絡層所需的信息,如包序號、時間戳等。Packet在網絡上發送時,會被分割成更小的數據包,以適應各種網絡環境和傳輸協議。
    Bunch和Packet之間的關係是層次性的。Bunch負責打包遊戲狀態的變化,而Packet負責在網絡上傳輸這些Bunch。在數據傳輸過程中,Bunch被組合成Packet,Packet在發送端被編碼爲可以在網絡上傳輸的二進制數據,然後在接收端被解碼還原爲Bunch,以便在遊戲中應用狀態變化。

image.png|325

2.1.2 Bunch 的結構

Bunch 分爲 FInBunch 和 FOutBunch,根據這個名字可以看出分別對應收到的 Bunch 結構和 發送的 Bunch 結構,其繼承鏈如下:

image.png

FInBunch 的結構如下:

class ENGINE_API FInBunch : public FNetBitReader  
{  
public:  
// 省略一些字段
   int32           PacketId;  // Note this must stay as first member variable in FInBunch for FInBunch(FInBunch, bool) to work  
   FInBunch *       Next;  
   UNetConnection *   Connection;   // 屬於哪個 Connection
   int32           ChIndex;  // channel 的下標
   int32           ChType;   // channel 的類型
   FName           ChName;  // channel 的名稱
   int32           ChSequence;  // Channel 的 Seqid
   uint8           bOpen:1;   // 是否是 Channel 的首包
   uint8           bClose:1;  // 是否是 Channel 的結束包
   uint8           bDormant:1;                // 是否處於休眠
   uint8           bIsReplicationPaused:1;       // 複製同步是否被暫停了
   uint8           bReliable:1;         // 是否爲可靠的 Bunch
   uint8           bPartial:1;                // 該 Bunch 是否被拆分
   uint8           bPartialInitial:1;       // 是不是分片傳輸中的第一個 Bunch
   uint8           bPartialFinal:1;         // 是不是分片傳輸中的最後一個 Bunch
}

FOutBunch 的結構如下:

class ENGINE_API FOutBunch : public FNetBitWriter  
{  
public:  
// 省略一些字段
   FOutBunch *             Next;  
   UChannel *          Channel;  
   double             Time;  
   int32              ChIndex;  
   int32              ChType;  
   FName              ChName;  
   int32              ChSequence;  
   int32              PacketId;  
   uint8              ReceivedAck:1;  // 標記這個數據包是否已經被確認,以避免重複發送
   uint8              bOpen:1;  
   uint8              bClose:1;  
   uint8              bDormant:1;  
   uint8              bReliable:1;  
   uint8              bPartial:1;             // Not a complete bunch  
   uint8              bPartialInitial:1;    // The first bunch of a partial bunch  
   uint8              bPartialFinal:1;         // The final bunch of a partial bunch  
}

Bunch 的信息中,除了一些分包相關的信息,最主要的便是 Channel 相關的信息了,比如這個 Bunch 屬於哪個 Channel?Channel 的類型是什麼?那麼什麼是 Channel ?其用處是什麼?

2.2 Channel 定義

UE 中,Channel 主要分爲三種類型:

  • ActorChannel: 用於在服務器和客戶端之間同步Actor狀態的通道。它負責在網絡上移動、旋轉、縮放等操作,並確保所有客戶端都具有相同的Actor狀態。它還負責同步Actor的變量和屬性。
  • ControlChannel:一個特殊類型的網絡通道,主要負責處理底層的網絡連接和控制消息。與其他類型的通道(如UActorChannel)主要用於遊戲數據傳輸不同,UControlChannel處理的消息與遊戲邏輯關係較少,主要用於維護網絡連接狀態、通知連接事件以及傳輸核心控制信息。ControlChannel 的一些職責示例如下:
  1. 連接建立和斷開:UControlChannel會處理網絡連接建立和斷開的消息。例如,當客戶端與服務器建立連接時,UControlChannel會發送和接收連接請求和響應,以便雙方建立通信。同樣,當連接斷開時,UControlChannel會負責發送斷開通知,通知另一方連接已關閉。
  2. 心跳檢測:爲了確保連接保持活躍,UControlChannel會定期發送和接收心跳消息。這些消息用於檢測雙方是否仍在線,以便在一方掉線時及時處理連接斷開事件。
  3. 通道管理:UControlChannel負責處理通道的打開和關閉。例如,當需要創建一個新的UActorChannel以傳輸遊戲對象數據時,UControlChannel會發送相應的打開通道請求。同樣,當某個通道不再需要時,UControlChannel會負責發送關閉通道請求。
  4. 控制消息:UControlChannel還可以處理其他一些控制消息,如暫停、恢復遊戲等。這些消息通常對遊戲邏輯產生一定影響,但主要用於維護遊戲狀態和連接。
  • VoiceChannel:主要處理語音數據,比如常見的遊戲中的隊伍聊天

2.3 Channel 的創建

  • Client :Client 上 Channel 的創建接口爲 UNetDriver::CreateInitialCilentChannels ,其實就是在 InitNetDriver 的時候就創建好了 Channel
    image.png

  • Server :Server 上 Channel 的創建時機如下:
    image.png

基本上都是在握手過程中就創建好了 Channel。其關係如下:

image.png

2.3 Client 發送 NMT_Hello

Server 端在 InitRemoteConnection 之後,會執行 UNetConnection::SetExpectedClientLoginMsgType(NMT_Hello) ,表示等待 Client 端發送 NMT_Hello 的消息,而 Client 端發送該消息的時機就在握手完畢之後。
Client 端在調用 BeginHandshake 的時候,會傳入一個 Delegates,Handshake 完畢之後會調用 Delegates. Broadcast,通知握手完畢,綁定了該 Delegate 的接口都會被執行,大致如下:

// 握手完畢的回調
void UPendingNetGame::InitNetDriver() {
	// 省略一些代碼
	// 發起握手,傳入握手完畢的回調
	ServerConn->Handler->BeginHandshaking( FPacketHandlerHandshakeComplete::CreateUObject(this, &UPendingNetGame::SendInitialJoin));
}

// SendInit
void UPendingNetGame::SendInitialJoin() {
	// 省略一些代碼
	// 發送 NMT_Hello
	FNetControlMessage<NMT_Hello>::Send(ServerConn, IsLittleEndian, LocalNetworkVersion, EncryptionToken);
}

因此握手完畢後,Client 端就會調用 UPendingNetGame::SendInitialJoin ,發送 NMT_Hello 給 Server 端。
這裏還有個問題,如何確定這個 Message 會發送給 ControlChannel ?實際上這裏由 FNetControlMessage<>::Send 接口處理,其內部實現會直接發送一個 FControlChannelOutBunch,該 Bunch 會直接使用 Channel[0] 初始化,Channel[0] 默認情況下就是 ControlChannel。

2.5 ControlChannel 處理 ControlMessage

2.5.1 Server

Server 端處理 Bunch 的 CallStack 如下:

其大致流程如下:

  • NetDriver 收到 Packet
  • NetConnection 拆分 Packet 成多個 Bunch
  • 根據 Bunch.ChIndex 找到對應的 Channel(Channel 緩存在 NetConnection)
  • Channel 調用 ReceivedBunch (不同的 Channel 會各自重寫該接口)
  • ControlChannel 收到 Message 後調用 NotifyControlMessage 進行廣播,執行回調,其中 Server 登錄流程相關的最主要的就是 UWorld::NotifyControlMessage 接口

2.5.2 Client

Client 端登錄過程中主要處理 ControlMessage 的接口爲 UPendingNetGame::NotifyControlMessage

2.6 登錄,加載地圖,創建 PlayerController

  • Server 端收到 NMT_Hello 後,會回覆 NMT_Challenge
  • Client 收到 NMT_Challenge 後,整合玩家數據 NickName,PlayerId 等,發送 NMT_Login
  • Server 收到 NMT_Login:
    • 設置 Connection 的 PlayerId
    • 調用 GameMode::PreLogin,這裏我們也可以定義自己的 PreLogin,來加一些 Token 校驗之類的確定是否讓玩家進入遊戲。
    • 返回 NMT_Welcome,同時會設置 LevelName,這樣客戶端就可以知道連接什麼地圖。
  • Client 收到 NMT_Welcome:
    • 設置地圖路徑,在 UPendingNetGame 的 URL 中,UEngine::TickWorldTravel 會一直輪詢 UPendingNetGame 的地圖 URL
    • Travel 到目標地圖
    • 返回 NMT_NetSpeed 表示成功連接
  • Server 收到 NMT_NetSpeed,沒有什麼特殊操作,只是簡單設置下 NetSpeed
  • Client 加載地圖完畢,發送 NMT_Join。UPendingNetGame::LoadMapCompleted -> UPendingNetGame::SendJoin
  • Server 收到 NMT_Join:
    • 如果對應的 Connection 沒有 PlayerController 則創建一個
    • 觸發 AGameModeBase::Login
    • 如果當前 World 的 Map 是 Transition 的或者在一個錯誤的 World,則也通知 Client 再次進行 Travel
      總體流程圖如下:
      image.png

3. 總結

個人將 UE 中,Client 和 Server 建立連接到進入遊戲中的過程分爲了 2 步:

  1. 建立一個 UDP 連接(其實 UDP 沒有連接的概念),並且在 Server 和 Client 都維護一個 UNetConnection
  2. 利用 Control Message 和 Control Channel 進行通信,進入遊戲,執行 GameMode 的登錄,加載地圖,創建 PlayerController 等跟 Gameplay 密切相關的操作
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章