本文主要剖析 UE5 网络中是如何进行属性同步和RPC的。
同步 Actor
要进行属性同步,首先就要先同步 Actor,但更要知道哪些 Actor 需要网络同步。
哪些 Actor 需要网络同步
Actor 需要设置 bReplicates
为 true,才会进行同步,
以 Spawn Pawn 为例,玩家登录之后会由 GameMode
创建 Pawn 实例。
1 | AActor* UWorld::SpawnActor( UClass* Class, FVector const* Location, FRotator const* Rotation, const FActorSpawnParameters& SpawnParameters ) |
若 bReplicates
为 true,则 RemoteRole 为 ROLE_SimulatedProxy,表示是远端为模拟代理。
1 | void AActor::PostInitProperties() |
将需要同步的 Actor 加入到 NetDriver中的一个集合里, 至此就找到了要网络同步的 Actor,需要注意一点是 Replicate
是支持动态开关的。
1 | void UNetDriver::AddNetworkActor(AActor* Actor) |
当前帧要同步哪些 Actor
找出了所有要网络同步的 Actor 后,就需要确认当前帧要同步哪些 Actor,毕竟不可能每帧都同步所有 Actor,带宽和计算成本都接受不了。
1 | void UNetDriver::TickFlush(float DeltaSeconds) |
经过代码裁剪,得出以下核心代码。
1 | int32 UNetDriver::ServerReplicateActors(float DeltaSeconds) |
ServerReplicateActors_PrepConnections
是用于计算此处需要给几个客户端同步,通常用于 ListenServer
,因为玩家的机器通常性能不会太好,而 Dedicated Server
当然是选择给所有客户端全部同步,因此此处逻辑不重要。
ServerReplicateActors_BuildConsiderList
看名字就能猜到,是计算哪些 Actor 可以被纳入考虑同步名单,主要是根据检查 Actor 的一些属性,比如该 Actor所属 NetDriver 和当前 NetDriver 是否一致,是否即将被删除。
有了考虑名单,就要根据优先级来排序 Actor, ServerReplicateActors_PrioritizeActors
就是来做这一部分工作的,其中会调用 Actor::IsNetRelevantFor
和 AActor::GetNetPriority
。
Actor::IsNetRelevantFor
是检查该 Actor 是否和当前 观察者 是否相关的,比如 NetCullDistanceSquared
这个参数就是在此刻用上的,检查和观察者的距离。
AActor::GetNetPriority
是获取 Actor 网络优先级,这部分逻辑比较有趣,所以单独拉出来看看。
1 | float AActor::GetNetPriority(const FVector& ViewPos, const FVector& ViewDir, AActor* Viewer, AActor* ViewTarget, UActorChannel* InChannel, float Time, bool bLowBandwidth) |
默认会根据 Actor 处于观察者的位置来计算优先级,如果 DotProduct < 0 则是背面,根据距离来调整优先级,若在正面,且视线相近则放大。
前面几篇提到过,Actor 是基于 ActorChannel 同步的,服务端需要通知客户端创建一个 ActorChannel,然后专门为该 Actor 进行同步。
首次同步,会为该 Actor 在本地创建 ActorChannel。
1 | int32 UNetDriver::ServerReplicateActors_ProcessPrioritizedActorsRange( UNetConnection* Connection, const TArray<FNetViewer>& ConnectionViewers, FActorPriority** PriorityActors, const TInterval<int32>& ActorsIndexRange, int32& OutUpdated, bool bIgnoreSaturation ) |
SetChannelActor
是属性同步和RPC的重点,但此处先跳过,后面会回来。但至少现在,已经找出当前帧要同步的 Actor,并为它创建了本地 ActorChannel。
序列化 Actor
同步一个东西通常都是用序列化的方式进行,UE5 也不例外,调用 Channel->ReplicateActor()
,进而使用 PackageMapClient 来序列化 Actor。
1 | int64 UActorChannel::ReplicateActor() |
PackageMapClient
在网络剖析的前面几篇提到过,每个连接有一个,就是专门用来序列化 Actor 的。
在深入序列化 Actor 之前,需要先了解 NetGUID
,这是用于表示某个 Object 的,无论在客户端还是服务端都是相同的,都能够指向同一个对象。
简单看一下 NetGUID
分配方式,根据是否为动态对象,划分出两个数组,每次分配都是递增。
1 | FNetworkGUID FNetGUIDCache::AssignNewNetGUID_Server( UObject* Object ) |
1 | static FNetworkGUID CreateFromIndex(uint64 NetIndex, bool bIsStatic) |
序列化 Actor 是一个递归的过程,为了方便后续的理解,这里简单阐述一下序列化的过程。
比如我们要序列化一个已经 Spawn 的 Actor,最直观的思路就是序列化当前 Actor 的一些属性,比如位置,旋转,速度,但这样实际上还不够,因为对方还不知道这个 Actor 是基于什么东西构造出来的,应该还要序列化出它的 CDO 类,CDO 可以理解为这个 Actor 的原型,根据这个原型 Archetype 实例化出这个 Actor,这个原型要么是 C++文件,要么是蓝图文件,所以是一定有路径的,因此要想序列化 Actor,需要先把它的原型给序列化好,不然就找不到它的原型无法构造它出来,在代码结构中称之为 ObjOuter。
因此序列化 Actor,会先打入 Actor 的 GUID,然后打入 Actor→Outer 的 GUID,发现 Outer 对端也没有收到过,这时就会打入 Outer 的路径,最后才是当前 Actor 的路径。
从发的角度可能很难理解,但是从客户端接收的角度就会好理解些,先是收到 Actor 的 GUID,暂存下来,然后递归函数继续收到 Actor 的 Outer 的 GUID 也暂存下来,继续递归发现没有新的 GUID 了,返回,开始读取 Outer 的路径,路径读完,返回递归,最后读取 Actor 的其他信息。
总之先是知道儿子的名字,然后查一下父亲的名字和地址,构造完父亲后,此时数据流中就只剩下儿子的地址,就可以构造出儿子。
简单结构如下(省略其他属性):
1 | Character GUID | BP_Characer GUID | BP_Character Path | Character Path | Character localtion ... |
UActorChannel::ReplicateActor
调用 UPackageMapClient::SerializeNewActor
开始序列化一个 Actor。
1 | bool UPackageMapClient::SerializeNewActor(FArchive& Ar, class UActorChannel *Channel, class AActor*& Actor) |
InternalWriteObject
会写入当前 Actor 信息,但是此时由于还未处于 导出 NetGUID 模式下,所以只会简单写入 GUID。
1 |
|
!NetGUID.IsValid()
说明已经写完了,没有更外层的对象需要序列化, IsExportingNetGUIDBunch
为 true 时才会一层层导出 Actor,该变量在 ExportNetGUID
中被设置。
1 | void UPackageMapClient::InternalWriteObject(FArchive & Ar, FNetworkGUID NetGUID, UObject* Object, FString ObjectPathName, UObject* ObjectOuter) |
最后是导出的 NetGUID 会被放入到 ExportBunches
,发送 Bunch 时,如果这里有值,则会将它放到 Bunch 的最前面发送出去。
到这里 Actor 同步的主要流程就都清楚了,后续就是补充上面未提到的一些东西。在首次序列化 Actor 时 允许重写 OnSerializeNewActor 来追加你想传递的信息,比如 PlayerController 就追加了 NetPlayerIndex
,当首次同步 Actor 时,可以通过 OnActorChannelOpen
将其读出。
1 | /** |
读取同步 Actor 的 Bunch逻辑在 void UActorChannel::ProcessBunch( FInBunch & Bunch )
此处就不再重复了,都是同样的几个函数,根据 Ar 读取写入模式来区分逻辑。
属性同步
属性同步的前提是要感知属性的变化,通常比较麻烦的做法就是每次修改完某个属性,就手动置脏,这种方式麻烦,但是性能高,因此 UE4.25 也支持了这个功能,叫做 push model。还有一种常见做法就是设置回调,每次修改属性时触发修改回调,来感知该属性的变化,通常在 lua
C#
这类语言中比较好实现。UE5的框架采用了一种更特殊的方式,即直接对比前后两次的内存。
要想实现对比前后两次的内存,首先就需要找到什么字段需要同步,以及需要同步的字段所在Actor中的内存地址。
找出需要同步的属性
FRepLayout
就是来记录 Replicator 属性布局的,从它的函数声明中就可以看出,它可以根据 Class、Struct、Function 中构造出来,Function 就是后面要提到的 RPC。
1 | /** Creates a new FRepLayout for the given class. */ |
1 | TSharedPtr<FRepLayout> FRepLayout::CreateFromClass( |
InitFromClass
会调用 UClass::SetUpRuntimeReplicationData
来收集需要同步的字段,这个函数会在蓝图创建或每次编译时执行,这也就是蓝图实现同步变量的原理。
通过遍历该类的所有字段,找出需要同步的字段。
1 | void UClass::SetUpRuntimeReplicationData() |
这里找出所有 RPC函数。
1 | for(TFieldIterator<UField> It(this,EFieldIteratorFlags::ExcludeSuper); It; ++It) |
蓝图也就是 非 CLASS_Native
则需要对属性进行一次稳定排序,保证之后的内存布局顺序是一致的。
1 | const bool bIsNativeClass = HasAnyClassFlags(CLASS_Native); |
对静态数组的处理则是每个槽位都先占位,并将存在 ClassReps 里。
1 | ClassReps.Reserve(ClassReps.Num() + NetProperties.Num()); |
ClassReps 存的内容很简单,一个是属性的指针,另一个是索引。
1 | /** List of replication records */ |
现在已经找出了所有需要同步的字段,以及 RPC 函数,但根据一开始的思路,还需要一段内存来存储上一次刷新的属性,这样才能做内存比对,知道哪些属性有变更。
计算每个属性在 ShadowBuffer 的位置
ShadowBuffer 就是一段用来存储上一次刷新时的属性的一段内存,每个 Actor 都有一个,既然知道要用它来存储同步属性,那么首先要计算属性应该被放到 ShadowBuffer 的哪一处,也就是内存偏移,这就是 Cmd 的作用。
Cmd 分为 FRepParentCmd
和FRepLayoutCmd
,每个 RepParentCmd 包含一个或多个 RepLayoutCmd,之所以需要这样,是因为需要同步的属性有可能是个 Struct
或者是 Array
,需要更确切的知道每个槽位的内存偏移,如果同步的都是 int 这种平坦的内存,那自然就不需要多弄一层 Cmd。
FRepParentCmd
的结构如下,其中 CmdStart
和 CmdEnd
指的是这个 ParentCmd 包含的 LayoutCmd 的左右边界。
1 | class FRepParentCmd |
1 | class FRepLayoutCmd |
此处就是根据 ClassReps
构建 ParentCmd 和 LayoutCmd。
1 | void FRepLayout::InitFromClass( |
在继续之前,需要知道支持属性同步的类型是不包括 TMap
和 TSet
的。对普通类型的处理非常简单,就是一个 ParentCmd 对应一个 LayoutCmd。
1 | // Add actual property |
对 Array 属性的特殊处理,可以看出 Array 的 LayoutCmd 最后一个 Cmd 为 ReturnCmd。
1 | const uint32 ArrayChecksum = AddArrayCmd(SharedParams, StackParams); |
对 Struct 的特殊处理,实现了NetDeltaSerialize函数的 Struct,不会生成 LayoutCmd,原因如注释所示。
*These structs will not have Child Rep Commands, but they will still have Parent Commands. This is because we generally don't care about their Memory Layout, but we need to be able to initialize them properly.*
这是提供了一个方法来用用户自定义如何进行计算增量逻辑,若无这个函数,则会默认用 UStructProperty::NetDeltaSerializeItem
,最经典的使用是 FastArray,因为普通的 Array 属性同步时,若增删了其中一个值,则需要发送该 Array 剩下的所有值。
Struct 若实现了 NetSerialize 则表明是自己决定如何序列化的,只会生成一个 LayoutCmd。
*These structs will have a single Child Rep Command for the FStructProperty. Similar to NetDeltaSerialize, we don't really care about the memory layout of NetSerialize structs, but we still need to know where they live so we can diff them, etc.
*
1 | UScriptStruct* Struct = StructProp->Struct; |
简单的图示如下:
1 | +------------------+------------------+ |
1 | +------------------+------------------+ |
此处设置同步条件,并赋值给 ParentCmd,比如说是不是初始化同步,或者是只同步给 Owner 之类的条件。
1 | // Initialize lifetime props |
建立Handle到Cmd数组的映射,主要因为动态Array需要特殊处理,存到 TArray<FHandleToCmdIndex> BaseHandleToCmdIndex;
1 | if (!ServerConnection || EnumHasAnyFlags(CreateFlags, ECreateRepLayoutFlags::MaySendProperties)) |
最后计算 ShadowOffset 也就是在 ShadowBuffer 的偏移。
1 | BuildShadowOffsets<ERepBuildType::Class>(InObjectClass, Parents, Cmds, ShadowDataBufferSize); |
按内存对齐,减小内存占用。
1 | template<ERepBuildType ShadowType> |
还会对 bool 进行特殊处理,每个 bool 值只占 1bit,具体可以查阅 BuildShadowOffsets_r
,就不一一列出了。至此解决了属性应该如何存放到 ShadowBuffer 的问题。
FRepLayout
会被存放到 NetDriver
中,而不是只放到 NetConnection
中,因为一个 Actor 可能同步给多个 Client,没必要比较多次。
理解同步过程中所需的数据结构
在继续往下学习属性同步之前,需要先理解以下一些数据结构。
- FObjectReplicator
可以理解为对象的同步器,内部存有 FRepLayout
、FRepState
、FReplicationChangelistMgr
,将这些功能串联起来。
- FRepLayout
描述同步属性的信息和内存布局,NetDriver 中存放,可给多条连接共享。
- FRepState
表示该对象在一条连接下的发送接收状态,因为每条连接的同步速率可能是不同的,所以需要单独记录。
- FReplicationChangelistMgr
里面有个 RepChangelistState
是用来做属性对比的,里面还会记录历史变更。
- FRepChangedPropertyTracker
因为属性同步是有条件的,比如同步条件是仅在初始化时同步,那么之后就不需要同步该属性了,这个类就是用来跟踪哪些属性的同步条件发生了变更的。
这些数据结构会在 UActorChannel::SetChannelActor
中构造,前面为 Actor 创建 ActorChannel 时提到过,此处重点在于 创建了 FObjectReplicator
。
1 | void UActorChannel::SetChannelActor(AActor* InActor, ESetChannelActorFlags Flags) |
1 | TSharedPtr<FObjectReplicator> UNetConnection::CreateReplicatorForNewActorChannel(UObject* Object) |
随后创建了 FRepState
,用于记录该连接下的属性发送接收状态信息。
在 FObjectReplicator
StartReplicating 时,先构造出 FReplicationChangelistMgr
然后立刻构造出 FRepChangelistState
它就是用来做属性比对的,自然 ShadowBuffer 就是它构造的。
1 | void FObjectReplicator::StartReplicating(class UActorChannel * InActorChannel) |
1 | FRepChangelistState::FRepChangelistState( |
构造出 ShadowBuffer,里面就是需要同步的属性的内存,
比较属性
比较属性的调用路径如下:
1 | bool FObjectReplicator::ReplicateProperties(FOutBunch& Bunch, FReplicationFlags RepFlags) |
1 | bool FObjectReplicator::ReplicateProperties_r( FOutBunch & Bunch, FReplicationFlags RepFlags, FNetBitWriter& Writer) |
1 | ERepLayoutResult FRepLayout::UpdateChangelistMgr( |
在比较属性之前,通过循环队列开辟一个新的 changeHistory,记录这次的属性变更。
1 | ERepLayoutResult FRepLayout::CompareProperties( |
若 变更记录满了,则进行合并。
1 | if ((RepChangelistState->HistoryEnd - RepChangelistState->HistoryStart) == FRepChangelistState::MAX_CHANGE_HISTORY) |
属性比较最终会到这,若发生变更,则将 Handle 加入到 Changed 中。
1 | static uint16 CompareProperties_r( |
这里需要特别注意对动态数组的处理,因为动态数组你不知道具体是有多少个,你只能写入有多少个值变更了,然后写入具体变更的 Handle,计算方式也很简单,index * 子元素数量 + 改变的子元素handle
,若动态数组存放的是一个 int,则子元素数量为1,可简化为 index + 1。
1 | StackParams.Changed.Add(Handle); |
也有可能数组长度减小,但数组原有的那部分完全一致,就不需要变更。
1 | else if (ArrayNum != ShadowArrayNum) |
但数组若出现中间插入或删除值,则需要把后面一连串的数据一起发送,非常浪费,这也是为什么后面引入了 FastArray。
简单看下属性根据类型进行比较。
1 | static FORCEINLINE bool PropertiesAreIdenticalNative( |
比较属性只需要走一次就可以了,后续同一帧内,不同的连接可以直接复用,所以 UpdateChangelistMgr
可以直接返回结果。
1 | ERepLayoutResult FRepLayout::UpdateChangelistMgr() |
属性比较完成后,还会将新属性更新到 ShadowBuffer 中。
发送变更属性
将所有变更记录,合并到当前连接所属的变更记录中,也就是 RepState 中,还是那句话,每条连接的同步进度是不同的,所以要为每条连接单独弄个 History 循环队列。
1 | bool FObjectReplicator::ReplicateProperties_r( FOutBunch & Bunch, FReplicationFlags RepFlags, FNetBitWriter& Writer) |
1 | bool FRepLayout::ReplicateProperties( |
UpdateChangelistHistory
指的是更新当前 RepState 的 历史记录,就是更新当前连接的历史项,当对方已经确认收到之后,就可以去掉这条历史记录了。
PreOpenAckHistory
指的是在对方打开这个 ActorChannel 的过程,产生的变更,需要临时记下来,随着对端创建该 ActorChannel 也要把这些属性变更下发过去。
1 | if (Changed.Num() > 0 || RepState->NumNaks > 0 || bFlushPreOpenAckHistory) |
一个小优化,共享序列化好的数据,避免反复序列化。
1 | if (!OwningChannel->Connection->IsInternalAck() && (GNetSharedSerializedData != 0)) |
最终将属性发送出去。
1 | else if (Changed.Num() > 0) |
WriteContentBlockPayload
主要用于区分当前数据来自于 Actor 还是 ActorComponent。
1 | bool FObjectReplicator::ReplicateProperties_r( FOutBunch & Bunch, FReplicationFlags RepFlags, FNetBitWriter& Writer) |
这个 Block 会追加到 真正的属性数据之前,所以才叫 BlockHeader。
1 | int32 UActorChannel::WriteContentBlockPayload( UObject* Obj, FNetBitWriter &Bunch, const bool bHasRepLayout, FNetBitWriter& Payload ) |
SendBunch
之后,会返回 PacketRange,将其和发出的历史记录关联起来,这样丢了什么数据,马上就能查出来。
1 | int64 UActorChannel::ReplicateActor() |
属性同步丢包
收到 Nak 后,会通知到 ActorChannel,随后通知到该 Packet 所携带的 Actor 的 FObjectReplicator 中。
1 | TMap< UObject*, TSharedRef< FObjectReplicator > > ReplicationMap; |
此处会将该属性变更的历史记录的 Resend 标记位 置为 true,等待后续重传(void FRepLayout::UpdateChangelistHistory
),属性同步丢了就丢了,反正保证每次同步的是最新的值就行。
1 | void FObjectReplicator::ReceivedNak( int32 NakPacketId ) |
同步指针
1 | UPROPERTY(Replicated) |
同步指针,有可能会出现这个对象还未同步给客户端,此时会先将其置空。一种很直观的思路是 记录这个属性在该类的内存偏移,下次当该 Actor 同步过来之后,再将它和这个 Actor 的 GUID 绑定。
收到 MyActorReference
属性后,需要将其反序列化,此时找不到该对象,则将其添加到 跟踪列表中。
1 | bool UPackageMapClient::SerializeObject( FArchive& Ar, UClass* Class, UObject*& Object, FNetworkGUID *OutNetGUID) |
在 NetDriver::TickFlush
会调用 FObjectReplicator::UpdateUnmappedObjects
最终调用UpdateUnmappedObjects_r
来更新 unmapped 的对象。
1 | void FRepLayout::UpdateUnmappedObjects_r( |
RPC
以 ClientSetHUD
为例:
1 | UFUNCTION(BlueprintCallable, Category="HUD", Reliable, Client) |
接收RPC 堆栈:
ClientSetHUD
其实和属性一样,都有个索引,至于参数也是用的 FProperty
,FRepLayout 会为 RPC 函数的参数创建一个单独的内存布局。