UE 提供了強大的網路同步機制:
UPROPERTY(Replicated)
就可以自動將其修改後的值同步到使用者端bReplicated
的 。首先來了解下如何應用 UE 中的屬性同步。首先思考一下,如何建立一個 Actor 然後讓他同步到各個使用者端?
bReplicated
為 True。建立並同步完 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); // 具體的複製屬性
}
上述定義主要有如下特點:
URPOPERTY
宏中 replicatedUsing
屬性來指定同步後要執行的回撥函數現在我們需要考慮一下,Actor 的屬性在什麼情況下會被複制?通常來說我們只需要在 Actor 屬性被修改時就需要同步到使用者端,但是什麼時候會被修改我們並不知道,因此引擎中會根據 Actor 複製頻率 來做同步檢查。參考後文中 4.4 優先順序和複製頻率 的內容。我們可以梳理出如下流程:
基本上每幀都需要檢查有哪些 Actor 需要同步,顯然這種檢查也是比較耗時的,由此 UE 也引入了 PushModel 技術,手動標記 Actor 哪些屬性已修改需要更新,從而節約檢查屬性的消耗。
如何建立,同步一個 Actor 的應用層流程基本梳理完畢,但是顯然需要知道其後面的原理,由此引出如下問題在後續的文章中解決:
Actor 同步只能從 Server 同步到 Client,Client 唯一向 Server 傳送請求的方式只有 RPC,屬性同步是單向的
RPC(Remote Procedure Call,遠端過程呼叫)是一種用於實現分散式應用程式的技術。通過 RPC,可以使分散式應用程式中的各個部分像原生程式碼一樣互動,即使它們不在同一臺計算機或在不同的網路上。
在 RPC 中,一個應用程式可以呼叫另一個應用程式中的函數或方法,就像呼叫本地函數一樣。這些函數和方法在不同的程序或計算機上執行,但對呼叫方來說,它們是透明的。呼叫方不需要了解遠端程式碼的具體實現細節,只需要知道如何呼叫它們並處理返回值。
RPC 的使用有一些前提準則,必須滿足這些條件才能呼叫
- 它們必須從 Actor 上呼叫。
- Actor 必須被複制。
- 如果 RPC 是從伺服器呼叫並在使用者端上執行,則只有實際擁有這個 Actor 的使用者端才會執行函數。
- 如果 RPC 是從使用者端呼叫並在伺服器上執行,使用者端就必須擁有呼叫 RPC 的 Actor。
- 多播 RPC 則是個例外:
- 如果它們是從伺服器呼叫,伺服器將在本地和所有已連線的使用者端上執行它們。
- 如果它們是從使用者端呼叫,則只在本地而非伺服器上執行。
- 現在,我們有了一個簡單的多播事件限制機制:在特定 Actor 的網路更新期內,多播函數將不會複製兩次以上。按長期計劃,我們會對此進行改善,同時更好的支援跨通道流量管理與限制。
UE 中有 3 種 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 在哪裡被執行,主要根據如下三個條件:
舉一個例子,有兩個使用者端 c1 和 c2 各自有 Pawn p1 和 p2,c1 的使用者端上能夠獲取到 p2 這個物件,但是無法利用 p2 呼叫 RPC,因為在 c1 上 p2 只是一個普通的 Pawn,其沒有對應的 c2 的 PlayerController(參考 [[總體框架#3. PlayerController|PlayerController 定義]])。也沒有對應的 Connection,因此無法執行 RPC。
- 實際上是否會呼叫到對端,主要根據
UObject::GetFunctionCallspace
這個介面返回的列舉來判定的。- 其次根據 Actor 所屬的 Connection,如果 Actor 不屬於任何一個 Connection(Owner 遞迴查詢找不到 PlayerController),那麼也是無法呼叫 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。個人如果需要獲取返回值,那麼就需要一個類似協程的概念,來獲取返回值,否則只能阻塞等待或者非同步等待,後者顯然程式碼可讀性也不是很好。
RPC 與屬性同步有些不同,RPC 可以 Server To Client 也可以 Client To Server,是一種雙向的通訊方式,而屬性同步只能 Server To Client,屬於單向同步。對於 RPC 的實現,有如下問題可以再進行深究:
每個 Actor 都有一個 LocalRole 和 RemoteRole 的概念,分別對應於 Actor 在本地和在對端的 Role,Role 主要分為 3 種:
LocalRole=Authority
只存在於伺服器(但是使用者端也有可能存在,比如 Spawn 一個 Actor 但是不標記為 Replicated)。關於各種 Role 常見的設定可以參考下圖:那麼這個 Role 有什麼用呢?個人認為有如下用處:
就目前而言,只有伺服器能夠向已連線的使用者端同步 Actor (使用者端永遠都不能向伺服器同步)。始終記住這一點, 只有 伺服器才能看到
Role == ROLE_Authority
和RemoteRole == ROLE_SimulatedProxy
或者ROLE_AutonomousProxy
。
UE 中 Actor
有關聯連線的概念,即這個 Actor
屬於哪個連線。在傳統的 C/S 伺服器中,每個使用者端和伺服器會有一條連線,在 UE 中會為每個連線建立一個 PlayerController
,這樣這個 PlayerController
就歸這條連線所有。
而如果一個 Actor
的 Owner 為 PlayerController
或者為 Pawn
並且這個 Pawn
擁有一個 PlayerController
,那麼這個 Actor
就歸屬於擁有這個 PlayerController
的連線。
這裡的關聯連線有什麼用呢?
考慮如下三種情況:
相關性是用於判斷 Actor 是否需要進行同步的重要依據。其主要判斷相關性的介面為 AActor::IsNetRelevantFor
。個人認為相關性最重要的一點是可以有效的節約頻寬和同步操作所帶來的 CPU 消耗。
比如場景的規模可能比較大,玩家特定時刻只能看到關卡中部分 Actor。被伺服器認為可見或者能夠影響使用者端的 Actor 組會被是為該使用者端的相關 Actor 組,伺服器只會讓使用者端知道其相關組內的 Actor。
bAlwaysRelevant
、歸屬於 Pawn 或 PlayerController、本身為 Pawn 或者 Pawn 是某些行為(如噪音或傷害)的發起者,則其具有相關性。bNetUseOwnerRelevancy
且擁有一個所有者,則使用所有者的相關性。bOnlyRelevantToOwner
且沒有通過第一輪檢查,則不具有相關性。bHidden == true
) 並且它的 Root Component 並沒有碰撞,那麼則不具有相關性,
AActor::IsNetRelevantFor()
會記錄一條警告,提示是否要將它設定為 bAlwaysRelevant=true
。AGameNetworkManager
被設定為使用基於距離的相關性,則只要 Actor 低於淨剔除距離,即被視為具有相關性。Pawn 和 PlayerController 將覆蓋
AActor::IsNetRelevantFor()
並最終具有不同的相關性條件。
每個 Actor 都有一個名為 NetPriority
的浮點變數。這個變數的數值越大,Actor 相對於其他"同伴"的頻寬就越多。和優先順序為 1.0 的 Actor 相比,優先順序是 2.0 的 Actor 可以得到兩倍的更新頻度。唯一影響優先順序的就是它們的比值。
計算 Actor 的當前優先順序時使用了函數 AActor::GetNetPriority
。為避免出現饑荒(starvation),AActor::GetNetPriority
使用 Actor 上次複製後經過的時間去乘以 NetPriority。同時,GetNetPriority
函數還考慮了 Actor 與觀察者的相對位置以及兩者之間的距離。
Actor 不是每一幀都進行復制的,每個 Actor 有個自己的每秒複製頻率 NetUpdateFrequency,每次檢查 Tick 的 DeltaTime > 1/NetUpdateFrequency,滿足條件才可以進行下一步複製檢查。
比如預設 PlayerState 每秒更新 1 次,而 Pawn 每秒更新 100 次(預設情況下伺服器 30 fps 執行,基本上每幀都會做複製檢查)。