《Exploring in UE4》网络同步原理深入(下)

《Exploring in UE4》网络同步原理深入(下)

这是侑虎科技第523篇文章,感谢作者Jerish供稿。欢迎转发分享,未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:793972859)

作者主页:https://www.zhihu.com/people/chang-xiao-qi-86,作者也是U Sparkle活动参与者,UWA欢迎更多开发朋友加入U Sparkle开发者计划,这个舞台有你更精彩!


我们之前分享过《Exploring in UE4》网络同步——上篇。这篇更新主要是在网络数据包格式、PacketHandler、条件复制、可靠数据传输、ReplicationGraph等方面做了进一步的分析与阐述。

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

目录(接上篇)

五、属性同步细节
  1、属性同步概述
  2、重要数据的初始化流程
  3、发送同步数据流程分析
  4、属性变化历史记录
  5、属性回调函数执行
  6、关于动态数组与结构体的同步
  7、UObject指针类型的属性同步
  8、条件复制(更新)
六、RPC执行细节
七、可靠数据传输(更新)
  1、数据包格式
  2、PacketHandler
  3、Bunch的发送时机
  4、可靠数据传输的实现
  5、属性的可靠传输
八、ReplicationGraph系统(更新)


五、属性同步细节

1、属性同步概述

属性同步是一个很复杂的模块,我在另一个关于UE4网络思考文章里面讲解了属性同步相关的使用逻辑以及注意事项。这里我尽可能的分析一下属性同步的实现原理。

有一点需要先提前说明一下,服务器同步的核心操作就是比较当前的同步属性是否发生变化,如果发生就将这个数据传到客户端。如果是普通逻辑处理,我们完全可以保存当前对象的一个拷贝对象,然后每帧去比较这个拷贝与真实的对象是否发生变化。不过,由于同步数据量巨大,我们不可能给每个需要同步的对象都创建一个新的拷贝,而且,如果这个逻辑暴露到逻辑层会使代码异常复杂难懂,所以这个操作要统一在底层处理。那么,UE4的基本思路就是获取当前同步对象的空间大小,然后保存到一个buffer里面,然后根据属性的OffSet给每个需要同步的属性初始化。这样,就保存了一份简单的"拷贝"用于之后的比较。当然,我们能这么做的前提是存在UE的Object对象反射系统。

(注意:虽然UE4的底层是可靠的UDP,但是不代表我们所有的网络数据都是可靠的。属性同步其实就并不完全可靠,除了一开始同步Actor的时候,属性同步是可靠的,但是后期的发送并不是,但是引擎内部有一定的机制可以保证客户端能收到服务器最后同步的属性消息,文章的最后一部分会谈到这些。)

下面开始进一步描述属性同步的基本思路:我们给一个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-9 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-10 GUID的分配与注册

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

//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 );
}

下面我们再分析客户端的接收流程,客户端在接收到服务器同步过来的一个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-11 客户端收到消息立刻按照路径注册GUID

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

// 情况一:客户端接收到服务器同步过来的一个新的Actor,需要执行Spawn spawn 成功后会执行RegisterNetGUID_Client进行GUID的注册
// UActorChannel::ProcessBunch DataChannel.cpp
bool SpawnedNewActor = false;
if( Actor == NULL)
{
    ......
    SpawnedNewActor = Connection->PackageMap->SerializeNewActor(Bunch,this,NewChannelActor);
    ......
}
// 情况二:客户端接收到一个含有GUID的消息立刻解析 解析成功后会执行RegisterNetGUIDFromPath_Client进行GUID的注册
//DataChannel.cpp
void UChannel::ReceivedRawBunch(FInBunch&Bunch, bool&bOutSkipAck)
{
   if( Bunch.bHasGUIDs )
   {
      Cast<UPackageMapClient>( Connection->PackageMap)->ReceiveNetGUIDBunch( Bunch );
      ......
   }
}
// UPackageMapClient::ReceiveNetGUIDBunchPackageMapClient.cpp
int32 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++构造函数里面添加;
4、使用UActorComponent::SetNetAddressable标记(这个只针对组件,其实蓝图里面创建的组件默认就会执行这个操作)

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

//PackageMapClient.cpp
boolFNetGUIDCache::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-12里ObjectLookup24对应的StaticMeshActor_20,他就是一个非Replicate但是从数据包直接加载的对象。)

下图5-12可以看出,分配GUID的对象不一定是游戏场景中存在的Actor,还可能是特定路径下某个资源对象,或者是一个蓝图类,或是一个CDO对象。进一步分析,一个在游戏里面实际存在的Actor想要同步,我们必须先将其资源文件,CDO对象先同步过去。然后再将实际的Actor同步,因为这样他才能正确的根据资源文件Spawn出来。而对于一个Actor的组件来说,他也需要等到他的Actor的资源文件,CDO对象先同步过去再进行同步。(由于网络包的异步性,这里并不是严格意义上的先后,而是指资源,CDO同步后,后面的Actor(组件)才能正常的反序列化成一个完整合法的对象。)

请输入图片描述
图5-12 GUID缓存Map

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

请输入图片描述
图5-13

8、条件复制

默认情况下所有的属性都是要同步的,除非我们设置条件复制。

void AActor::GetLifetimeReplicatedProps( TArray< FLifetimeProperty > & OutLifetimeProps ) const
{
    DOREPLIFETIME_CONDITION( AActor, ReplicatedMovement, COND_SimulatedOnly );
}

上面的代码表示我们只在模拟客户端进行ReplicatedMovement属性的同步。当然,具体的同步控制条件还有很多。

请输入图片描述

如果想更自由的控制条件复制,就需要使用COND_Custom,并在当前Actor的PreReplication函数里面使用DOREPLIFETIME_ACTIVE_OVERRIDE,通过最后一个参数控制是否要进行同步。

DOREPLIFETIME_ACTIVE_OVERRIDE(AActor, ReplicatedMovement, bUseReplicatedMovement);

条件复制的基本原理是通过宏展开获取当前该属性的UProperty,然后封装放到参数OutLifetimeProps里面。

#define DOREPLIFETIME(c,v) \
{ \
    static UProperty* sp##v = GetReplicatedProperty(StaticClass(), c::StaticClass(),GET_MEMBER_NAME_CHECKED(c,v)); \
    for ( int32 i = 0; i < sp##v->ArrayDim; i++ )                           \
    {                                                                       \
        OutLifetimeProps.AddUnique( FLifetimeProperty( sp##v->RepIndex + i ) ); \
    }                                                                       \
}

在每个ACtor的Replayout初始化的时候会去真正的调用GetLifetimeReplicatedProps并将LifetimeProps标记到对应的要同步的Parent数组里面(所以如果我们不在函数GetLifetimeReplicatedProps里面标记同步属性,这个属性即使标记了replicated也不会同步),同时LifetimeProps会把当前属性是否是条件复制(FRepParentCmd.Condition)放到Parent对应的标记里面。

请输入图片描述

另一边,在RepState初始化的时候会有一个ConditionMap来记录当前的条件复制情况。ConditionMap就是一个大小为enum ELifetimeCondition长度的bool数组,记录当前的复制条件。

请输入图片描述

现在,当我们正式进入序列化发送的时候,我们开始执行判断这个属性是否应该发送。判断方式就是找到当前属性的FRepParentCmd.Condition同时查看RepState的ConditionMap是否满足当前的条件。

请输入图片描述

从上面的堆栈可以看出,如果这时候不满足条件,当前的属性就不会写到序列化的bunch里面也就不会发送到当前的Connection里面。


六、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这个对象系统里,我们可以通过反射系统(通过名字在UClass里面查找,里面有一个存储TFunction的map)查找到函数对应的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); \
} \

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


七、可靠数据传输

UE4默认收发包都在主线程处理,收包可以通过控制CVarNetIpNetDriverUseReceiveThread来开启线程单独处理。

请输入图片描述
发包堆栈

请输入图片描述
收包堆栈

1、数据包格式

再次拿出来一般网络数据包的格式,可以看到虚幻里面的网络包是精确到bit的,这些信息都可以通过FBitWriter与FBitReader去读与写。

请输入图片描述
网络包分为Ack与Bunch两种

请输入图片描述
对于ActorChannel,Bunch分为属性Bunch与RPC Bunch

平时看函数堆栈的时候我们可能看到Bunch、RawBunch、Packet、RawPacket等。所谓的Bunch就是上面图所展示的(ActorChannel发送数据的Bunch分为属性Bunch与RPC Bunch),Bunch如果太大就会被拆分成很多个小的Bunch,一旦拆分成小的bunch那么这个bunch就不是一个完整的bunch(就可以叫做一个Rawbunch,具体逻辑在UChannel::SendBunchInner里面),这些bunch可以都被塞到一个Sendbuffer里面,如果这样直接发出去,就是一个Packet。每一个Sendbuffer发出前还可能会被PacketHandler处理,处理之后就是RawPacket。按照这样的理解,你就能看懂下面的堆栈了。

请输入图片描述
客户端与服务器通过ControlChannel建立连接的某次通信堆栈

另外,由于平时我们的发包都是按照最小单位Byte来发送的,UE4里面又精确到bit。所以会在Sendbuffer最后面添加1bit的结束标志位,另一端在收到包的时候就可以先找到最后一个为1的bit,把后面的0删除,前面剩下的就是原始的网络包。

2、PacketHandler

PacketHandler是用来对原始数据包(Packet)进行处理的一个“工具”,里面可以自由的添加组件来对原始数据包进行多层处理,目前引擎内置的组件有握手组件StatelessConnectHandlerComponent、各种加密组件FEncryptionComponent、可靠数据传输组件ReliabilityHandlerComponent等。由于组件的不确定性,所以网络的消息包头也是不确定的。比如加密组件可能会对一个Packet进行加密,然后在前面添加一个2bit的头部以及1bit的结束标志位,也因此各个组件应该固定的顺序处理packet。默认情况下回一直存在一个StatelessConnectHandlerComponent组件。

由于PacketHandle组件可能对原有的packet进行加密从而导致位发生变化,所以PacketHandle组件本身也会对处理过的数据后添加一个bit的结束标志位。

请输入图片描述

3、Bunch的发送时机

每次只要执行sendrawbunch(可能在netdrivertick里worldtick里面的代理tick,也可能在worldtick里tickgroup里面)就会设置TimeSensitive为true,就会触发flushnet,所以说只要每帧有数据就会发送。只要里面有sendbuffer或者到时间了就会触发lowlevelsend,调用socket的发送

请输入图片描述

4、可靠数据传输的实现

可靠数据传输的基本原理就是接收方对每一个包都要做Ack回应,如果接收方没收到Ack,那么就要进行重传。

UE4底层默认是主动重传,只要没有按顺序收到bunch就会重传。每个包有一个OutPacketId(记录在Connection里面),一个packet可能包括N个Bunch,每个bunch也会记录当前所在的OutPacketId。

简单来说,发送端会记录一个已经传送成功的序号(已经收到的Ack.OutAckPacketId)假如发送端发了10个包(1-10),接收端收到了1那么会回复一个ack,里面是OutAckPacketId 1。然后发生丢包,接收端收到了序号5,那么就会回复一个ack5,这时候发送端会更新当前的OutAckPacketId并重传序号2-4所有的packet(保存在connection的缓存里面)。所以,可以保证所有的包到上层都是严格有序的。

if( AckPacketId>OutAckPacketId )
{
    for (int32 NakPacketId = OutAckPacketId + 1; NakPacketId<AckPacketId; NakPacketId++, OutPacketsLost++, OutTotalPacketsLost++, Driver->OutTotalPacketsLost++)
    {
        UE_LOG(LogNetTraffic, Verbose, TEXT("   Received virtual nak %i (%.1f)"), NakPacketId, (Reader.GetPosBits()-StartPos)/8.f );
        ReceivedNak( NakPacketId );
    }
    OutAckPacketId = AckPacketId;
}

除了Bunch里面的OutPacketId外,每个channel里面的还有一套ChSequenceID,记录了当前通道内可靠数据包的序号,每次发送加1。每个Connection里面会有N个Channel,每个Channel发出去的可靠数据包的数量会以Connection->OutReliable数组的形式存储,而真正发出去与接收到的数据包会缓存在OutRec链表与InRec链表链表里面,每次发送一个数据包就会添加到OutRec里面并设置其Ack状态为0,收到一个Ack的时候就会遍历当前Channel的OutRec链表,将对应Ack设为1,调用Channel::ReceivedAcks()并清空OutRec中被确认过的前面的所有缓存。OutRec并没有限制大小,所以理论上这里会出现内存溢出的情况,不过在逻辑上层还有一些自己的处理机制,比如Channel可以设置阈值,超过阈值就退化成停等协议,具体内容请参考UChannel::SendBunchInner。

每个通过有一个ChIndex,connection在接收Bunch的时候可以通过这个Index找到对应的Channel再下发消息。

5、属性的可靠传输

首先要确认一点,属性同步本身并不是可靠的,也就是他的属性bunch所在的packet如果丢失并不会将这个packet重新发送。只有Actor在第一次同步的时候才会设置合格属性bunch为Reliable。

// Send initial stuff.
//UActorChannel::ReplicateActor
if( OpenPacketId.First != INDEX_NONE && !Connection->bResendAllDataSinceOpen )
{       //第一次收到spawn的ack会把后面不可靠的属性也重新同步一遍
    if( !SpawnAcked && OpenAcked )
    {
        // After receiving ack to the spawn, force refresh of all subsequent unreliable packets, which could
        // have been lost due to ordering problems. Note: We could avoid this by doing it in FActorChannel::ReceivedAck,
        // and avoid dirtying properties whose acks were received *after* the spawn-ack (tricky ordering issues though).
        SpawnAck
ed = 1;
        for (auto RepComp = ReplicationMap.CreateIterator(); RepComp; ++RepComp)
        {
            RepComp.Value()->ForceRefreshUnreliableProperties();
        }
    }
}
else
{       //第一次同步是可靠的
    Bunch.bClose = Actor->bNetTemporary;
    Bunch.bReliable = true; // Net temporary sends need to be reliable as well to force them to retry
}

那么属性是怎样做到可靠的呢?我发现即使接收方即使接收到的Packet里面的bunch不是reliable的,在通道不关闭、不是拆分的Bunch等情况下还是会回复一个Ack的,所以发送端可以接收到一个Ack从而知道当前的属性是否被另一端接收到。

当发生丢包或者乱序的时候,RepState就会记录当前Nak的数量,并对当前的同步发送历史信息进行标记。

void FRepLayout::ReceivedNak( FRepState * RepState, int32 NakPacketId ) const
{
    if ( RepState == NULL )
    {
        return;     // I'm not 100% certain why this happens, the only think I can think of is this is a bNetTemporary?
    }

    for ( int32 i = RepState->HistoryStart; i < RepState->HistoryEnd; i++ )
    {
        const int32 HistoryIndex = i % FRepState::MAX_CHANGE_HISTORY;

        FRepChangedHistory & HistoryItem = RepState->ChangeHistory[ HistoryIndex ];

        if ( !HistoryItem.Resend && HistoryItem.OutPacketIdRange.InRange( NakPacketId ) )
        {
            check( HistoryItem.Changed.Num() > 0 );
            HistoryItem.Resend = true;
            RepState->NumNaks++;
        }
    }
}

请输入图片描述

当下一帧要进行属性同步的时候,就会把之前的历史记录合并到最新的历史记录里面,然后一起发出去,这样达到了不用重发丢失的bunch还能保证属性可靠的效果了。这一块的逻辑主要在FRepLayout::ReplicateProperties里面,关于属性变化的历史记录可以参考上面第五章第4小节。


八、ReplicationGraph

ReplicationGraph是Epci官方针对堡垒之夜网络同步优化而加入的新的插件系统,可以大大减少Actor的同步与遍历,比较适合对大世界场景进行网络同步优化。这一块已经有文章写的比较清晰了,所以我只是简单的列举其优化点与基本原理。

通常服务器在同步Actor到各个连接的时候,会遍历场景中所有标记Replicated的Actor,但是实际上与玩家距离比较远的根本就不需要遍历,更不用说同步,所以ReplicationGraph加入了GridSpatialization2D节点系统,把N*N的格子,并把Actor放到当前所有与他有关的格子里,这样一个玩家靠近他的时候就从当前子集所在的格子里面找一下有没有那个Actor就可以了(只遍历所有在这个格子里的Actor即可)。

请输入图片描述

当然,作为一个系统不仅仅是提供这样一种功能和优化,其里面还内置了很多节点用于不同的同步需求(比如可以对不同的Connection进行某一个Actor的特定属性进行共享序列化),你也可以自定义一个节点专门处理某些需要特殊处理的Actor,严格控制它的同步时机。这一块可以参考官方的Shootergame项目。

注:ReplicationGraph是一个纯C++插件系统,使用需要修改配置文件DefaultEngine.ini里面的内容。


文末,再次感谢Jerish的分享,如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。
QQ群:793972859

也欢迎大家来积极参与U Sparkle开发者计划,简称“US”,代表你和我,代表UWA和开发者在一起!