前言
基于 AActor 的属性同步框架,可以通过 UActorChannel 通道来在两端收发 Bunch 进而能够在客户端和服务器的两个 AActor 间传递数据,但注意仅会由服务器向客户端进行 AActor 的属性同步。
RPC 调用也是基于 AActor 属性同步框架实现的,但和属性同步不同的是,客户端和服务器都可以发起RPC调用。
我们知道 UE C++ 具有反射机制,我们可由一个 UFunction 来表示一个类的成员函数,而我们再由一个 UObject* 作为 this 对象,就可以在运行时动态的调用这个对象的成员函数了。
基于这个想法,我们如果可以在 Bunch 中,标识出作为 this 的 UObject* ,以及要调用的 UFunction* 成员函数对象,再向 Bunch 中编码进调用实参,那么就可以将 Bunch 发送给对端来让对端动态的调用指定对象的成员函数。这就是 RPC 函数的实现机制。
如何编码 RPC Bunch
为了和 AActor 属性同步的 Bunch 进行区分,我们将进行 RPC 调用构建的 Bunch 称为 RPC Bunch 。在 RPC Bunch 中需要编码进如下信息才能能够远程动态调用一个函数。
- 调用对象
每个 AActor 和其内含参与同步的组件对象,都对应一个 FObjectReplicator 来处理它们内含属性的同步,且 AActor 和其内含的组件对象的属性会编码到一个 Bunch 中,并在 Bunch 中划分属性块,来区分每个属性属于哪个对象
在发送端只要利用对象的 FObjectReplicator 来将 RPC 调用需要的信息,写入到 Bunch 中相应的属性块中,就能标识调用对象了。在接收端可解析到每个属性块属于哪个对象,进而获取到对应的 FObjectReplicator ,并调用 FObjectReplicator::ReceivedBunch() 处理对象的属性块解析。而在发现需要响应 RPC 调用时,只需由 FObjectReplicator::GetObject() 来获取调用对象,它通常就是一个 AActor 对象,或其内含的参与同步的组件对象
如上,由于目前仅由 AActor 和其内含参与同步的 UActorComponent 组件,会在 Bunch 中写入属性块,因此目前仅可以在 AActor 和 UActorComponent 类中声明 RPC 函数 - 调用函数的 UFunction
在发送端,由函数名在调用对象的 UClass 中可以获取到要调用函数的 UFunction
注意 UFunction 是继承自 UObject 的路径稳定对象,因此我们仅需一个 NetGuid 即可以传送它,而在接收端可以立即求解到 - 调用实参
如果要编码一个动态函数调用所属的数据,我们就需要了解这个函数形参的声明类型和顺序
UFunction 是继承 UStruct ,并由其 UStruct->Children 链表中,记录此函数的参数,返回值,局部变量的 UProperty 对象
我们知道对于每个进行同步的 UE类 或 UE结构体,都存在一个 FRepLayout 对象来记录这个类型的同步属性的结构信息,并处理同步属性的编解码。同样的,对于 UFunction 也有对应 FRepLayout 来处理其调用实参的编解码。
参见 FRepLayout::CreateFromFunction() 函数来创建一个 UFunction 对应的 FRepLayout
参见 FRepLayout::SendPropertiesForRPC() 函数来将 RPC 调用实参封装成一个 RPC Bunch ,和属性同步不同的是这里并没有和影子缓冲区对比的逻辑
可靠 和 非可靠 RPC 的差别
在 UFUNCTION() 中使用 Client , Server , NetMulticast 说明符声明 RPC 函数时
必须同时由 Reliable 或 Unreliable 来声明 RPC 函数的可靠性,由 Reliable 标识为可靠的 RPC 函数,由 Unreliable 标识为不可靠的 RPC 函数
调用可靠 PRC 函数时,会保证此 RPC 函数在对端一定执行成功,但调用不可靠的 RPC 函数时并不保证这一点。
实现上,调用可靠的 RPC 函数时,在发送端会将其 RPC Bunch 的 FOutBunch->bReliable 置为为 true 来以可靠 Bunch 发送。也就是说在发送端通道的 UChannel->OutRec 链表中会缓存这个 RPC Bunch ,当发生丢包时会重发此 RPC Bunch 来保证它一定发送成功
一些额外的细节
对于 Client 和 Server 声明的 RPC 函数,无论是否可靠,其 RPC Bunch 构造好后都是立即发送的。
对于 NetMulticast 声明的广播 RPC 函数,若可靠则 RPC Bunch 会立即发送,若不可靠则会随着下次此 RPC 函数所在对象向此链接进行属性同步时,才会一起发送。
对于不可靠的 RPC Bunch , 若链接的发送缓存已经饱和,则会丢弃它。
对于不可靠的 Multicast 广播 RPC 函数除外,因为它的 RPC Bunch 并不会立即发送,因此不会立即导致链接的发送缓存溢出。
当客户端由 net.DelayUnmappedRPCs 控制台变量,开启 RPC 函数的延迟调用时。
若客户端接收到的为可靠的 RPC Bunch ,且其内的对象引用实参无法求解到,则会延迟等到所有对象引用实参都求解成功后,才执行 RPC 函数。
若客户端接收到的为不可靠的 RPC Bunch ,则即使有对象引用实参无法求解到,也会立即调用。
对象引用的同步
很神奇,在 RPC 函数中可以声明 UObject* 类型的参数,也就是说我们可以通过网络来传递一个 UObject* 指针。需要明确的一点是,无论是在 AActor 属性同步时,还是在调用 RPC 函数时,我们传递的是这个 UObject* 指针的指向,而并不是这个 UObject* 指针指向对象的所有属性。也就是我们同步的是一个对象引用,而不是对象。
为了能完成对象引用的同步,如何能在两端唯一的标识一个对象引用的指向就成了关键。如果你对 UObject 系统了解的话,你会知道每个 UObject 在构造时都会指定一个 Outer ,来标识这个对象的父对象是哪个,且每个对象在其 Outer 对象下的名字必须是唯一的。基于这一点我们可以知道由对象的 Outer 链由上到下迭代,获取每个对象的名字,就可以得到这个对象的路径,而由这个对象路径就可在当前引擎中唯一的标识这个对象。
如上每个对象都具有唯一的对象路径,如果一个对象可以在客户端和服务器有相同的对象路径,我们就可以通过传递这个对象路径来在两端唯一的标识这对关联的对象。但遗憾的是虽然每个对象在当前运行时里的路径是唯一的,但在对端的运行时里和自己关联的那个对象的路径,和自己却并不一定相同。因此仅当一个对象的路径在网络上是稳定的时,这个指向这个对象的对象引用才可以同步成功。参见 UObject::IsNameStableForNetworking() 虚函数的实现,可以了解引擎中哪些对象的路径是在网络上稳定的。
对于一个引用,我们知道了只有它指向路径稳定对象时,它才能同步成功。但我们并不希望直接通过发送对象的路径,来同步这个对象引用,因为对象的路径实在是太长了。因此引擎实现了一套由 NetGuid 映射对象的机制,使得我们只需要传输一个 NetGuid 就可以标识对象引用的指向,而一个 NetGuid 本质上就是一个 uint32 而以。
实现上,在两端的一对链接中会有一对 UPackageMapClient 对象,来处理 NetGuid 的分配,注册,和管理。但注意仅服务器可以权威的分配 NetGuid ,然后将这个 NetGuid 和 对象路径发送给客户端,客户端接收到后会完成这个 NetGuid 的注册。但注意客户端完成 NetGuid 的注册后,并未真正求解到这个 NetGuid 对应的对象,而仅仅是记录了这个 NetGuid 对应对象的路径信息。而之后服务器再同步一个对象引用时,只需向客户端发送 NetGuid 即可,客户端则可由 UPackageMapClient 中获取到这个 NetGuid 对应对象的路径信息,再求解到对应的对象。
路径稳定的对象可能已创建,但也可能还未创建。常见未创建的情况包含如下两种。
如果你加载过 uasset 文件来获取对象,你可以知道你指定的文件路径,实际上就是对象路径。也就是说对于一个位于 uasset 文件中的对象,它也可能是路径稳定的。也就是说服务器同步来一个对象引用时,这个引用指向的对象可能还安静的位于客户端的硬盘中。在未开启异步加载的情况下,客户端通常会立即加载这个uasset 文件来求解到对应对象。但如果开启异步加载的话,就无法立即求解了。- 此外还有一种未创建的情况,对于 AActor 和 UActorComponent 它们会由服务器同步给客户端,来使客户端动态创建它们。如果当指向这些对象的 NetGuid 同步过来时,也可能无法立即求解到它。
当客户端接收到一个 NetGuid 时,如果其对应的对象还未创建,此时通过 NetGuid 对应的路径信息并不能立即求解到对象。对于 RPC 调用来说,这可能还好一点,大不了在接收端调用 RPC 函数时某个 UObject* 引用实参为 nullptr 而已。但对于 AActor 属性同步来说,由于服务器在同步并接收应答后就认为客户端和自己的属性保持一致了,在服务器该属性未发生变化的情况下,服务器之后就不再同步了。因此如果客户端没有立即求解到,就会导致其和服务器的属性变得不一致,但服务器并不知道。
为了解决这种问题,客户端会有一种延迟求解 NetGuid 的机制,来保证这个 NetGuid 对应的对象在创建后,客户端能主动再次求解之前的 NetGuid 。和如上两种无法立即求解的情况对应,延迟求解也有两种机制来实现,这里就不再详细说了,或者等有空再说吧。。。。额额
可靠 RPC 在客户端的延迟执行
如果你的一个 RPC 函数在客户端被执行时,其某些对象引用实参莫名的为 nullptr 则了解这个概念可能会帮助到你。
注意,服务器不会有 PRC 函数的延迟执行机制,永远立即执行。
而在客户端可由 net.DelayUnmappedRPCs 控制台变量,来控制是否开启 RPC 函数的延迟执行,但默认它是关闭的。
当客户端由 net.DelayUnmappedRPCs 控制台变量,开启了 RPC 函数的延迟执行。参见 ObjectReplicator::ReceivedRPC() 中,若发现要执行的为一个可靠的 RPC 函数,且其包含未求解成功的对象引用类型的调用实参时,此时不会立即调用此函数,而是会将此函数的调用信息添加到 FObjectReplicator->PendingLocalRPCs 中
而当有新的 NetGuid 求解成功后,会调用到 FObjectReplicator::UpdateUnmappedObjects() 函数,在此函数中会检测这些延迟调用的 RPC 函数中,是否所有对象引用都求解成功了,若是则再次调用 FObjectReplicator::ReceivedRPC() 来执行 RPC 函数
注意
- 服务器不会有 PRC 函数的延迟执行机制,永远立即执行
- 客户端的非可靠 RPC 函数也永远不会延迟执行,即使包含未求解成功的对象引用,也立即执行
RPC Bunch 的发送策略
在发送端,调用 UNetDriver::ProcessRemoteFunctionForChannel() 来构造 RPC Bunch ,并处理其发送逻辑时,可由参数指定一个 ERemoteFunctionSendPolicy 枚举来指定发送策略,决定如何向通道发送此 RPC Bunch
目前引擎中,默认的发送策略都为 ERemoteFunctionSendPolicy::Default ,其逻辑如下
- 对于 Client 和 Server 声明的 RPC 函数,无论是否可靠,其 RPC Bunch 构造好后,都会调用通道的 UChannel::SendBunch() 立即发送
- 对于 NetMulticast 声明的广播 RPC 函数,若可靠则 RPC Bunch 会立即发送,若不可靠则会随着下次此 RPC 函数所在对象向此链接进行属性同步时,才会一起发送
此外,在服务器使用同步图时,还可以在同步图的 UReplicationGraph->RPCSendPolicyMap 中对某些 UFunction 进行配置,使得他们的发送策略为 UNetDriver::ForceSend
优缺点
- 立即发送及时性高,但会占用更多的开销
- 对于服务器调用,客户端执行的 RPC 函数,若客户端还未创建对应的 AActor 。
立即发送会使得服务器立即同步一次属性,使客户端创建此 AActor 并执行 RPC 函数。
缓存发送则仅当 AActor 和该链接相关,并需要向这个链接同步属性时,才会使客户端创建此 AActor ,并执行 RPC 函数