文/Jerish 专栏:https://zhuanlan.zhihu.com/c_164452593

前言

UE同步是一块比较复杂而庞大的模块,里面设计到了很多设计思想,技巧,技术。我这里主要是从同步的流程分析,以同步的机制为讲解核心,给大家描述里面是怎么同步的,会大量涉及UE同步模块的底层代码,稍微涉及一点计算机网络底层(Socket相关)相关的知识。

PS:如果只是想知道怎么使用同步,建议阅读这篇文章 关于网络同步的理解与思考[概念理解]

目录

一.基本概念

二.通信的基本流程

三.连接的建立

- 1. 服务器网络模块初始化流程

- 2. 客户端网络模块初始化流程

- 3. 服务器与客户端建立连接流程

四.Actor的同步细节

- 1.组件(子对象)同步

五.属性同步细节

- 1. 属性同步概述

- 2. 重要数据的初始化流程

- 3. 发送同步数据流程分析

- 4. 属性变化历史记录

- 5. 属性回调函数执行

- 6. 关于动态数组与结构体的同步

- 7. UObject指针类型的属性同步

六.RPC执行细节

一. 基本概念

UE网络是一个相当复杂的模块,这篇文章主要是针对Actor同步,属性同步,RPC等大致的阐述一些流程以及关键的一些类。这里我尽可能将我的理解写下来。

在UE里面有一些和同步相关的概念与类,这里逐个列举一下并做解释:

底层通信:

1.Bunch

一个Bunch里面主要记录了Channel信息,NGUID。同时包含其他的附属信息如是否是完整的Bunch,是否是可靠的等等,可以简单理解为一个数据包,该数据包的数据可能不完整,继承自FNetBitWriter

InBunch:从Channel接收的数据流串

OutBunch:从Channel产生的数据流串

2.FBitWriter

字节流书写器,可以临时写入比特数据用于传输,存储等,继承自FArchive

3.FSocket

所有平台Socket的基类。

FSocketBSD:使用winSocket的Socket封装

4.Packet

从Socket读出来/输出的数据

5.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发送消息,大致的效果如下:

112228z4co8qsb7iib8hsc.jpg图2-1 远程进程通信图
而对于UE4进程内部服务器Server与客户端Client1的通信,与上面的模型基本相似:

112228j4dz82kxw22e8kxx.jpg图2-2 UE4远程进程通信图
那这个里面的Channel是什么意思呢?简单理解起来就是一个通信轨道。为了实现规范与通信效率,我们的一个服务器针对某个对象定义了Channel通道,这个通道只与客户端对应的Channel通道互相发送与接收消息。这个过程抽象起来与TCP/UDP套接字的传输过程很像,套接字是在消息发送到进程前就进行处理,来控制客户端进程A只会接收到服务器对应进程A的消息,而这里是在UnrealEditor.exe进程里面处理,让通道1只接收到另一端通道1发送的消息。

上面的只是针对一个服务器到客户端的传输流程,那么如果是多个客户端呢?

112227paqqjjqva4745qja.jpg图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,所以我们再次细化上面的示意图:

112228edu31md33dd32m0c.jpg图2-4 Connection下的Channel通信图
到这里我们基本上就了解了UE4的基本通信架构了,下面我们进一步分析网络传输数据的流程。首先我们要知道,UE4的数据通信是建立在UDP-Socket的基础上的,与其他的通信程序一样,我们需要对Socket的信息进行封装发送以及接收解析。这里面主要涉及到Bunch,RawBunch,Packet等概念,建议参考第一部分的基本概念去理解,很多注释已经加在了流程图里面。如图所示:

112429zzcy3lalzkxle1k3.jpg图2-5 发送同步信息流程图
112428fz3333b6j38jmpp3.jpg图2-6 接收同步信息流程图
三. 连接的建立

前面的内容已经提到过,UE的网通通信是基于Channel的,而ControlChannel就是负责

控制客户端与服务器建立连接的通道,所以客户端与服务器的连接信息都是通过UControlChannel执行NotifyControlMessage函数处理的。下面首先从服务器与客户端的网络模块初始化说起,然后描述二者连接建立的详细流程:

1.服务器网络模块初始化流程

从创建GameInstance开始,首先创建NetDriver来驱动网络初始化,进而根据平台创建对应的Socket,之后在World里面客户端的消息。

112632kl5ivy5138zy6bka.jpg图3-1 服务器网络模块初始化流程图
2.客户端网络模块初始化流程

客户端前面的初始化流程与服务器很相似,也是首先构建NetDriver,然后根据平台创建对应的Socket,同时他还会创建一个到服务器的ServerConnection。由于客户端没有World信息,所以要使用一个新的类来检测并处理连接信息,这个类就是UpendingNetGame。

112631h0m5il3b0uh1orb5.jpg图3-2 客户端网络模块初始化流程图
3.服务器与客户端建立连接流程

二者都完成初始化后,客户端就会开始发送一个Hello类型的ControlChannel消息给服务器(上面客户端初始化最后一步)。服务器接收到消息之后开始处理,然后会根据条件再给客户端发送对应的消息,如此来回处理几个回合,完成连接的建立,详细流程参考下图:

(该流程是本地局域网的连接流程,与在线查找服务器列表并加入有差异)

112631ilcyte8cmpuz1st1.jpg图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默认的有些不同)

112930o1jskq42pm0ee7m0.jpg图4-1 服务器同步Actor堆栈图
下面描述客户端是如何接收到服务器同步过来的Actor的。首先客户端TickDispatch检测服务器的消息,收到消息后通过Connection以及Channel进行解析,最后一步解析出完整数据的操作在UActorChannel:rocessBunch执行,在这个函数里面:

1.如果发现当前的ActorChannel对应的Actor为NULL,就对当前的Bunch进行反序列化Connection->ackageMap->SerializeNewActor(Bunch, this, NewChannelActor);解析出Actor的内容并执行PostInitializeComponents。如果Actor不为NULL,跳过这一步(参考下面图一堆栈)

2.随后根据Bunch信息找到同步过来的属性值并对当前Actor对应的属性进行赋值

3.最后执行PostNetInit调用Actor的BeginPlay。(参考下面堆栈)

下面截取了客户端接收到同步Actor并初始化的调用堆栈:

112929bj1sjdfyxjrjfhax.jpg图4-2 客户端接收并序列化同步的Actor堆栈图
112930k1m8xxy10hqdyqyp.jpg图4-3 客户端初始化同步过来Actor堆栈图
从上面的描述来看,基本上我们可以很容易的分析出当前的Actor是否被同步,比如在UActorChannel::ReceivedBunch里面打个断点,看看当前通道里有没有你要的Actor就可以了。

1.组件(子对象)同步

组件(还有其他子对象)是挂在Actor上面的,所以组件的同步与Actor同步是紧密相连的,当一个Actor进行同步的时候会判断所有的子对象是否标记了Replicate,如果标记了,就对其以及其属性进行同步。

这些子对象同步方式(RPC等)也与Actor相差无几,实际上他们想要同步的话需要借助ActorChannel创建自己的FObjectReplicator以及属性同步的相关数据结构。简单来说,就是一个Actor身上的组件同步需要借用这个Actor的通道来进行。下面3段代码是服务器序列化子对象准备发送的逻辑:
 

  1. //UActorChannel::ReplicateActor()DataChannel.cpp
  2. // The Actor
  3. WroteSomethingImportant = ActorReplicator->ReplicateProperties( Bunch, RepFlags );
  4. // 子对象的同步操作
  5. WroteSomethingImportant = Actor->ReplicateSubobjects(this, Bunch, RepFlags);
  6. //ActorReplication.cpp
  7. boolAActor::ReplicateSubobjects(UActorChannel *Channel, FOutBunch *Bunch, FReplicationFlags *RepFlags)
  8. {
  9. check(Channel);
  10. check(Bunch);
  11. check(RepFlags);
  12. bool WroteSomething = false;
  13.  
  14. for (int32 CompIdx =0; CompIdx < ReplicatedComponents.Num(); ++CompIdx )
  15. {
  16. UActorComponent * ActorComp = ReplicatedComponents[CompIdx].Get();
  17. //如果组件标记同步
  18. if (ActorComp ActorComp->GetIsReplicated())
  19. {
  20. WroteSomething = ActorComp->ReplicateSubobjects(Channel, Bunch, RepFlags); // Lets the component add subobjects before replicating its own properties.检测组件否还有子组件
  21. WroteSomething = Channel->ReplicateSubobject(ActorComp, *Bunch, *RepFlags); // (this makes those subobjects supported, and from here on those objects may have reference replicated) 同步该组件
  22. }
  23. }
  24. return WroteSomething;
  25. }
  26. //DataChannel.cpp
  27. boolUActorChannel::ReplicateSubobject(UObject *Obj, FOutBunchBunch, constFReplicationFlagsRepFlags)
  28. {
  29. if ( !Connection->Driver->GuidCache->SupportsObject( Obj ) )
  30. {
  31. FNetworkGUID NetGUID = Connection->Driver->GuidCache->AssignNewNetGUID_Server(Obj ); //Make sure he gets a NetGUID so that he is now supported
  32. }
  33.  
  34. bool NewSubobject = false;
  35. if (!ObjectHasReplicator(Obj))
  36. {
  37. Bunch.bReliable = true;
  38. NewSubobject = true;
  39. }
  40. //组件的属性同步需要先在当前的ActorChannel里面创建新的FObjectReplicator
  41. bool WroteSomething = FindOrCreateReplicator(Obj).Get().ReplicateProperties(Bunch, RepFlags);
  42. if (NewSubobject !WroteSomething)
  43. {
  44. ......
  45. }
  46. return WroteSomething;
  47. }

 

 

图5-1服务器发送属性堆栈图
113318pawasuit8f3ic8wm.jpg图5-2客户端接收属性堆栈图
从发送堆栈中我们可以看到属性同步是在执行ReplicatActor的同时进行的,所以我们也可以猜到属性同步的准备工作应该与Actor的同步准备工作是密不可分的。前面Actor同步的讲解中我们已经知道,当Actor同步时如果发现当前的Actor没有对应的通道,就会给其创建一个通道并执行SetChannelActor。这个SetChannelActor所做的工作就是属性同步的关键所在,这个函数里面会对上面四个关键的类构造并做初始化,详细的内容参考下图:

113319uwwg6r171zwvgyee.jpg图5-3 SetChannelActor流程解析图
图中详细的展示了几个关键数据的初始化,不过第一次看可能对这个几个类的关系有点晕,下面给大家简单画了一个类图。

113319g99d2fs91gk1d29s.jpg图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.发送同步数据流程分析

前面我们基本上已经做好了同步属性的基本工作,下面开始执行真正的同步流程。

113508k09oy0z5doc3ho0y.jpg图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函数。

113509bffa31atr030xact.jpg图5-6 服务器同步属性流程图
4. 属性变化历史记录

ChangeHistory属性在在FRepState以及FRepChangelistState里面都存在,不过每次同步前都是先更新FRepChangelistState里面的ChangeHistory,随后在发送前将FRepChangelistState的本次同步发生变化数据拷贝到FRepState的ChangeHistory本次即将发送的变化属性对应的数组元素里面。简单来说,就是FRepState的ChangeHistory一般只保存当前这一次同步发生变化的属性序号,而FRepChangelistState可以保存之前所有的变化的历史记录(更准确的说是最近的64次变化记录)。

113509mmvoyvq4tl8lcvz9.jpg图5-7
5.属性回调函数执行

虽然属性同步是由服务器执行的,但是FObjectReplicator,RepLayOut这些数据可并不是仅仅存在于服务器,客户端也是存在的,客户端也有Channel,也需要执行SetChannelACtor。不过这些数据在客户端上的作用可能就有一些变化,比如Staticbuffer,服务器是用它存储上次同步后的对象,然后与当前的Object比较看是否发生变化。在客户端上,他是用来临时存储当前同步前的对象,然后再把通过过来的属性复制给当前Object,Object再与Staticbuffer对象比较,看看属性是否发生变化,如果发生变化,就在Replicator的RepState里面添加一个函数回调通知RepNotifies。

在随后的ProcessBunch处理中,会执行RepLayout->CallRepNotifies( RepState, Object );处理所有的函数回调,所以我们也知道了为什么接收到的属性发生变化才会执行函数回调了。

113508juumzummcd22zdzm.jpg图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类型的数组,当前只有一个元素)

113626gkext5eko75orwwk.jpg图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分配)

113626uyxnzxrybza5bgsb.jpg图5-11 GUID的分配与注册
下面两段代码是服务器同步对象前检测或分配GUID的逻辑:

 

 

  1. //UPackageMapClient::SerializeObjectPackageMapClient.cpp
    • //IsSaving表示序列化,即发送流程IsLoading表示反序列化,即接收流程
      • //由于知乎有字数限制,这里不粘贴完整代码
        • if (Ar.IsSaving())
          • {
            • //获取或分配GUID
              • FNetworkGUID NetGUID = GuidCache->GetOrAssignNetGUID(Object );
                • if (OutNetGUID)
                  • {
                    • *OutNetGUID = NetGUID;
                      • }
                        • ......
                          • }
                            • // PackageMapClient.cpp
                              • FNetworkGUIDFNetGUIDCache::GetOrAssignNetGUID(constUObject * Object )
                                • {
                                  • //查看当前UObject是否支持网络复制
                                    • if( !Object !SupportsObject( Object) )
                                      • {
                                        • return FNetworkGUID();
                                          • }
                                            • ......
                                              • //服务器注册该对象的GUID
                                                • return AssignNewNetGUID_Server( Object );
                                                  • }
图5-12 客户端收到消息立刻按照路径注册GUID
下面两段代码是客户端反序列化获取并注册GUID的逻辑:

 

 

  1. // 情况一:客户端接收到服务器同步过来的一个新的Actor,需要执行Spawn spawn 成功后会执行RegisterNetGUID_Client进行GUID的注册
    • // UActorChannel::ProcessBunch DataChannel.cpp
      • bool SpawnedNewActor = false;
        • if( Actor == NULL)
          • {
            • ......
              • SpawnedNewActor = Connection->PackageMap->SerializeNewActor(Bunch,this,NewChannelActor);
                • ......
                  • }
图5-13 GUID缓存Map

最后再给出一个UObject作为RPC的参数发送前的GUID分配堆栈:

113757gxaxskk4fzq8chc0.jpg图5-14

六. RPC执行细节

RepLayOut参照表不止同步的对象有,函数也同样有,RPC的执行同样也是通过属性同步的这个框架。比如我们在代码里面写了一个Client的RPC函数ClientNotifyRespawned,那UHT会给我们生成一个.genenrate.cpp文件,里面会有这个函数的真正的定义如下:

 

 

  1. 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);
              •  
              • }
图6-1 RPC函数的RepLayOut初始化堆栈图
关于RPC的发送,有一个地方需要特别注意一下,就是UIpNetDriver::ProcessRemoteFunction函数。这个函数处理了RPC的多播事件,如果一个多播标记为Reliable,那么他默认会给所有的客户端执行该多播事件,如果其标记的是unreliable,他就会检测执行该RPC的Actor与各个客户端的网络相关性,相关才会执行。简单总结来说,就是一般情况下多播RPC并不一定在所有的客户端都执行,他应该只在同步了触发这个RPC的Actor的端上执行。

 

 

  1. //UIpNetDriver::ProcessRemoteFunction
    • //这里很详细的阐述UE这么做的原因
图6-2 接收RPC函数的传递的参数堆栈图
114202iayp7e4e077xj9ab.jpg图6-3 客户端执行RPC函数堆栈图
最后客户端是怎样调用到带_Implementation的函数呢?这里又需要用到反射的机制。我们看到UHT其实会给函数生成一个.genenrate.h文件,这个文件就有下面这样的宏代码,把宏展开的话其实就是一个标准的C++文件,我们通过函数指针最后找到的就是这个宏里面标记的函数,进而执行我们自己定义的_Implementation函数。

 

 

  1. 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); \
                • } \
复制代码


锐亚教育

锐亚教育,游戏开发论坛|游戏制作人|游戏策划|游戏开发|独立游戏|游戏产业|游戏研发|游戏运营| unity|unity3d|unity3d官网|unity3d 教程|金融帝国3|8k8k8k|mcafee8.5i|游戏蛮牛|蛮牛 unity|蛮牛