游戏开发论坛

 找回密码
 立即注册
搜索
查看: 7205|回复: 0

《Exploring in UE4》网络同步原理深入[原理分析]

[复制链接]

8723

主题

8783

帖子

1万

积分

版主

Rank: 7Rank: 7Rank: 7

积分
11952
发表于 2018-5-15 11:43:05 | 显示全部楼层 |阅读模式
文/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发送消息,大致的效果如下:

v2-435da5991ccd1370a9f7db006028f77e_hd.jpg
图2-1 远程进程通信图

而对于UE4进程内部服务器Server与客户端Client1的通信,与上面的模型基本相似:

v2-d1f719f0e1585d8fd9497cf2d60181fa_hd.jpg
图2-2 UE4远程进程通信图

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

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

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

v2-72448dadb314ae0f3f34d98884d4b588_hd.jpg
图2-4 Connection下的Channel通信图

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

v2-6566c8aae77e3c46230b68932c6a790d_hd.jpg
图2-5 发送同步信息流程图

v2-489d364d675db9f013052cf7a9aae9b0_hd.jpg
图2-6 接收同步信息流程图

三. 连接的建立

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

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

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

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

v2-f8f272ee604eb8b90d78e516797c24ea_hd.jpg
图3-1 服务器网络模块初始化流程图

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

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

v2-75ec8da89a395f8b61f13cf322f01451_hd.jpg
图3-2 客户端网络模块初始化流程图

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

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

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

v2-10ab13cdb82dcd0f448617837d22f2aa_hd.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默认的有些不同)

v2-d37ddfc56edb3718373f0ba93b08d276_hd.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并初始化的调用堆栈:

v2-259f024cf3cf3f9aa73a3f2aa036ab10_hd.jpg
图4-2 客户端接收并序列化同步的Actor堆栈图

v2-e6ef996a7f5ef180877f914c21c4e871_hd.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.    for (int32 CompIdx =0; CompIdx < ReplicatedComponents.Num(); ++CompIdx )
  14.    {
  15.         UActorComponent * ActorComp = ReplicatedComponents[CompIdx].Get();
  16.         //如果组件标记同步
  17.         if (ActorComp && ActorComp->GetIsReplicated())
  18.         {
  19.            WroteSomething |= ActorComp->ReplicateSubobjects(Channel, Bunch, RepFlags);                // Lets the component add subobjects before replicating its own properties.检测组件否还有子组件
  20.            WroteSomething |= Channel->ReplicateSubobject(ActorComp, *Bunch, *RepFlags);        // (this makes those subobjects 'supported', and from here on those objects may have reference replicated)        同步该组件        
  21.         }
  22.    }
  23.    return WroteSomething;
  24. }
  25. //DataChannel.cpp
  26. boolUActorChannel::ReplicateSubobject(UObject *Obj, FOutBunch&Bunch, constFReplicationFlags&RepFlags)
  27. {
  28.    if ( !Connection->Driver->GuidCache->SupportsObject( Obj ) )
  29.    {
  30.         FNetworkGUID NetGUID = Connection->Driver->GuidCache->AssignNewNetGUID_Server(Obj );        //Make sure he gets a NetGUID so that he is now 'supported'
  31.    }

  32.    bool NewSubobject = false;
  33.    if (!ObjectHasReplicator(Obj))
  34.    {
  35.         Bunch.bReliable = true;
  36.         NewSubobject = true;
  37.    }
  38.    //组件的属性同步需要先在当前的ActorChannel里面创建新的FObjectReplicator
  39.    bool WroteSomething = FindOrCreateReplicator(Obj).Get().ReplicateProperties(Bunch, RepFlags);
  40.    if (NewSubobject && !WroteSomething)
  41.    {
  42.       ......
  43.    }
  44.    return WroteSomething;
  45. }
复制代码

下面一段代码是客户端接收服务器同步过来的子对象逻辑:

  1. // void UActorChannel::ProcessBunch( FInBunch & Bunch )DataChannel.cpp
  2. // 该函数前面的代码主要是是进行反序列化当前Actor的相关操作
  3. while ( !Bunch.AtEnd() && Connection != NULL&& Connection->State != USOCK_Closed )
  4. {
  5.    bool bObjectDeleted = false;
  6.    //当前通道的Actor以及反序列化成功,这里开始继续从Bunch里面寻找子对象进行反序列化
  7.    //如果当前Actor没有子组件,这里返回的就是Actor自身
  8.    ......
  9.    TSharedRef<FObjectReplicator>& Replicator = FindOrCreateReplicator( RepObj );
  10.    bool bHasUnmapped = false;
  11.    // 找到当前子对象(或当前Actor)的Replicator以后,这里开始进行属性值的读取了
  12.    if ( !Replicator->ReceivedBunch( Bunch, RepFlags, bHasUnmapped ) )
  13.    {
  14.        ......
  15.    }
  16.    ......
  17. }
复制代码

前面Actor同步有提到,当从ActorChannel解析Bunch信息的时候就可以尝试对该数据流进行Actor的反序列化。从这段代码可以进一步看出,Actor反序列化之后会立刻开始判断Bunch里面是否存在其子对象,如果存在还会进一步读取子对象同步过来的属性值。如果没有子对象,就读取自身同步过来的属性。

关于子组件的反序列化还分为两种情况。要想理解这两种情况,还需要清楚两个概念——动态组件与静态组件。

对于静态组件:一旦一个Actor被标记为同步,那么这个Actor身上默认所挂载的组件也会随Actor一起同步到客户端(也需要序列化发送)。什么是默认挂载的组件?就是C++构造函数里面创建的默认组件或者在蓝图里面添加构建的组件。所以,这个过程与该组件是否标记为Replicate是没有关系的。

对于动态组件:就是我们在游戏运行的时候,服务器创建或者删除的组件。比如,当玩家走进一个洞穴时,给洞穴里面的火把生成一个粒子特效组件,然后同步到客户端上,当玩家离开的时候再删除这个组件,玩家的客户端上也随之删除这个组件。

对于动态组件,我们必须要设置他的Replicate属性为true,即通过函数 AActorComponent::SetIsReplicated(true)来操作。而对于静态组件,如果我们不想同步组件上面的属性,我们就没有必要设置Replicate属性。下面截取了函数ReadContentBlockHeader部分代码来区分这两种情况:

  1. //静态组件,不需要客户端Spawn
  2. FNetworkGUID NetGUID;
  3. UObject * SubObj = NULL;
  4. Connection->PackageMap->SerializeObject(Bunch, UObject::StaticClass(), SubObj, &NetGUID );
  5. //动态组件,需要在客户端Spawn出来
  6. FNetworkGUID ClassNetGUID;
  7. UObject * SubObjClassObj = NULL;
  8. 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.重要数据的初始化流程

下面的两个图分别是属性同步的服务器发送堆栈以及客户端的接收堆栈。

v2-e12ef8a44aa73b06f94c53441adc0ae7_hd.jpg
图5-1服务器发送属性堆栈图

v2-48ccdd62605942fd019196daf2bce054_hd.jpg
图5-2客户端接收属性堆栈图

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

v2-1848274acf1d55c7a777c45291a1c8ae_hd.jpg
图5-3 SetChannelActor流程解析图

图中详细的展示了几个关键数据的初始化,不过第一次看可能对这个几个类的关系有点晕,下面给大家简单画了一个类图。

v2-b6c8e3d230a36237a74ddece09cee093_hd.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.发送同步数据流程分析

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

v2-926cc7c52b4d088d7b348223b3f6d95e_hd.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函数。

v2-f89d30f139c5231ccb9648eec46098ac_hd.jpg
图5-6 服务器同步属性流程图

4. 属性变化历史记录

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

v2-6788f4d87248fe375118b55b166d475c_hd.jpg
图5-7

5.属性回调函数执行

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

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

v2-8cda64697b59299a9f244ecad77cda15_hd.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类型的数组,当前只有一个元素)

v2-238f55719c4fef6a342c7e921c1e97c0_hd.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分配)

v2-b606a1b630a7052defb88ba890afbef9_hd.jpg
图5-11 GUID的分配与注册

下面两段代码是服务器同步对象前检测或分配GUID的逻辑:

  1. //UPackageMapClient::SerializeObjectPackageMapClient.cpp
  2. //IsSaving表示序列化,即发送流程IsLoading表示反序列化,即接收流程
  3. //由于知乎有字数限制,这里不粘贴完整代码
  4. if (Ar.IsSaving())
  5. {
  6.    //获取或分配GUID
  7.    FNetworkGUID NetGUID = GuidCache->GetOrAssignNetGUID(Object );
  8.    if (OutNetGUID)
  9.    {
  10.         *OutNetGUID = NetGUID;
  11.    }
  12.    ......
  13. }
  14. // PackageMapClient.cpp
  15. FNetworkGUIDFNetGUIDCache::GetOrAssignNetGUID(constUObject * Object )
  16. {
  17.     //查看当前UObject是否支持网络复制
  18.     if( !Object || !SupportsObject( Object) )
  19.     {  
  20.       return FNetworkGUID();
  21.     }
  22.     ......
  23.     //服务器注册该对象的GUID
  24.      return AssignNewNetGUID_Server( Object );
  25. }
复制代码


下面我们再分析客户端的接收流程,客户端在接收到服务器同步过来的一个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,比如地图里面的一座山。这种情况我们稍后讨论

v2-cc580b3841fc6a7721ae2314b5cddf39_hd.jpg
图5-12 客户端收到消息立刻按照路径注册GUID

下面两段代码是客户端反序列化获取并注册GUID的逻辑:

  1. // 情况一:客户端接收到服务器同步过来的一个新的Actor,需要执行Spawn spawn 成功后会执行RegisterNetGUID_Client进行GUID的注册
  2. // UActorChannel::ProcessBunch DataChannel.cpp
  3. bool SpawnedNewActor = false;
  4. if( Actor == NULL)
  5. {
  6.     ......
  7.     SpawnedNewActor = Connection->PackageMap->SerializeNewActor(Bunch,this,NewChannelActor);
  8.     ......
  9. }
复制代码

  1. // 情况二:客户端接收到一个含有GUID的消息立刻解析 解析成功后会执行RegisterNetGUIDFromPath_Client进行GUID的注册
  2. //DataChannel.cpp
  3. void UChannel::ReceivedRawBunch(FInBunch&Bunch, bool&bOutSkipAck)
  4. {
  5.    if( Bunch.bHasGUIDs )
  6.    {
  7.       Cast<UPackageMapClient>( Connection->PackageMap)->ReceiveNetGUIDBunch( Bunch );
  8.       ......
  9.    }
  10. }
  11. // UPackageMapClient::ReceiveNetGUIDBunchPackageMapClient.cpp
  12. int32 NumGUIDsRead = 0;
  13. while(NumGUIDsRead <NumGUIDsInBunch )
  14. {
  15.    UObject * Obj = NULL;
  16.    InternalLoadObject(InBunch,Obj, 0 );
  17.    ......
  18. }
复制代码


上面大部分讨论的都是标记Replicate的Actor或组件,但是并不是只有这样的对象才能分配GUID。对于直接从数据包加载出来的对象(前面说过如地图里面的山),我们可以直接认为服务器上的该地形对象与客户端上对应的地形对象就是一个对象。所以,我们看到还存在其他可以分配GUID的情况,官方文档上有介绍,我这里直接总结出来:

有四种情况下UObject对象的引用可以在网络上传递成功。

1.标记replicate

2.从数据包直接Load

3.通过Construction scripts添加或者C++构造函数里面添加

使用UActorComponent::SetNetAddressable标记(这个只针对组件,其实蓝图里面创建的组件默认就会执行这个操作)

下面这段代码展示了该UObject是否支持网络复制的条件,正好符合我上面的总结:

  1. //PackageMapClient.cpp
  2. boolFNetGUIDCache::SupportsObject(constUObject * Object )
  3. {
  4.   if( !Object )
  5.   {
  6.     return true;
  7.   }
  8.   FNetworkGUID NetGUID = NetGUIDLookup.FindRef(Object);
  9.   //是否已经分配网络ID
  10.   if( NetGUID.IsValid() )
  11.   {
  12.     return true;
  13.   }
  14.   //是否是数据包加载或者默认构造的
  15.   if( Object->IsFullNameStableForNetworking())  
  16.   {
  17.     return true;
  18.   }
  19.   //不重载的情况下还是会走到IsFullNameStableForNetworking里面
  20.   if( Object->IsSupportedForNetworking() )
  21.   {
  22.     return true;
  23.   }
  24.    return false;
  25. }
复制代码


我这里以地图里面的静态模型为例简单进行分析。对于地图创建好的一个静态模型,服务器只要发送该对象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(组件)才能正常的反序列化成一个完整合法的对象)

v2-92fbbb4a64861672491f1b7b58204aab_hd.jpg
图5-13 GUID缓存Map


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

v2-d56eab2ba715c19f091654ba9aec6428_hd.jpg
图5-14


六. RPC执行细节

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

  1. void APlayerController::ClientNotifyRespawned(class APawn* NewPawn, bool IsFirstSpawn)

  2. {

  3. PlayerController_eventClientNotifyRespawned_Parms Parms;

  4. Parms.NewPawn=NewPawn;

  5. Parms.IsFirstSpawn=IsFirstSpawn ? true : false;

  6. ProcessEvent(FindFunctionChecked(ENGINE_ClientNotifyRespawned),&Parms);

  7. }
复制代码

而我们在代码里的函数之所以必须要加上_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函数一同发送。

v2-699db5726151dc19cbac091c2ff616d8_hd.jpg
图6-1 RPC函数的RepLayOut初始化堆栈图

关于RPC的发送,有一个地方需要特别注意一下,就是UIpNetDriver::ProcessRemoteFunction函数。这个函数处理了RPC的多播事件,如果一个多播标记为Reliable,那么他默认会给所有的客户端执行该多播事件,如果其标记的是unreliable,他就会检测执行该RPC的Actor与各个客户端的网络相关性,相关才会执行。简单总结来说,就是一般情况下多播RPC并不一定在所有的客户端都执行,他应该只在同步了触发这个RPC的Actor的端上执行。

  1. //UIpNetDriver::ProcessRemoteFunction
  2. //这里很详细的阐述UE这么做的原因
复制代码


简单概括了RPC的发送,这里再说一下RPC的接收。当客户端收到上面的RPC发来的数据后,他需要一步一步的解析。首先,他会执行ReceivePropertiesForRPC来接收解析RPC函数传来的参数并做一些判断确定是否符合执行条件,如果符合就会通过ProcessEvent去处理传递过来的属性信息,找到对应的函数地址(或者说函数指针)等,最后调用该RPC函数。

这里的ReplayOut里面的Parents负责记录当前Function的属性信息以及属性位置,在网络同步的过程中,客户端与服务器保存一个相同的ReplayOut,客户端才能在反序列化的时候通过OffSet位置信息正确的解析出服务器传来的RPC函数的N个参数。

v2-25d53dbc56b4c363347f2b0b889f5cc7_hd.jpg
图6-2 接收RPC函数的传递的参数堆栈图

v2-5623b2c8540a6707edc7c8cbcb3351c8_hd.jpg
图6-3 客户端执行RPC函数堆栈图

最后客户端是怎样调用到带_Implementation的函数呢?这里又需要用到反射的机制。我们看到UHT其实会给函数生成一个.genenrate.h文件,这个文件就有下面这样的宏代码,把宏展开的话其实就是一个标准的C++文件,我们通过函数指针最后找到的就是这个宏里面标记的函数,进而执行我们自己定义的_Implementation函数。

  1. virtual void ClientNotifyRespawned_Implementation(class APawn* NewPawn, bool IsFirstSpawn);\
  2. DECLARE_FUNCTION(execClientNotifyRespawned) \
  3. { \
  4.     P_GET_OBJECT(APawn,NewPawn); \
  5.     P_GET_UBOOL(IsFirstSpawn); \
  6.     P_FINISH; \
  7.     this->ClientNotifyRespawned_Implementation(NewPawn,IsFirstSpawn); \
  8. } \
复制代码

RPC的数据包相比属性同步不同,是调用的时候立刻产生的Bunch并放到Sendbuffer里面的,按照UE4一帧的执行顺序(收包-Tick-发包),一般RPC的数据要比属性同步要早放到buffer里面,所以经常出现RPC与属性同步顺序不对导致的同步问题。
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

作品发布|文章投稿|广告合作|关于本站|游戏开发论坛 ( 闽ICP备17032699号-3 )

GMT+8, 2024-4-25 12:27

Powered by Discuz! X3.4

Copyright © 2001-2021, Tencent Cloud.

快速回复 返回顶部 返回列表