UE4 网络同步原理三 RPC

Posted 程序员毛寸

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了UE4 网络同步原理三 RPC相关的知识,希望对你有一定的参考价值。

目录

  • RPC

    • RPC 执行权限

    • RPC 调用

      • 本地调用

      • 网络调用

    • RPC 接收

    • 注意事项

  • 总结


RPC

RPC (远程过程调用)是在本地调用但在其他机器(不同于执行调用的机器)上远程执行的函数。RPC 函数非常有用,可允许客户端或服务器通过网络连接相互发送消息。

在使用 RPC 时,还必须要了解所有权的工作方式,因为它决定了大多数 RPC 将在哪里运行。

一般我们在函数的开头会预置 Client、Server 或 Multicast 关键字。这是引擎在内部所做的一个约定,用来告诉程序员所用的函数将分别在客户端、服务器或所有客户端上调用。它有一个非常重要的作用,就是事先确定该函数将在多人游戏会话期间被哪些机器调用。

RPC 需要标记为可靠或不可靠,否则会编译报错。

LogCompile: Error: Replicated function: 'reliable' or 'unreliable' is required

RPC 执行权限

主要是通过函数 GetFunctionCallspace 来判断,返回本地和远程的执行权限。

有两个判断条件:

  • 函数标记(执行方式)

  • Actor 所有权

详细如下:

UE4 网络同步原理三 RPC

UE4 网络同步原理三 RPC

RPC 调用

本地调用

有些 RPC 函数是需要在调用端直接执行的。

网络调用

服务器的多播调用

1、为当前的 RPC 函数创建一个 FRepLayout 用于复制相关信息

2、首先会遍历所有的客户端连接,排除不相关的客户端。有种情况除外,如果设置了标志位 CVarAllowReliableMulticastToNonRelevantChannels,RPC 调用是可靠的,并且当前 Actor 的通道还存在的情况下,就算与某个客户端不相关,也会发送给这个客户端调用。

CVarAllowReliableMulticastToNonRelevantChannels:允许可靠的服务器多播函数调用被发送到不相关的客户端,只要他们是一个现有的 ActorChannel。

3、如果当前的调用方 Actor 没有打开 Actor 通道,会新建通道。同时会调用函数 ReplicateActor 初始化复制一次当前的 Actor。初始化复制的 Bunch 会优先于当前的 RPC 数据 Bunch 发送。

重点:因此客户端调用某个 RPC 函数的时候,必定已经接收到了 Actor 的初始化复制信息,成功的执行了 BeginPlay 中的逻辑。

4、RPC 的参数会按顺序(从左边到右边)序列化。调用参数属性 Property 的序列化函数 NetSerializeItem。

误区:可能很多人会以为参数为对象引用的时候,调用 RPC 函数前会先把参数对象先比较属性,同步最新的属性数据过去,然后才在客户端调用 RPC 函数。其实是不会的,引擎只在对应的 Actor 通道上同步属性,会有自己的同步频率。

  • 如果是对象属性(FObjectPropertyBase,包括指针和引用),需要调用函数 UPackageMapClient::SerializeObject 去序列化一个对象的引用。只是序列化对象的引用,不会去更新这个对象。只需要把当前对象的 NetGUID 序列化进去即可,对端从缓存中找到对应的 Actor 对象即可。(如果是某个对象的属性,会把这个归属对象的 NetGUID 一起复制过去,一直向上查找 Owner。)

  • 如果是结构体属性(FStructProperty),会调用结构体的序列化函数 NetSerialize 进行全量复制。当然,如果我们不需要全量复制,只需要复制某些属性,按需序列化即可。

如果某个参数 Actor 对象还没有同步过给客户端(或者是当前客户端新生成的对象),意味着对端还没有这个对象,那么本端会注册该对象的 NetGUID,对端解析出来的 NetGUID 在缓存中找不到,是个 Null 对象。对象作为 RPC 参数的前提是这个对象在双端中是存在的。


举个例子,在服务器的某个函数中,刚生成一个 Actor 就作为 RPC 的参数调用这个 RPC,如下所示。

AActor* CurActor = GetWorld()->SpawnActor<AActor>(AActor::StaticClass(), FTransform::Identity);
if (CurActor)
{
CurActor->SetReplicates(true);
MulticastEvent(CurActor);
}

有几种情况:

  • 如果是可靠的 RPC,对端解析出来的 CurActor 是个 Null 对象。

  • 如果是不可靠的 RPC,对端解析出来的 CurActor 是个正常对象。

为什么呢?我们来分析下几种发送时机就很清楚了。

  • 如果是可靠的 RPC,发送给远端的时候会立即放入发送缓存中,优先级是非常高的。

  • 如果是第一次复制 的 Actor,需要 NetDriver 下一次调用 ServerReplicateActors 考虑去同步需要复制的 Actor 时,才会去同步,根据优先级放入发送缓存中。由于是新生成的 Actor,第一次初始化复制的优先级是相对较高的。

  • 如果是不可靠的 RPC,会暂时缓存起来,放入 RPC 队列中,当要复制当前 RPC 的调用对象 Actor 时,随着属性的同步而同步。先序列化属性,后序列化 RPC 函数队列。

当然,如果调用 RPC 的 Actor 也是第一次复制,参数 Actor 也是第一次复制,则优先级不确定,那么客户端的接收顺序不确定,参数是否正常解析也不确定了。

这些都是正常情况下来分析的,如果是网络问题(丢包等),则不确定性更大。

如果设置了标志位 CVarDelayUnmappedRPCs,情况也不同。

CVarDelayUnmappedRPCs:延迟未映射的 RPC。如果为 false,则 RPC 函数将立即使用 Null 参数执行。如果为 true,则延迟接收具有未映射对象引用的 RPC,直到未映射对象已接收或者已加载。(RPC 接收的时候会具体讲这个设置)

不确定性的根源是对象作为 RPC 参数时,对象本身的复制依赖于对象所在的 Actor 通道,而参数对象只复制对象的引用 NetGUID,RPC 的复制依赖于 RPC 调用对象的 Actor 通道。一个对象作为 RPC 参数的前提是能在执行端找到这个对象

如果 RPC 的对象参数在对端找不到时,会出现警告,需要注意下并调整逻辑实现。

UE_LOG( LogNetPackageMap, Warning, TEXT( "FNetGUIDCache::SupportsObject: %s NOT Supported." ), *Object->GetFullName() );


5、序列化完成之后,发送分两种情况:

一种是立即发送:

如果是可靠的 RPC,会立即调用函数 SendBunch。可能立即发送出去,可能放入缓存 SendBuffer 中。

可以理解为第一时间放入发送队列中。优先级是非常高的。

一种是放入队列:

如果是不可靠的 RPC,会暂时放入 RPC 缓冲区中,当调用函数 ReplicateProperties 去复制属性的时候,如果 RPC 缓冲区中有数据,会加入到 Bunch 结尾。

不可靠的 RPC 函数是随着属性的复制一起复制过去的,先解析属性,再调用函数。

不知是否还记得 Bunch 的数据信息,序列化是有顺序的,就像这样:

 *		|----------------|
* | NetGUID ObjRef |
* |----------------|
* | |
*
| Properties... |
* | |
*
| RPCs... |
* | |
* |----------------|
* | </End Tag> |
* |----------------|

当我们修改属性数据,再执行 RPC 函数(不可靠)的时候,属性的修改已经接收到了。

以上都是服务器的多播调用。

其它 RPC 网络调用

基本流程与上面的流程一样,不同的是服务器的多播调用是遍历所有的客户端连接,而这里只是发给当前 Actor 的网络连接。

RPC 调用堆栈

UE4 网络同步原理三 RPC

RPC 接收

当我们接收属性时,可以根据解析出来的字段信息判断是否是结构体属性或者 PRC 函数。调用函数 ReceivedRPC 去接收一个 RPC ,需要去解析调用对象、函数名称、函数参数。

  • 调用对象就是我们的对象复制器了,RPC 函数复制依附在调用对象的复制器上。

  • 字段信息中有函数名称,直接去对象的 UClass 中找到对应的 UFunction。

  • 会根据函数标志位判断是否是 RPC 函数,以及是否可以在当前端执行,当前端没有执行权限的 RPC 函数不会执行。

  • 反序列化参数信息

  • 有调用对象、调用函数、函数参数,就可以直接调用处理了。

调用 RPC 函数之前,如果携带验证(WithValidation)的函数,会先调用验证函数,一般是验证一些参数是否合理,比如是否越界,是否为空,验证不通过,不会执行 RPC 函数。

如果设置了延迟未映射的 RPC 标志位 CVarDelayUnmappedRPCs(引擎默认未启用),当该 RPC 包含有未映射对象引用时,会将该 RPC 缓存起来,当需要更新未映射对象 UpdateUnmappedObjects 的时候,会去遍历 RPC 缓存,查看是否有可以执行的 RPC,当未映射的对象参数全部接收以后,才会执行 RPC 函数。

UpdateUnmappedObjects

UNetDriver 会记录一些 FNetworkGUID 到 TSet< FObjectReplicator* > 的映射列表 GuidToReplicatorMap,存储着有哪些对象(FObjectReplicator)引用着当前对象(FNetworkGUID)。

UNetDriver 会维护一些未映射的对象复制器列表 UnmappedReplicators(TSet< FObjectReplicator* >)。当对象的子对象引用属性或者 RPC 的对象引用参数尚未复制到客户端时(通过复制过来的 NetGUID 找不到对应的对象),会把当前的对象复制器加入列表中。

UNetDriver 调用函数 TickFlush 的时候,会查看最近导入的网络 NetGUID,在映射中 GuidToReplicatorMap 查找是否有引用它们的复制器,并且如果有未映射引用的对象复制器,会调用函数 UpdateUnmappedObjects 更新未映射引用的子对象。

分两种情况:

一种是 Actor 对象。对象维护者一个绝对属性偏移到属性的 GUID 引用的映射 GuidReferencesMap。需要去查找所有未映射的子对象引用,是否可以通过 NetGUID 找出对象,可以找到后需要重新去解析对象(相当于对象指针赋值),如果有属性通知,需要添加到属性通知列表中。处理完所有引用映射后,会更新一次引用映射(UpdateGuidToReplicatorMap),同时调用一遍属性通知函数。(当然,涉及到数据的接收,函数 PreNetReceive (接收数据前调用)和 PostNetReceive (接收数据后调用)也会调用的)

一种是 RPC。如果没有挂起(正在等待完全加载)的 Guid,则强制执行(就算为 Null)。RPC 函数不会死等未映射的对象引用参数。一般情况下,RPC 函数的 Bunch 数据和引用对象参数的 Bunch 数据不会超过一帧,大部分在当前帧接收到,有可能顺序不一致。

误区:RPC 参数是对象的时候,客户端解析出的对象参数,要么全部正常,要么全部为 Null。其实是不对的,最简单的例子,如果这个 Actor 对象只在某些客户端中存在,或者只复制到某些客户端上,那么就只在这些客户端中解析正常,其它客户端中解析为 Null。(亲测经历)

RPC 接收堆栈

注意事项

官方文档

1、它们必须从 Actor 上调用。

2、Actor 必须被复制。

3、如果 RPC 是从服务器调用并在客户端上执行,则只有实际拥有这个 Actor 的客户端才会执行函数。

4、如果 RPC 是从客户端调用并在服务器上执行,客户端就必须拥有调用 RPC 的 Actor。

5、多播 RPC 则是个例外:

  • 如果它们是从服务器调用,服务器将在本地和所有已连接的客户端上执行它们。

  • 如果它们是从客户端调用,则只在本地而非服务器上执行。

  • 现在,有了一个简单的多播事件限制机制:在特定 Actor 的网络更新期内,多播函数将不会复制两次以上。按长期计划,引擎会对此进行改善,同时更好的支持跨通道流量管理与限制。


总结

1、服务器会每帧都去调用函数 ServerReplicateActors,找出与每个客户端连接有相关性的需要复制的 Actor,遍历所有需要复制的属性,与上次发送的备份数据进行比较,把修改过的属性复制到客户端。

2、Actor 的复制包括 Actor 属性的复制、所有子组件的复制、所有对象的 RPC 的复制(包括子组件的 RPC)。每个 Actor 通道都涉及到多个对象(当前对象和所有的子组件)的复制,每个对象都有一个对象复制器,管理着所有的可复制的属性。

3、属性的复制都是增量复制,所有在服务器中已修改的属性都会复制到客户端中。未修改的属性不会复制。

4、Bunch 携带两类数据,属性复制和 RPC 数据。同一个 Actor 的数据发送顺序是:可靠的 RPC 数据 -> 属性数据 -> 不可靠的 RPC 数据。(可靠的 RPC 是单独一个 Bunch,后两个是同一个 Bunch)

5、上一篇文章知道可靠的数据在同一个 Actor 通道是有序的,不同的 Actor 通道数据顺序是不确定的。不可靠的数据丢包后引擎传输协议底层不会处理。

那么这篇文章中对 Actor 的属性复制而言,当属性复制发生丢包后,下次复制会合并之前的已修改需要发送的属性。Actor 在上层自己管理属性复制,是最终可靠的。

6、对象引用(指针)作为属性或者 RPC 参数时,是通过复制 NetGuid 来实现的,在远端找到同一个对象。对象的属性复制依赖于对象所属 Actor 的复制,与当前对象和 RPC 的复制无关。

7、客户端接收完所有数据(属性数据和不可靠的 RPC)后,才会调用已修改的属性通知函数,如果是第一次复制,会调用函数 PostNetInit,执行 BeginPlay 逻辑。(正常情况下,RPC 会优先于属性通知函数前执行)

8、结构体属性默认是可复制的,不复制的属性需要标记 NotReplicated。

9、结构体可以自定义序列化,并不是所有的可复制属性都是有效的,无效的属性可以不复制,复制的属性可以进行压缩,减少流量。

10、当结构体内的属性包含动态数组时,可以继承效率高的 Fast Array,此时结构体需要方法 NetDeltaSerialize,用于动态数组属性的复制。其它属性依赖于正常的属性复制。

11、结构体的方法 NetSerialize 和 NetDeltaSerialize 是可以共存的,调用时机不一样,NetSerialize 是用于 RPC 参数进行全量复制调用的,而 NetDeltaSerialize 是用于动态数组属性的增量复制,是基于 Actor 的复制进行的。

网络提示

官方总结

  • 尽可能少用 RPC 或复制蓝图函数。在合适情况下改用 RepNotify。

  • 组播函数会导致会话中各连接客户端的额外网络流量,需尤其少用。

  • 若能保证非复制函数仅在服务器上执行,则服务器 RPC 中无需包含纯服务器逻辑。

  • 将可靠 RPC 绑定到玩家输入时需谨慎。玩家可能会快速反复点击按钮,导致可靠 RPC 队列溢出。应采取措施限制玩家激活此项的频率。

  • 若游戏频繁调用 RPC 或复制函数,如 tick 时,则应将其设为不可靠。

  • 部分函数可重复使用。调用其响应游戏逻辑,然后调用其响应 RepNotify,确保客户端和服务器拥有并列执行即可。

  • 检查 Actor 的网络角色可查看其是否为 ROLE_Authority。此方法适用于过滤函数中的执行,该函数同时在服务器和客户端上激活。

  • 使用 C++ 中的 IsLocallyControlled 函数或蓝图中的 Is Locally Controlled 函数,可检查 Pawn 是否受本地控制。基于执行是否与拥有客户端相关来过滤函数时,此方法十分拥有。

  • 构造期间 Pawn 可能未被指定控制器,因此避免在构造函数脚本中使用 IsLocallyControlled。

最后

这篇文章详细讲解了下 UE4 同步相关的细节,涉及到了很多的源码,更多的是我对源码的理解,以及一些个人思考。

虽然网上已经有一些写的很好的 UE4 同步相关的文章,但是具体细节还是需要自己去研究源码,顺便记录下过程,写一些心得体会。如果有时间,最好的方式还是阅读源码。

当然,重点还是想写篇关于 UReplicationGraph 的文章,所以需要先了解标准版的复制流程,顺便写了这篇文章。

内容篇幅有点长,如果发现有错误或者想交流学习,可以联系我。