UnrealEngine

2023-04-19 15:00:47

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,顯然它是一個比較重要的網路管理類,這裡簡單看下其結構

可以看到主要負責:

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

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

  • UDemoNetDriver:用來支援遊戲錄影和回放(類似守望先鋒的擊殺回放)
  • UWebSocketNetDriver:用於實現 WebSocket 協定的網路通訊。WebSocket 是一種基於 TCP 的網路協定,允許在使用者端和伺服器之間進行雙向通訊,可以實現實時通訊和資料傳輸。通過使用 UWebSocketNetDriver,可以在 UE4中使用 WebSocket 協定進行網路通訊
  • UIpNetDriver:用於實現基於 IP(Internet Protocol)的網路通訊
    Server 端完整的繫結埠監聽的流程大致如下:

可以看到其實和普通的 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 這個作業系統介面

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,加密解密等
    雙方完整握手的流程如下:

1.5 QA

1.5.1 丟包處理

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

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

大致如下:

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,以便在遊戲中應用狀態變化。

2.1.2 Bunch 的結構

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

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

  • Server :Server 上 Channel 的建立時機如下:

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

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
      總體流程圖如下:

3. 總結

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

  1. 建立一個 UDP 連線(其實 UDP 沒有連線的概念),並且在 Server 和 Client 都維護一個 UNetConnection
  2. 利用 Control Message 和 Control Channel 進行通訊,進入遊戲,執行 GameMode 的登入,載入地圖,建立 PlayerController 等跟 Gameplay 密切相關的操作