前言
本篇文章将从引擎源码入手介绍UE4网络同步的细节,并且也主要是以ReplicationGraph作为切入口进行叙述。 文章将分为两部:
第一部分是介绍UE4网络同步的整体流程,是如何在做网络同步的,这其中内容很多,先主要是打通整理的流程,一些方面的细节可能不够深入,打算后面用其他的文章单独讲解
第二部分主要是介绍ReplicationGraph的使用,介绍其背后的逻辑,ReplicationGraph极大的降低了网络同步的压力
注意:本文基于的引擎版本是UE4.26
网络同步的大概流程
UE4的框架设计的相当好,让使用者能简单的使用就能做出联网的功能,它自己封装了通信协议,自己实现了底层可靠UDP,提供了简单的使用方法,不像其他引擎需要客户端后台自己指定协议,后台还需要实现一套网络通信的框架。UE4已经把这些全包了。下面就来介绍一下UE4是如何做到的。
引擎会在每帧针对每个连接去做网络同步,大致如下的流程:
引擎会在每帧做网络同步,在每个连接上去GatherActors,得到这一帧需要在这个连接上要同步的Actor列表,然后遍历每个Actor去做同步的细节逻辑:
属性同步:同步Actor自身的和SubObject(例如UActorComponent)的
UnreliableRPC:注意UnreliableRPC的发送是在属性同步的流程中的
其中GatherActors
阶段,主要就是ReplicationGraph做的事情,根据相关性减少收集到的Actor数量,降低消耗,让不需要同步的Actor不被Gather到。如果不用ReplicationGraph,会在NetDriver中处理,则会收集所有的可同步Actor。
这里说个题外话,UE4的这种同步方式应该叫被动同步,同步逻辑是不知道这一帧内哪些Actor要同步,所以得遍历所有的可同步Actor一个个去处理,然后再针对每个actor根据一些数据判断当帧是否要同步。这种被动式的思路贯穿了整个同步逻辑,包括后续的包的ack确认,丢包重传的逻辑。为了解决这种思路带来的性能问题,UE4又引入了NetDormancy机制来优化性能,NetDormancy将在下一篇文章中详细介绍。
展望:如果能做到触发式的方式就最好了,业务层修改属性值了,能触发到底层,告知底层这个actor需要同步,这样每次同步的时候就知道哪些actor需要同步,就不需要遍历所有的可同步Actor了
在引擎的每一帧内,网络同步逻辑的情况如下:
可以看到:
收数据包是在引擎每帧的开始,网络数据到达驱动游戏逻辑
发数据包是在每一帧的结尾,游戏逻辑产生的同步数据,在游戏逻辑做完后放在最后发送
网络同步细节
下面进入到网络同步的细节内容,先从上层看一看网络同步的整体架构,介绍几个关键的类,再细节介绍每个类,将整体流程串起来。
整体框架
整体架构如下,下图中从下到上是从底层不断往业务层的方向,几个关键的类名
下面按照上图中从下往上介绍:
UDP Socket:UE4是用UDP做为网络通信协议的
UNetDriver:创建Socket的时候会创建UNetDriver,项目中实际用到的是继承它的子类:
UIpNetDriver
,NetDriver驱动着网络同步,管理着每个客户端的连接(UNetConnection),网络同步的发起点就是UNetDriver。UNetDriver中有个成员UReplicationDriver
,UReplicationGraph便继承自它,如果此成员有定义,则网络同步的过程将由UReplicationDriver接管,就是由ReplicationGraph接管,当然用户可以自定义继承自UReplicationDriver
的子类,实现自定义的逻辑。
UReplicationGraph:引擎实现的继承自
UReplicationDriver
的子类,一般用这个就可以了,如有特殊需求,可自己继承UReplicationDriver
,自己实现逻辑。ReplicationGraph中会通过多个ReplicationGraphNode来管理Actor,实现不同的相关性,用户可自定义ReplicationGraphNode来根据业务需要定制化Actor的相关性,具体的逻辑会在下文介绍
UNetReplicationGraphConnection:使用ReplicationGraph才有,每个客户端连接都有一个UNetReplicationGraphConnection对象,可以认为是在使用ReplicationGraph的时候的NetConnection,相当于是对UNetConnection的封装,其有成员属性是UNetConnection的指针。最终的细节逻辑也是使用UNetConnection的功能
UNetConnection:每个客户端连接都有一个这个对象,管理着这个连接下所有要同步Actor的逻辑,也就是说管理着要在此连接同步的Actor的UActorChannel。实现收发数据包,将数据包交给UActorChannel处理或者收到来自UActorChannel写入的数据包。实际使用的其子类:UIpConnection
UActorChannel: 要同步的Actor都会创建对应的UActorChannel,注意ActorChannel是和连接相关的,也就是说一个Actor如果要在所有连接同步,那么每个连接都会为这个Actor创建一个UActorChannel对象。此对象接受来自UNetConnection的数据包或者将数据写给UNetConnection
FObjectReplicator:每个可同步的UObject都会创建一个FObjectReplicator,例如一个Actor会创建一个FObjectReplicator,Actor的所有可同步的Component也会对应创建FObjectReplicator。负责每一个可同步UObject做网络同步的逻辑,包括RPC的处理。可靠性传输的逻辑也在这里处理
FRepLayout:FObjectReplicator的成员。FRepLayout记录着类的同步属性在内存中的排布,负责属性对比,属性增量数据的处理,FRepLayout处理完数据后就得到了哪些属性是变化的,变化的属性中增量变化的是什么,将最终的数据序列化成要同步的数据包。注意,每个类型对应着一个FRepLayout实例,也就是说同一类型的不同实例对应着一个FReplayout对象,因为同一类型的不同实例他们的同步属性内存排布是一样的。同一类型的不同实例是通过FRepLayout为桥梁,使用不同的FRepState来记录数据。
后续会对上述的这些类再做进一步的介绍
上面所说的几个类的继承关系如下,项目中实际使用的都是下面的子类实例
例子说明
举个例子来说明上述的几个类的实际情况。
假设有两个客户端连接到DS,有两个同步的Actor:Actor1和Actor2,他们属于不同的类型,Actor1的类型是Class1,Actor2的类型是Class2,Actor1同步只同步给第一个客户端,Actor2同步给两个客户端。Actor1上还有个Component1是可同步的,但是Actor2没有可同步的Component。
则有如下的图示关系。
说明:
会创建一个UNetDriver对象
会创建两个UNetConnection对象,分别对应一个客户端
因为Actor1和Actor2会同步给第一个客户端,因此在Connection1上,会创建这两个Actor的ActorChannel:UActorChannel1和UActorChannel2
因为只有Actor2会同步给第二个客户端,因此在Connection2上,会创建Actor2的Channel:UActorChannel3
这样就有三个UActorChannel的实例
由于Actor1自身同步以及一个Component1也同步,因此在Connection1上会创建两个FObjectReplicator分别处理Actor自身的同步和Component1的同步
由于Actor2只有自身同步,因此在Connection2上只会创建一个FObjectReplicator
这样总共有四个FObjectReplicator,被UActorChannel管理
这样总共有四个FObjectReplicator对象
但是FRepLayout只有三个对象,因为每个UObject类型只会创建一个FRepLayout对象
上面简单阐述了不同类的关系和各自的作用,下面将更加具体的进行讲解
服务器创建网络监听
说明:本文中部分使用思维导图来说明流程,流程顺序是从左到右,从上到下。
可见是在LoadMap的时候就开始初始化网络相关的逻辑,首先创建了UNetDriver
,然后在NetDriver中创建了UReplicationDriver
、创建Socket,等待来自客户端的连接请求
客户端创建的流程和握手流程在这里不展开讲解,后续可能用单独的一篇文章进行说明。
同步Actor的流程(服务器发包)
先看一个完整的流程图,从GatherActor->到ReplicateActor,其中的属性对比和属性同步->得到数据后往SendBuffer写数据
下面将对Actor的同步具体流程进行讲解,将会按照服务器上同步Actor的流程一步一步讲解,也会更具体的说明上面提到的一些重点类的处理逻辑。(注意:下文将按照这个图的流程顺序介绍不同环节对应逻辑和重点类,在看下文时可以时不时结合这个流程图来看)
UReplicationGraph::ServerReplicateActors
在启用ReplicationGraph的情况下,UReplicationGraph::ServerReplicateActors
是对Actor进行网络同步的入口函数,这里不细讲是如何做相关性的,怎么处理相关性在下面一个单独的章节讲。这里只说明Gather到Actor之后的处理。先看看进入到ReplicationGraph的流程
开始流程
每一帧中做同步Actor的流程:
前面提到,如果开启了ReplicationGraph则ReplicateActor的过程交由ReplicationGraph托管,因此结果就是:在每一帧的末尾通过TickFlushEvent
事件通知到NetDriver中,再交由ReplicationGraph接管。到UReplicationGraph::ServerReplicateActors
函数中处理,下面接着讲这个函数
ServerReplicateActors
ServerReplicateActors
这个函数做了三件事:
GatherActors:给每个连接收集要同步的Actors,Gather细节将在下文的ReplicationGraph章节细讲
ReplicateActor:遍历第一步中的每个Actor,做ReplicateSingleActor的逻辑,也就是具体同步每一个Actor,最后调用到UActorChannel中处理
对丢失相关性的Actor CloseChannel:在最后会遍历所有的ActorChannel,根据CloseFrameNumber来决定是否要Close ActorChannel,从而让客户端销毁此Actor
重点记住上面的三个阶段能让整体的同步流程更加清晰。如下图所示。
其中GatherActor阶段的代码如下:(将会在下文ReplicationGraph中详细讲解Gather阶段)
这里会遍历所有的ReplicationGraph Node去GatherActor,将得到的Actors列表写入到Parameters
参数中,后续就针对Parameters
中的Actors列表进行同步处理,针对每个符合条件的Actor调用UReplicationGraph::ReplicateSingleActor
函数处理
这里再提个题外话:对于第三阶段,是一个性能消耗点,因为要遍历这个链接的所有的可同步Actor,判断CloseFrameNumber是否小于当前帧号(下文会提到),如果满足,则CloseChannel。这里又是一种被动式的处理方式,需要遍历所有Actor根据一些数据来判断,引擎没有主动感知式的发现哪些Actor需要CloseChannel。同样,引擎引入的NetDormancy机制能降低这里的消耗
同样,更具体的ReplicationGraph细节,将会在下文专门讲解
UReplicationGraph::ReplicateSingleActor
上文收集到了Actor后,要对每个Actor进行Replicate操作,就进入到UReplicationGraph::ReplicateSingleActor
的流程。
(再次强调,本文是建立在使用ReplicationGraph的基础上,这里的ReplicateActor的流程也会和不使用ReplicationGraph不一样),没启用ReplicationGraph时,这部分逻辑在UNetDriver中处理
下面将根据上面流程图梳理一下
1.更新ActorInfo数据
ActorInfo.LastRepFrameNum = FrameNum;
ActorInfo.NextReplicationFrameNum = FrameNum + ActorInfo.ReplicationPeriodFrame;
这里更新了两个数值:上一次同步的帧号和下一次要同步的帧号。一个Actor的同步频率的调节就是生效于此,引擎会根据NextReplicationFrameNum
帧号在前面提到的ReplicateActorListsForConnection_Default
函数中和当前帧号对比来决定这个Actor是否需要在这一帧被同步。
我们在Actor中配置的这个值NetUpdateFrequency
便是生效于此,这个值换算成了ReplicationPeriodFrame
,从而影响了Actor隔几帧才同步一次。
蓝图上的配置:
FConnectionReplicationActorInfo
上述说的ActorInfo就是这个类型,简单介绍一下这个类型。(注意:这是ReplicationGraph才有的数据结构,不使用ReplicationGraph则不使用这一套逻辑)
在每个Connection上,引擎都会给要在这个连接同步的Actor创建所属于这个Connection的ActorInfo。也就是说一个Actor在多个连接上都存在一份这个连接上的ActorInfo,这个和UActorChannel是类似的,也是一个Actor会有多个UActorChannel对象,它们属于不同的连接上的数据。
上图列举了几个主要的成员属性
Channel:在这个连接上这个Actor对应的UActorChannel对象
CullDistance/CullDistanceSquared: 这个Actor的网络裁剪距离,我们在Actor上配置的
NetCullDistanceSquared
就会生效于此NextReplicationFrameNum: 上面提到了,这个Actor下一次应该被同步的帧号
LastRepFrameNum:上一次被同步的帧号
ReplicationPeriodFrame:同步间隔,这里是帧数,也就是隔多少帧才同步一次
ActorChannelCloseFrameNum:这个Actor在这个连接上需要被Close ActorChannel的帧号,会在对这个Actor做一次Replicate时更改,引擎会通过这个帧号和当前帧号对比,如果小于当前帧号则CloseChannel,给客户端发CloseChannel的包,客户端就会在本地Destroy这个Actor。这个是用于非Dormant Actor的。引擎会根据ReplicationPeriodFrame和一些TimeOut的数据去更新此值。此值的使用会在ReplicationGraph章节中讲解
bDormantOnConnection:Actor在这个连接上是否进入到了Dormant状态。(引擎的NetDormancy机制后面会用一篇文章专门讲解)
2.CallPreReplication
在同步之前准备一些数据或者控制哪些属性是否要同步
2.1 AActor::PreReplication
这里会调用
GatherCurrentMovement
,会收集计算Actor的移动数据,填充和移动相关的几个同步属性这里可以调用
DOREPLIFETIME_ACTIVE_OVERRIDE
宏来控制某个属性是否在这一帧要被同步,例如DOREPLIFETIME_ACTIVE_OVERRIDE(AActor, ReplicatedMovement, IsReplicatingMovement());
,最后一个参数为true则表示需要同步
2.2 调用每个Component的PreReplication函数
3.首次同步Actor,创建UActorChannel
在同步单个Actor时,主要是依赖UActorChannel来进行,对具体Actor属性的收集、RPC的处理、收到包后解析给具体的Actor等等都是在UActorChannel中处理。是网络同步中的重点内容,是衔接Connection和上层业务的桥梁,收数据时,Connection将收到的数据交给UActorChannel,UActorChannel再解数据交给具体业务层实现的逻辑;发数据时,由UActorChannel来收集具体业务层定义的同步数据,再交由Connection层去发送数据。
引擎会给一个Actor在多个Connection(当然是这个Actor在这些Connection上是要被同步的)上分别创建一个UActorChannel对象。
判断ActorInfo.Channel是否为空来决定是否要创建ActorChannel
3.1 UNetConnection::CreateChannelByName 函数创建Channel
如果ActorInfo.Channel==nullptr,则创建UActorChannel
创建过程如下:
当第一次在这个连接上同步这个Actor的时候,这个时候Channel为空,则需要去创建Channel。UNetConnection负责管理、创建ActorChannel。
(其实,说第一次同步Actor才去创建ActorChannel不太准确。如果引入了NetDormancy,就不是第一次同步Actor才创建Channel了,这时ActorChannel指针同样是为空,也是会走这个流程,NetDormancy将会在后面一篇文章专门讲解)
下面来讲解一下流程的关键步骤:
获取ChannelIndex,
GetFreeChannelIndex
:每个ActorChannel都会分配一个Index,这个Index是Channels数组中的下标。UNetConnection存储着一个TArray<UChannel*> Channels;
数组,在UNetConnection构造的时候初始化了这个数组的大小通过Console命令
net.MaxChannelSize
可以改写MaxChannelSize的值,默认值是32767const int32 UNetConnection::DEFAULT_MAX_CHANNEL_SIZE = 32767;
所以,这个引擎默认创建UNetConnection时,会创建Channels数组,其大小是32767,每创建一个Channel时就从这里获取一个值为空的Index,并将创建的UActorChannel指针放到这个数组中Index的位置存储起来
从NetDriver的ActorChannel Pool获取对象:NetDriver上有个Pool --
TArray<UChannel*> ActorChannelPool;
,在创建和Close ActorChannel时都从这里取或放最后将创建的ActorChannel在NetConnection上管理起来:
Channels[ChIndex] = Channel; OpenChannels.Add(Channel);
这里管理起来方便同步的其他逻辑使用
3.2 UActorChannel::SetChannelActor
创建完UActorChannel后,这一步主要是将Actor和ActorChannel关联起来,并创建FObjectReplicator
将ActorChannel 和 Actor 的对应关系在UNetConnection和UReplicationGraphNetConnection中对应起来,存成一个Actor--ActorChannel的Map
UActorChannel::FindOrCreateReplicator: 在UActorChannel中创建 FObjectReplicator,用于后续的属性对比,同步数据处理过程. 将创建出来的对象放入UActorChannel中管理起来 ——
TMap< UObject*, TSharedRef< FObjectReplicator > > ReplicationMap;
注意这里只是给Actor这个UObject创建FObjectReplicator,Actor可同步的Component的FObjectReplicator的创建会在后续的流程中发生将创建出来Actor的FObjectReplicator通过UActorChannel的属性
ActorReplicator
管理起来,赋值给它.TSharedPtr<FObjectReplicator> ActorReplicator;
,在同步Actor的时候就直接拿ActorReplicator
指针调用相关函数
4.UActorChannel::StartBecomingDormant
4.UActorChannel::StartBecomingDormant
判断如果需要进入Dormancy状态,则进行处理。这里不展开讲解,会在后面一篇文章专门讲解NetDormancy机制
到这里,UActorChannel ReplicatorActor 之前的逻辑处理完了,接下来进入到UActorChannel讲解如何ReplicateActor
UActorChannel::ReplicateActor
前面提到过,一个可同步的Actor在需要同步的Connection上都会创建对应的UActorChannel,涉及到这个Actor的属性同步,RPC最终都是到UActorChannel进行处理。例如解包确定是哪个属性被同步了、如何去触发OnRep回调、收到了RPC在这里解包去调用哪个RPC函数等等。
这一节主要讲UActorChannel::ReplicateActor
过程
1.数据的准备
1.1 创建 FOutBunch
引擎是通过Bunch包来进行数据传输,每个Actor的同步数据都会装填到一个Bunch包中,这个FOutBunch包贯穿了Actor同步的整个过程,最终是将Bunch进行包装进行网络数据传输。发出去的是FOutBunch,接收数据是FInBunch
直接看FOutBunch的内容:
定义了这个Bunch包相关的数据
Next:用链表将发送的reliable bunch包串联起来,后续在收到ack,nak的时候会进行处理
ChIndex:channel index,客户端在收包时创建UActorChannel时也会用这个Index,使用相同的Index
PacketId:网络通信的数据最终的封装成了Packet,就会有一个PacketId,此值表明此Bunch是属于哪一个Packet
ChSequence: bunch包的序号,自增值
ReceivedAck: 是否是ack
bOpen:是否是OpenBunch包
bClose:是否是CloseBunch包
bReliable:是否是Reliable的包,OpenBunch和CloseBunch这里值为true,RPC这里也是true,引擎会保证reliable=true的包会被重发。但是属性同步的Bunch包bReliable=false,因此是不会重发属性同步的bunch包,但是不能说属性同步是不可靠的,因为引擎会不断去保证最终的属性是同步的,因此属性同步到客户端是不保证及时也不保证顺序。
bPartial:当Bunch包超过一个值后会被分成多个发送
CloseReason:CloseBunch包的原因,有如下几种
Destroy:表示是Actor销毁后给客户端发的CloseBunch,客户端会销毁此Actor
Dormancy:表示进入dormant状态,给客户端发的CloseBunch包,客户端不会销毁此Actor
LevelUnloaded:streaming level 被卸载了
Relevancy:相关性丢失,例如NetViewer超出了同步范围,客户端收到包后会销毁Actor
上述算是Bunch包头,实际传输的数据在其父类中,使用字节数组传输二进制数据,FOutBunch的继承关系如下:
在构造FOutBunch包后,属性同步数据就写入到父类的FBitWriter的字节数组Buffer中。
引擎很多地方都用到FArchive,网络收发包,资源的存储,加载等等涉及到序列化的地方都用到了,很多不同类型的子类用在不同的地方。
另外对于收到网络包时,引擎会将其封装成FInBunch
类型进行处理,下文会讲到。
1.2 创建FReplicationFlags
此结构封装了几个标记位,用于处理此次Replicate的Actor相关的数据,和我们在定义网络同步属性的Condition息息相关。
我们在GetLifetimeReplicatedProps
函数中定义属性的Cond类型ELifetimeCondition
枚举对应着FReplicationFlags
ELifetimeCondition中各个的含义就可以对应着FReplicationFlag中各个值是怎么赋值的,在费解之时可以看引擎代码中对FReplicationFlags的处理。
另外Condition和ReplicationFlags的对应关系在函数FSendingRepState::BuildConditionMapFromRepFlags
中可以看到。
2.第一次同步一个Actor
当第一次同步一个Actor的时候,需要在Bunch包中序列化一些Actor相关的基础数据,能让客户端收到包后能找到相关的资源,然后去SpawnActor:
2.1 创建NetGUID
NetGUID是客户端和服务器识别一个UObject的途径,客户端和服务器同一个Object就通过设置他们为同一个NetGUID来相互联系。UE4可以同步指针属性,而客户端指针和服务器指针的值不可能相同,而他们指向的对象却是我们希望的,那就是在同步指针对象的时候,其实序列化的NetGUID,从而在客户端能通过NetGUID去找到客户端的对象,这样就能在客户端生成一个指针值指向此对象从而和服务器关联起来。
在同步一个Actor时,NetGUID是必须要序列化的,会将分配的NetGUID 序列化到Bunch包中
引擎将对象分为Static和Dynamic,Static属于静态物体,例如场景中摆放的Actor,而Dynamic是Runtime Spawn出来的Actor。
引擎通过
FNetGUIDCache
对象在管理NetGUID以及和UObject的联系,这个对象是UPackageMapClient
的成员:TSharedPtr< FNetGUIDCache > GuidCache;
,而UPackageMapClient又由Connection所持有GuidCache通过
NetGUIDLookup
缓存着GUID和UObject的对应关系。如果分配过则直接返回。同样,在客户端收到一个Actor的OpenBunch包的时候,同样可以通过GuidCache判断,这个Actor是否在客户端已经存在,如果存在就可以将UActorChannel和此Actor对应起来,这样就不会存在两份同一个Actor的两份对象了。分配NetGUID:引擎通过宏ALLOC_NEW_NET_GUID( IsStatic )来分配,可见NetGUID是自增的
在UPackageMapClient中有成员
int32 UniqueNetIDs[2];
,记录了Static和Dynamic 两种类型的最大的GUID,因此数组的大小为2.Static actor的NetGUID是奇数的;Dynamic Actor的NetGUID是偶数的。
2.2 序列化Object
序列化Object的时候,会先序列化Actor自身,再序列化Archtype数据,再序列化Level数据。最后在同步这个Actor的时候会把当前的transform,速度等都序列化。
关键的,让客户端能通过去Spawn这个Actor,需要将这个Actor的Path Name序列化到Bunch包中,这样客户端就能找到此Actor对应的资源去Spawn Actor。
因此这里是存在一个优化点的,因为序列化PathName会很大,长串的字符串,比较占用带宽,因此可以通过给这些PathName分配ID,将序列化PathName换成序列化ID,这样极大减少流量。
3.ReplicateProperties做属性对比、构造同步数据
第一次同步Actor序列化相关的数据后,就要开始进行属性数据的同步了。如果不是第一次同步某个Actor,则直接到此步骤。 这一过程涉及到几个类:FObjectReplicator,FRepLayout等 先介绍一下这几个类,代码的执行流程在介绍完这几个类后再讲解
3.1 FObjectReplicator
ReplicateProperties 是在FObjectReplicator中进行的,每一个同步的UObject,都对应着一个FObjectReplicator对象,这个对象负责了属性的对比,同步属性的提取,RepState的维护,丢包的处理,属性同步可靠的处理等等。属性同步的逻辑就是在FObjectReplicator中进行的,是一个非常关键、重要的角色类。
由于FObjectReplicator和同步的状态相关,因此,一个UObject在每个需要同步这个UObject的连接上都会创建一个FObjectReplicator对象。
下面列举了FObjectReplicator的几个重要的属性和方法:
说明:下面提到这个UObject
是指这个FObjectReplicator对应的UObject
属性:
ObjectNetGUID:这个FObjectReplicator对应的 UObject的NetGUID(用于将客户端和服务器的UObject对象通过这个NetGUID联系起来)
ChangelistMgr: 这个管理着这个UObject的属性改变相关的数据,每个UObject对应一个Mgr对象,一个UObject对象在多个连接上共享一个ChangeListMgr对象
RepLayout: 这个管理着UObject的内存布局等数据,一个类型对应着一个FRepLayout对象。注意不是一个实例对应一个FRepLayout对象,而是一个类型。
RepState:同步过程中的一些状态数据。其有成员
ReceivingRepState
和SendingRepState
分别处理收包和发包的一些状态数据Connection: 这个FObjectReplicator对应的连接
OwningChannel:这个UObject所属的Actor对应的ActorChannel,在这个连接上的。
RemoteFunctions:unreliable multicast 的 RPC 调用数据会序列化到这里。可以看到unreliabnle multicast 的RPC是和属性同步过程一起处理的。但是reliable RPC是另一个逻辑。
函数:
ReplicateCustomDeltaProperties:同步一些Custom的属性,例如FastArray
ReplicateProperties:同步非Custom的属性
ReceivedNak:收到了丢包,会根据丢包的PackageID在FSendingRepState中的ChangeHistory做上标记,这样下一次做属性同步的时候就能再次将丢包中包含的属性数据再次填入bunch包中发送
ReceivedBunch:收包的处理
CallRepNotifies:会调用属性的OnRep回调函数
下面先来看看 FObjectReplicator的创建过程。
创建的FObjectReplicator会在UActorChannel上管理起来,UObject指针作为key的Map:
TMap< UObject*, TSharedRef< FObjectReplicator > > ReplicationMap;
但是对于Actor来讲,给Actor创建的FObjectReplicator对象会直接被UActorChannel的成员属性ActorReplicator
引用着,这个Actor的其他UActorComponent的FObjectReplicator就存在这个ReplicationMap
中管理起来。
可以看到在创建FObjectReplicator的过程中,连续创建出了多个关键的类型:FReplayout,FRepChangedPropertyTracker,FRepState, FSendingRepState, FReceivingRepState 等,后续会针对这几个类型讲解。
创建完FObjectReplicator后,立马调用了FObjectReplicator::StartReplicating
函数,在这个函数中创建了一个重要的对象:FReplicationChangelistMgr
,这个对象主要做属性对比的功能,下面承接着看看这个对象的创建过程
3.2 FReplicationChangelistMgr
每个UObject实例都对应一个FReplicationChangelistMgr对象,会存储在UNetDriver的ReplicationChangeListMap
中,并且在这个UObject对象对应的FObjectReplicator中引用这个这个UObject对象的FReplicationChangelistMgr对象。
FReplicationChangelistMgr 对象是做属性对比的关键类,其中存储着每个同步属性的ShadowData,也就是说每个同步属性上一次同步的数据,这样就能对比当前帧的属性是否较上次同步是否发生了变化,只有发生了变化才需要再次同步。
FReplicationChangelistMgr 主要是依靠其成员属性FRepChangelistState RepChangelistState
来存储属性变化的list。
值得注意的是,每个UObject的FReplicationChangelistMgr对象是被所有连接共享的,当这个UObject要被同步给所有连接的时候,这个UObject对象的change list 相关数据其实只需要一份,因为这个UObject对象只有一个。因此在多个连接要同步这个UObject对象的时候只需要一份这个ChangelistMgr对象即可。
下面是Mgr的创建过程:
从图中可见,在创建 Mgr 对象时,会去初始化ShadowBuffer,会去从UObject的CDO数据中去获取数据来初始化ShadowBuffer。
这里有个UE同步的坑:某些情况下可能导致属性没同步给客户端。因为初始化ShadowBuffer是用的CDO数据,如果是蓝图类的话,注意这里的CDO数据就是蓝图上配置过的最新的值,假设有个Actor,有个同步属性P,在蓝图类上这个值被改成了x,我们把这个Actor拖入场景中,这时已经实例化了,这个时候,我们将场景中的Actor属性P改成了y,注意这个时候CDO的值仍然是蓝图类的数据P的CDO的值是x。当在DS上同步这个已经拖入场景中的actor时,游戏逻辑将属性P又改成了x,在属性对比的时候发现,当前P的值是x,和ShadowBuffer中的值一样也是x,则DS就不会同步这个属性P了。但是,由于是在场景中的Actor,客户端也会Spawn这个Actor,跟随场景一起Spawn,可是,客户端这个时候Spawn出来,属性P的值是y。这个时候就产生了bug,客户端并没有得到服务器修改后的值x
FReplicationChangelistMgr 在 NetDriver 中进行了缓存
3.3 FRepLayout
FRepLayout负责了属性对比,待同步属性数据提取的工作。其随FObjectReplicator的创建而创建。
引擎会为UClass创建对应的FReplayout对象,是每个UObject类型创建一个FRepLayout对象,这是和FObjectReplicator等类不一样的点(为每个UObject实例创建)。同样,NetDriver中缓存着UClass和FRepLayout的对应关系。
这个类之所有叫Layout
,主要是因为这个类记录了某个同步类型的可同步属性的布局信息,将所有的可同步属性按照在内存中的布局进行了抽象,因此称其为Layout
。
可以去看引擎代码的 RepLayout.h
文件,里面对FRepLayout类的注释写的比较清楚。
FReplayout创建:
一个类型的FReplayout对象是这个类型的所有实例都在使用,而不同实例就靠不同的RepState来记录着不同实例当前同步相关的数据,下文会提到RepState类型。
3.3.0 Cmd
所谓的Cmd 其实是指可同步的属性的一些数据的抽象,包含了属性类型,内存偏移等等。在FReplayout中有下面两种类型的Cmd:
/** Top level Layout Commands. */
TArray<FRepParentCmd> Parents;
/** All Layout Commands. */
TArray<FRepLayoutCmd> Cmds;
这两个数组就记录着这个类的可同步属性的布局,在FRepLayout创建的时候创建好了,Parents是上层的属性,我们用UPROPERTY宏修饰的Replicated 成员属性,而Cmds则包括了子结构的属性,例如一个同步结构体属性的内部属性。
引用引擎代码的注释的原话:
3.3.1 FRepParentCmd
例如一个Actor,那么引擎就会给这个Actor的每个成员属性都创建一个FRepParentCmd对象 下面列举了 FRepParentCmd 的成员
// 对应的哪个同步属性的反射数据,UPROPERTY标记的属性引擎会生成对应的FProperty对象
FProperty* Property;
const FName CachedPropertyName;
/**
* If the Property is a C-Style fixed size array, then a command will be
created for every element in the array.
* This is the index of the element in the array for which the command
represents.
*
* This will always be 0 for non array properties.
*/
int32 ArrayIndex;
/** Absolute offset of property in Object Memory. */
// 在UObject对象的内存中的偏移量
int32 Offset;
/** Absolute offset of property in Shadow Memory. */
// ShadowData中的偏移量,都是为了快速定位到属性对应的数据
int32 ShadowOffset;
/**
* CmdStart and CmdEnd define the range of FRepLayoutCommands (by index in
FRepLayouts Cmd array) of commands
* that are associated with this Parent Command.
*
* This is used to track and access nested Properties from the parent.
*/
// 在FRepLayout中Cmd属性的index
uint16 CmdStart;
/** @see CmdStart */
uint16 CmdEnd;
// 我们在定义同步属性时在GetLifeTime函数中定义的同步条件
ELifetimeCondition Condition;
ELifetimeRepNotifyCondition RepNotifyCondition;
/**
* Number of parameters that we need to pass to the RepNotify function (if
any).
* If this value is INDEX_NONE, it means there is no RepNotify function
associated
* with the property.
*/
int32 RepNotifyNumParams;
ERepParentFlags Flags;
可以看到,FRepParentCmd记录着这个属性和同步相关的一些数据,最主要的是内存布局,这样在定位到这个属性和做属性对比的时候就能快速找到属性值。
3.3.2 FRepLayoutCmd
每个同步属性会创建一个FRepLayoutCmd对象,落实到最小单位,主要是为了解决嵌套的属性,例如结构体和数组。
一个可同步的结构体,引擎会给这个结构体创建一个FRepParentCmd
对象,另外也会给这个结构体自身的每个成员属性都创建对应的FRepLayoutCmd
对象,相当于FRepLayoutCmd
是最小的属性单元。
下面是类的定义:
class FRepLayoutCmd
{
public:
/** Pointer back to property, used for NetSerialize calls, etc. */
// 指向了哪个UPROPERTY
FProperty* Property;
/** For arrays, this is the cmd index to jump to, to skip this arrays inner elements. */
// 对于数组属性,此属性在数组中的Index
uint16 EndCmd;
/** For arrays, element size of data. */
// 数组中单个元素的大小
uint16 ElementSize;
/** Absolute offset of property in Object Memory. */
// 内存偏移,用于快速定位到属性值
int32 Offset;
/** Absolute offset of property in Shadow Memory. */
// 在ShadowData中的内存偏移
int32 ShadowOffset;
/** Handle relative to start of array, or top list. */
uint16 RelativeHandle;
/** Index into Parents. */
// 和其相关的TopLevel的Parants数组的Index
uint16 ParentIndex;
/** Used to determine if property is still compatible */
uint32 CompatibleChecksum;
ERepLayoutCmdType Type;
ERepLayoutCmdFlags Flags;
};
3.4 FRepChangedPropertyTracker
此对象用于存储属性的一些元数据,还能在Replay中存储一些自定义数据,Replay中会将这些自定义数据序列化到Replay文件中。UE4支持了Replay的功能,具体如何使用请查阅相关的文档和代码。
下面列举除了几个重要的成员属性:
/** Activation data for top level Properties on the given Actor / Object. */
TArray<FRepChangedParent> Parents;
// 这就是Replay中业务层可自定义的数据,可写入到Replay文件中
TArray<uint8> ExternalData;
uint32 ExternalDataNumBits;
创建流程:
引擎会给每个UObject创建一个Tracker对象,同样也在UNetDriver中有缓存
对于TArray<FRepChangedParent> Parents;
属性,是在FRepLayout中对其进行初始化的,代码如下:
可见,TArray<FRepChangedParent> Parents
属性和FReplayout中的Parents数组是对应起来的
看看FRepChangedParent
的定义:
/** Stores meta data about a given Replicated property. */
class FRepChangedParent
{
public:
FRepChangedParent(): Active(1), OldActive(1), IsConditional(0) {}
/** Whether or not this property is currently Active (i.e., considered for
replication). */
uint32 Active: 1;
/** The last updated state of Active, used to track when the Active state
changes. */
uint32 OldActive: 1;
/**
* Whether or not this property has conditions that may exclude it from
replicating to a given connection.
* @see FRepState::ConditionMap.
*/
uint32 IsConditional: 1;
};
直接看注释即可,FRepChangedParent存储着每个属性是否应该被同步的信息
3.5 FRepState
同步的状态,其只有两个成员属性,有个收数据的状态,一个发数据的状态
/** May be null on connections that don't receive properties. */
TUniquePtr<FReceivingRepState> ReceivingRepState;
/** May be null on connections that don't send properties. */
TUniquePtr<FSendingRepState> SendingRepState;
创建过程如下:
3.5.1 FSendingRepState
发数据时,用到的一些状态。以及nak的数据 下面列举几个关键的成员属性
/** Index in the buffer where changelist history starts (i.e., the Oldest
changelist). */
int32 HistoryStart;
/** Index in the buffer where changelist history ends (i.e., the Newest
changelist). */
int32 HistoryEnd;
/** Number of Changelist history entries that have outstanding Naks. */
int32 NumNaks;
/** Circular buffer of changelists. */
FRepChangedHistory ChangeHistory[MAX_CHANGE_HISTORY];
逻辑会根据History相关的Index来判断要将哪些数据写入到同步包中。属性同步的可靠性保证也和此类的属性有关系。
后续打算扩展这里的逻辑单独写一篇文章进行讲解,这里是属性同步的底层逻辑,以及属性同步的可靠性的实现都是和这个类有关。
3.5.2 FReceivingRepState
这是在收到包时使用的,例如客户端收到包后是否需要触发OnRep回调,就需要依赖这里记录的StaticBuffer
属性,如果发现有变化才需要调用OnRep,或者是AlwaysNotify。
另外RepNotifies数组 记录着这个对象的RepNotify回调函数
/** Latest state of all property data. Only valid on clients. */
FRepStateStaticBuffer StaticBuffer;
/** Map of Absolute Property Offset to GUID Reference for properties. */
FGuidReferencesMap GuidReferencesMap;
/** List of properties that have RepNotifies that we will need to call on
Clients. */
TArray<FProperty*> RepNotifies;
3.6 ReplicateProperties过程
在介绍了上面几种关键类后,下面介绍一下属性同步的关键过程。从FObjectReplicator开始,这里是每个UObject同步属性的入口。
3.6.1 属性对比过程
引擎在4.2x的某个版本引入了PushModel,开启PushModel后,需要在属性改变的地方调用一个宏来标脏处理,这样在做属性对比的时候就直接去遍历标脏的属性进行处理,而不需要遍历所有的可同步属性去进行处理。
属性对比期间,可同步属性定义的RepCondition也起作用了
属性对比的详细细节这里不展开讲,细节内容比较多
这里又是一处UE4被动式同步逻辑的体现,当在没有开启PushModel的时候,FReplayout 中会遍历所有的可同步属性去判断属性是否有变化,在这里,引擎是不知道哪些属性有变化,而是需要被动式的一个个属性去检测是否有变化。
3.6.2 将可同步数据写入过程:FRepLayout::ReplicateProperties
这里主要是通过上一步的属性对比结果,将FSendingRepState中的History data进行处理,写入到Writer中,最后将数据写入到最开始创建的FOutBunch中,调用函数UActorChannel::WriteContentBlockPayload
处理
值得注意的是,Unreliable RPC 是随着属性同步一起处理的,处理完RemoteFunctions数据后,会清掉这个OutBunch数据,因此Unreliable RPC丢掉了就不会再发,而属性同步的Bunch包丢掉之后,还会继续同步丢掉的属性数据。
另外Reliable RPC不是在这里处理,走的另外一个路径,后面可用单独的文章专门进行说明。Reliable RPC,每次调用是直接创建一个Reliable的 FOutBunch包,因此其是及时的。并且引擎也保证了同一个ActorChannel的RPC是有顺序的。也是可靠的,对方一定会收到RPC调用。
Custom Data 的处理主要是针对于自定的结构体,FastArray,这里会对Customdata进行属性对比。关于如果自定义同步数据,后续有机会也通过一篇文章来专门讲解
在这个过程中,还有一个逻辑:在发现RepFlag发生变化后,会调用一次
FRepLayout::RebuildConditionalProperties
来构造RepCondition的数据
关于如何取得属性同步的数据这其中的逻辑不在这里详细展开,望后期单独用一篇文章详细说明这其中的逻辑。
4.ReplicateSubobjects同步子对象
当Actor的所有同步属性都处理完后,就来处理子对象的同步——也就是处理UActorComponent的同步
可见最后都是在FObjectReplicator中进行处理,执行的逻辑和前面提到的同步Actor自己的属性时是一样的。
同样,这里仍然是UE4被动式同步逻辑的体现,引擎无法知道哪些Component的属性发生了变化,而是需要一个个Component去遍历,去检查属性有没有变化,走全所有的同步逻辑才能最终发现Component需要同步
5.发送Bunch包
5.1 Bunch包最大值
获取Bunch包的最大值
// This is the max number of bits we can have in a single bunch
const int64 MAX_SINGLE_BUNCH_SIZE_BITS = Connection->GetMaxSingleBunchSizeBits();
在UNetConnection中有接口获取到Bunch包的最大值:
MaxPacket 值是在Connection初始化的时候,传入的,默认值是
enum { MAX_PACKET_SIZE = 1024 }; // MTU for the connection
MAX_BUNCH_HEADER_BITS:
enum { MAX_BUNCH_HEADER_BITS = 256 };
, bunch 的头部大小MAX_PACKET_TRAILER_BITS:
enum { MAX_PACKET_TRAILER_BITS = 1 };
相关定义如下所示
enum { RELIABLE_BUFFER = 256 }; // Power of 2 >= 1.
enum { MAX_PACKETID = FNetPacketNotify::SequenceNumberT::SeqNumberCount }; // Power of 2 >= 1, covering guaranteed loss/misorder time.
enum { MAX_CHSEQUENCE = 1024 }; // Power of 2 >RELIABLE_BUFFER, covering loss/misorder time.
enum { MAX_BUNCH_HEADER_BITS = 256 };
enum { MAX_PACKET_RELIABLE_SEQUENCE_HEADER_BITS = 32 /*PackedHeader*/ + FNetPacketNotify::SequenceHistoryT::MaxSizeInBits };
enum { MAX_PACKET_INFO_HEADER_BITS = 1 /*bHasPacketInfo*/ + NetConnectionHelper::NumBitsForJitterClockTimeInHeader + 1 /*bHasServerFrameTime*/ + 8 /*ServerFrameTime*/};
enum { MAX_PACKET_HEADER_BITS = MAX_PACKET_RELIABLE_SEQUENCE_HEADER_BITS + MAX_PACKET_INFO_HEADER_BITS };
enum { MAX_PACKET_TRAILER_BITS = 1 };
如果Bunch包,超过了MAX_SINGLE_BUNCH_SIZE_BITS的值,则需要将Bunch包分片处理,会生成PartialBunch,加入到OutGoingBunch中
5.2 发送过程
Bunch包是有限制大小的,超出最大限制的Bunch包需要进行拆包
一个UActorChannel同时存在的Bunch包的数量是有限制的,默认256个,也就是说如果有256个Bunch包没处理,就会关闭连接。在写代码的时候切记千万不要频繁的发Reliable RPC,因为每一个Reliable RPC都会构造一个Reliable的Bunch包,在弱网的情况下更容易堆积到256个的限制。如果需要频繁发Reliable RPC可以改成UnReliable RPC,或者服务器上可以将RPC改成属性同步去告诉客户端。
enum { RELIABLE_BUFFER = 256 }; // Power of 2 >= 1.
256值的定义Reliable FOutBunch包会通过链表连接起来,后续用于丢包重传和收到ack后的移除
最终将数据写入到UNetConnection的SendBuffer中,最终给客户端发包是在
UNetConnection::FlushNet
中处理
网络层Socket发包
前面将如何做属性同步,如何将最终要同步的数据写入到SendBuffer中,这里就看一下,有了这些数据后,是如何执行的UDP发包
在UNetConnection的Tick中去FlushNet,最终通过网络发包
在PacketHandler中,
PacketHandler::OutgoingHigh
函数可以对最终的二进制数据进行一些修改,例如加密等。同样对于收包的时候也有一个函数PacketHandler::IncomingHigh
可以对收到的数据进行一些修改,例如解密后再传到UActorChannel。引擎提供了一层中间层方便自定义对二进制数据的处理。当然引擎也自带了一些加密数据的处理方式。可见下图中的几个文件代码:FNetPacketNotify类型管理着一些序列号,以及判断是否有丢包,以及ack的判断等等
ReplicateActor过程总结
到这里,服务器对Actor进行同步的完整过程讲完了,部分内容还不够细致,一篇文章着实写不下,涉及到的内容太多了。后续会将属性同步过程中的部分细节单独摘出来写几篇文章,例如如何做到属性的可靠性同步、RPC同步的细节、ACK,NAK的处理等等。
被动式同步方式
文章中多次提到了UE4的被动式同步方式,这种思路导致了网络同步上的性能消耗,下面将几处逻辑总结一下:
GatherActor阶段:在收集Actor时,是收集所有相关的Actor列表,遍历Actor列表去一个个处理,处理之前是不知道哪些Actor在这一帧内同步属性被修改了,需要后续更多的逻辑去判断是否这个Actor在这一帧需要同步
属性对比阶段:针对一个Actor或Component,引擎同样是不能提前知道这个Actor有哪些属性改变,同样得遍历所有属性去做对比才知道是否发生了改变,发生了改变才需要去同步。当然这里说的是PushModel关闭的情况,即使新版本种提供了PushModel功能,但是引擎并没有将属性全部改成支持PushModel的情况
同步Component阶段:一个Actor的Components,引擎同样不知道哪些Component在这一帧发生了属性变化,得遍历所有的可同步Component,每一个Component都得走一遍属性对比过程
CheckCloseChannel阶段:在同步的最后,引擎得遍历此连接上的所有ActorChannel,去根据CloseFrameNumber来决定当前帧这个Actor是否需要CloseChannel,从而在客户端去销毁这个Actor。同样引擎是不知道当前帧哪些actor因为失去了相关性而去CloseChannel,它得遍历所有的ActorInfo去检查。
上面列举了四大点都体现出了UE4的被动同步方式,这给同步性能带来了极大的影响,当然引擎也自己加入了一些特性来解决这些问题:
引入ReplicationGraph,降低了GatherActor阶段处理Actor的数量
引入NetDormancy,进一步降低了GatherActor阶段处理Actor的数量,同时也降低了CheckCloseChannel阶段遍历的次数。后续会用一篇文章单独讲解NetDormancy
引入了PushModel,降低了属性对比执行的次数,降低了消耗
但是,还是有没有被解决的问题:例如如何减少遍历Component的消耗;对于这一帧没有发生改变的Actor是不是完全不用走属性对比过程等等。在同步优化时,可以通过引入上面说的引擎引入的三种特性极大的降低同步消耗,进一步可以自己改引擎的实现,进一步降低这种被动式同步方式带来的问题。
撇开UE4讲,同步应该采用主动触发式的同步方式,在底层处理同步的时候,应该是需要知道哪些Actor需要被同步,哪些属性需要被同步,哪些Actor需要被CloseChannel,这样就省去了很多无谓的消耗。希望虚幻引擎在后续的发展中能朝着这个方向去发展。当然,各位也可以发挥自己的聪明才智将UE4的这种同步方式改成主动触发式的同步方式。
客户端收包流程
对于客户端收包流程,这里就不展开了,后续单独写一篇文章讲解。一些基本的对象类型和发包时一样,熟悉了发包过程,收包过程就比较好认识。有些不一样的地方是收到数据包后如何处理Spawn Actor,涉及到资源是如何异步加载或同步加载,涉及到同步指针属性情况下有哪些特殊的处理等等
这里提一句,同步指针的时候会等到客户端创建了此UObject时才会去触发OnRep回调
同步资源UObject指针的时候,引擎会自动加载好对应的资源。例如同步蒙太奇对象指针。这里的加载又有同步加载和异步加载,引擎有开关控制
同步指针,在序列化的时候是序列化的NetGUID,上文已讲过
ReplicationGraph细节
前文提到过,本篇文章是基于在启用了ReplicationGraph的背景下讲解的,前文也多处提到了ReplicationGraph,由于文章的篇幅限制,将ReplicationGraph的细节放到下一篇文章讲解。
文章链接:(二)UE4网络同步之ReplicationGraph
系列文章: