UnrealEngine

2023-04-11 18:02:08

1 網路同步機制

UE 提供了強大的網路同步機制:

  • RPC :可以在本地呼叫,對端執行
  • 屬性同步:標記一個屬性為 UPROPERTY(Replicated) 就可以自動將其修改後的值同步到使用者端
  • 移動複製:Actor 開啟了移動複製後會自動複製位置,旋轉和速度
  • 建立和銷燬:Server 建立 Actor 時根據其許可權會在所有連線使用者端生成遠端代理
    UE 基本上都是基於 Actor 進行同步的。Actor 同步的前提需要標記 Actor 為 bReplicated 。首先來了解下如何應用 UE 中的屬性同步。

2 Actor 同步

2.1 如何同步一個 Actor

首先思考一下,如何建立一個 Actor 然後讓他同步到各個使用者端?

  • 在哪裡建立?建立 Actor 的操作顯然需要在伺服器端執行,如果在使用者端執行,這個 Actor 只會在這個使用者端可見。

  • 如何讓 Actor 同步? 標記 Actor 的 bReplicated 為 True。

2.2 如何同步 Actor 的屬性

建立並同步完 Actor 之後,下一步是能夠支援 Actor 的資料能夠正常同步到使用者端,首先在應用層如何支援這一操作?
假設我們有一把武器,需要同步武器的彈藥數量,那麼需要進行如下定義

/** weapon.h **/
class AWeapon : public  {
	UPROPERTY(replicatedUsing=OnRep_Ammo) // 可選屬性,當 Ammo 成功同步後會呼叫該函數
	int32 Ammo; // 彈藥數量
	UFUNCTION()  
	virtual void OnRep_Ammo();
	virtual void GetLifetimeReplicatedProps(TArray< FLifetimeProperty > & OutLifetimeProps) const override; // 屬性複製條件控制
}
/** weapon.cpp **/
void AWeapon::GetLifetimeReplicatedProps(TArray< FLifetimeProperty > & OutLifetimeProps) const {
   Super::GetLifetimeReplicatedProps(OutLifetimeProps);  
   DOREPLIFETIME(AWeapon, Ammo); // 具體的複製屬性
}

上述定義主要有如下特點:

  • Actor 支援同步時,如果有自定義需要同步的屬性,需要重寫 GetLifetimeReplicatedProps 函數,並在其中標註要複製的具體屬性
  • 同步屬性時,可以通過 URPOPERTY 宏中 replicatedUsing 屬性來指定同步後要執行的回撥函數

2.3 Actor 同步流程

現在我們需要考慮一下,Actor 的屬性在什麼情況下會被複制?通常來說我們只需要在 Actor 屬性被修改時就需要同步到使用者端,但是什麼時候會被修改我們並不知道,因此引擎中會根據 Actor 複製頻率 來做同步檢查。參考後文中 4.4 優先順序和複製頻率 的內容。我們可以梳理出如下流程:

基本上每幀都需要檢查有哪些 Actor 需要同步,顯然這種檢查也是比較耗時的,由此 UE 也引入了 PushModel 技術,手動標記 Actor 哪些屬性已修改需要更新,從而節約檢查屬性的消耗。

2.4 小結

如何建立,同步一個 Actor 的應用層流程基本梳理完畢,但是顯然需要知道其後面的原理,由此引出如下問題在後續的文章中解決:

Actor 同步只能從 Server 同步到 Client,Client 唯一向 Server 傳送請求的方式只有 RPC,屬性同步是單向的

3 RPC 使用分析

3.1 什麼是 RPC

RPC(Remote Procedure Call,遠端過程呼叫)是一種用於實現分散式應用程式的技術。通過 RPC,可以使分散式應用程式中的各個部分像原生程式碼一樣互動,即使它們不在同一臺計算機或在不同的網路上。
在 RPC 中,一個應用程式可以呼叫另一個應用程式中的函數或方法,就像呼叫本地函數一樣。這些函數和方法在不同的程序或計算機上執行,但對呼叫方來說,它們是透明的。呼叫方不需要了解遠端程式碼的具體實現細節,只需要知道如何呼叫它們並處理返回值。

RPC 的使用有一些前提準則,必須滿足這些條件才能呼叫

  1. 它們必須從 Actor 上呼叫。
  2. Actor 必須被複制。
  3. 如果 RPC 是從伺服器呼叫並在使用者端上執行,則只有實際擁有這個 Actor 的使用者端才會執行函數。
  4. 如果 RPC 是從使用者端呼叫並在伺服器上執行,使用者端就必須擁有呼叫 RPC 的 Actor。
  5. 多播 RPC 則是個例外:
  • 如果它們是從伺服器呼叫,伺服器將在本地和所有已連線的使用者端上執行它們。
  • 如果它們是從使用者端呼叫,則只在本地而非伺服器上執行。
  • 現在,我們有了一個簡單的多播事件限制機制:在特定 Actor 的網路更新期內,多播函數將不會複製兩次以上。按長期計劃,我們會對此進行改善,同時更好的支援跨通道流量管理與限制。

3.2 RPC 的種類

UE 中有 3 種 RPC :

  • Server : 僅在 Server 上呼叫
  • Client :僅在 Client 上呼叫
  • NetMulticast :在與伺服器連線的所有使用者端及伺服器本身上呼叫
    這三種 RPC 只需要在函數呼叫的宣告中加上對應的標記即可。

3.2.1 如何確定 RPC 在哪裡被執行

當 RPC 函數在伺服器上呼叫時,有如下情況:


Actor 所有權 未複製 NetMulticast Server Client
Client Owned Actor 在伺服器上執行 在伺服器和所有使用者端上執行 在伺服器上執行 在 actor 的所屬使用者端上執行
Server Owned Actor 在伺服器上執行 在伺服器和所有使用者端上執行 在伺服器上執行 在伺服器上執行
Unowned Actor 在伺服器上執行 在伺服器和所有使用者端上執行 在伺服器上執行 在伺服器上執行

當 RPC 函數在使用者端上呼叫時,如下:


Actor 所有權 未複製 NetMulticast Server Client
Owned By Invoking Client 在執行呼叫的使用者端上執行 在執行呼叫的使用者端上執行 在伺服器上執行 在執行呼叫的使用者端上執行
Owned By a different client 在執行呼叫的使用者端上執行 在執行呼叫的使用者端上執行 丟棄 在執行呼叫的使用者端上執行
Server Owned Actor 在執行呼叫的使用者端上執行 在執行呼叫的使用者端上執行 丟棄 在執行呼叫的使用者端上執行
Unowned Actor 在執行呼叫的使用者端上執行 在執行呼叫的使用者端上執行 丟棄 在執行呼叫的使用者端上執行

事實上最終判斷 RPC 在哪裡被執行,主要根據如下三個條件:

  1. 呼叫端是誰(Client/Server)
  2. 呼叫的 Actor 屬於哪個連線
  3. RPC 的型別(Server/Client/NetMulticast)

舉一個例子,有兩個使用者端 c1 和 c2 各自有 Pawn p1 和 p2,c1 的使用者端上能夠獲取到 p2 這個物件,但是無法利用 p2 呼叫 RPC,因為在 c1 上 p2 只是一個普通的 Pawn,其沒有對應的 c2 的 PlayerController(參考 [[總體框架#3. PlayerController|PlayerController 定義]])。也沒有對應的 Connection,因此無法執行 RPC。

  1. 實際上是否會呼叫到對端,主要根據 UObject::GetFunctionCallspace 這個介面返回的列舉來判定的。
  2. 其次根據 Actor 所屬的 Connection,如果 Actor 不屬於任何一個 Connection(Owner 遞迴查詢找不到 PlayerController),那麼也是無法呼叫 RPC 的。

3.3 RPC 的使用

UE 中,一個 RPC 函數的宣告和定義如下(以 Client 呼叫 Server 執行的 RPC 為例):

/** weapon.h **/
class AWeapon : public  {
	UFUNCTION(Server)
	void Fire();
}

/** weapon.cpp **/
void AWeapon::Fire_Implementation() {
	/** do weapon fire **/ 
}

此時只需要在 Client 端使用如下操作:

AWeapon* Weapon = GetWeapon();
Weapon->Fire();

就能直接呼叫 Server 端的 Fire 介面了。關於其背後實現的原理,可以參考 [[原理#4. QA#4.5 RPC 函數如何執行的|RPC函數執行原理]]。
這裡需要注意一點,UE 的 RPC 是沒有返回值的,統一都是 void。個人如果需要獲取返回值,那麼就需要一個類似協程的概念,來獲取返回值,否則只能阻塞等待或者非同步等待,後者顯然程式碼可讀性也不是很好。

3.4 小結

RPC 與屬性同步有些不同,RPC 可以 Server To Client 也可以 Client To Server,是一種雙向的通訊方式,而屬性同步只能 Server To Client,屬於單向同步。對於 RPC 的實現,有如下問題可以再進行深究:

4 Actor 同步概念

4.1 NetRole

每個 Actor 都有一個 LocalRole 和 RemoteRole 的概念,分別對應於 Actor 在本地和在對端的 Role,Role 主要分為 3 種:

  • ROLE_SimulatedProxy
  • ROLE_AutonomousProxy
  • ROLE_Authority
    通常 LocalRole=Authority 只存在於伺服器(但是使用者端也有可能存在,比如 Spawn 一個 Actor 但是不標記為 Replicated)。關於各種 Role 常見的設定可以參考下圖:

4.1.1 AutonomousProxy 和 SimulatedProxy 的區別

  • AutonomousProxy 和 SimulatedProxy 基本只存在於使用者端,ROLE_AutonomousProxy 用於處理本地玩家的輸入,並將這些輸入傳送到伺服器進行處理,而 ROLE_SimulatedProxy 用於處理其他玩家的輸入,並在使用者端上模擬 Actor 在伺服器上的執行。因此通常 AutonomousProxy 只存在於 PlayerController 和其 Possess 的 Pawn。
  • SimulatedProxy 是標準的模擬途徑,通常是根據上次獲得的速率對移動進行推算。當伺服器為特定的 actor 傳送更新時,使用者端將向著新的方位調整其位置,然後利用更新的間歇,根據由伺服器傳送的最近的速率值來繼續移動 actor。
  • AutonomousProxy 通常只用於 PlayerController 所擁有的 actor。這說明此 actor 會接收來自真人控制者的輸入,所以在我們進行推算時,我們會有更多一些的資訊,而且能使用真人輸入內容來補足缺失的資訊(而不是根據上次獲得的速率來進行推算)。

4.1.2 小結

那麼這個 Role 有什麼用呢?個人認為有如下用處:

  • 在 C/S 模式下,基本可以認為 LocalRole 為 Authority 的 Actor 當前就是處於伺服器環境下,用來區分伺服器還是使用者端
  • 引擎對於 AutonomousProxy 和 SimulatedProxy 做了區分,用來更好的模擬玩家輸入

就目前而言,只有伺服器能夠向已連線的使用者端同步 Actor (使用者端永遠都不能向伺服器同步)。始終記住這一點, 只有 伺服器才能看到 Role == ROLE_Authority 和 RemoteRole == ROLE_SimulatedProxy 或者 ROLE_AutonomousProxy

4.2 關聯連線

UE 中 Actor關聯連線的概念,即這個 Actor 屬於哪個連線。在傳統的 C/S 伺服器中,每個使用者端和伺服器會有一條連線,在 UE 中會為每個連線建立一個 PlayerController,這樣這個 PlayerController 就歸這條連線所有。
而如果一個 Actor 的 Owner 為 PlayerController 或者為 Pawn 並且這個 Pawn 擁有一個 PlayerController,那麼這個 Actor 就歸屬於擁有這個 PlayerController 的連線。
這裡的關聯連線有什麼用呢?
考慮如下三種情況:

  • 需要確定哪個使用者端將執行執行於使用者端的 RPC
  • Actor 複製與連線相關性(比如 bOnlyRelevantToOwner 為 True 的 Actor,只有擁有這個 Actor 的 Connection 才會收到這個 Actor 的屬性更新,比如 PlayerController)
  • 涉及 Owner 的 Actor 屬性複製條件(比如 COND_OnlyOwner 只能複製給 Owner)

連線所有權

4.3 相關性

相關性是用於判斷 Actor 是否需要進行同步的重要依據。其主要判斷相關性的介面為 AActor::IsNetRelevantFor 。個人認為相關性最重要的一點是可以有效的節約頻寬和同步操作所帶來的 CPU 消耗
比如場景的規模可能比較大,玩家特定時刻只能看到關卡中部分 Actor。被伺服器認為可見或者能夠影響使用者端的 Actor 組會被是為該使用者端的相關 Actor 組,伺服器只會讓使用者端知道其相關組內的 Actor。

  1. 如果 Actor 是 bAlwaysRelevant、歸屬於 Pawn 或 PlayerController、本身為 Pawn 或者 Pawn 是某些行為(如噪音或傷害)的發起者,則其具有相關性。
  2. 如果 Actor 是 bNetUseOwnerRelevancy 且擁有一個所有者,則使用所有者的相關性。
  3. 如果 Actor 是 bOnlyRelevantToOwner 且沒有通過第一輪檢查,則不具有相關性。
  4. 如果 Actor 被附加到另一個 Actor 的骨架模型,它的相關性將取決於其所在基礎的相關性。
  5. 如果 Actor 是不可見的 (bHidden == true) 並且它的 Root Component 並沒有碰撞,那麼則不具有相關性,
    • 如果沒有 Root Component 的話,AActor::IsNetRelevantFor() 會記錄一條警告,提示是否要將它設定為 bAlwaysRelevant=true
  6. 如果 AGameNetworkManager 被設定為使用基於距離的相關性,則只要 Actor 低於淨剔除距離,即被視為具有相關性。

Pawn 和 PlayerController 將覆蓋 AActor::IsNetRelevantFor() 並最終具有不同的相關性條件。

4.4 優先順序和複製頻率

4.4.1 優先順序

每個 Actor 都有一個名為 NetPriority 的浮點變數。這個變數的數值越大,Actor 相對於其他"同伴"的頻寬就越多。和優先順序為 1.0 的 Actor 相比,優先順序是 2.0 的 Actor 可以得到兩倍的更新頻度。唯一影響優先順序的就是它們的比值。
計算 Actor 的當前優先順序時使用了函數 AActor::GetNetPriority。為避免出現饑荒(starvation),AActor::GetNetPriority 使用 Actor 上次複製後經過的時間去乘以 NetPriority。同時,GetNetPriority 函數還考慮了 Actor 與觀察者的相對位置以及兩者之間的距離。

4.4.2 複製頻率

Actor 不是每一幀都進行復制的,每個 Actor 有個自己的每秒複製頻率 NetUpdateFrequency,每次檢查 Tick 的 DeltaTime > 1/NetUpdateFrequency,滿足條件才可以進行下一步複製檢查。
比如預設 PlayerState 每秒更新 1 次,而 Pawn 每秒更新 100 次(預設情況下伺服器 30 fps 執行,基本上每幀都會做複製檢查)。