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

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

正文

属性同步是 服务器-客户端 架构多人游戏的基础功能,玩家的位置、血量、名称等属性,都需要服务器同步到客户端。UE4 对此也有完善的支持,本文将首先介绍 UE4 服务器如何同步属性。

UE4 网络传输概览

首先总体介绍以下 UE4 的网络传输模型,下图是经典的 Server-Client 模式。Server 与多个 Client 连接,每个连接称为一个 Connection,Server 上有多个 Actor,可同步到 Client,每个 Actor 在每个 Connection 中都有一个 ActorChannel ,负责实现该 Actor 的属性同步和 rpc 。

image.png

宏和声明

要同步一个 UClass 的属性,首先要用 UPROPERTY 宏标记属性,并且加上 Replicated 标签

UCLASS()
class ATest : public AActor
{
	GENERATED_BODY()

	UPROPERTY(Replicated)
	int IntProperty;

	UPROPERTY(Replicated)
	FRepAttachment StructProperty;

	UPROPERTY(Replicated)
	TArray<int> ArrProperty;

	UPROPERTY(Replicated)
	FVector VecProperty;

};

之后还要实现 GetLifetimeReplicatedProps 函数,其中声明属性同步方式

void ATest::GetLifetimeReplicatedProps(TArray< FLifetimeProperty >& OutLifetimeProps) const
{
	Super::GetLifetimeReplicatedProps(OutLifetimeProps);
	DOREPLIFETIME(ATest, IntProperty);
	DOREPLIFETIME(ATest, VecProperty);
	DOREPLIFETIME_CONDITION(ATest, StructProperty, COND_InitialOnly);
	DOREPLIFETIME_CONDITION(ATest, ArrProperty, COND_OwnerOnly);
}

这样 属性就能从服务器同步到客户端

基本的声明为 DOREPLIFETIME,这样每当服务器属性改变,都会同步到所有客户端上。还可以给同步加上条件,比如第 5 行的 COND_InitialOnly只会在 Actor 第一次时同步到客户端。第 6 行的 COND_OwnerOnly只会把属性同步给该 Actor 的 "Owner" 客户端,即 Authority 客户端。还有很多其他同步条件,就不一一列举了。

UHT 对 UPROPERTY(Replicated) 宏处理

UHT 会对 UPROPERTY(Replicated) 标签生产带有反射信息代码,用于支持后续属性同步功能。对于上述例子,生产的网络同步相关代码如下:

void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
enum class ENetFields_Private : uint16 
{ 
	NETFIELD_REP_START=(uint16)((int32)Super::ENetFields_Private::NETFIELD_REP_END + (int32)1), 
	IntProperty=NETFIELD_REP_START, 
	StructProperty, 
	ArrProperty, 
	VecProperty, 
	NETFIELD_REP_END=VecProperty	}; 
NO_API virtual void ValidateGeneratedRepEnums(const TArray<struct FRepRecord>& ClassReps) const override;

首先看到 UHT 自动生成了 GetLifetimeReplicatedProps 函数声明,因此我们可以不自己在头文件里声明一遍,接着就是重点 ENetFields_Preivate enum,简称ENetFields

ENetFields

该 enum 为所有 replicated 属性 定义了一个编号 ,称为 RepIndex,之后属性同步时,会用 RepIndex 关联的 CmdIndex 标识传输的属性,FPropertyRepIndex 与此处一致。每个类的 ENetFileds 并不是从 0 开始计数,而是从基类的最后一个 ENetFields enum,称为 ENETFIELD_RED_END。这也不难理解,ATest 类继承自 AActor,那么 ATest 自然也要支持 AActor 类部分的属性同步,因此需要把所有基类的 ENetFields 都 “继承” 下来。

GetLifetimeReplicatedProps函数

GetLifetimeReplicatedProps 函数用于 向引擎注册一个类的网络同步属性 ,在为 Actor 创建 Channel 时,会调用到该函数。

GetLifetimeReplicatedProps 函数描述 一个属性如何被网络同步,其中大量使用了宏,直接从最常见的 DOREPLIFETIME 宏开始分析,其他宏也类似。展开后如下

#define DOREPLIFETIME(c,v) DOREPLIFETIME_WITH_PARAMS(c,v,FDoRepLifetimeParams())

#define DOREPLIFETIME_WITH_PARAMS(c,v,params) \
{ \
	FProperty* ReplicatedProperty = GetReplicatedProperty(StaticClass(), c::StaticClass(),GET_MEMBER_NAME_CHECKED(c,v)); \
	RegisterReplicatedLifetimeProperty(ReplicatedProperty, OutLifetimeProps, params); \
}

第一行中出现了 FDoRepLifetimeParams 类,它定义了属性同步的 Condition, RepNotify 函数触发形式,以及是否为 PushModel,其中 PushModel 是 UE4.25 新加的特性,可以减少服务器 CPU 开销,这里略过,之后有文章介绍。 DOREPLIFETIME 宏都使用默认值即可。

struct ENGINE_API FDoRepLifetimeParams
{
	/** Replication Condition. The property will only be replicated to connections where this condition is met. */
	ELifetimeCondition Condition = COND_None;
	
	/**
	 * RepNotify Condition. The property will only trigger a RepNotify if this condition is met, and has been
	 * properly set up to handle RepNotifies.
	 */
	ELifetimeRepNotifyCondition RepNotifyCondition = REPNOTIFY_OnChanged;
	
	/** Whether or not this property uses Push Model. See PushModel.h */
	bool bIsPushBased = false;
};

接着在第三行的宏中,先获取该属性的 FProperty,然后使用 RegisterReplicatedLifetimeProperty 函数注册信息,最终调用到这里。

void RegisterReplicatedLifetimeProperty(
	const NetworkingPrivate::FRepPropertyDescriptor& PropertyDescriptor,
	TArray<FLifetimeProperty>& OutLifetimeProps,
	const FDoRepLifetimeParams& Params)
{
	for (int32 i = 0; i < PropertyDescriptor.ArrayDim; i++)
	{
		const uint16 RepIndex = PropertyDescriptor.RepIndex + i;
		FLifetimeProperty* RegisteredPropertyPtr = OutLifetimeProps.FindByPredicate([&RepIndex](const FLifetimeProperty& Var) { return Var.RepIndex == RepIndex; });

		FLifetimeProperty LifetimeProp(RepIndex, Params.Condition, Params.RepNotifyCondition, Params.bIsPushBased);

		if (RegisteredPropertyPtr)
		{
			// Disabled properties can be re-enabled via DOREPLIFETIME
			if (RegisteredPropertyPtr->Condition == COND_Never)
			{
				// Copy the new conditions since disabling a property doesn't set other conditions.
				(*RegisteredPropertyPtr) = LifetimeProp;
			}
			else
			{
				// Conditions should be identical when calling DOREPLIFETIME twice on the same variable.
				checkf((*RegisteredPropertyPtr) == LifetimeProp, TEXT("Property %s was registered twice with different conditions (old:%d) (new:%d)"), PropertyDescriptor.PropertyName, RegisteredPropertyPtr->Condition, Params.Condition);
			}
		}
		else
		{
			OutLifetimeProps.Add(LifetimeProp);
		}
	}
}
  • 函数第一个参数为 FRepPropertyDescriptor 实例,根据 FProperty 创建,包含了属性名称,ENetFields 值,和 ArrayDimArrayDim 大部分情况都是 1,因为很少使用静态数组。
  • 第二个参数是 GetLifetimeReplicatedProps 传来的 FLifetimeProperty 数组。

该函数中,会根据 PropertyDescriptor 实例信息,创建 FLifetimeProperty 实例,包含 RepIndex,Condition,RepNotifyCondition,bIsPushBased 信息,引擎网络模块最终根据 FLifetimeProperty 数据进行属性同步操作

class FLifetimeProperty
{
public:

	uint16 RepIndex;
	ELifetimeCondition Condition;
	ELifetimeRepNotifyCondition RepNotifyCondition;
	bool bIsPushBased;
	...
};

至此 GetLifetimeReplicatedProps 函数操作结束,主要工作就是 注册网络同步属性的信息,更具体的,是注册给 RepLayout

RepLayout

RepLayout 是 UE4 runtime 实现属性同步的核心,有 管理属性历史值比较属性发送属性数据 等功能。

  • 管理属性历史值:当属性发生改变时才会同步,因此引擎需要保存属性的历史值,才能进行比较,发现改变。
  • 比较属性:比较两个值,判断属性是否改变。
  • 发送属性数据:属性可以设置同步 Condition,有些属性只需同步给特定客户端。并且属性是差量同步的,因此需要管理客户端返回的 acknak ,判断当前要发送哪些值。

本文重点关注 UE4 属性同步方式,对网络框架不做过多介绍。

首先看 RepLayout 中的几个概念。

FRepLayout 类结构

该类用于维护某一类型的所有同步属性,类型可以是 UClassUStruct 或者 UFunction。对于一种类型,只会有一个对应的 FReplayout 实例,类型的多个实例共享。

属性

  • int32 ShadowDataBufferSize:创建一个 Shadow buffer 实例需要的内存
  • TArray<FRepParentCmd> Parents:顶层 layout Command
  • TArray<FRepLayoutCmd> Cmds:所有 layout Command
  • TArray<FHandleToCmdIndex> BaseHandleToCmdIndex:Handle 到 Command 的映射关系

RepParentCmd

Cmd 用于指导一个 Property 如何同步 ,分为 RepParentCmdRepLayoutCmd。一个ParentCmd 对应一个 Property,是比较顶层的概念。普通 Property,如 intboolRepParentCmd 会关联一个 RepLayoutCmd,用于描述同步细节,而复杂的 Property,如 TArrayUStruct,有多个 RepLayoutCmd,显然后者同步更为复杂。

为什么要有 Cmd?理论上讲,UE4 可以把所有反射信息都存储在 FProperty 中,包括上述信息的。但这样不仅使 FProperty 过于臃肿,更重要的,每次处理属性同步都去遍历一遍 UClassFProperty 链表,会产生性能浪费,其中包含了许多非 Replicate 属性,遍历它们是没必要的。同时由于 FProperty 用链表组织,缓存不友好。因此 UE 使用专门的 Cmd 数组记录属性同步信息,精简且缓存友好。类似的设计在 GC 的 Token 中也有体现。

RepLayout 中的 Parents 数组记录了类中所有的 RepParentCmd,Cmds 数组记录了类中所有的 FRepLayoutCmd

FRepParentCmd

属性

  • FProperty* Property:对应的Property
  • int32 ArrayIndex:属性在 xx[] 数组中下标,非数组类型属性都是 0
  • nt32 Offset:属性在对象中的内存偏移
  • int32 ShadowOffset:属性在 Shadow Memory 中的内存偏移
  • uint16 CmdStart uint16 CmdEnd:CmdStart 和 CmdEnd 记录关联的 RepLayoutCmd 在 Cmds 数组中下标,[CmdStart, CmdEnd-1]

下举个例子,如果定义这么一个类,有三个同步属性 a,b,c

UCLASS()
class ATest : public AActor
{
	GENERATED_BODY()

	UPROPERTY(Replicated)
	int a;

	UPROPERTY(Replicated)
	FRepAttachment b;

	UPROPERTY(Replicated)
	TArray<int> c;

	UPROPERTY()
	FVector d;

};

那么生成的 Parents 和 Cmds 如下:

RepLayoutCmd

指导单一元素如何同步,可以是一个普通的顶层 Property,也可以是 UStruct 中的嵌套 Property,或者 Tarray 中的元素。

属性

FRepLayoutCmd

  • FProperty* Property:对应的底层 Property,如果是 UStruct,对应了 UStruct 的各个子Property
  • uint16 ElementSize:属性的大小
  • int32 Offset:子 Property 在对象中的偏移
  • int32 ShadowOffset:子 Property 在 ShadowMemory 中的内存偏移
  • uint16 RelativeHandle:Cmd 在 Cmds 数组中的下标+1
  • uint16 ParentIndex:对应 ParentCmd 的下标
  • uint32 CompatibleChecksum:checksum

Type:类型,包括 PropertyInt,DynamicArray,Return 等等

Type 中,PropertyInt, PropertyBool 等都好理解,DynamicArray 和 Return 比较特殊。DynamicArray 表示一个 Array 的开始,因为数组长度是动态的,运行时才知道元素数量,因此引擎在运行时需要特殊处理。Return 表示一串 RepLayoutCmd 的结束,会加在 DynamicArray 一串的后面,表示 Array 结束,也会加在类对应的整个 Cmd 末尾,表示所有 RepLayoutCmd 的结束。从这可以看出,Cmds 类似一种编码。

普通 Property只会生产一个 RepLayoutCmd,Offset 也与 RepParentCmd 相同。UStruct 会对其中每个 UProperty 生产一个子 RepLayoutCmd,Property 属性为子 UProperty,Offset 为子 UProperty 在顶层对象中的偏移,Array 则会先生成 DynamicArray,表示 Array 的 Cmd 开始,然后对内部元素 Property 生产 Cmd,这是一个递归的过程,考虑 Array 元素是 UStruct,或者 Array 套 Array 的情况,这些 Cmd 创建结束后会添加 Return Cmd。

可以发现,RepParentCmd 的 CmdStart 和 CmdEnd 属性,以及 RepLayoutCmd 的 ParentIndex 属性,使两者建立了双向联系。

到这里虽然还没介绍运行时 RepLayout 的工作流程,但不妨猜测一下,RepLayout 应该会在运行时遍历 Parents 和 Cmds,根据 Property,Offset,ElementSize 等信息进行属性同步。

加入 RepLayoutCmd 细节后,上面的例子如下所示:

image.png

可以看到其中的 Offset 已经加上了内存对齐,比如 AttachParent,虽然在 int 属性之后,但有 4 Byte 的内存对齐,导致 Offset 为 8。

当然,也有一些特例,Cmd 并不如上述所示。

对于自己实现了 NetDeltaSerialize 函数的 UStruct,不会生成子 Cmd,因为 UE 不需要处理该属性的内存结构,UStruct 自己决定属性何时改变,如何网络同步。

对于自己实现了 NetSerialize 函数的 UStruct,只会生成一个子 Cmd,UE 虽然不关心 UStruct 内部的内存布局,但需要知道内存所在位置,这样才能做 diff。

NetSerializeNetDeltaSerialize 将在之后介绍。

ShadowMemory

ShadowMemory 在属性同步中是一个重要的概念。当属性改变时,需要执行同步,那么首要工作就是发现哪些属性改变了,属性的哪些部分改变了,这样才能把改变的部分同步下去。

一种方式为改变属性后触发一个回调函数,告诉 UE 我们改了什么,但这样严重影响效率,因为改变属性是高频操作,会触发海量的回调,而且如果属性出现 A->B->A 形式的修改,其实最终没改变,不需要修改,但触发了几次无意义的回调。

因此 UE 使用了另一种方式,存储了一份同步属性的历史副本,会间隔一段时机把属性最新值和副本比较,从而发现哪些属性发生了改变。整个副本称为 ShadowMemory。引擎代码中对应实现的变量名为 ShadowBuffer

每个 Actor 实例都有自己的 ShadowBuffer,因为属性各自独立。ShadowBuffer 大小为所有 Replicated 属性之和,在加上一些内存对齐消耗,可以认为是 Actor 属性的一个子集。运行时,只遍历 Repilcated 属性,然后去 ShadowBuffer 中找历史值比较即可。

继续上面的例子,创建的 ShadowBuffer 如下:

类的属性中,只有 a,b,c 需要创建 ShadowBuffer,d 因为不同步,因此不需要加入 ShadowBuffer。然后由于内存对齐需要(实际 8 byte 对齐),a 属性后面有 4 byte 填充,这也会反应到 ShadowBuffer 中。乍看之下,属性同步还是会多使用不少内存空间的。

创建&初始化

当 NetDriver 要同步 Actor 时,会检查该 Class 是否有 RepLayout,没有则调用 FRepLayout::CreateFromClass 函数创建。

创建后,再调用 FRepLayout::InitFromClass 函数进行初始化。

void FRepLayout::InitFromClass(
	UClass* InObjectClass,
	const UNetConnection* ServerConnection,
	const ECreateRepLayoutFlags CreateFlags)
{
  • 首先对于 InObjectClass 的 FProperty,要设置它们的 RepIndex 值,与 ENetFields 对应。之后初始化 Class 的 ClassReps 属性,其中存储了从基类到当前类的所有要同步 Property 和它们的 ArrayIndex,后者一般都为 0。
  • 之后对于 ClassReps 中每一个 Property,要在 Parents 中添加一条 RepParentCmd 记录,并记下下标 ParentHandle。然后根据 Property 类型,向 Cmds 数组添加 Cmd 实例, ArrayProperty 和 StructProperty 需要特殊处理,添加多个子 Cmd,其余普通属性则直接向 Cmds 数组添加一个 Cmd 实例即可。添加的第一个子 Cmd 下标设置为 RepParentCmd.CmdStart ,最后一个子 Cmd 下标设置为 RepParentCmd.CmdEnd
  • 处理完所有 ClassReps,会向 Cmds 数组中再添加一个 ERepLayoutCmdType::Return Cmd,表示该 UClass 的 Property 处理结束。
  • 之后调用前文提到的 GetLifetimeReplicatedProps 函数,得到 FLifetimeProperty 数组,然后用它来初始化 Parents 数组,设置 Condition,RepNotifyCondition,RepNotifyNumParams 等属性。
  • 对于 Actor.RemoteRole 属性,需要特殊处理,RemoteRole 虽然会同步,但 server 和 client 的值不同。此时会把该属性在 Parents 数组中对应的 RepParentCmd.Condition 设成 COND_None,并设置 ERepParentFlags::IsConditional 标记。Remote 和 Role 的实现原理在之后会介绍。
  • 执行 BuildHandleToCmdIndexTable_r 函数,建立 Handle 到 Cmd 数组的映射,主要因为 Array 需要特殊处理。
  • 执行 BuildShadowOffsets 函数,计算各 Cmd 属性在 ShadowBuffer 中的偏移,记录在 ShadowOffset 属性中。

至此,该 Class 对应的 Replayout 已初始化完成。此时还未创建 ShadowBuffer,注意 Replayout 和类型一一对应,ShadowBuffer 和实例一一对应。

创建ShadowBuffer

当初次同步一个 Actor 实例时,需要为其创建 ShadowBuffer。ShadowBuffer 最终是一个属性,但嵌套层次比较深。

这里涉及 UE 的属性同步组件,先做下简要介绍

  • ActorChannel: 负责传输 Actor 及 Component 的属性和 RPC,创建一个 Actor 连接并维护连接。
  • ActorReplicator: 真正负责处理属性同步和 rpc。
  • ChangelistMgr: 维护了属性的变动历史,用于确定要属性同步要发送的内容。
  • RepChangelistState: 实际存储属性变动历史
  • StaticBuffer:ShadowBuffer

初次同步 Actor,首先要创建 ActorChannel 和 ActorReplicator,然后再依次创建 ChangelistMgr 和 RepChangelistState。此时必然已执行过之前的初始化操作,有了 RepLayout 实例,也知道 ShadowBuffer 大小,通过 FRepLayout::CreateShadowBuffer 函数创建ShadowBuffer。创建完后,会把 Actor 当前最新属性复制进 ShadowBuffer。

image.png

猜你喜欢

转载自juejin.im/post/7126827604664909860