《Exploring in UE4》網路同步原理深入[原理分析]

前言

UE同步是一塊比較複雜而龐大的模塊,裡面設計到了很多設計思想,技巧,技術。我這裡主要是從同步的流程分析,以同步的機製為講解核心,給大家描述裡面是怎麼同步的,會大量涉及UE同步模塊的底層代碼,稍微涉及一點計算機網路底層(Socket相關)相關的知識。

PS:如果只是想知道怎麼使用同步,建議閱讀這篇文章 關於網路同步的理解與思考[概念理解]

目錄

一.基本概念

二.通信的基本流程

三.連接的建立

- 1. 伺服器網路模塊初始化流程

- 2. 客戶端網路模塊初始化流程

- 3. 伺服器與客戶端建立連接流程

四.Actor的同步細節

- 1.組件(子對象)同步

五.屬性同步細節

- 1. 屬性同步概述

- 2. 重要數據的初始化流程

- 3. 發送同步數據流程分析

- 4. 屬性變化歷史記錄

- 5. 屬性回調函數執行

- 6. 關於動態數組與結構體的同步

- 7. UObject指針類型的屬性同步

六.RPC執行細節


一. 基本概念

UE網路是一個相當複雜的模塊,這篇文章主要是針對Actor同步,屬性同步,RPC等大致的闡述一些流程以及關鍵的一些類。這裡我儘可能將我的理解寫下來。

在UE裡面有一些和同步相關的概念與類,這裡逐個列舉一下並做解釋:

底層通信:

  • Bunch

    一個Bunch裡面主要記錄了Channel信息,NGUID。同時包含其他的附屬信息如是否是完整的Bunch,是否是可靠的等等,可以簡單理解為一個數據包,該數據包的數據可能不完整,繼承自FNetBitWriter

    InBunch:從Channel接收的數據流串

    OutBunch:從Channel產生的數據流串

  • FBitWriter

    位元組流書寫器,可以臨時寫入比特數據用於傳輸,存儲等,繼承自FArchive
  • FSocket

    所有平台Socket的基類。

    FSocketBSD:使用winSocket的Socket封裝
  • Packet

    從Socket讀出來/輸出的數據
  • UPackageMap

    生成與維護Object與NGUID的映射,負責Object的序列化。每一個Connection對應一個UPackageMap

    (Packet與Bunch的區別:Packet裡面可能不包含Bunch信息)

基本網路通信:

  • NetDriver

    網路驅動,實際上我們創建使用的是他的子類IPNetDriver,裡面封裝了基本的同步Actor的操作,初始化客戶端與伺服器的連接,建立屬性記錄表,處理RPC函數,創建Socket,構建並管理當前Connection信息,接收數據包等等基本操作。NetDriver與World一一對應,在一個遊戲世界裡面只存在一個NetDriver。UE裡面默認的都是基於UDPSocket進行通信的。
  • Connection

    表示一個網路連接。伺服器上,一個客戶端到一個伺服器的一個連接叫一個ClientConnection。在客戶端上,一個伺服器到一個客戶端的連接叫一個ServerConnection。
  • LocalPlayer

    本地玩家,一個客戶端的窗口ViewportClient對應一個LocalPlayer,Localplayer在各個地圖切換時不會改變。

  • Channel

    數據通道,每一個通道只負責交換某一個特定類型特定實例的數據信息。ControlChannel:客戶端伺服器之間發送控制信息,主要是發送接收連接與斷開的相關消息。在一個Connection中只會在初始化連接的時候創建一個該通道實例。

    VoiceChannel:用於發送接收語音消息。在一個Connection中只會在初始化連接的時候創建一個該通道實例。

    ActorChannel:處理Actor本身相關信息的同步,包括自身的同步以及子組件,屬性的同步,RPC調用等。每個Connection連接里的每個同步的Actor都對應著一個ActorChannel實例。

    常見的只有這3種:枚舉裡面還有FileChannel等類型,不過沒有使用。
  • PlayerController

    玩家控制器,對應一個LocalPlayer,代替本地玩家控制遊戲角色。同時對應一個Connection,記錄了當前的連接信息,這和RPC以及條件屬性複製都是密切相關的。另外,PlayerController記錄他本身的ViewTarget(就是他控制額Character),通過與ViewTarget的距離(太遠的Actor不會同步)來進行其他Actor的同步處理。
  • World

    遊戲世界,任何遊戲邏輯都是在World裡面處理的,Actor的同步也受World控制,World知道哪些Actor應該同步,保存了網路通信的基礎設施NetDriver。
  • Actor

    在世界存在的對象,沒有坐標。UE4大部分的同步功能都是圍繞Actor來實現的。
  • Dormant

    休眠,對於休眠的Actor不會進行網路同步

屬性同步相關:

  • FObjectReplicator

    屬性同步的執行器,每個Actorchannel對應一個FObjectReplicator,每一個FObjectReplicator對應一個對象實例。設置ActorChannel通道的時候會創建出來。

  • FRepState

    針對每個連接同步的歷史數據,記錄同步前用於比較的Object對象信息,存在於FObjectReplicator裡面。
  • FRepLayOut

    同步的屬性布局表,記錄所有當前類需要同步的屬性,每個類或者RPC函數有一個。
  • FRepChangedPropertyTracker

    屬性變化軌跡記錄,一般在同步Actor前創建,Actor銷毀的時候刪掉。
  • FReplicationChangelistMgr

    存放當前的Object對象,保存屬性的變化歷史記錄

二. 通信的基本流程

如果我們接觸過網路通信,應該了解只要知道對方的IP地址以及埠號,伺服器A上進程M_1_Server可以通過套接字向客戶端B上的進程M_1_Client發送消息,大致的效果如下:

圖2-1 遠程進程通信圖

而對於UE4進程內部伺服器Server與客戶端Client1的通信,與上面的模型基本相似:

圖2-2 UE4遠程進程通信圖

那這個裡面的Channel是什麼意思呢?簡單理解起來就是一個通信軌道。為了實現規範與通信效率,我們的一個伺服器針對某個對象定義了Channel通道,這個通道只與客戶端對應的Channel通道互相發送與接收消息。這個過程抽象起來與TCP/UDP套接字的傳輸過程很像,套接字是在消息發送到進程前就進行處理,來控制客戶端進程A只會接收到伺服器對應進程A的消息,而這裡是在UnrealEditor.exe進程裡面處理,讓通道1隻接收到另一端通道1發送的消息。

上面的只是針對一個伺服器到客戶端的傳輸流程,那麼如果是多個客戶端呢?

圖2-3 Channel通信圖

每一個客戶端叫做一個Connection,如圖,就是一個server連接到兩個客戶端的效果。對於每一個客戶端,都會建立起一個Connection。在伺服器上這個Connection叫做ClientConnection,對於客戶端這個Connection叫做ServerConnection。每一個Channel都會歸屬於一個Connection,這樣這個Channel才知道他對應的是哪個客戶端上的對象。

接下來我們繼續細化,圖中的Channel只標記了1,2,3,那麼實際上都有哪些Channel?這些Channel對應的都是什麼對象?其實,在第一部分的概念里我已經列舉了常見的3中Channel,分別是ControlChannel,ActorChannel,以及VoiceChannel。一般來說,ControlChannel與VoiceChannel在遊戲中只存在一個,而ActorChannel則對應每一個需要同步的Actor,所以我們再次細化上面的示意圖:

圖2-4 Connection下的Channel通信圖

到這裡我們基本上就了解了UE4的基本通信架構了,下面我們進一步分析網路傳輸數據的流程。首先我們要知道,UE4的數據通信是建立在UDP-Socket的基礎上的,與其他的通信程序一樣,我們需要對Socket的信息進行封裝發送以及接收解析。這裡面主要涉及到Bunch,RawBunch,Packet等概念,建議參考第一部分的基本概念去理解,很多注釋已經加在了流程圖裡面。如圖所示:

圖2-5 發送同步信息流程圖

圖2-6 接收同步信息流程圖


三. 連接的建立

前面的內容已經提到過,UE的網通通信是基於Channel的,而ControlChannel就是負責

控制客戶端與伺服器建立連接的通道,所以客戶端與伺服器的連接信息都是通過UControlChannel執行NotifyControlMessage函數處理的。下面首先從伺服器與客戶端的網路模塊初始化說起,然後描述二者連接建立的詳細流程:

1.伺服器網路模塊初始化流程

從創建GameInstance開始,首先創建NetDriver來驅動網路初始化,進而根據平台創建對應的Socket,之後在World裡面監聽客戶端的消息。

圖3-1 伺服器網路模塊初始化流程圖

2.客戶端網路模塊初始化流程

客戶端前面的初始化流程與伺服器很相似,也是首先構建NetDriver,然後根據平台創建對應的Socket,同時他還會創建一個到伺服器的ServerConnection。由於客戶端沒有World信息,所以要使用一個新的類來檢測並處理連接信息,這個類就是UpendingNetGame。

圖3-2 客戶端網路模塊初始化流程圖

3.伺服器與客戶端建立連接流程

二者都完成初始化後,客戶端就會開始發送一個Hello類型的ControlChannel消息給伺服器(上面客戶端初始化最後一步)。伺服器接收到消息之後開始處理,然後會根據條件再給客戶端發送對應的消息,如此來回處理幾個回合,完成連接的建立,詳細流程參考下圖:

(該流程是本地區域網的連接流程,與在線查找伺服器列表並加入有差異)

圖3-3 客戶端伺服器連接建立流程圖


四. Actor的同步細節

Actor的同步可以說是UE4網路裡面最大的一個模塊了,裡面包括屬性同步,RPC調用等,這裡為了方便我將他們拆成了3個部分來分別敘述。

有了前面的描述,我們已經知道NetDiver負責整個網路的驅動,而ActorChannel就是專門用於Actor同步的通信通道。

這裡對Actor同步做一個比較細緻的描述:伺服器在NetDiver的TickFlush裡面,每一幀都會去執行ServerReplicateActors來同步Actor的相關內容。在這裡我們需要做以下處理:

  1. 獲取到所有連接到伺服器的ClientConnections,首先獲取引擎每幀可以同步的最大Connection的數量,超過這個限制的忽略。然後對每個Connection幾乎都要進行下面所有的操作
  2. 找到要同步的Actor,只有被放到World.NetworkActors裡面的Actor才會被考慮,Actor在被Spawn時候就會添加到這個NetworkActors列表裡面
  3. 找到客戶端玩家控制的角色ViewTarget(ViewTaget與攝像機綁定在一起),這個角色的位置是決定其他Actor是否同步的關鍵
  4. 驗證Actor,對於要銷毀的以及所有權Role為ROLE_NONE的Actor不會同步
  5. 是否到達Actor同步時間,Actor的同步是有一定頻率的,Actor身上有一個NetUpdateTime,每次同步前都會通過下面這個公式來計算下一次Actor的同步時間,如果沒有到達這個時間就會放棄本次同步Actor->NetUpdateTime = World->TimeSeconds + FMath::SRand() * ServerTickTime + 1.f/Actor->NetUpdateFrequency;
  6. 如果這個Actor設置OnlyRelevantToOwner,那麼就會放到一個特殊的列表裡面OwnedConsiderList然後只同步給屬於他的客戶端。否則會把Actor放到ConsiderList裡面
  7. 對於休眠狀態的Actor不會進行同步,對於要進入休眠狀態的Actor也要特殊處理關閉同步通道
  8. 查看當前的Actor是否有通道Channel,如果沒有,還要看看Actor是否已經加在了場景,沒有載入就跳過同步
  9. 接第8個條件——沒有Channel的情況下,還會執行Actor::IsNetRelevantFor判斷是否網路相關,對於不可見的或者太遠的Actor會返回false,不會同步
  10. Actor的同步數量可能非常大,所以有必要對所有的Actor進行一個優先順序的排列

    處理完上面的邏輯後會對優先順序表裡的所有Actor進行排序

  11. 排序後,如果連接沒有載入此 actor 所在的關卡,則關閉通道(如果存在)並繼續

    每 1 秒鐘調用一次 AActor::IsNetRelevantFor,確定 actor 是否與連接相關,如果不相關的時間達到 5 秒鐘,則關閉通道

    如果要同步的Actor沒有ActorChannel就給其創建一個並綁定Actor,執行同步並更新NetUpdateTime = Actor->GetWorld()->TimeSeconds + 0.2f * FMath::FRand();

    如果此連接出現飽和剩下的 actor會根據連接相關時間判斷是否在下一個時鐘更新
  12. 執行UActorChannel::ReplicateActor執行真正的Actor同步以及內部數據的同步,這裡會將Actor(PackageMap->SerializeNewActor),Actor子對象以及其屬性序列化(ReplicateProperties)封裝到OutBunch並發送給客戶端

    (備註:我們當前版本下面的邏輯都是寫在UNetDriver::ServerReplicateActors裡面,4.12以後的UE4已經分別把Connection預處理,獲取同步Actor列表,優先順序處理等邏輯封裝到單獨的函數里了,詳見ServerReplicateActors_BuildConsiderlist, ServerReplicateActors_PrioritizedActors, ServerReplicateActors_ProsessPrioritizedActors等函數

    優先順序排序規則是什麼?答案是按照是否有controller,距離以及是否在視野。通過FActorPriority構造代碼可以定位到APawn::GetNetPriority,這裡面會計算出當前Actor對應的優先順序,優先順序越高同步越靠前,是否有Controller的權重最大)

    總之,大體上Actor同步的邏輯就是在TickFlush裡面去執行ServerReplicateActors,然後進行前面說的那些處理。最後對每個Actor執行ActorChannel::ReplicateActor將Actor本身的信息,子對象的信息,屬性信息封裝到Bunch並進一步封裝到發送緩存中,最後通過Socket發送出去。

下面是伺服器的同步Actor的發送Bunch堆棧:(代碼修改過,與UE默認的有些不同)

圖4-1 伺服器同步Actor堆棧圖

下面描述客戶端是如何接收到伺服器同步過來的Actor的。首先客戶端TickDispatch檢測伺服器的消息,收到消息後通過Connection以及Channel進行解析,最後一步解析出完整數據的操作在UActorChannel::ProcessBunch執行,在這個函數裡面:

  1. 如果發現當前的ActorChannel對應的Actor為NULL,就對當前的Bunch進行反序列化Connection->PackageMap->SerializeNewActor(Bunch, this, NewChannelActor);解析出Actor的內容並執行PostInitializeComponents。如果Actor不為NULL,跳過這一步(參考下面圖一堆棧)
  2. 隨後根據Bunch信息找到同步過來的屬性值並對當前Actor對應的屬性進行賦值
  3. 最後執行PostNetInit調用Actor的BeginPlay。(參考下面堆棧)

下面截取了客戶端接收到同步Actor並初始化的調用堆棧:

圖4-2 客戶端接收並序列化同步的Actor堆棧圖

圖4-3 客戶端初始化同步過來Actor堆棧圖

從上面的描述來看,基本上我們可以很容易的分析出當前的Actor是否被同步,比如在UActorChannel::ReceivedBunch裡面打個斷點,看看當前通道里有沒有你要的Actor就可以了。

1.組件(子對象)同步

組件(還有其他子對象)是掛在Actor上面的,所以組件的同步與Actor同步是緊密相連的,當一個Actor進行同步的時候會判斷所有的子對象是否標記了Replicate,如果標記了,就對其以及其屬性進行同步。

這些子對象同步方式(RPC等)也與Actor相差無幾,實際上他們想要同步的話需要藉助ActorChannel創建自己的FObjectReplicator以及屬性同步的相關數據結構。簡單來說,就是一個Actor身上的組件同步需要借用這個Actor的通道來進行。下面3段代碼是伺服器序列化子對象準備發送的邏輯:

//UActorChannel::ReplicateActor() DataChannel.cpp// The ActorWroteSomethingImportant |= ActorReplicator->ReplicateProperties( Bunch, RepFlags );// 子對象的同步操作WroteSomethingImportant |= Actor->ReplicateSubobjects(this, &Bunch, &RepFlags);//ActorReplication.cppboolAActor::ReplicateSubobjects(UActorChannel *Channel, FOutBunch *Bunch, FReplicationFlags *RepFlags){ check(Channel); check(Bunch); check(RepFlags); bool WroteSomething = false; for (int32 CompIdx =0; CompIdx < ReplicatedComponents.Num(); ++CompIdx ) { UActorComponent * ActorComp = ReplicatedComponents[CompIdx].Get(); //如果組件標記同步 if (ActorComp && ActorComp->GetIsReplicated()) { WroteSomething |= ActorComp->ReplicateSubobjects(Channel, Bunch, RepFlags); // Lets the component add subobjects before replicating its own properties.檢測組件否還有子組件 WroteSomething |= Channel->ReplicateSubobject(ActorComp, *Bunch, *RepFlags); // (this makes those subobjects supported, and from here on those objects may have reference replicated) 同步該組件 } } return WroteSomething;}//DataChannel.cppboolUActorChannel::ReplicateSubobject(UObject *Obj, FOutBunch&Bunch, constFReplicationFlags&RepFlags){ if ( !Connection->Driver->GuidCache->SupportsObject( Obj ) ) { FNetworkGUID NetGUID = Connection->Driver->GuidCache->AssignNewNetGUID_Server(Obj ); //Make sure he gets a NetGUID so that he is now supported } bool NewSubobject = false; if (!ObjectHasReplicator(Obj)) { Bunch.bReliable = true; NewSubobject = true; } //組件的屬性同步需要先在當前的ActorChannel裡面創建新的FObjectReplicator bool WroteSomething = FindOrCreateReplicator(Obj).Get().ReplicateProperties(Bunch, RepFlags); if (NewSubobject && !WroteSomething) { ...... } return WroteSomething;}

下面一段代碼是客戶端接收伺服器同步過來的子對象邏輯:

// void UActorChannel::ProcessBunch( FInBunch & Bunch )DataChannel.cpp// 該函數前面的代碼主要是是進行反序列化當前Actor的相關操作while ( !Bunch.AtEnd() && Connection != NULL&& Connection->State != USOCK_Closed ){ bool bObjectDeleted = false; //當前通道的Actor以及反序列化成功,這裡開始繼續從Bunch裡面尋找子對象進行反序列化 //如果當前Actor沒有子組件,這裡返回的就是Actor自身 ...... TSharedRef<FObjectReplicator>& Replicator = FindOrCreateReplicator( RepObj ); bool bHasUnmapped = false; // 找到當前子對象(或當前Actor)的Replicator以後,這裡開始進行屬性值的讀取了 if ( !Replicator->ReceivedBunch( Bunch, RepFlags, bHasUnmapped ) ) { ...... } ......}

前面Actor同步有提到,當從ActorChannel解析Bunch信息的時候就可以嘗試對該數據流進行Actor的反序列化。從這段代碼可以進一步看出,Actor反序列化之後會立刻開始判斷Bunch裡面是否存在其子對象,如果存在還會進一步讀取子對象同步過來的屬性值。如果沒有子對象,就讀取自身同步過來的屬性。

關於子組件的反序列化還分為兩種情況。要想理解這兩種情況,還需要清楚兩個概念——動態組件與靜態組件。

對於靜態組件:一旦一個Actor被標記為同步,那麼這個Actor身上默認所掛載的組件也會隨Actor一起同步到客戶端(也需要序列化發送)。什麼是默認掛載的組件?就是C++構造函數裡面創建的默認組件或者在藍圖裡面添加構建的組件。所以,這個過程與該組件是否標記為Replicate是沒有關係的。

對於動態組件:就是我們在遊戲運行的時候,伺服器創建或者刪除的組件。比如,當玩家走進一個洞穴時,給洞穴裡面的火把生成一個粒子特效組件,然後同步到客戶端上,當玩家離開的時候再刪除這個組件,玩家的客戶端上也隨之刪除這個組件。

對於動態組件,我們必須要設置他的Replicate屬性為true,即通過函數 AActorComponent::SetIsReplicated(true)來操作。而對於靜態組件,如果我們不想同步組件上面的屬性,我們就沒有必要設置Replicate屬性。下面截取了函數ReadContentBlockHeader部分代碼來區分這兩種情況:

//靜態組件,不需要客戶端SpawnFNetworkGUID NetGUID;UObject * SubObj = NULL;Connection->PackageMap->SerializeObject(Bunch, UObject::StaticClass(), SubObj, &NetGUID );//動態組件,需要在客戶端Spawn出來FNetworkGUID ClassNetGUID;UObject * SubObjClassObj = NULL;Connection->PackageMap->SerializeObject(Bunch, UObject::StaticClass(), SubObjClassObj, &ClassNetGUID );

我們在這兩段代碼看到了FNetworkGUID的使用,因為這裡涉及到UObject的引用(指針)同步。對於不同端的同一個對象,他們的內存地址肯定是不同的,那伺服器上指向A的指針同步到客戶端上如何也能正確的指向A呢?這就需要通過FNetworkGUID來解析,具體細節在下一節屬性同步裡面分析。


五. 屬性同步細節

1.屬性同步概述

屬性同步是一個很複雜的模塊,我在另一個關於UE4網路思考文章裡面講解了屬性同步相關的使用邏輯以及注意事項。這裡我儘可能的分析一下屬性同步的實現原理。

有一點需要先提前說明一下,伺服器同步的核心操作就是比較當前的同步屬性是否發生變化,如果發生就將這個數據通過到客戶端。如果是普通邏輯處理,我們完全可以保存當前對象的一個拷貝對象,然後每幀去比較這個拷貝與真實的對象是否發生變化。不過,由於同步數據量巨大,我們不可能給每個需要同步的對象都創建一個新的拷貝,而且這個邏輯如果暴露到邏輯層的話會使代碼異常複雜難懂,所以這個操作要統一在底層處理。那麼,UE4的基本思路就是獲取當前同步對象的空間大小,然後保存到一個buffer裡面,然後根據屬性的OffSet給每個需要同步的屬性初始化。這樣,就保存了一份簡單的「拷貝」用於之後的比較。當然,我們能這麼做的前提是存在UE的Object對象反射系統。

下面開始進一步描述屬性同步的基本思路:我們給一個Actor類的同步屬性A做上標記Replicates(先不考慮其他的宏),然後UClass會將所有需要同步的屬性保存到ClassReps列表裡面,這樣我們就可以通過這個Actor的UClass獲取這個Actor上所有需要同步的屬性,當這個Actor實例化一個可以同步的對象並開始創建對應的同步通道時,我們就需要準備屬性同步了。

首先,我們要有一個同步屬性列表來記錄當前這個類有哪些屬性需要同步(FRepLayout,每個對象有一個,從UClass裡面初始化);其次,我們需要針對每個對象保存一個緩存數據,來及時的與發生改變的Actor屬性作比較,從而判斷與上一次同步前是否發生變化(FRepState,裡面有一個Staticbuff來保存);然後,我們要有一個屬性變化跟蹤器記錄所有發生改變同步屬性的序號(可能是因為節省內存開銷等原因所以不是保存這個屬性),便於發送同步數據時處理(FRepChangedPropertyTracker,對各個Connection可見,被各個Connection的Repstate保存一個共享指針,新版本被FRepChangelistState替換)。最後,我們還需要針對每個連接的每個對象有一個控制前面這些數據的執行者(FObjectReplicator)

這四個類就是我們屬性同步的關鍵所在,在同步前我們需要對這些數據做好初始化工作,然後在真正同步的時候去判斷與處理。

註:在4.12後的版本,新增了一個屬性,FReplicationChangelistMgr。FReplicationChangelistMgr 裡面保存了FRepChangelistState,FRepChangelistState屬性可謂是兼顧FRepState以及FRepChangedPropertyTracker雙重功能,他裡面有一個Staticbuff來保存Object對象一個緩存數據,用來在伺服器比較對象屬性是否發生變化,同時又有一個FRepChangedHistory來記錄所有發生過屬性變化的歷史記錄[大小有限制]。然而,這不代表他能替代FRepState與FRepChangedPropertyTracker。目前,客戶端在檢測屬性是否發生變化時使用的仍舊是RepState裡面的Staticbuff。在處理條件屬性複製時的判斷使用的仍然是FRepChangedPropertyTracker

2.重要數據的初始化流程

下面的兩個圖分別是屬性同步的伺服器發送堆棧以及客戶端的接收堆棧。

圖5-1伺服器發送屬性堆棧圖

圖5-2客戶端接收屬性堆棧圖

從發送堆棧中我們可以看到屬性同步是在執行ReplicatActor的同時進行的,所以我們也可以猜到屬性同步的準備工作應該與Actor的同步準備工作是密不可分的。前面Actor同步的講解中我們已經知道,當Actor同步時如果發現當前的Actor沒有對應的通道,就會給其創建一個通道並執行SetChannelActor。這個SetChannelActor所做的工作就是屬性同步的關鍵所在,這個函數裡面會對上面四個關鍵的類構造並做初始化,詳細的內容參考下圖:

圖5-3 SetChannelActor流程解析圖

圖中詳細的展示了幾個關鍵數據的初始化,不過第一次看可能對這個幾個類的關係有點暈,下面給大家簡單畫了一個類圖。

圖5-4屬性同步相關類圖

具體來說,每個ActorChannel在創建的時候會創建一個FObjectReplicator用來處理所有屬性同步相關的操作,同時會把當前對應通道Actor的同步的屬性記錄在FRepLayOut的Parents數組裡面(Parents記錄了每個屬性的UProperty,複製條件,在Object裡面的偏移等)。

同時把這個RepLayOut存儲到RepState裡面,該RepState指針也會被存儲到FObjectReplicator裡面,RepState會申請一個緩存空間用來存放當前的Object對象(並不是完整對象,只包含同步屬性,但是佔用空間大小是一樣的,用於客戶端比較)

當然,FObjectReplicator還會保存一個指向FReplicationChangelistMgr的指針,指針對應對象裡面的FRepChangelistState也申請一個緩存空間staticbuff用來存放當前的Object對象(用於伺服器比較),同時還有一個ChangeHistory來保存屬性的變化歷史記錄。

FRepChangedPropertyTracker在創建RepState的同時也被創建,然後通過FRepLayOut的Parents數量來初始化他的記錄表的大小,主要記錄對應的位置是否是條件複製屬性,RepState裡面保存一個指向他的指針。

關於Parents屬性與CMD屬性:Replayout裡面,數組parents示當前類所有的需要同步的屬性,而數組cmd會將同步的複雜類型屬性(包括數組、結構體、結構體數組但不包括類類型的指針)進一步展開放到這裡面。比如ClassA裡面有一個StructB屬性,這個屬性被標記同步,StructB屬性會被放到parents裡面。由於StructB裡面有一個Int類型C屬性以及D屬性,那麼C和D就會被放到Cmd數組裡面。有關結構體的屬性同步第5部分還有詳細描述

3.發送同步數據流程分析

前面我們基本上已經做好了同步屬性的基本工作,下面開始執行真正的同步流程。

圖5-5伺服器發送屬性堆棧圖

再次拿出伺服器同步屬性的流程,我們可以看到屬性同步是通過FObjectReplicator::

ReplicateProperties函數執行的,進一步執行RepLayout->ReplicateProperties。這裡面比較重要的細節就是伺服器是如何判斷當前屬性發生變化的,我們在前面設置通道Actor的時候給FObjectReplicator設置了一個Object指針,這個指針保存的就是當前同步的對象,而在初始化RepChangelistState的同時我們還創建了一個Staticbuffer,並且把buffer設置和當前Object的大小相同,對buffer取OffSet把對應的同步屬性值添加到buffer裡面。所以,我們真正比較的就是這兩個對象,一般來說,staticbuffer在創建通道的同時自己就不會改變了,只有當與Object比較發現不同的時候,才會在發送前把屬性值置為改變後的。這對於長期同步的Actor沒什麼問題,但是對於休眠的Actor就會出現問題了,因為每次刪除通道並再次同步強制同步的時候這裡面的staticbuff都是Object默認的屬性值,那比較的時候就可能出現0不同步這樣奇怪的現象了。真正比較兩個屬性是否相同的函數是PropertiesAreIdentical(),他是一個static函數。

圖5-6 伺服器同步屬性流程圖

4. 屬性變化歷史記錄

ChangeHistory屬性在在FRepState以及FRepChangelistState裡面都存在,不過每次同步前都是先更新FRepChangelistState裡面的ChangeHistory,隨後在發送前將FRepChangelistState的本次同步發生變化數據拷貝到FRepState的ChangeHistory本次即將發送的變化屬性對應的數組元素裡面。簡單來說,就是FRepState的ChangeHistory一般只保存當前這一次同步發生變化的屬性序號,而FRepChangelistState可以保存之前所有的變化的歷史記錄(更準確的說是最近的64次變化記錄)。

圖5-7

5.屬性回調函數執行

雖然屬性同步是由伺服器執行的,但是FObjectReplicator,RepLayOut這些數據可並不是僅僅存在於伺服器,客戶端也是存在的,客戶端也有Channel,也需要執行SetChannelACtor。不過這些數據在客戶端上的作用可能就有一些變化,比如Staticbuffer,伺服器是用它存儲上次同步後的對象,然後與當前的Object比較看是否發生變化。在客戶端上,他是用來臨時存儲當前同步前的對象,然後再把通過過來的屬性複製給當前Object,Object再與Staticbuffer對象比較,看看屬性是否發生變化,如果發生變化,就在Replicator的RepState裡面添加一個函數回調通知RepNotifies。

在隨後的ProcessBunch處理中,會執行RepLayout->CallRepNotifies( RepState, Object );處理所有的函數回調,所以我們也知道了為什麼接收到的屬性發生變化才會執行函數回調了。

圖5-8 客戶端屬性回調堆棧圖

6.關於動態數組與結構體的同步

結構體:UE裡面UStruct類型的結構體與C++的Struct不一樣,在反射系統中對應的是UScriptStruct,他本身可以被標記Replicated並且結構體內的數據默認都會被同步,而且如果裡面有還子結構體的話也也會遞歸的進行同步。如果不想同步的話,需要在對應的屬性標記NotReplicated,而且這個標記只對UStruct有效,對UClass無效。這一段的邏輯在FRepLayout::InitFromObjectClass處理,ReplayOut首先會讀取Class裡面所有的同步屬性並逐一的放到FRepLayOut的數組Parents裡面,這個Parents裡面存放的就是當前類的繼承樹裡面所有的同步屬性。隨後對Parents裡面的屬性進一步解析(FRepLayout:: InitFromProperty_r),如果發現當前同步屬性是數組或者是結構體就會對其進行遞歸展開,將數組的每一個元素/UStruct裡面的每一個屬性逐個放到FRepLayOut的Cmds數組裡面,這個過程中如果遇到標記了NotReplicate的UStruct內部屬性,就跳過。所以Cmds裡面存放的就是對數組或者結構體進一步展開的詳細屬性。

(下圖中:TimeArray是普通數組,StructTest是包含三個元素的結構體,StructTestArray是StructTest類型的數組,當前只有一個元素)

圖5-10 Cmds內部成員截圖

Struct :結構內的數據是不能標記Replicated的。如果你給Struct裡面的屬性標記Replicated,UHT在編譯的時候就會提醒你編譯失敗」Struct members cannot be replicated」。這個提示多多少少會讓人產生誤解,實際上這個只是表明UStruct內部屬性不能標記Replicated而已。最後,UE裡面的UStruct不可以以成員指針的方式在類中聲明。

數組:數組分為兩種,靜態數組與動態數組。靜態數組的每一個元素都相當於一個單獨的屬性存放在Class的ClassReps裡面,同步的時候也是會逐個添加到RepLayOut的Parents裡面,參考上面的圖5-9。UE裡面的動態數組是TArray,他在網路中是可以正常同步的,在初始化RepLayOut的Cmds數組的時候,就會判斷當前的屬性類型是否是動態數組(UArrayProperty),並會給其cmd.type做上標記REPCMD_DynamicArray。後面在同步的時候,就會通過這個標記來對其做特殊處理。比如伺服器上數組長度發生變化,客戶端在接收同步過來的數組時,會執行FRepLayout::ReceiveProperties_DynamicArray_r來處理動態數組。這個函數裡面會矯正當前對象同步數組的大小。

7.UObject指針類型的屬性同步

上一節組件同步提到了FNetworkGUID,這引申出一個值得思考的細節。無論是屬性同步,還是作為RPC參數。我們都可能產生疑問,我在傳遞一個UObject類型的指針時,這個UObject在客戶端存在么?如果存在,我如何能通過伺服器的一個指針找到客戶端上相同UObject的指針?

這個處理就需要通過FNetworkGUID了。伺服器在同步一個對象引用(指針)的時候,會給其分配專門的FNetworkGUID並通過網路進行發送。客戶端上通過識別這個ID,就可以找到對應的UObject。

那麼這個ID是什麼時候分配的?如何發送的呢?

首先我們分析伺服器,伺服器在同步一個UObject對象時(包括屬性同步,Actor同步,RPC參數同步三種情況),他都需要對這個對象進行序列化(UPackageMapClient::SerializeObject),而在序列化對象前,要檢查GUID緩存表(TMap<FNetworkGUID, FNetGuidCacheObject>ObjectLookup;),如果GUID緩存表裡面有,證明已經分配過,反之則需要分配一個GUID,並寫到數據流裡面。不過一般來說,GUID分配並不是在發送數據的時候才進行,而是在創建FObjectReplicator的時候(如圖通過NetDriver的GuidCache分配)

圖5-11 GUID的分配與註冊

下面兩段代碼是伺服器同步對象前檢測或分配GUID的邏輯:

//UPackageMapClient::SerializeObjectPackageMapClient.cpp//IsSaving表示序列化,即發送流程IsLoading表示反序列化,即接收流程//由於知乎有字數限制,這裡不粘貼完整代碼if (Ar.IsSaving()){ //獲取或分配GUID FNetworkGUID NetGUID = GuidCache->GetOrAssignNetGUID(Object ); if (OutNetGUID) { *OutNetGUID = NetGUID; } ......}// PackageMapClient.cppFNetworkGUIDFNetGUIDCache::GetOrAssignNetGUID(constUObject * Object ){ //查看當前UObject是否支持網路複製 if( !Object || !SupportsObject( Object) ) { return FNetworkGUID(); } ...... //伺服器註冊該對象的GUID return AssignNewNetGUID_Server( Object );}

下面我們再分析客戶端的接收流程,客戶端在接收到伺服器同步過來的一個Actor時他會通過UPackageMapClient::SerializeNewActor對該Actor進行反序列化。如果這個Actor是第一次通過過來的,他就需要對這個ACtor進行Spawn,Spawn結束後就會調用函數FNetGUIDCache::RegisterNetGUID_Client進行客戶端該對象的GUID的註冊。這樣,伺服器與客戶端上「同」一個對象的GUID就相同了。下次,伺服器再同步指向這個Actor的指針屬性時就能正確的找到客戶端對應的對象了。

不過等等,前面說的UObject,這裡怎麼就直接變成Actor了,如果是組件同步呢?他的GUID在客戶端是怎麼獲取並註冊的?

其實對於一個非Actor對象,客戶端不需要在接收到完整的對象數據後再獲取並註冊GUID。他在收到一個函數GUID的Bunch串時就可以立刻執行GUID的註冊,然後會通過函數FNetGUIDCache::GetObjectromNetGUID去當前的客戶端裡面尋找這個對象。找到之後,再去完善前面的註冊信息。為什麼要找而不是讓伺服器同步過來?因為有一些對象不需要同步,但是我們也知道他在客戶端與伺服器就是同一個UObject,比如地圖裡面的一座山。這種情況我們稍後討論

圖5-12 客戶端收到消息立刻按照路徑註冊GUID

下面兩段代碼是客戶端反序列化獲取並註冊GUID的邏輯:

// 情況一:客戶端接收到伺服器同步過來的一個新的Actor,需要執行Spawn spawn 成功後會執行RegisterNetGUID_Client進行GUID的註冊// UActorChannel::ProcessBunch DataChannel.cppbool SpawnedNewActor = false;if( Actor == NULL){ ...... SpawnedNewActor = Connection->PackageMap->SerializeNewActor(Bunch,this,NewChannelActor); ......}

// 情況二:客戶端接收到一個含有GUID的消息立刻解析 解析成功後會執行RegisterNetGUIDFromPath_Client進行GUID的註冊//DataChannel.cppvoid UChannel::ReceivedRawBunch(FInBunch&Bunch, bool&bOutSkipAck){ if( Bunch.bHasGUIDs ) { Cast<UPackageMapClient>( Connection->PackageMap)->ReceiveNetGUIDBunch( Bunch ); ...... }}// UPackageMapClient::ReceiveNetGUIDBunchPackageMapClient.cppint32 NumGUIDsRead = 0;while(NumGUIDsRead <NumGUIDsInBunch ){ UObject * Obj = NULL; InternalLoadObject(InBunch,Obj, 0 ); ......}

上面大部分討論的都是標記Replicate的Actor或組件,但是並不是只有這樣的對象才能分配GUID。對於直接從數據包載入出來的對象(前面說過如地圖裡面的山),我們可以直接認為伺服器上的該地形對象與客戶端上對應的地形對象就是一個對象。所以,我們看到還存在其他可以分配GUID的情況,官方文檔上有介紹,我這裡直接總結出來:

有四種情況下UObject對象的引用可以在網路上傳遞成功。

  1. 標記replicate
  2. 從數據包直接Load
  3. 通過Construction scripts添加或者C++構造函數裡面添加

使用UActorComponent::SetNetAddressable標記(這個只針對組件,其實藍圖裡面創建的組件默認就會執行這個操作)

下面這段代碼展示了該UObject是否支持網路複製的條件,正好符合我上面的總結:

//PackageMapClient.cppboolFNetGUIDCache::SupportsObject(constUObject * Object ){ if( !Object ) { return true; } FNetworkGUID NetGUID = NetGUIDLookup.FindRef(Object); //是否已經分配網路ID if( NetGUID.IsValid() ) { return true; } //是否是數據包載入或者默認構造的 if( Object->IsFullNameStableForNetworking()) { return true; } //不重載的情況下還是會走到IsFullNameStableForNetworking裡面 if( Object->IsSupportedForNetworking() ) { return true; } return false;}

我這裡以地圖裡面的靜態模型為例簡單進行分析。對於地圖創建好的一個靜態模型,伺服器只要發送該對象GUID以及對象的名稱(帶序號)即可。當客戶端接收消息的時候,首先緩存GUID相關信息,隨後通過函數FNetGUIDCache::GetObjectromNetGUID從本地找到對應的Object。(如圖5-13里ObjectLookup[24]對應的StaticMeshActor_20,他就是一個非Replicate但是從數據包直接載入的對象)

下圖5-13可以看出,分配GUID的對象不一定是遊戲場景中存在的Actor,還可能是特定路徑下某個資源對象,或者是一個藍圖類,或是一個CDO對象。進一步分析,一個在遊戲裡面實際存在的Actor想要同步的話,我們必須先將其資源文件,CDO對象先同步過去。然後再將實際的Actor同步,因為這樣他才能正確的根據資源文件Spawn出來。而對於一個Actor的組件來說,他也需要等到他的Actor的資源文件,CDO對象先同步過去再進行同步。(由於網路包的非同步性,這裡並不是嚴格意義上的先後,而是指資源,CDO同步後,後面的Actor(組件)才能正常的反序列化成一個完整合法的對象)

圖5-13 GUID緩存Map

最後再給出一個UObject作為RPC的參數發送前的GUID分配堆棧:

圖5-14


六. RPC執行細節

RepLayOut參照表不止同步的對象有,函數也同樣有,RPC的執行同樣也是通過屬性同步的這個框架。比如我們在代碼裡面寫了一個Client的RPC函數ClientNotifyRespawned,那UHT會給我們生成一個.genenrate.cpp文件,裡面會有這個函數的真正的定義如下:

void APlayerController::ClientNotifyRespawned(class APawn* NewPawn, bool IsFirstSpawn){ PlayerController_eventClientNotifyRespawned_Parms Parms; Parms.NewPawn=NewPawn; Parms.IsFirstSpawn=IsFirstSpawn ? true : false; ProcessEvent(FindFunctionChecked(ENGINE_ClientNotifyRespawned),&Parms);}

而我們在代碼里的函數之所以必須要加上_Implementation,就是因為在調用端裡面,實際執行的是.genenrate.cpp文件函數,而不是我們自己寫的這個。同時結合下面的RPC執行堆棧,我們可以看到在Uobject這個對象系統里,我們可以通過反射系統查找到函數對應的UFuntion結構,同時利用ProcessEvent函數來處理UFuntion。通過識別UFunction裡面的標記,可以知道這個函數是不是一個RPC函數,是否需要發送給其他的端。

當我們開始調用CallRemoteFunction的時候,RPC相關的初始化就開始了。NetDiver會進行相關的初始化,並試著獲取RPC函數的Replayout,那麼問題是函數有屬性么?正常來說,函數本身就是一個執行過程,函數名是一個起始的執行地址,他本身是沒有內存空間,更不用說存儲屬性了。不過,在UE4的反射系統裡面,函數可以被額外的定義為一個UFunction,從而保存自己相關的數據信息。RPC函數的參數就被保存在UFunction的基類Ustruct的屬性鏈表PropertyLink裡面,RepLayOut裡面的屬性信息就是從這裡獲取到的。

一旦函數的RepLayOut被創建,也同樣會放到NetDiver的RepLayoutMap裡面。隨後立刻調用FRepLayout::SendPropertiesForRPC將RPC的參數序列化封裝與RPC函數一同發送。

圖6-1 RPC函數的RepLayOut初始化堆棧圖

關於RPC的發送,有一個地方需要特別注意一下,就是UIpNetDriver::ProcessRemoteFunction函數。這個函數處理了RPC的多播事件,如果一個多播標記為Reliable,那麼他默認會給所有的客戶端執行該多播事件,如果其標記的是unreliable,他就會檢測執行該RPC的Actor與各個客戶端的網路相關性,相關才會執行。簡單總結來說,就是一般情況下多播RPC並不一定在所有的客戶端都執行,他應該只在同步了觸發這個RPC的Actor的端上執行。

//UIpNetDriver::ProcessRemoteFunction//這裡很詳細的闡述UE這麼做的原因

簡單概括了RPC的發送,這裡再說一下RPC的接收。當客戶端收到上面的RPC發來的數據後,他需要一步一步的解析。首先,他會執行ReceivePropertiesForRPC來接收解析RPC函數傳來的參數並做一些判斷確定是否符合執行條件,如果符合就會通過ProcessEvent去處理傳遞過來的屬性信息,找到對應的函數地址(或者說函數指針)等,最後調用該RPC函數。

這裡的ReplayOut裡面的Parents負責記錄當前Function的屬性信息以及屬性位置,在網路同步的過程中,客戶端與伺服器保存一個相同的ReplayOut,客戶端才能在反序列化的時候通過OffSet位置信息正確的解析出伺服器傳來的RPC函數的N個參數。

圖6-2 接收RPC函數的傳遞的參數堆棧圖

圖6-3 客戶端執行RPC函數堆棧圖

最後客戶端是怎樣調用到帶_Implementation的函數呢?這裡又需要用到反射的機制。我們看到UHT其實會給函數生成一個.genenrate.h文件,這個文件就有下面這樣的宏代碼,把宏展開的話其實就是一個標準的C++文件,我們通過函數指針最後找到的就是這個宏裡面標記的函數,進而執行我們自己定義的_Implementation函數。

virtual void ClientNotifyRespawned_Implementation(class APawn* NewPawn, bool IsFirstSpawn); DECLARE_FUNCTION(execClientNotifyRespawned) { P_GET_OBJECT(APawn,NewPawn); P_GET_UBOOL(IsFirstSpawn); P_FINISH; this->ClientNotifyRespawned_Implementation(NewPawn,IsFirstSpawn); }

推薦閱讀:

戰崑崙手游好玩嗎?
《貪吃蛇大作戰》這款遊戲好玩嗎?
模擬飛行體驗飛翔?
玩捕魚遊戲有好處嗎?
我的世界(Minecraft)Switch版本體驗如何?

TAG:網路遊戲 | 遊戲開發 | 虛幻引擎 |