【转载】UE4属性同步 —— 服务器同步属性(二)

原文链接:南京周润发 | UE4属性同步(一)服务器同步属性
由于原文太长,所以我分成了两部分

正文

服务器同步属性

接下来进入属性同步的重点,分析服务器在 runtime 如何同步 Actor 的属性。

NetDriver::Tick

UE 使用 NetDriver 处理网络相关功能,NetDriver 有 Tick 循环,对于属性同步主要关注 UNetDriver::ServerReplicateActors 函数,其大致流程如下:

  • 遍历所有 ClientConnection,对每个客户端处理要发送的数据
    • 对一个 ClientConnection,根据 ViewTarget 位置等信息,计算 Actor 相关性,判断要不要同步该 Actor 数据给这个客户端
    • 如果要同步,之后进入 FObjectReplicator::ReplicateProperties 函数,同步属性。

我们主要关注的就是 FObjectReplicator::ReplicateProperties 函数,该函数的主要操作为比较属性与发送属性同步数据。

比较属性

比较属性除了使用上文提到的 ShadowBuffer,还需要使用 RepChangeState,SendingRepState 等组件。

FRepChangelistState

用于管理属性的 changelist 和 ShadowBuffer。属性可以多次发生改变,UE 需要在每次比较属性时记录下哪些属性改变了,并把属性对应 Cmd 的 RelivateHandle 存储在 RepChangelistState 中,称为 changelist。ChangelistState 与 Actor 一一对应,多个 ClientConnection 间共享。

changelist 数据结构为环形 buffer,当 buffer 满时会把早期的多个 changelist 合并成一个,因此不会丢弃数据。

属性:

  • FRepChangedHistory ChangeHistory[MAX_CHANGE_HISTORY]:存储 changelist 的环形 buffer, MAX_CHANGE_HISTORY 默认 64
  • TUniquePtr<struct FCustomDeltaChangelistState> CustomDeltaChangelistState:针对实现了CustomDelta函数属性的changelist,一般用不到
  • int32 HistoryStart:buffer 中 changelist 开始的下标,最早的 changelist
  • int32 HistoryEnd:buffer 中 changelist 结束的下标,最新的 changelist,有效 Changelist 为 [HistoryStart, HistoryEnd)
  • int32 CompareIndex:属性被比较的次数
  • FRepStateStaticBuffer StaticBuffer:Shadowbuffer 的实现,存储了 replicate 属性最新的一份拷贝值
  • FRepSerializationSharedInfo SharedSerialization:序列化后的共享数据

其中 FRepChangedHistory 表示一个单独的 changelist,跟踪变化的属性。

FRepChangedHistory

  • FPacketIdRange OutPacketIdRange:changelist 最终通过 packet 发送,记录了 changelist 对应 packe 的范围
  • TArray<uint16> Changed:存储变化属性对应 Cmd 的 Handle
  • bool Resend:changelist 如果被 nak 了,是否需要重新发送。

FReplicationChangelistMgr

管理 RepChangelistState,以及上次 Changelist 更新的 Frame 和上次 InitialReplication 更新的Frame。

CompareProperties

这里开始真正做属性比较操作。首先从环形 buffer RepChangelistState.ChangeHistory 中取 HistoryEnd 下标元素,作为当前可用的 Changelist,由此可见 HistoryEnd 总是代表一个空闲changelist 。

然后调用 CompareParentProperties 函数做属性细节比较,最终会填充 changelist 的 Changed 属性,把 Cmd 的 Handle 写入。调用结束后,在 Changed 末尾添加 0,作为终结符,注意 Handle 是从 1 开始计数的。Changed 属性如何构造在下面介绍。

之后 HistoryEnd 再增长一位,如果这导致环形 buffer 满了,则要合并最早的两个 Changelist 来产生一个新的空闲,称为 MergeChangeList 。这个步骤和 Changed 内部结构有关,等分析完 CompareProperties_r 后再做介绍。

CompareProperties_r

对于每个 ParentCmd,需要做详细、底层的属性比较,然后更新 StaticBuffer 并把 Handle 写入 Changed 数组,由 CompareProperties_r 函数完成。其中会遍历 ChildCmds,对具体的 Property 做比较,分为 DynamicArray 和普通 Property 两种。

普通 Property

使用 PropertiesAreIdenticalNative 函数进行比较,根据属性类型采用不同比较方式。对大部分属性,直接把当前对象内存和 ShadowBuffer 内存 Cast 成对应属性,然后用 "==" 比较。

template<typename T>
bool CompareValue(const T * A, const T * B)
{
	return *A == *B;
}

有些属性比较特殊,需要使用 FProperty 的 Identical 接口进行比较,比如 Bool,可以指定其在对象中只使用一个 bit,多个连续的 bool 属性共用一个 byte 存储。那么直接 Cast<bool> 其实比较了一个 byte,是不对的,FBoolProperty::Identical 内容如下,可用看到是取了 byte 里的 offset 进行比较

bool FBoolProperty::Identical( const void* A, const void* B, uint32 PortFlags ) const
{
	check(FieldSize != 0);
	const uint8* ByteValueA = (const uint8*)A + ByteOffset;
	const uint8* ByteValueB = (const uint8*)B + ByteOffset;
	return ((*ByteValueA ^ (B ? *ByteValueB : 0)) & FieldMask) == 0;
}

再比如常用的 AActor* 指针属性,并不是简单的比较指针值。假如服务器上指针指向的 Actor 被销毁,然后在想同地址创建了另一个新的 Actor,可能 Actor 类型也不相同,那么必须把 Actor 的类型、 ObjectName 等信息都纳入比较,FObjectPtrProperty::Identical 函数也确实这么做的。

当比较发现属性不同时,会把对象当前属性通过 Fproperty::CopySingleValue 接口写入 ShadowBuffer,使 ShadowBuffer 保持最新,然后把 Cmd.Handle 加入到 Changed 数组中。

一个例子如下,对象的 int 属性和 FRepAttachment 属性都有差异,后者部分差异,因此把对应属性的 Handle 添加到 Changed 数组,然后更新 ShadowBuffer。

DyncmicArray 和普通属性区别为它的长度不固定,不能直接比较两块内存中的内容,使用CompareProperties_Array_r函数进行比较。

首先把 ShadowBuffer 中 Array 长度和当前 Array 长度保持一致,会增加或删除元素。然后遍历两个 Array,对其中元素使用 CompareProperties_r 函数进行比较。Array 中的元素使动态创建的,因此没有 Local 概念,UE 会在比较 Array 时创建 LocalHandleChangeLocal 两个变量,CompareProperties_r 使用它们作为参数并更新。

当比较结束后,会有三种情况。

第一种为 ChangedLocal 非空,表示新的数组长度中,对象数组值和 ShadowBuffer 数组值不同,此时要在 Changed 数组中依次写入如下内容,其中最重要的是 ChangedLocal。

StackParams.Changed.Add(Handle);
StackParams.Changed.Add((uint16)NumChangedEntries);		// This is so we can jump over the array if we need to
StackParams.Changed.Append(ChangedLocal);
StackParams.Changed.Add(0);

第二种情况是 Array 长度减少,但新长度内容与 ShadowBuffer 种内容一致,因此 ChangedLocal 是空的。此时 Changed 写入如下内容:

StackParams.Changed.Add(Handle);
StackParams.Changed.Add(0);
StackParams.Changed.Add(0);

第三种是对象 Array 和 ShadowBuffer 中 Array 完全相同,那么它们就是相等的,Changed 不需要添加任何内容。

以下是一个例子,ShadowBuffer中有一个元素不同,且缺少两个元素:

MergeChangeList

当生成新的 Changelist 后,如果 ChangeHistory 满了,就要合并最早的两个 Changelist,称为 MergeChangeList。如果一个 Changelist 为空,则直接使用另一个 Changelist 数据即可。都不为空时,需要从前往后遍历两个 Changelist,对应普通属性,逐个添加 Handle 即可。对 DynamicArray,需要继续遍历 Array 对应的 Handle,把它们都加入。最复杂的情况是 Array 在两个 Changelist 中都有改变,这时要把 Array 中每个元素对应的 Handle,采用递归 MergeChangeList 的方式合并。

ClientConnection 间复用比较结果

上文的 UpdateChangelistMgr 流程是对于一个 ClientConnection 更新时做的,而 Server 可用同时连接多个 ClientConnection,对每个 Client 都比较一次 ShadowBuffer 显然没有必要,比较结果可用在多个 ClientConnection 间复用。当一次 UpdateChangelistMgr 执行结束后,UE 会把当前 Frame 存储在 ChangelistMgr 中,表示 ChangelistMgr 上次更新的 Frame。当下次更新 ClientConnection ,进入 UpdateChangelistMgr 函数时,如果发现当前 Frame 与 ChangelistMgr 中记录的 Frame 相同,则表示当前 Frame 已做过比较。那么有没有可能上次比较和这次比较间 Actor 的属性值改变了?不会改变,因为服务器属性同步的更新发生在一个 FrameTick 的末尾,此时不会再执行 Actor 的 tick 和物理更新的 tick。此时就不必再产生新的 Changelist 了,注意 ChangelistMgr 被所有 ClientConnection 共享。

这个共享由 net.ShareShadowState 开关控制,默认打开。

InitialReplication 比较特殊,涉及到 Role 的处理,因此由另一个开关 net.ShareInitialCompareState 控制,默认关闭。

属性没有改变如何处理

特别的,如果这次比较发现属性没有任何变化(也是大部分情况),得到的 Changed 数组就是空的。UE 不会把它加到 Changelist 中,因为没有意义,于是在 CompareProperties 函数中就直接返回了,不做 Changelist 写入和 MergeChangelist 等操作,不然总是要执行 MergeChangelist。

发送属性同步数据

FRepLayout::ReplicateProperties

当完成属性比较,记录变化的属性后,就要发送属性同步数据了,由 FRepLayout::ReplicateProperties 函数完成。

生成 Changed 数据是对于 Actor 的,主要使用 FRepChangelistState ,而发送数据是对于 ClientConnection 的,主要使用 FSendingRepState,这点需要注意。因为 FRepChangelistStateFSendingRepState 有些变量名称会相同,要避免混淆。

SendingRepState

服务器发送属性同步数据时需要用到的信息,每个 ClientConnection 都有一个。

  • LastChangelistIndex:最近一次从 FRepChangelistState 中同步 changelist 的下标
  • FRepChangedHistory ChangeHistory[MAX_CHANGE_HISTORY]:存储发送的 Changelist,也是环形buffer
  • int32 HistoryStart:ChangeHistory 开始的下标
  • int32 HistoryEnd:ChangeHistory 下一个可用的下标

FRepChangedHistory

ChangeHistory中的元素类型

  • FPacketIdRange OutPacketIdRange:Changed 发送后所在 Packets 的 Id 范围
  • TArray<uint16> Changed:属性变化的 Handle
  • bool Resend:当收到 Nak 时,该Changed 是否需要重发

收集要发送的Changed

首先要知道需要发送哪些 Changed。

发送 Changed 可以认为和产生 Changed 解耦,类似消费者和生产者,发送时 FRepChangelistState 中可能有多个新的 Changed,也可能没有新 Changed。FSendingRepState.LastChangelistIndex 记录了最近从 FRepChangelistState.Changelist 中同步 Changed 的下标,而 FRepChangelistState.HistoryEnd 记录了 Changelist中 最新 Changed 下标。因此 [LastChangelistIndex, HistoryEnd) 就是这次需要发送的新产生 Changed。如果有多个 Changed,同样需要通过 MergeChangelist 合并成一个发送,这样比较好管理。之后再把 FSendingRepState.LastChangelistIndex 更新到 FRepChangelistState.HistoryEnd

FSendingRepState.ChangeHistory

要发送的 Changed 需要单独存储,因为引擎需要记录 Changed在 哪些 Packet 中,Changed 有没有收到等信息,这些都是对于某个 ClientConnection 而言的。而且 FRepChangelistState 会执行 MergeChangelist 操作,合并多个 Changelist,发送时如果也用这份数据,会导致错乱,因为当收集要发送的 Changed 后,会把它们存储于 FSendingRepState.ChangeHistory 中。ChangeHistory 是环形buffer,HistoryStart 和 HistoryEnd 用于管理 buffer 开始和结束。每次收集 Changed 时,都会从 ChangeHistory 中取一个新的可用位置存储 Changed,因此两个 ChangeHistory 并不对应,HistoryStart 和 HistoryEnd 也不对应,各自作用不同。

一个例子如下,FRepChangelistState.ChangeHistory 中的两个 Changed 通过 MergeChangelist 写入到 FSendingRepState.ChangeHistory 中。

发送数据

目前为止,我们知道了改变属性对应的 Handle 列表,接下来需要根据这些 Handle 找到对应属性,然后序列化属性内容,通过 FRepLayout::SendProperties 函数实现。

宏观来看,如果要同步一个属性给客户端,发送的信息首先会包含这个属性 “是什么”,然后是这个属性的具体内容。这样客户端收到数据后才知道要如何解析。属性对应的 Handle 就能作为属性的标识,可以关联到 ParentCmd,ReplayoutIndex 等内容。因此 UE 发送的一个基础属性同步数据由 Handle +属性内容构成。

普通属性

除了 TArray 都是普通属性,UStruct 也属于普通属性。

首先写入属性对应的 Handle,就是 Changed 数组中的元素。虽然 Handle 定义为 int32,但实际发送是会用变长 int 方式发送,UE 实现为 SerializeIntPacked 接口。变长 int 会根据数值大小使用不同数量的 Byte,1 Byte 可表示到 127,2 Byte 可表示到 65535,因此可用把频繁变化的属性声明尽量靠前,这样可用尽量使用小的 Handle。Handle 是针对最底层属性的,UStruct 包含的每个子属性都会有一个 Handle,这样就实现了 UStruct 的增量更新,更加高效。

属性数据的序列化通过 Fproperty::NetSerializeItem 接口实现,这是网络序列化版本的接口,作为对比,普通序列化接口为 Fproperty::SerializeItem 。一个区别为前者使用 Bit 流序列化,更加精细,可以减少数据量,更适合网络传输。

举个例子,BoolProperty 的两个接口为。直观理解,一个 Bit 就能表示 bool 值了,NetSerializeItem 也确实这么做的。

void FBoolProperty::SerializeItem( FStructuredArchive::FSlot Slot, void* Value, void const* Defaults ) const
{
	check(FieldSize != 0);
	uint8* ByteValue = (uint8*)Value + ByteOffset;
	uint8 B = (*ByteValue & FieldMask) ? 1 : 0;
	Slot << B;
	*ByteValue = ((*ByteValue) & ~FieldMask) | (B ? ByteMask : 0);
}

bool FBoolProperty::NetSerializeItem( FArchive& Ar, UPackageMap* Map, void* Data, TArray<uint8> * MetaData ) const
{
	check(FieldSize != 0);
	uint8* ByteValue = (uint8*)Data + ByteOffset;
	uint8 Value = ((*ByteValue & FieldMask)!=0);
	Ar.SerializeBits( &Value, 1 );
	*ByteValue = ((*ByteValue) & ~FieldMask) | (Value ? ByteMask : 0);
	return true;
}

还有一个区别为 Actor 的处理,网络传输一个 Actor,其实传输的是其 NetId 。其接口实现如下:

bool FObjectPropertyBase::NetSerializeItem( FArchive& Ar, UPackageMap* Map, void* Data, TArray<uint8> * MetaData ) const
{
	UObject* Object = GetObjectPropertyValue(Data);
	bool Result = Map->SerializeObject( Ar, PropertyClass, Object );
	SetObjectPropertyValue(Data, Object);
	return Result;
}

当然,Object 序列化远比这几行代码复杂,之后会有专门介绍。

对于其他普通属性,NetSerializeItem 接口与普通的 SerializeItem 接口相同。

TArray

网络同步时,动态数组总是需要特殊处理。

处理方式与生成 Changelist 类似,首先写入 ArrayProperty 对应的 Handle,然后写入数组长度。之后对数组中元素递归调用 SendProperties_r 函数,写入数据。完毕后,再写入 0 表示数组序列化完毕。

共享序列化数据

changelist 可以在多个 connection 间共享,序列化数据同样可以共享,但情况会复杂一些。

哪些属性的序列化数据可共享

如果属性的序列化数据,在不同 connection 下可能不同,那该属性的序列化数据就不能共享。

目前有三种这样的属性,FieldPath,Object 指针和实现了 NetSerialize 函数的UStruct,重点关注后两个。网络同步中每个 Object 有对应的 NetworkGUID,通过同步 NetworkGUID 实现 Object 的同步,而同一个 Object 在不同 connection 中的 NetworkGUID 不一定相同,定义在 PackageMap 中,因此不能复用。UStruct 通过实现 NetSerialize 函数,可自定义序列化内容,参数中包含 PackageMap 信息,因此理论上可序列化不同内容到不同的 Connection,因此默认该属性序列化内容不能复用,但也有例外,用于可指定 WithNetSharedSerialization 为 true,来显示告诉 UE 序列化内容可复用,那么 UE 还是会复用这些序列化数据的。

共享序列化数据存储

RepChangelistState.SharedSerialization 存储了最近一次 SendProperties 函数调用中发送的可共享序列化属性数据。RepSerializationSharedInfo 主要有 SerializedProperties 和 SharedPropertyInfo 两个属性。 SerializedProperties 就是多个属性序列化后的二进制数据。SharedPropertyInfo 是一个数组,元素为 FRepSerializedPropertyInfo ,其中包含属性的 Guid,对应二进制流的下标和长度,Guid 由属性的 CmdIndex,ArrayIndex,ArrayDepth 等数据组成,可唯一标识一个属性。

创建/使用共享序列化数据

BuildSharedSerialization 函数实现。该步骤在产生 Changelist 之后, SendProperties 之前,那么在 SendProperties 时,可共享的属性已经序列化完毕了,直接使用即可。BuildSharedSerialization 函数的过程和发送属性类似,都是调用 FProperty的NetSerializeItem 方法,序列化属性。

至此,服务器发送属性流程结束。此时要同步的属性数据已序列化到 NetBitWriter 中,再往后就是网络底层的数据包发送和接收流程了,这些需要专门一章介绍。同时服务器属性同步的一些细节也有待展开,比如 Object 如何同步,Role 如何处理,自定义 NetSerialize 如何使用等。

猜你喜欢

转载自juejin.im/post/7126863871280676871