UE4 FastArray

FastArray处理流程
FastArray消息架构图

提问

  1. 普通TArray和FastArray的增删改查有什么区别? (文中有FastArray详细讲解)

  2. 普通TArray有哪些缺点, FastArray优化了哪些点? (文末指出)

  3. 类似于HistoryChangedList, FastArray怎么实现的? (Retirement+HistoryList)

  4. ReceivingRepState, SendingRepState, FastArray又是怎么实现的? (存放在自身结构体FFastArraySerializer中)

  5. UnmapedGUID, FastArray怎么实现的? (存放在FFastArraySerializer.GuidReferencesMap中)

  6. FastArray第一次同步和谁对比, 是全量同步吗?(和Archetype对比, 根据Changelist同步)

关键变量

FFastArraySerializer

ItemMap

存储Item.ReplicationIDFastArray数组中的Index的映射关系.

1
2
/** Maps Element ReplicationID to Array Index.*/
TMap<int32, int32> ItemMap;

IDCounter

IDCounter初始化为0, 每次有Item初始化(新Item)时候自增, 该变量用于Item.ReplicationID初始化.

1
2
/** Counter used to assign IDs to new elements. */
int32 IDCounter;

FFastArraySerializerItem

ReplicationID

唯一标志Item的ID, 一旦赋值, 终生不变. 一般用做Key, 查询该Item.

1
2
UPROPERTY(NotReplicated)
int32 ReplicationID;

ReplicationKey

每次Item修改都会+1. 用于对比Item是否发生变化.

1
2
UPROPERTY(NotReplicated)
int32 ReplicationKey;

MostRecentArrayReplicationKey

只在客户端使用, 用于记录最近更新时候的FastArrayReplicationKey. 用于Replay时候, 剔除不正确的数据.

1
2
UPROPERTY(NotReplicated)
int32 MostRecentArrayReplicationKey;

FNetFastTArrayBaseState

存放在FSendingRepState.RecentCustomDeltaState中, 用于存储历史数据.

IDToCLMap

一个Map, 存储element's ReplicationIDReplicationKey的映射. 该数据时历史数据, 用于和当前数据进行对比, 进而只发送dirty数据.

1
2
/** Maps an element's Replication ID to Index. */
TMap<int32, int32> IDToCLMap;

ArrayReplicationKey

历史的ArrayReplicationKey数据. 如果ArrayReplicationKey没有发生变化, 则不需要重建.

1
int32 ArrayReplicationKey;

不需要重建, 则直接返回.

如果需要重建, 则直接创建新的, 并重新赋值. 相关函数:FFastArraySerializer.FastArrayDeltaSerialize_DeltaSerializeStructs

FFastArraySerializerHeader.BaseReplicationKey

赋值. 在序列化FastArray数据的时候, 将OldStateArrayReplicationKey写入DeltaHeaderBaseReplicationKey中.

在序列化DeltaHeader时, 将BaseReplicationKey序列化到Bunch中.

客户端在解析DeltaHeader时, 将BaseReplicationKey解析出来.

总结: BaseReplicationKeyOldStateArrayReplicationKey, 和DeltaHeader.ArrayReplicationKey联合起来表示当前比较的范围:\([BaseReplicationKey, ArrayReplicationKey]\). BaseReplicationKey是用来处理Replay的.

目前只有处理Replay时候用到了BaseReplicationKey.

DS处理FastArray

FastArray Layout

ParentCmd存储顶层数据结构, 在此处为FExampleFastArray成员变量, FRepLayoutCmd存储ParentCmd展开后的属性. 而Handle存储了数组对象展开后成员的FRepLayoutCmd索引值. 序列化时只序列化RelativeHandle, 客户端根据RelativeHandle就可以知道当前消息处理的是哪个成员.

下图中, Cmd可以根据RelativeHandle定位到FHandleToCmdIndex数组中的Item, FHandleToCmdIndex数组可以通过Item中的CmdIndex定位到对应的Cmd. FRepLayoutCmd.RelativeHandleFHandleToCmdIndex.CmdIndex是他们二者相互查找的方式.

image-20230901104719738

Parent结构:

Cmd结构:

RelativeHandle:

FastArray Layout的构建

在初始化Replayout(FRepLayout.InitFromClass),会创建属性的描述类型: FRepParentCmdFRepLayoutCmd.

针对IsCustomDelta类型,在遍历UClass.ClassReps时, 将其FRepParentCmd.Flags设置为IsStructProperty|IsCustomDelta|IsFastArray.

此时, FastArray的FRepParentCmd.Flags为

针对LifetimeProps, 还要附加ERepParentFlags::IsLifetime属性, 但是要排除ERepParentFlags::IsCustomDelta类型. 所以, FastArray的FRepParentCmd.Flags并不包含ERepParentFlags::IsLifetime, 这样后续对于IsCustomDelta类型, 不进行Compare.

特殊处理IsFastArray类型

特殊的, 针对IsFastArray类型, 要将其放入FRepLayout.LifetimeCustomPropertyState中. 一定是IsCustomDelta中的IsCustomDelta类型.

不进行Compare

FNetSerializeCB::UpdateChangelistMgrFRepLayout.ReplicateProperties执行时时会过滤掉IsCustomDelta类型, 因为 IsCustomDelta类型的FRepParentCmd.Flags不包含ERepParentFlags::IsLifetime, 不会进入比较函数, 进而比较结果ChangedList中不会包含CustomDeltaProperty, 所以CustomDeltaProperty不会在FRepLayout.ReplicateProperties中被序列化.

image-20230815155143872

预先处理CustomDeltaProperty ChangeList

保证提取一个可用的HistoryItem, 供这次修改使用. 从下图可以看出FRepLayout::PreSendCustomDeltaProperties主要是处理FastArray中的HistoryItemList, 如果没有可用的HistoryItem, 则将EndItem强制Reset, 用于填充此次比较的结果. 注意, 该行为是每帧最多执行一次, 即所有Connection共用一个HistoryList.

处理所有CustomDeltaProperty

遍历所有CustomDeltaProperty, 依次进行处理.

RecentCustomDeltaState:使用RecentCustomDeltaState作为OldState, 与新创建的NewState进行比较.

清理RetirementList

根据LastAckPacketID, 删除已经无用的RetirementItem.

自定义实现比较并获得ChangedElements

关键数据IDToCLMap

IDToCLMap: ReplicationID到数组中ReplicationKey的映射. 在数组发生变化时候, 进行重建, 并且根据Old和New的IDToCLMap比较, 得出删除的数据. 如果数组中Item只是位置发生了变化无任何影响. 

每个需要ReplicationUObject都有一个FObjectReplicator与之相对应. 每个Connection都有一份RetirementList. RetirementItem中就包括IDToCLMap.

NewState是每次比较时候, 新创建的:

等到数据比较完成之后, 将旧数据放入了FSendingRepState.Retirement中, , 新数据变成旧数据, 放入ReplicateCustomDeltaProperties.UsingCustomDeltaStates中.

填充NewState的IDToCLMap, 对比ReplicationKey, 填充changedElementList

在处理FastArray的属性同步时, 如果数组内容发生变化, 会重建IDToCLMap. 并对比ReplicationKey, 填充ChangedList

计算ChangedItems和DeletedItems

根据ReplicationID将新旧IDToCLMap进行对比, 计算已经删除Item. 注意, 这里只是根据ReplicationID进行比较, 并没有根据数组的位置比较. 而且是旧的IDToCLMap中存在某个ReplicationID,但是新的中没有, 就代表删除.

核心:计算ChangedList

构建Shadow

背景知识: C++可以通过对象地址+Offset唯一确定某个对象的成员变量, 取址后, 可以根据该地址直接对该成员进行操作. UE使用该特性, 对Property进行各种直接操作.

初始化FastArray对应的FLifetimeCustomDeltaProperty, 并存放在FRepLayout.LifetimeCustomPropertyState中. 其中关键变量为:FastArrayArrayReplicationKeyNameFastArrayItemReplicationIDNameOffset, 为后续查询做准备.

结构引用图
VS中内存分布

通过对象指针+成员偏移, 找到对应的成员地址, 在通过成员对象提取出其存储数据的起始地址. 这块地址为存放着连续Item的数组.

可以通过TScriptArray.Insert探究出其内存分布. 即所有Item存储在一块连续内存中.

Shadow变更流程: 首先将Shadow中数据调整成和当前ItemArray数组一致, 即shadow中Index位置存储的ReplicationID要和当前Item数组中Index位置的ReplicationID一致.

然后, 如果Shadow数组大小小于ObjectArrayNumber, 要扩充Shadow数组大小. 并且如果需要扩充shadow, 则需要将Shadow中扩充的部分填充好ReplicationID, 并全部设置为New.

image-20230904214916457

经过上述调整, 最终ShadowArray和ObjectArray大小一致, 并且Index位置所对应Item的ReplicationID都是一样的.

计算出这一帧和上一帧的ChangeList

构建完成Shadow, 就可以用它和ObjectArray进行比较了, 并且在比较的同时, 会将Shadow值更到最新. 而且会得出ChangeList.

合并历史需要Resend数据

如果ChangeList为空, 重发所有数据

这种情况发生在超高丢包率情况下, 64个ChangeList已经不够用了, 导致该帧比较结果+历史比较结果已经不能正确表示真是的修改值.

发送数据

根据收集到的ChangeList发数据, 并且结尾添加结束符:0.

DS填充消息

消息结构图

  1. Bunch=BunchHeader+BunchBody, BunchHeader唯一确定该Bunch处理的对象是哪个Actor.

  2. BunchBody=ContentBlockHeader+ContentBlockBody, ContentBlockHeader:唯一确定当前处理的是哪个UObject

  3. ContentBlockBody=FieldHeader+FieldBody, FieldHeader唯一标识当前要处理的是哪个Property.

  4. FieldBody=FastArrayHeader+FastArrayBody, FastArrayHeader有当前FastArray的信息, 比如修改的梳理, FastArrayReplicationID, 删除元素的ReplicationID列表等.

  5. FastArrayBody是由一个list组成, ListItem=ReplicationID+bDirty+Item详细内容.

填充DeltaHeader

遍历当前所有Item, 填充NewState.IDToCLMap(ReplicationID2ReplicationKey), 然后将NewStateOldState中的IDToCLMap进行比较, 统计DeletedIndices(已删除Item的ReplicationID), NumChanged(变化的Item), 记录BaseReplicationKey(OldState.ArrayReplicationKey)和ArrayReplicationKey(NewState.ArrayReplicationKey).

序列化Header信息

序列化需要发送的数据

数据格式:

添加和修改

默认流程是支持增加和修改的, 上述流程已经说的很详细了, 这里不再赘述.

删除

DS上构建删除元素(ReplicationIDList), 序列化到FastArrayHeader中, 发送到客户端.

逻辑上DS删除元素是直接删除的, 在TickFlush-SerializeDeltaCustomData期间, 会根据OldState(RecentCustomDelta)和NewState ReplicationID-ReplicationKey的比较结果, 得出DS上删除的元素的ReplicationID. 将ID序列化到Header中, 然后发送的客户端.

客户端解析收到的FastArrayHeader, 找到ReplicationID对应的Index, 然后删除之.

在客户端, 反序列化FastArrayHeader得到删除的ReplicationID, 根据ReplicationID找到对应的Index,

删除前回调:

客户端真正触发删除操作:

客户端处理FastArray

// 客户端在RecvBunch时,调用NetDeltaSerialize调用堆栈
--UIpNetDriver.TickDispatch()
|--UNetConnection.ReceivedRawPacket()
| |--UNetConnection.ReceivedPacket()
| | |--UChannel.ReceivedRawBunch
| | | |--UChannel.ReceivedNextBunch
| | | | |--UChannel.ReceivedSequencedBunch
| | | | | |--UActorChannel.ReceivedBunch
| | | | | | |--UActorChannel.ProcessBunch
| | | | | | | |--FObjectReplicator.ReceivedBunch
| | | | | | | | |--FNetSerializeCB.ReceiveCustomDeltaProperty
| | | | | | | | | |--FRepLayout.ReceiveCustomDeltaProperty
| | | | | | | | | | |--// 执行NetDeltaSerialize函数, 注意, 这里是被动调的, 无感知的. 即: Recv时无感知调用NetDeltaSerialize函数.
| | | | | | | | | | |--CppStructOps->NetDeltaSerialize(Params, Params.Data)
| | | | | | | | | | | |--FFastArraySerializer.FastArrayDeltaSerialize
| | | | | | | | | | | | |--FFastArraySerializer.FastArrayDeltaSerialize_DeltaSerializeStructs
| | | | | | | | | | | | | |--FFastArraySerializer.TFastArraySerializeHelper<Type, SerializerType>.ReadDeltaHeader
| | | | | | | | | | | | | |--Parms.NetSerializeCB->NetDeltaSerializeForFastArray(DeltaSerializeParams)
| | | | | | | | | | | | | | |--FRepLayout.DeltaSerializeFastArrayProperty()
| | | | | | | | | | | | | | | |-- // 解析DS发来的Properties的数据 | | | | | | | | | | | | | |--Helper.template PostReceiveCleanup()
| | | | | | | | | | | | | | |--// for all deleted items
| | | | | | | | | | | | | | |--FFastArraySerializerItem.PreReplicatedRemove()
| | | | | | | | | | | | | | |--FFastArraySerializer.PreReplicatedRemove()
| | | | | | | | | | | | | | |--// for all added items
| | | | | | | | | | | | | | |--FFastArraySerializerItem.PostReplicatedAdd()
| | | | | | | | | | | | | | |--FFastArraySerializer.PostReplicatedAdd()
| | | | | | | | | | | | | | |--// for all changed items
| | | | | | | | | | | | | | |--FFastArraySerializerItem.PostReplicatedChange()
| | | | | | | | | | | | | | |--FFastArraySerializer.PostReplicatedChange()
| | | | | | | | | | | | | | |--// 调用RemoveAtSwap删除DeletedItems. 注意:经过此操作后不能保证顺序一致
| | | | | | | | | | |--// 将Property添加到RepNotifyQueue中
| | | | | | | | | | |--UE4_RepLayout_Private.QueueRepNotifyForCustomDeltaProperty
| | | | | | | | | | | |--// 将Property添加到RepNotifies中
| | | | | | | | | | | |--ReceivingRepState->RepNotifies.AddUnique(Property);
| | | | | | | |--// 遍历所有Replicator, 执行PostReceivedBunch, 其中就包括OnRep函数
| | | | | | | |--for (auto RepComp = ReplicationMap.CreateIterator(); RepComp; ++RepComp)
| | | | | | | |-- ObjectReplicator->PostReceivedBunch()
| | | | | | | | |--// !bIsServer && bHasReplicatedProperties:客户端并且有ReplicatedProerties才会执行FObjectReplicator.PostNetReceive
| | | | | | | | |--FObjectReplicator.PostNetReceive()
| | | | | | | | | |--Object->PostNetReceive();
| | | | | | | | |--FObjectReplicator::CallRepNotifies
| | | | | | | | | |--FReceivingRepState* ReceivingRepState = RepState->GetReceivingRepState();
| | | | | | | | | |--RepLayout->CallRepNotifies(ReceivingRepState, Object);
| | | | | | | | | | |--Object->ProcessEvent(RepNotifyFunc, nullptr);
| | | | | | | | | | | |--AActor.ProcessEvent
| | | | | | | | | | | | |--UObject.ProcessEvent
| | | | | | | | | | | | | |--UFunction.Invoke
| | | | | | | | | | | | | | |--ABasePlayerController.OnRep_ExampleFastArray

解析DeltaHeader

解析ChangedItem

先解析出ReplicationID:

解析是否有数据修改:bDirty, 如果有数据修改, 则开始递归解析该Item的属性.

PostReceiveCleanup回调处理

删除之前, 回调PreRemove函数:

添加之后, 回调PostAdd函数:

修改之后, 调用PostChanged函数:

执行删除操作:

客户端更新GUID

DS不需要更新GUID, 因为DS都赋值者, 所有UObject对应的GUID都是在DS上赋值(Replication/RPC的时候注册到FNetGUIDCache中). 这种情况只可能发生在客户端. 客户端需要更新的原因: 客户端处理消息是有先后顺序的, 有的时候某些对象在序列化GUID的时候, 对应的UObject(Actor)还没有创建出来, 就无法找到对应UObject(Actor), 所以需要对于UmmapedGUID进行延迟绑定.

这里需要关注两个变量: FReceivePropertiesSharedParams.bOutHasUnmappedFReceivePropertiesSharedParams.bOutGuidsChanged.

在处理收到的属性消息时, 如果返回值为true, 则表示具有Unmaped的GUID.

进一步深究发现, 在ReceivePropertyHelper序列化属性时会传入PackageMap, 如果发现有Umapped的GUID就会将其放入UPackageMap.TrackedUnmappedNetGuids中, 然后根据TrackedUnmappedGuids数量来确定是否有UmappedGUID(TrackedUnmappedGuids数量大约0).

并且在处理结果中, 我们还看到, 在这份代码中还有比较, 如果发现与之前的有不同, 则将bOutGuidsChanged设置为true.

在FastArray反序列化(FRepLayout.DeltaSerializeFastArrayProperty)完成之后, 会将bOutGuidsChangedbOutHasUnmapped合并到FNetDeltaSerializeInfo.bGuidListsChangedFNetDeltaSerializeInfo.bOutHasMoreUnmapped中. 因为有其他模块也有可能含有Unmapped和Changed数据, 所以需要合并.

FastArray反序列化, 会将计算得到的bOutHasUnmappedbOutGuidsChanged传递出来. 如果bOutGuidsChanged为true, 则会调用FObjectReplicator.UpdateGuidToReplicatorMap, 即调用INetSerializeCB.GatherGuidReferencesForFastArray函数. 当bOutHasUnmapped为true, 则最终会将

bHasUnmapped信息传递出来, 如果bHasUnmapped为true, 则会将其放入UNetDriver.UnmappedReplicators中, 等待TickFlush时候调用.

反序列化+GatherGuidReferences+UpdateUnmappedObjects流程

客户端会先执行反序列化, 然后会得出bHasUnmappedGUID和bChangedGUID. 如果bChangedGUID为true, 则进行Gather流程, 即重新收集GUID. 如果bHasUnmappedGUID为true, 则进行UpdateUnmappedObject流程, 即尝试重新将GUID映射到对应的Object上.

// FastArray GatherGuidReferences+反序列化+UpdateUnmappedObjects流程
--UIpNetDriver.TickDispatch
|--UNetConnection.ReceivedRawPacket
| |--UNetConnection.ReceivedPacket
| | |--UChannel.ReceivedRawBunch
| | | |--UChannel.ReceivedNextBunch
| | | | |--UChannel.ReceivedSequencedBunch
| | | | | |--UActorChannel.ReceivedBunch
| | | | | | |--UActorChannel.ProcessBunch
| | | | | | | |--FObjectReplicator.ReceivedBunch
| | | | | | | | |--FNetSerializeCB.ReceiveCustomDeltaProperty()
| | | | | | | | | |--FNetSerializeCB.ReceiveCustomDeltaProperty()
| | | | | | | | | | |--FRepLayout.ReceiveCustomDeltaProperty()
| | | | | | | | | | | |--FFastArraySerializer.FastArrayDeltaSerialize()
| | | | | | | | | | | | |--FFastArraySerializer.FastArrayDeltaSerialize_DeltaSerializeStructs()
| | | | | | | | | | | | | |--FNetSerializeCB.NetDeltaSerializeForFastArray
| | | | | | | | | | | | | | |--FRepLayout.DeltaSerializeFastArrayProperty
| | | | | | | | | | | | | | | |--// 根据ReplicationID,向GuidReferencesMap_StructDelta中添加元素
| | | | | | | | | | | | | | | |--FGuidReferencesMap& GuidReferences = ArraySerializer.GuidReferencesMap_StructDelta.FindOrAdd(ID);
| | | | | | | | | | | | | | | |--ReceiveProperties_r()
| | | | | | | | | | | | | | | | |--ReceivePropertyHelper()
| | | | | | | | | | | | | | | | | |--//在反序列化Item时候, 会将UnmappedGUID放入UPackageMap.TrackedUnmappedNetGuids中.
| | | | | | | | | | | | | | | | | |--Cmd.Property->NetSerializeItem();
| | | | | | | | | | | | | | | | | |--// 如果TrackedUnmappedNetGuids中不为空, 则证明有UnmappedGUID, 会将bHasUnmapped设置为true
| | | | | | | | | | | | | | | | | |--// 如果新旧UnmappedGUID(FGuidReferences.UnmappedGUIDs和TrackedUnmappedGuids)不一致,则证明GUID发生了变化,会将bOutGuidsChanged设置为true
| | | | | | | | | | | | | | | | |--// 如果ReceivePropertyHelper的返回值为true, 会将Params.bOutHasUnmapped设置为true,证明有Unmapped的数据
| | | | | | | | | | | | | | | | |--Params.bOutHasUnmapped = true;
| | | | | | | | | | | | |--FFastArraySerializer.TFastArraySerializeHelper.PostReceiveCleanup()
| | | | | | | | |--if (Parms.bOutHasMoreUnmapped)
| | | | | | | | |--{
| | | | | | | | |-- bOutHasUnmapped = true;
| | | | | | | | |--}
| | | | | | | | |--
| | | | | | | | |--if (Parms.bGuidListsChanged)
| | | | | | | | |--{
| | | | | | | | |-- bGuidsChanged = true;
| | | | | | | | |--}
| | | | | | | | |---//如果bGuidsChanged为true,则将执行FObjectReplicator.UpdateGuidToReplicatorMap
| | | | | | | | |--FObjectReplicator.UpdateGuidToReplicatorMap()
| | | | | | | | | |--FRepLayout.GatherGuidReferences
| | | | | | | | | | |--FFastArraySerializer.FastArrayDeltaSerialize
| | | | | | | | | | | |--FFastArraySerializer.FastArrayDeltaSerialize_DeltaSerializeStructs
| | | | | | | | | | | | |--FNetSerializeCB.GatherGuidReferencesForFastArray
| | | | | | | | | | | | | |--FRepLayout.GatherGuidReferencesForFastArray
| | | | | | | | | | | | | | |--// 将FFastArraySerializer.GuidReferencesMap_StructDelta中的信息放入FFastArraySerializer.GuidReferencesMap中
| | | | | | | | | | | | | | |--FRepLayout.GatherGuidReferences_r()
| | | | | | | |--// 如果bHasUnmapped为true,会将Replicator放入UnmappedReplicators中
| | | | | | | |--Connection->Driver->UnmappedReplicators.Add(&Replicator.Get());
|--// 遍历AllReplicators,如果为UnmappedReplicators,则UpdateUnmappedObjects
|--FObjectReplicator.UpdateUnmappedObjects()
| |--FRepLayout.UpdateUnmappedObjects
| | |--ReceiveProperties_r.FRepLayout.UpdateUnmappedObjects
| | | |--FFastArraySerializer.FastArrayDeltaSerialize_DeltaSerializeStructs
| | | | |--INetSerializeCB.UpdateUnmappedGuidsForFastArray
| | | | | |--FRepLayout.UpdateUnmappedGuidsForFastArray
| | | | | | |--// 遍历所有ArraySerializer.GuidReferencesMap_StructDelta,执行UpdateUnmappedObjects_r操作
| | | | | | |--FRepLayout.UpdateUnmappedObjects_r()
| | | | | | | |-- // 遍历所有UnmappedGUID进行查询, 如果找到了对应Object,进行序列化
| | | | | | | |--Cmd.Property->NetSerializeItem(Reader, PackageMap, Data + AbsOffset);
| | | | | | | |-- // 如果还有UnmappedGUID, 会将bOutHasMoreUnmapped设置成true
| |--FObjectReplicator.PostNetReceive
| |--FObjectReplicator.UpdateGuidToReplicatorMap()
| |--FObjectReplicator.CallRepNotifies
| | |--FRepLayout.CallRepNotifies()
| | | |--遍历所有FReceivingRepState.RepNotifies,回调其对应的UFunction
|--if ( !bHasMoreUnmapped )
|--{
|-- UnmappedReplicators.Remove( Replicator );
|--}

MoveGuidToUnmapped流程

当ActorChannel销毁时候, 触发MoveGuidToUnmapped.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// FastArray MoveGuidToUnmapped流程
--UIpNetDriver.TickDispatch.for
|--UNetConnection.ReceivedRawPacket
| |--UNetConnection.ReceivedPacket
| | |--UChannel.ReceivedRawBunch
| | | |--UChannel.ReceivedNextBunch
| | | | |--UChannel.ReceivedSequencedBunch
| | | | | |--UChannel.ConditionalCleanUp
| | | | | | |--UActorChannel.CleanUp
| | | | | | | |--UActorChannel.DestroyActorAndComponents
| | | | | | | | |--UActorChannel.MoveMappedObjectToUnmapped
| | | | | | | | | |--UNetDriver.MoveMappedObjectToUnmapped
| | | | | | | | | | |--FObjectReplicator.MoveMappedObjectToUnmapped
| | | | | | | | | | | |--FRepLayout.MoveMappedObjectToUnmapped
| | | | | | | | | | | | |--FFastArraySerializer.FastArrayDeltaSerialize
| | | | | | | | | | | | | |--FFastArraySerializer.FastArrayDeltaSerialize_DeltaSerializeStructs
| | | | | | | | | | | | | | |--FNetSerializeCB.MoveGuidToUnmappedForFastArray
| | | | | | | | | | | | | | | |--FRepLayout.CreateReplicationChangelistMgr
| | | | | | | | | | | | | | | | |--FRepLayout.MoveMappedObjectToUnmapped_r
| | | | | | | | | | | | | | | | | |--GuidReferences.MappedDynamicGUIDs.Remove(GUID);
| | | | | | | | | | | | | | | | | |--GuidReferences.UnmappedGUIDs.Add(GUID);

从上述流程可以看出, 当一个Actor销毁时候, 会将其Actor内部所OwnedGUID所关联的对象对应的GUID, 全部从MappedGUID移动到UnmappedGUID, 而不是删除. 那什么时候删除呢? 对于DS来讲, 当发生变化时候, 会将设置成全新的, 然后将新的GUID同步到客户端. 客户端发起新的流程, 会走一遍序列化+UpdateUnmappedObjects流程.

那么客户端呢? 如果一个客户端主动将一个Actor销毁, UnmappedGUID还会清理吗? 不会了, 从堆栈中可以看出, MoveMappedObjectToUnmapped都是通过ActorChannel调用的, 客户端销毁一个Actor, 并不会将其ActorChannel销毁, 所以, 不会触发MoveMappedObjectToUnmapped.

FastArray相互套用怎么传输

被嵌套的FastArray会被当做普通结构图进行传输, 失去了其本身FastArray对比等功能. 所以嵌套能用, 但是会被当做普通数组一样对待.

这里注意以下几点, 证明FastArray是不能被嵌套的.

  1. 在遍历所有ParentCmd时候, 针对每个IsCustomDelta的ParentCmd会遍历其Cmd, 在这些cmd中如果发现一个是FastArray,就会break, 导致Replayout中没有被嵌套FastArray的信息, 即其属性成员的各种Offset无法被找到. 而且, 可以注意到, 如果FastArray被其他结构嵌套, 由于其ParentCmd不是IsCustomDelta, 则其也不会存在FLifetimeCustomDeltaProperty结构. 即FastArray不能被嵌套.

FLifetimeCustomDeltaProperty记录FastArray的各种属性成员的Offset, 如果没有FLifetimeCustomDeltaProperty, 很多操作无法执行. 比如说在FRepLayout::DeltaSerializeFastArrayProperty中用到的函数FLifetimeCustomDeltaState.GetCustomDeltaProperty.

image-20230906094512029
  1. FastArray的比较是自定义实现的, 如果FastArray嵌套FastArray, 那么其内容的递归比较也不支持
调用通用比较函数

但是通用比较函数没有针对FastArray进行特殊处理:

如果数组中两个Item位置发生变化, 会传输数据吗?

如果调用的时Memcopy, 则不会传输数据:

示例:

可以清晰的看到, 在传输数据的时候, 发现其没有任何变化, 不会序列化数据.

如果调用=, 则会

由于FFastArraySerializerItem重写了操作符=, 在复制的时候会将ReplicationID和ReplicationKey重置, 所以相当于全新数据, 会全部同步.

总结

如果仅仅是数组位置发生变化 而Item内容没有发生任何变化, 则会不进行任何数据传输. 在使用FastArray时候, 交换两个Item位置的时候, 不要使用=, 否则会发生无意义的数据传输. UE原生的TArray.Swap, TArray.RemoveAt, TArray.RemoveAtSwap等都不会调用=, 直接进行内存级别的赋值, 推荐使用.

FastArray有使用ShadedSerialized吗?

首先, 看一下什么是ShadedSerialized: ShadedSerialized是在多个Connection之间共用一份数据, 省去其中重复执行序列化操作的时间. 同一帧之内起效果, 隔帧失效. 其次, 看一下ShadedSerialized是怎么使用的: 在属性比较之后, Gather出ChangedList(里面存储着RelativeHandle), 针对每个RelativeHandle, 如果该属性开启ShadedSerialized, 并且该帧已经序列化了, 则取出其序列化结果直接使用.

好了, 有了以上基础, 可以看看FastArray了. 针对FastArray, 其调用序列化并发送数据的接口为:FRepLayout::SendProperties_r, 观察期传入参数SharedInfo为空, 则一定不会有ShadedSerialized.

那么能不能改造成支持ShadedSerialized呢?

答案肯定是能的, 参考普通属性的ShadedSerialized, 在FDeltaArrayHistoryState中添加一个成员, 用于存储FastArray的数据, 从目前来看, 其要存放成一个Map成员, ReplicationID-ShadedInfo, 即针对每个Item,都要建立一个ShadedInfo数据.

FastArray每帧最多只进行一次比较吗? 怎么去重的?

针对FastArray, 这一帧和上一帧的内容比较次数, 在一帧内, 最多只执行一次.

FRepLayout::PreSendCustomDeltaProperties阶段, 会记录每次执行比较操作的帧数CustomDeltaChangelistState.CompareIndex, 如果该帧执行过比较, 则直接略过. 这里特殊说一下, 如果是第一次执行, 会取出一个新的HistoryItem, 重置, 然后用作这次比较. 如果HistoryList环形buffer超标了, HistoryEnd和HistoryStart重合了, 强制重置第一个HistoryStartItem, 然后将HistoryStart后延, 即强制提供一个可用的HistoryItem.

在比较过程中, 取出History数据, 如果HistoryItem执行过比较, 就直接略过. 如果是第一次, 则FDeltaArrayHistoryItem.bWasUpdated为false, 则进行比较并更新historyList, 否则直接跳过了.

FastArray第一次和谁比较?

和Archetype对比, 根据Changelist同步.

1
2
3
4
5
6
// FastArray RecentDeltaCustom初始化, RecentDeltaCustom每个Connection独一份
--UNetConnection.CreateReplicatorForNewActorChannel
|--FObjectReplicator.InitWithObject
| |--FObjectReplicator.InitRecentProperties // 注意这里传入的UObject为CDO
| | |--// 遍历所有CustomDeltaProperty, 执行SendCustomDeltaProperty, 这里会初始化FSendingRepState.RecentCustomDeltaState .
| | |--FObjectReplicator.SendCustomDeltaProperty

这样, 在创建Replicator时候, 使用CDO, 对FSendingRepState.RecentCustomDeltaState进行初始化. 所以后续对比的其实是CDO内的FastArray数据.

使用CDO初始化FSendingRepState.RecentCustomDeltaState数据.

总结

FastArray快在哪里

快在Array中间一个元素发生变化, 不会影响其他元素. 而TArray会重传变动后面的所有数据. 因为TArray是按照位置进行比较的, 而FastArray是按照ReplicationID进行比较的.

FastArray本身有哪些问题

  1. 如果大量连续丢包, 并超过64(HistoryList环形buffer大小), 则传输的数据只能保证最终数据一致, 而不能保证过程中, 某些数据的顺序.例如FastArray中只有Item1和Item2两个元素, 当遇到连续丢包, 并且已经超过了64(HistoryList环形buffer大小), 那么在比较过程中, 就不会从HistoryList中获取数据, 只发送本帧修改的数据, 这样会导致一段时间内只能收到当帧修改的数据. 只有当帧没有数据变化, 才会触发ChangedItem全部发送的逻辑, 即保证最后数据的正确性.
  2. 没有使用ShadedSerialized, 当Connection较多时候, 大量使用FastArray会造成性能问题.
  3. 不能保证数组顺序, 例如DS数组中内容为 0,1,2, 同步到客户端就可能是1,2,0.
  4. FastArray内部不支持嵌套FastArray, 如果嵌套了, 会被当做普通结构体进行同步
  5. 任何数据中嵌套了FastArray, 都会被当做普通结构体进行传输(可以认为是普通数据). 只有FastArray在ParentCmd层级, 才会被真正当做FastArray使用.
  6. 如果Item过多, 每个RelativeHandle都使用uint32进行数据传输, 那么会非常耗费. 方法1: 可以考虑使用bit替换, 每个成员变量都会用一个bit表示其是否发生了变化, 如果有变化则进行序列化(参考RPC参数的发送).方法2: 或者限制范围, 比如该结构体参数个数为5, 那么可以将RelativeHandle限制在3个bit内. 总结:还要结合项目具体情况使用, 比如某些结构体频繁改动, 并且结构体内部成员很多, 可以使用方法1. 如果某些结构体成员很多, 但内部只有少量数据变动, 但是变动很频繁, 可以使用方法2. 还可以二者结合使用, 动态方式, 比如统计之前的使用状态, 预计下次最优的使用方式.

注意

FastArray没有HistoryChangedList, 只有RetirementList, 并且它只存储ReplicationID-Replicationkey.