Unreal Engine 的属性同步

Unreal Engine的属性同步机制基本介绍

UE中的属性同步系统与上文中介绍的基于property_cmd的属性同步机制很不一样。前面介绍的属性同步系统在任意属性在修改时都会创建一个数据包来包含此修改信息,属性的修改数据包按序发送到客户端,客户端再按序回放修改。而UE的属性同步系统则不基于单次属性修改,而是基于游戏逻辑的定期Tick来进行属性对比。对于每个网络同步的AActor,都会创建一个ShadowBuffer来记录上次对比之后的属性值。在NetDriver::Tick时,会执行到UNetDriver::ServerReplicateActors,这个函数会遍历所有网络同步的Actor,将这个Actor上的属性最新值与对应的ShadowBuffer进行对比,将每个有变化的属性字段都生成一个带有连续自增ID的属性变化通知数据包,并存储在一个ChangedHistory队列之中。对于同步了此Actor的所有客户端连接里的对应ActorChannel,会记录此客户端已经回复收到的属性变化通知数据包的最大连续ID,这个IDChangedHistory中记录的最大ID时间的所有属性变化通知数据包都将被打包发送到当前的ActorChannel中。不过这里的属性同步网络包会被标记为不可靠数据包(只有当此AActorChannel第一次打开时才标记为可靠数据包),因此丢包之后并不会触发重传。当服务器收到客户端发过来的一个属性包ACK之后,这个ActorChannel记录的属性变化最大ID才会被更新。在这种ACK之后再更新的机制下,即使属性同步包丢失也没有多大影响,因为后续的属性差异diff会将需要同步给客户端的数据重新计算出来。然后重复的发包也不会有问题,因为客户端在发现属性的新值与旧值一样时,逻辑会进行忽略。

Unreal Engine中的同步属性声明

Unreal Engine中的属性同步依托于整个Actor的同步,Unreal在默认情况下Actor网络同步的网络同步是关闭的。要开启特定Actor的网络同步需要手动设置ActorbReplicates属性为true,也可以调用AActor::SetReplicates方法来设置该变量。


AShooterCharacter::AShooterCharacter ()
{
    SetReplicates(true);
}

但是Actor启用网络同步后并不是所有的属性都会同步,只有将对应属性标记为需要网络同步后,Unreal才会同步该属性。所需要做的是在UPROPERTY中添加Replicated,这代表该属性需要网络同步,下面展示了一小段来自ShooterGameShooterCharacter.h里声明的一些:

/** current targeting state */
UPROPERTY(Transient, Replicated)
uint8 bIsTargeting : 1;

还需要为所需同步属性设置Lifetime,覆写AActor::GetLifetimeReplicatedProps方法,该函数用于定义哪些属性需要在网络同步。这个函数接收一个DOREPLIFETIME宏生成的属性数组,最后在数组中的每个元素都代表一个需要同步的属性。

void AShooterCharacter::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
    Super::GetLifetimeReplicatedProps(OutLifetimeProps);
    // Replicate to everyone
    DOREPLIFETIME(AShooterCharacter, bIsTargeting);
}

在上面的属性同步声明中,设置了AShooterCharacter::bIsTargeting这个变量为往所有包含了此Actor的客户端进行该属性的同步推送。除了默认的往所有客户端同步之外,这里还有一些额外选项来设置同步条件:

// 只在客户端创建对应actor的时候同步此属性 属性的后续修改不参与同步
DOREPLIFETIME_CONDITION(AShooterCharacter, bIsTargeting, COND_InitialOnly);
// 只同步给当前actor的主控客户端
DOREPLIFETIME_CONDITION(AShooterCharacter, bIsTargeting, COND_OwnerOnly);
// 只同步给非主控客户端
DOREPLIFETIME_CONDITION(AShooterCharacter, bIsTargeting, COND_SkipOwner);

除了基本的值类型可以参与属性同步之外,UE的属性同步能支持所有能被RPC发送的数据类型,例如TArray, AActor*/UActorComponent*以及通过UStruct声明的结构体,下面是其他的几个定义在AShooterCharacter上的同步属性:

// ShooterTypes.h
/** replicated information on a hit we've taken */
USTRUCT()
struct FTakeHitInfo
{
	GENERATED_USTRUCT_BODY()

	/** The amount of damage actually applied */
	UPROPERTY()
	float ActualDamage;

	/** The damage type we were hit with. */
	UPROPERTY()
	UClass* DamageTypeClass;

	// other codes
};

// ShooterCharacter.h
/** weapons in inventory */
UPROPERTY(Transient, Replicated)
TArray<class AShooterWeapon*> Inventory;

/** currently equipped weapon */
UPROPERTY(Transient, ReplicatedUsing = OnRep_CurrentWeapon)
class AShooterWeapon* CurrentWeapon;

/** Replicate where this pawn was last hit and damaged */
UPROPERTY(Transient, ReplicatedUsing = OnRep_LastTakeHitInfo)
struct FTakeHitInfo LastTakeHitInfo;

注意后面两个属性的UPROPERTY宏,没有用Replicated而是使用了ReplicatedUsing=xxxx形式,这种形式的声明代表如果客户端同步到了新的属性值,则对应的xxxx函数会被调用来作为属性通知:

/** play hit or death on client */
UFUNCTION()
void OnRep_LastTakeHitInfo();
/** current weapon rep handler */
UFUNCTION()
void OnRep_CurrentWeapon(class AShooterWeapon* LastWeapon);

这个xxxx函数的声明支持两种函数签名类型,一种是带修改之前的属性值作为参数的void函数,另外一种是不带任何参数的void函数,带参数的函数可以方便客户端自己处理之前属性值的卸载逻辑,根据客户端自身逻辑选择一种声明即可。有了属性通知函数之后,可以省下服务器端通知客户端进行特定属性更新后处理的RPC

除了可以在AActor上声明同步属性之外,UE还支持在UActorComponent上以同样的方式声明同步属性,当某个UActorComponent参与到AActor的网络同步时,此UActorComponent上的同步属性也会被打包发送到拥有此AActor的客户端,后续的属性更新也会走这个AActorChannel进行推送。

将一个UActorComponent设置为参与网络同步有两种方式。如果这个UActorComponent是静态存在于某个AActor里的,则可以以下面的形式来参与网络同步:

AMyActor::AMyActor()
{
	bReplicates = true;
	MyActorComponent = CreateDefaultSubobject<UMyActorComponent>(TEXT("MyActorComponent"));
}
UMyActorComponent::UMyActorComponent()
{
	SetIsReplicatedByDefault(true);
}

如果此UActorComponent是运行时按需添加到某个AActor之上的,则在添加之后调用此接口来参与网络同步:

if (MyActorComponent)
{
	MyActorComponent->SetIsReplicated(true);
}

同步属性的注册收集

我们先来介绍一个FRepLayout,这个结构体记录了当前Actor所有需要同步的属性,创建的时候先根据对象所对应的UClass来初始化:

void FRepLayout::InitFromClass(
	UClass* InObjectClass,
	const UNetConnection* ServerConnection,
	const ECreateRepLayoutFlags CreateFlags);

内部会调用这个UClass::SetUpRuntimeReplicationData函数来收集所有参与网络同步的UProperty,并分配一个唯一标识符

void UClass::SetUpRuntimeReplicationData()
{
	if (!HasAnyClassFlags(CLASS_ReplicationDataIsSetUp) && PropertyLink != NULL)
	{
		if (UClass* SuperClass = GetSuperClass())
		{
			SuperClass->SetUpRuntimeReplicationData();
			// 如果有父类 则先复制父类里的所有同步属性描述信息
			ClassReps = SuperClass->ClassReps;
			FirstOwnedClassRep = ClassReps.Num();
		}
		else
		{
			ClassReps.Empty();
			FirstOwnedClassRep = 0;
		}

		// Track properties so me can ensure they are sorted by offsets at the end
		TArray<FProperty*> NetProperties;
		// 遍历当前类上定义的所有UProperty 排除父类
		for (TFieldIterator<FField> It(this, EFieldIteratorFlags::ExcludeSuper); It; ++It)
		{
			if (FProperty* Prop = CastField<FProperty>(*It))
			{
				// 如果propertyFlags里有cpf_net 则代表参与网络同步 等价于增加了Replicated 或者ReplicatedUsing
				if ((Prop->PropertyFlags & CPF_Net) && Prop->GetOwner<UObject>() == this)
				{
					NetProperties.Add(Prop);
				}
			}
		}

		ClassReps.Reserve(ClassReps.Num() + NetProperties.Num());
		for (int32 i = 0; i < NetProperties.Num(); i++)
		{
			NetProperties[i]->RepIndex = ClassReps.Num();
			for (int32 j = 0; j < NetProperties[i]->ArrayDim; j++)
			{
				new(ClassReps)FRepRecord(NetProperties[i], j);
			}
		}
	}
}

其实每个参与网络同步UProperty被分配的序号与xxx.generated.h定义的ENetFields_Private是相等的。

// shootercharacter.generated.h

enum class ENetFields_Private : uint16 \
{ \
	NETFIELD_REP_START=(uint16)((int32)Super::ENetFields_Private::NETFIELD_REP_END + (int32)1), \
	Inventory=NETFIELD_REP_START, \
	CurrentWeapon, \
	LastTakeHitInfo, \
	bIsTargeting, \
	bWantsToRun, \
	Health, \
	NETFIELD_REP_END=Health	}; \

这里的所有网络同步属性字段都分配了一个唯一值,且是连续的整数,最小值等于父类里定义的ENetFields_Private的最大值加1,这样可以保证字段索引不会与继承链上的任意类定义的属性字段冲突,同时尽可能的将属性字段索引的值变小。

然后UHTxxx.gen.cpp里生成了每个UProperty的描述信息,这里以CurrentWeapon为例展示其描述代码:

const UE4CodeGen_Private::FObjectPropertyParams Z_Construct_UClass_AShooterCharacter_Statics::NewProp_CurrentWeapon = { "CurrentWeapon", "OnRep_CurrentWeapon", (EPropertyFlags)0x0020080100002020, UE4CodeGen_Private::EPropertyGenFlags::Object, RF_Public|RF_Transient|RF_MarkAsNative, 1, STRUCT_OFFSET(AShooterCharacter, CurrentWeapon), Z_Construct_UClass_AShooterWeapon_NoRegister, METADATA_PARAMS(Z_Construct_UClass_AShooterCharacter_Statics::NewProp_CurrentWeapon_MetaData, UE_ARRAY_COUNT(Z_Construct_UClass_AShooterCharacter_Statics::NewProp_CurrentWeapon_MetaData)) };

注意这里的(EPropertyFlags)0x0020080100002020,这是一个uint64值,我们声明UProperty时指定的Replicated字段会在这个flag的对应位置设置为1.

然后UHT会生成一些注册代码来收集当前类上的所有UProperty,并用来初始化当前UClass的注册信息:

const UE4CodeGen_Private::FPropertyParamsBase* const Z_Construct_UClass_AShooterCharacter_Statics::PropPointers[] = {
	// other props
	(const UE4CodeGen_Private::FPropertyParamsBase*)&Z_Construct_UClass_AShooterCharacter_Statics::NewProp_CurrentWeapon,
	// other props
};

const UE4CodeGen_Private::FClassParams Z_Construct_UClass_AShooterCharacter_Statics::ClassParams = {
	&AShooterCharacter::StaticClass,
	"Game",
	&StaticCppClassTypeInfo,
	DependentSingletons,
	FuncInfo,
	Z_Construct_UClass_AShooterCharacter_Statics::PropPointers, // 当前类的所有属性定义
	nullptr,
	UE_ARRAY_COUNT(DependentSingletons),
	UE_ARRAY_COUNT(FuncInfo),
	UE_ARRAY_COUNT(Z_Construct_UClass_AShooterCharacter_Statics::PropPointers),
	0,
	0x008000A5u,
	METADATA_PARAMS(Z_Construct_UClass_AShooterCharacter_Statics::Class_MetaDataParams, UE_ARRAY_COUNT(Z_Construct_UClass_AShooterCharacter_Statics::Class_MetaDataParams))
};

有了上述注册信息之后,我们就可以初始化一个UClass对应的FRepLayout结构了,主要包括如下几个数据成员:

/** 以此FRepLayout创建的ShadowBuffer内存大小  */
int32 ShadowDataBufferSize;

/** 最顶层的同步属性描述信息 */
TArray<FRepParentCmd> Parents;

/** 细化后的最底层同步属性描述信息*/
TArray<FRepLayoutCmd> Cmds;

/** 存储业务层使用的handler到Cmds数组里索引的映射 */
TArray<FHandleToCmdIndex> BaseHandleToCmdIndex;

Cmd用于指导一个Property如何同步,分为RepParentCmdRepLayoutCmd。一个参与网络同步的Property对应一个FRepParentCmd,同时对应一个或多个FRepLayoutCmd,对应关系与这个Property的类型相关。普通Property,如intbool,他的FRepParentCmd只会关联一个FRepLayoutCmd,用于描述同步细节。而复杂的Property,如TArrayUStruct,会关联多个FRepLayoutCmd

class FRepParentCmd
{
public:
	FProperty* Property; // 对应的Property描述结构
	const FName CachedPropertyName; // 对应的Property名字
	// 如果是C类型静态数组声明的同步属性 则数组里每个元素都会生成一个FRepParentCmd 内部的ArrayIndex则为元素索引
	// 其他情况下此字段为0
	int32 ArrayIndex;

	// 此属性对应内存地址相对于this指针的偏移 
	int32 Offset;
	// 此属性在ShadowBuffer中的偏移
	int32 ShadowOffset;

	// 此FRepParentCmd对应的连续的FRepLayoutCmd 为 Cmds[CmdStart, CmdEnd) 左开右闭
	uint16 CmdStart;
	uint16 CmdEnd;

	// 这是是UProperty宏里提供的同步条件 回调函数等信息
	ELifetimeCondition Condition;
	ELifetimeRepNotifyCondition RepNotifyCondition;
	int32 RepNotifyNumParams;
	ERepParentFlags Flags;
};

FRepParentCmd主要提供同步条件和回调函数等信息,真正参与执行属性Diff的是FRepLayoutCmd,下面是这个结构体的完整声明:

class FRepLayoutCmd
{
public:

	// 对应的具体Property指针 如果parent是Ustruct 则会指向struct里的property成员
	FProperty* Property;

	// 对于array类型 指向其parent的cmdend 用来跳过后续成员
	uint16 EndCmd;

	// 对于Tarray来说其成员的大小
	uint16 ElementSize;

	// 当前Property变量在对象中的内存偏移
	int32 Offset;

	// 当前property在shadowbuffer中的内存偏移
	int32 ShadowOffset;

	// 当前Cmd在Cmds数组里的索引+1
	uint16 RelativeHandle;

	// 其对应的FRepParentCmd的索引
	uint16 ParentIndex;

	// 校验比对用的checksum
	uint32 CompatibleChecksum;

	// 当前property的数据类型
	ERepLayoutCmdType Type;
	ERepLayoutCmdFlags Flags;
};

这里的UStruct生成FRepLayoutCmd又有两个特例:

  1. 如果一个UStruct自己实现了NetDeltaSerialize这个UE规定的增量Diff接口,则不会生成FRepLayoutCmd,不参与通用Diff流程。

  2. 如果一个UStruct自己实现了NetSerialize这个UE规定的数据打包接口,则只生成一个FRepLayoutCmd,该UStruct将当作一个整体来参与同步

FRepLayoutCmdType字段记录了当前具体Property的数据类型,是一个枚举值:

/** Various types of Properties supported for Replication. */
enum class ERepLayoutCmdType : uint8
{
	DynamicArray			= 0,	//! Dynamic array
	Return					= 1,	//! Return from array, or end of stream
	Property				= 2,	//! Generic property

	PropertyBool			= 3,
	PropertyFloat			= 4,
	PropertyInt				= 5,
	PropertyByte			= 6,
	PropertyName			= 7,
	PropertyObject			= 8,
	PropertyUInt32			= 9,
	PropertyVector			= 10,
	// other types
};

这里除了我们熟知的各种基本类型和UObject指针类型之外,比较显眼的就是开头的DynamicArrayReturn,这两个都是用来描述TArray<T>Property信息的。TArray<T>FRepParentCmd的展开规则如下:

  1. 首先生成一个ERepLayoutCmdType::DynamicArray类型的FRepLayoutCmd,代表TArray的开始
  2. 生成T类型的FRepParentCmd类型对应的所有FRepLayoutCmd
  3. 生成一个ERepLayoutCmdType::Return类型的一个FRepLayoutCmd,代表当前TArray展开结束。

这里需要注意, T可能是UStruct或者TArray,或者多层嵌套,所以可能会有递归展开过程。

下图中就比较生动形象的展示出了有三个同步属性int a; FRepAttachment b; TArray<int> c;UObject对应的完整FRepLayout构建过程:

ue的Freplayout

UE属性的Diff

属性diff的发起者在FObjectReplicator::ReplicateProperties中:

FSendingRepState* SendingRepState = (bUseCheckpointRepState && CheckpointRepState.IsValid()) ? CheckpointRepState->GetSendingRepState() : RepState->GetSendingRepState();

const ERepLayoutResult UpdateResult = FNetSerializeCB::UpdateChangedHistoryMgr(*RepLayout, SendingRepState, *ChangedHistoryMgr, Object, Connection->Driver->ReplicationFrame, RepFlags, OwningChannel->bForceCompareProperties || bUseCheckpointRepState);


// 调用FRepLayout的属性diff接口
const bool bHasRepLayout = RepLayout->ReplicateProperties(SendingRepState, ChangedHistoryMgr->GetRepChangedHistoryState(), (uint8*)Object, ObjectClass, OwningChannel, Writer, RepFlags);

// 处理一些自己提供了NetDeltaSerialize的属性
ReplicateCustomDeltaProperties(Writer, RepFlags);

这里引入了FRepChangedHistoryState这个结构,用于管理属性的ChangedHistoryShadowBuffer。属性可以多次发生改变,UE需要在每次比较属性时记录下哪些属性改变了,并把属性对应CmdRelativeHandle存储在RepChangedHistoryState中,称为ChangedHistoryChangedHistoryStateActor一一对应,多个ClientConnection间共享。下面是FRepChangedHistoryState的完整定义:

/** 环形队列最大元素个数 */
static const int32 MAX_CHANGE_HISTORY = 64;

/** 环形队列数组  */
FRepChangedHistory ChangeHistory[MAX_CHANGE_HISTORY];

/** 有些自定义了CustomDelta函数属性的ChangedHistory 一般用不到 此处暂时忽略 */
TUniquePtr<struct FCustomDeltaChangedHistoryState> CustomDeltaChangedHistoryState;

/** 环形队列的开始元素的索引 指向最早的ChangedHistory*/
int32 HistoryStart;

/** 环形队列的结束元素索引 指向最新的ChangedHistory */
int32 HistoryEnd;

/*属性被比较的次数*/
int32 CompareIndex;

/** 服务端存储的当前actor所有同步属性的最新值 即ShadowBuffer*/
FRepStateStaticBuffer StaticBuffer;

/** 序列化之后的共享数据 */
FRepSerializationSharedInfo SharedSerialization;

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

FNetSerializeCB::UpdateChangedHistoryMgr直接转发到RepLayout::UpdateChangedHistoryMgr并最终调用到FRepLayout::CompareProperties来真正进行属性diff操作。这个函数首先从ChangedHistory里获取HistoryEnd对应的FRepChangedHistory:

// 更新属性比较次数统计
RepChangedHistoryState->CompareIndex++;

// 从循环队列里找到HistoryEnd对应的Item
const int32 HistoryIndex = RepChangedHistoryState->HistoryEnd % FRepChangedHistoryState::MAX_CHANGE_HISTORY;
FRepChangedHistory& NewHistoryItem = RepChangedHistoryState->ChangeHistory[HistoryIndex];
// 标记哪些cmd有diff需要同步
TArray<uint16>& Changed = NewHistoryItem.Changed;

然后调用CompareParentProperties开始遍历所有的FRepParentCmddiff,并将diff里需要同步的FRepLayoutCmdHandler写入到NewHistoryItem.Changed数组中:

FComparePropertiesSharedParams SharedParams{
	/*bIsInitial=*/ !!RepFlags.bNetInitial,
	/*bForceFail=*/ false,
	Flags,
	Parents,
	Cmds,
	RepState,
	RepChangedHistoryState,
	(RepState ? RepState->RepChangedPropertyTracker.Get() : nullptr),
	NetSerializeLayouts,
	/*PushModelState=*/UE4_RepLayout_Private::GetPerNetDriverState(RepChangedHistoryState),
	/*PushModelProperties=*/ LocalPushModelProperties,	
	/*bValidateProperties=*/GbPushModelValidateProperties,
	/*bIsNetworkProfilerActive=*/UE4_RepLayout_Private::IsNetworkProfilerComparisonTrackingEnabled(),
	/*bChangedNetOwner=*/ RepState && RepState->RepFlags.bNetOwner != RepFlags.bNetOwner
};

FComparePropertiesStackParams StackParams{
	Data,
	RepChangedHistoryState->StaticBuffer.GetData(),
	Changed,
	Result
};

CompareParentProperties(SharedParams, StackParams);

这里的Compare其实就是对每个ParentCmd进行遍历,调用CompareProperties_r

static void CompareParentProperties(
	const FComparePropertiesSharedParams& SharedParams,
	FComparePropertiesStackParams& StackParams)
{
	for (int32 ParentIndex = 0; ParentIndex < SharedParams.Parents.Num(); ++ParentIndex)
	{
		UE4_RepLayout_Private::CompareParentPropertyHelper(ParentIndex, SharedParams, StackParams);
	}
}
static bool CompareParentPropertyHelper(
	const int32 ParentIndex,
	const FComparePropertiesSharedParams& SharedParams,
	FComparePropertiesStackParams& StackParams)
{
	const bool bDidPropertyChange = CompareParentProperty(ParentIndex, SharedParams, StackParams);
	return bDidPropertyChange;
}
static bool CompareParentProperty(
	const int32 ParentIndex,
	const FComparePropertiesSharedParams& SharedParams,
	FComparePropertiesStackParams& StackParams)
{
	const FRepParentCmd& Parent = SharedParams.Parents[ParentIndex];

	const FRepLayoutCmd& Cmd = SharedParams.Cmds[Parent.CmdStart];
		
	const int32 NumChanges = StackParams.Changed.Num();

	// Note, Handle - 1 to account for CompareProperties_r incrementing handles.
	CompareProperties_r(SharedParams, StackParams, Parent.CmdStart, Parent.CmdEnd, Cmd.RelativeHandle - 1);

	return !!(StackParams.Changed.Num() - NumChanges);
}

CompareProperties_r负责每个ParentCmd对应的FRepLayoutCmd进行详细、底层的属性比较,然后更新StaticBuffer并把RelativeHandle写入Changed数组。在FRepLayoutCmd中对具体的Property做比较,又分为了两种情况:

  1. 普通Property,使用PropertiesAreIdenticalNative函数进行比较,根据属性类型采用不同比较方式。对大部分属性,直接把当前对象内存和ShadowBuffer内存Cast成对应属性,然后用"=="比较。
template<typename T>
bool CompareValue(const T * A, const T * B)
{
	return *A == *B;
}

有些属性比较特殊,需要使用FPropertyIdentical接口进行比较,比如Bool只占据一个bit,一般使用位域来声明多个连续的bool属性,这样可以共用一个byte存储,避免padding引发各种内存浪费。此时直接Cast<bool>其实比较了一个byte,这样的结果是不对的,需要使用FBoolProperty::Identical进行比较,该函数定义如下:

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;
}

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

  1. 动态数组类型的Property,即TArray。这里的比较就复杂了,因为TArray内存储Item内容的内存并不是在ShadowBuffer中,需要调用CompareProperties_Array_r来进行比较,这个函数会首先将ShadowBuffer中对应的TArray调用resize方法进行扩容或者缩容,使其与最新的TArray的大小保持一致,然后遍历所有的Item来调用CompareProperties_r。不过这里的CompareProperties_r不能用原来传入的Changed数组来记录哪些Item发生了变化,此时需要新建一个ChangedLocal数组来记录变化了的Item的索引。 CompareProperties_Array_r内部比较结束之后,需要下发Diff数据有两种情况:

    1. ChangedLocal不是空的,代表扩缩容之后的ShadowArray里存在一个或者多个Item与最新TArray结果不一样,此时往Changed数组中写入下列内容:
    const int32 NumChangedEntries = ChangedLocal.Num();
    StackParams.Changed.Add(Handle); // 这里的handle是当前tarray的handle
    StackParams.Changed.Add((uint16)NumChangedEntries);	
    StackParams.Changed.Append(ChangedLocal); // 写入所有有变化的Item的索引+1 这样避免0值
    StackParams.Changed.Add(0);// 代表当前TArray的修改数据结束
    
    1. TArray的长度减少,但是剩余元素的内容与ShadowBuffer中记录的内容一致,此时ChangedLocal是空的,但是我们需要通知客户端TArray长度缩减了:
    StackParams.Changed.Add(Handle);// 这里的handle是当前tarray的handle
    StackParams.Changed.Add(0); // 现存元素里没有变化
    StackParams.Changed.Add(0); // 代表当前TArray的修改数据结束
    

这里我们发现:如果TArray只进行了扩容,也不会更新Changed数组,导致服务端TArray扩容之后,客户端感知不到新的数组大小。业务方要避免客户端出现依赖此情况的代码逻辑。

CompareParentProperties对比完成之后,将在Changed数组末尾添加一个标志元素0,代表结果结束,因为FRepLayoutCmd::RelativeHandle是从1开始计数的。然后执行RepChangedHistoryState->HistoryEnd++来标记此Item已经被使用,同时加入环形队列是否已经没有剩余元素,如果满了则将(HistoryStart, HistoryStart+1)对应的两个Item合并到HistoryStart+1, 同时HistoryStart++,以确保RepChangedHistoryState->HistoryEnd对应的Item永远都是空闲可用的。

前述的UpdateChangedHistoryMgr流程是对于一个ClientConnection更新时做的,而Server可用同时连接多个ClientConnection,对每个Client都比较一次ShadowBuffer显然没有必要,比较结果可用在多个ClientConnection间复用。当一次UpdateChangedHistoryMgr执行结束后,UE会把当前Frame存储在ChangedHistoryMgr中,表示ChangedHistoryMgr上次更新的Frame。当下次更新ClientConnection进入UpdateChangedHistoryMgr函数时,如果发现当前FrameChangedHistoryMgr中记录的Frame相同,则表示当前Frame已做过比较。因为服务器属性同步的更新发生在一个FrameTick的末尾,此后再无其他逻辑,所以可以保证Frame相同时,属性也是相同的。

UE 属性Diff结果的下发

目前为止,我们知道了改变属性对应的Handle列表以及一些辅助数据,接下来需要根据这些Handle找到对应属性,然后序列化属性内容,发送到ActorChannel之中。之前我们提到了属性同步相关数据包不是reliable的,相关Bunch丢失之后不会触发重传,为了后续将丢失的属性信息构造新的Bunch下发下去,每个ActorChannel里维护了一个FSendingRepState结构来记录已经下发的属性Bunch信息,其主要成员变量是一个FRepChangedHistory的循环队列:

class FSendingRepState
{
	// 循环队列的开始与结束索引
	int32 HistoryStart;
	int32 HistoryEnd;

	// 当前state上遇到了多少丢失的包
	int32 NumNaks;

	 // 上次从对应Actor的FRepChangelistState同步数据时的FRepChangelistState::HistoryEnd
	int32 LastChangelistIndex;

	// 上次从对应Actor的FRepChangelistState同步数据时的FRepChangelistState::CompareIndex
	int32 LastCompareIndex;

	static constexpr int32 MAX_CHANGE_HISTORY = 32;
	FRepChangedHistory ChangeHistory[MAX_CHANGE_HISTORY];
}

每次一个ActorChannel往下发送属性同步数据时,先获取对应ActorFRepChangelistState::HistoryEnd,然后将[FSendingRepState::HistoryEnd, FRepChangelistState::HistoryEnd)之间的所有FRepChangedHistory进行合并,并填充到FSendingRepState::ChangeHistory这个循环队列的末尾:

// FRepLayout::ReplicateProperties
RepState->LastCompareIndex = RepChangelistState->CompareIndex;

const int32 PossibleNewHistoryIndex = RepState->HistoryEnd % FSendingRepState::MAX_CHANGE_HISTORY;
FRepChangedHistory& PossibleNewHistoryItem = RepState->ChangeHistory[PossibleNewHistoryIndex];
TArray<uint16>& Changed = PossibleNewHistoryItem.Changed;

for (int32 i = RepState->LastChangelistIndex; i < RepChangelistState->HistoryEnd; ++i)
{
	const int32 HistoryIndex = i % FRepChangelistState::MAX_CHANGE_HISTORY;

	FRepChangedHistory& HistoryItem = RepChangelistState->ChangeHistory[HistoryIndex];

	TArray<uint16> Temp = MoveTemp(Changed);
	MergeChangeList(Data, HistoryItem.Changed, Temp, Changed);
}

RepState->LastChangelistIndex = RepChangelistState->HistoryEnd;
RepState->HistoryEnd++;

上面的代码解释了FSendingRepState追赶FRepChangelistState的过程,最终会生成一个Changed数组。追赶完成之后,此FSendingRepState还要对设置了属性同步条件的属性进行过滤。默认设置下标明了Replicated的属性会往所有包含了对应ActorChannel的客户端连接同步,但是根据逻辑需求,我们可以标记某个属性的额外同步条件,是否OwnerOnly是否SimulatedOnly等。在往客户端下发之前,我们需要过滤掉一些与当前NetConnection不相关的属性,为此FSendingRepState中还声明了一个InactiveParents的成员变量:

/** Cached set of inactive parent commands. */
TBitArray<> InactiveParents;

这里的RepState->InactiveParents是一个BitArray,元素大小为FParentCmd数组的大小,如果InactivateParent[Index]的值为true,则代表FReplayout::Parents[Index]在当前FSendingRepState中不需要同步。这个InactiveParents初始化时所有的值为false,再根据FSendingRepStateRepFlags来计算出每个FParentCmd对应bit正确的值:

TStaticBitArray<COND_Max> FSendingRepState::BuildConditionMapFromRepFlags(const FReplicationFlags RepFlags)
{
	TStaticBitArray<COND_Max> ConditionMap;

	// Setup condition map
	const bool bIsInitial = RepFlags.bNetInitial ? true : false;
	const bool bIsOwner = RepFlags.bNetOwner ? true : false;
	const bool bIsSimulated = RepFlags.bNetSimulated ? true : false;
	const bool bIsPhysics = RepFlags.bRepPhysics ? true : false;
	const bool bIsReplay = RepFlags.bReplay ? true : false;

	ConditionMap[COND_None] = true;
	ConditionMap[COND_InitialOnly] = bIsInitial;

	ConditionMap[COND_OwnerOnly] = bIsOwner;
	ConditionMap[COND_SkipOwner] = !bIsOwner;

	ConditionMap[COND_SimulatedOnly] = bIsSimulated;
	ConditionMap[COND_SimulatedOnlyNoReplay] = bIsSimulated && !bIsReplay;
	ConditionMap[COND_AutonomousOnly] = !bIsSimulated;

	ConditionMap[COND_SimulatedOrPhysics] = bIsSimulated || bIsPhysics;
	ConditionMap[COND_SimulatedOrPhysicsNoReplay] = (bIsSimulated || bIsPhysics) && !bIsReplay;

	ConditionMap[COND_InitialOrOwner] = bIsInitial || bIsOwner;
	ConditionMap[COND_ReplayOrOwner] = bIsReplay || bIsOwner;
	ConditionMap[COND_ReplayOnly] = bIsReplay;
	ConditionMap[COND_SkipReplay] = !bIsReplay;

	ConditionMap[COND_Custom] = true;
	ConditionMap[COND_Never] = false;

	return ConditionMap;
}

void FRepLayout::RebuildConditionalProperties(
	FSendingRepState* RESTRICT RepState,
	const FReplicationFlags& RepFlags) const
{
	SCOPE_CYCLE_COUNTER(STAT_NetRebuildConditionalTime);
	
	TStaticBitArray<COND_Max> ConditionMap = FSendingRepState::BuildConditionMapFromRepFlags(RepFlags);
	for (auto It = TBitArray<>::FIterator(RepState->InactiveParents); It; ++It)
	{
		It.GetValue() = !ConditionMap[Parents[It.GetIndex()].Condition];
	}

	RepState->RepFlags = RepFlags;
}
// FRepLayout::ReplicateProperties
if (RepState->RepFlags.Value != RepFlags.Value)
{
	RebuildConditionalProperties(RepState, RepFlags);

}

有了这个InactiveParents数组之后,我们就可以执行Changed数组里的Handle相关性过滤了:

void FRepLayout::FilterChangeList(
	const TArray<uint16>& Changelist,
	const TBitArray<>& InactiveParents,
	TArray<uint16>& OutInactiveProperties,
	TArray<uint16>& OutActiveProperties) const
{
	FChangelistIterator ChangelistIterator(Changelist, 0);
	FRepHandleIterator HandleIterator(Owner, ChangelistIterator, Cmds, BaseHandleToCmdIndex, 0, 1, 0, Cmds.Num() - 1);

	OutInactiveProperties.Empty(1);
	OutActiveProperties.Empty(1);

	while (HandleIterator.NextHandle())
	{
		const FRepLayoutCmd& Cmd = Cmds[HandleIterator.CmdIndex];
		// 需要同步的放到OutActiveProperties数组中 不需要的则放到OutInactiveProperties数组中
		TArray<uint16>& Properties = InactiveParents[Cmd.ParentIndex] ? OutInactiveProperties : OutActiveProperties;
			
		Properties.Add(HandleIterator.Handle);
	}
}

// FRepLayout::ReplicateProperties
// Filter out the final changelist into Active and Inactive.
TArray<uint16> UnfilteredChanged = MoveTemp(Changed);
TArray<uint16> NewlyInactiveChangelist;
FilterChangeList(UnfilteredChanged, RepState->InactiveParents, NewlyInactiveChangelist, Changed);

Changed数组经过InActiveParent过滤之后生成一个新的Changed数组,接下来我们利用此Changed数组来生成数据包, 这部分是通过FRepLayout::SendProperties函数来实现的:

FChangedHistoryIterator ChangedHistoryIterator(Changed, 0);
FRepHandleIterator HandleIterator(Owner, ChangedHistoryIterator, Cmds, BaseHandleToCmdIndex, 0, 1, 0, Cmds.Num() - 1);

SendProperties_r(RepState, Writer, bDoChecksum, HandleIterator, Data, 0, &SharedInfo);

这里会使用Changed数组构造一个树形迭代器,通过后序遍历来优先处理被嵌套的Property,因此这里的SendProperties_r会递归的调用自身。

void FRepLayout::SendProperties_r(
	FSendingRepState* RESTRICT RepState,
	FNetBitWriter& Writer,
	const bool bDoChecksum,
	FRepHandleIterator& HandleIterator,
	const FConstRepObjectDataBuffer SourceData,
	const int32 ArrayDepth,
	const FRepSerializationSharedInfo* const RESTRICT SharedInfo) const
{
	const bool bDoSharedSerialization = SharedInfo && !!GNetSharedSerializedData;

	while (HandleIterator.NextHandle())
	{
		const FRepLayoutCmd& Cmd = Cmds[HandleIterator.CmdIndex];
		const FRepParentCmd& ParentCmd = Parents[Cmd.ParentIndex];

		UE_LOG(LogRepProperties, VeryVerbose, TEXT("SendProperties_r: Parent=%d, Cmd=%d, ArrayIndex=%d"), Cmd.ParentIndex, HandleIterator.CmdIndex, HandleIterator.ArrayIndex);
		
		FConstRepObjectDataBuffer Data = (SourceData + Cmd) + HandleIterator.ArrayOffset;
		if (Cmd.Type == ERepLayoutCmdType::DynamicArray)
		{
			// 处理动态数组的同步
		}
		else
		{
			// 处理简单类型的同步
			WritePropertyHandle(Writer, HandleIterator.Handle, bDoChecksum);

			const int32 NumStartBits = Writer.GetNumBits();

			// This property changed, so send it
			Cmd.Property->NetSerializeItem(Writer, Writer.PackageMap, const_cast<uint8*>(Data.Data));

			const int32 NumEndBits = Writer.GetNumBits();

		}
		// 
	}
}

这里跟之前Diff流程一样,区分了简单类型和动态数组类型。对于简单类型来说,先写入对应的HandlerWriter,然后再把这个Property的最新值也写入,调用的是Property::NetSerializeItem这个虚方法,以最节省流量的bit流的形式去写入:

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);
	// 只写入一个bit
	Ar.SerializeBits( &Value, 1 );
	*ByteValue = ((*ByteValue) & ~FieldMask) | (Value ? ByteMask : 0);
	return true;
}

bool FByteProperty::NetSerializeItem( FArchive& Ar, UPackageMap* Map, void* Data, TArray<uint8> * MetaData ) const
{
	// 根据当前property的可能取值范围去写入最少的bit
	if (Ar.EngineNetVer() < HISTORY_ENUM_SERIALIZATION_COMPAT)
	{
		Ar.SerializeBits(Data, Enum ? FMath::CeilLogTwo(Enum->GetMaxEnumValue()) : 8);
	}
	else
	{
		Ar.SerializeBits(Data, GetMaxNetSerializeBits());
	}

	return true;
}

处理动态数组时,先写入数组对应的Handle,然后再写入当前数组的大小:

WritePropertyHandle(Writer, HandleIterator.Handle, bDoChecksum);
const FScriptArray* Array = (FScriptArray *)Data.Data;
const FConstRepObjectDataBuffer ArrayData(Array->GetData());

// Write array num
uint16 ArrayNum = Array->Num();
Writer << ArrayNum;

然后再以当前Array作为顶层Property调用SendProperties_r来处理子元素的序列化:

// 获取改变了多少个元素
const int32 ArrayChangedCount = HandleIterator.ChangedHistoryIterator.Changed[HandleIterator.ChangedHistoryIterator.ChangedIndex++];

const int32 OldChangedIndex = HandleIterator.ChangedHistoryIterator.ChangedIndex;
// 每个子handler对应的property类型
TArray<FHandleToCmdIndex>& ArrayHandleToCmdIndex = *HandleIterator.HandleToCmdIndex[Cmd.RelativeHandle - 1].HandleToCmdIndex;

FRepHandleIterator ArrayHandleIterator(HandleIterator.Owner, HandleIterator.ChangedHistoryIterator, Cmds, ArrayHandleToCmdIndex, Cmd.ElementSize, ArrayNum, HandleIterator.CmdIndex + 1, Cmd.EndCmd - 1);

SendProperties_r(RepState, Writer, bDoChecksum, ArrayHandleIterator, ArrayData, ArrayDepth + 1, SharedInfo);

HandleIterator.ChangedHistoryIterator.ChangedIndex++;
// 最后加上0结尾 代表array结束
WritePropertyHandle(Writer, 0, bDoChecksum);		// Signify end of dynamic array

当一个Bunch被下发之后,FObjectReplicator::PostSendBunch( FPacketIdRange & PacketRange, uint8 bReliable )中会执行下面这段代码:


for (int32 i = SendingRepState->HistoryStart; i < SendingRepState->HistoryEnd; ++i)
{
	const int32 HistoryIndex = i % FSendingRepState::MAX_CHANGE_HISTORY;

	FRepChangedHistory & HistoryItem = SendingRepState->ChangeHistory[HistoryIndex];

	if (HistoryItem.OutPacketIdRange.First == INDEX_NONE)
	{
		HistoryItem.OutPacketIdRange = PacketRange;

		if (!bReliable && !SendingRepState->bOpenAckedCalled)
		{
			SendingRepState->PreOpenAckHistory.Add(HistoryItem);
		}
	}
}

这段代码遍历当前SendingRepState->ChangeHistory循环队列,获取其中还没有设置关联OutPacketIdRangeHistoryItem,将当前PacketRange关联上。当客户端汇报了特定Packet丢失时,逻辑会走到void FObjectReplicator::ReceivedNak( int32 NakPacketId )中,这里会检查这个包是否带有属性同步数据,如果带有属性同步数据,则会执行下面的代码:

for (int32 i = SendingRepState->HistoryStart; i < SendingRepState->HistoryEnd; ++i)
{
	const int32 HistoryIndex = i % FSendingRepState::MAX_CHANGE_HISTORY;

	FRepChangedHistory& HistoryItem = SendingRepState->ChangeHistory[HistoryIndex];
	// 如果当前丢失的packetid在发送了这个item的packet range之中 则认为对应的属性需要重传
	if (!HistoryItem.Resend && HistoryItem.OutPacketIdRange.InRange(NakPacketId))
	{
		check(HistoryItem.Changed.Num() > 0);
		HistoryItem.Resend = true;
		++SendingRepState->NumNaks;
	}
}

有了这个补发标记之后,在FRepLayout::ReplicateProperties时传递给FRepLayout::SendPropertiesChanged数组,不仅需要考虑FSendingRepState追赶FRepChangelistState的过程,还需要考虑所有需要补发的FRepChangedHistory

// FRepLayout::UpdateChangelistHistory
const int32 AckPacketId = Connection->OutAckPacketId; // 当前Connection汇报的已经ack的最大PacketId
for (int32 i = RepState->HistoryStart; i < RepState->HistoryEnd; i++)
{
	const int32 HistoryIndex = i % FSendingRepState::MAX_CHANGE_HISTORY;

	FRepChangedHistory& HistoryItem = RepState->ChangeHistory[HistoryIndex];

	if (HistoryItem.OutPacketIdRange.First == INDEX_NONE)
	{
		// 从此History开始后续的所有History都没有通过Bunch进行发送 不再需要遍历下去了
		break;
	}
	// 判断该Item是否需要重发
	if (AckPacketId >= HistoryItem.OutPacketIdRange.Last || HistoryItem.Resend || DumpHistory)
	{
		if (HistoryItem.Resend || DumpHistory)
		{
			// 如果需要重发 则将此Item里的Changed数据合并到最终的Changed数组中
			TArray<uint16> Temp = MoveTemp(*OutMerged);
			MergeChangeList(Data, HistoryItem.Changed, Temp, *OutMerged);
			if (HistoryItem.Resend)
			{
				RepState->NumNaks--;
			}
		}
		// 合并之后当前Item就可以废弃了 因为数据已经合并到HistoryEnd之中了
		HistoryItem.Reset();
		RepState->HistoryStart++;
	}
}

UE属性同步数据的客户端回放

客户端在接收到网络数据包之后,调用UActorChannel::ProcessBunch来处理,如果发现这是一个属性同步包,则会找到此ActorFObjectReplicator调用ReceivedBunch来解析内部的属性Diff数据:

bool FObjectReplicator::ReceivedBunch(FNetBitReader& Bunch, const FReplicationFlags& RepFlags, const bool bHasRepLayout, bool& bOutHasUnmapped)
{
	// 处理属性同步的相关核心代码
	const FRepLayout& LocalRepLayout = *RepLayout;
	bool bGuidsChanged = false;
	FReceivingRepState* ReceivingRepState = RepState->GetReceivingRepState();
	// Handle replayout properties
	if (bHasRepLayout)
	{
		EReceivePropertiesFlags ReceivePropFlags = EReceivePropertiesFlags::None;
		bool bLocalHasUnmapped = false;

		if (!LocalRepLayout.ReceiveProperties(OwningChannel, ObjectClass, RepState->GetReceivingRepState(), Object, Bunch, bLocalHasUnmapped, bGuidsChanged, ReceivePropFlags))
		{
			UE_LOG(LogRep, Error, TEXT( "RepLayout->ReceiveProperties FAILED: %s" ), *Object->GetFullName());
			return false;
		}
	}
}

这里会调用当前对象的FRepLayoutReceiveProperties,因为属性描述信息都在FRepLayout之中。这里ReceiveProperties会调用ReceiveProperties_r进行递归解析:

FReceivePropertiesSharedParams Params{
	bDoChecksum,
	// We can skip swapping roles if we're not an Actor layout, or if we've been explicitly told we can skip.
	EnumHasAnyFlags(ReceiveFlags, EReceivePropertiesFlags::SkipRoleSwap) || !EnumHasAnyFlags(Flags, ERepLayoutFlags::IsActor),
	InBunch,
	bOutHasUnmapped,
	bOutGuidsChanged,
	Parents,
	Cmds,
	NetSerializeLayouts,
	Object,
	OwningChannel->Connection->GetInTraceCollector()
};

FReceivePropertiesStackParams StackParams{
	FRepObjectDataBuffer(Data),
	FRepShadowDataBuffer(RepState->StaticBuffer.GetData()),
	&RepState->GuidReferencesMap,
	0,
	Cmds.Num() - 1,
	bEnableRepNotifies ? &RepState->RepNotifies : nullptr
};

// Read the first handle, and then start receiving properties.
ReadPropertyHandle(Params);
if (ReceiveProperties_r(Params, StackParams))
{
	return true;
}
return false;

这个ReceiveProperties_r与之前的CompareProperties_r一样,使用StackParams作为递归执行环境:

static bool ReceiveProperties_r(FReceivePropertiesSharedParams& Params, FReceivePropertiesStackParams& StackParams)
{
	for (int32 CmdIndex = StackParams.CmdStart; CmdIndex < StackParams.CmdEnd; ++CmdIndex)
	{
		// 遍历所有的Cmd 获取ReadHandle对应的Cmd
		const FRepLayoutCmd& Cmd = Params.Cmds[CmdIndex];
		check(ERepLayoutCmdType::Return != Cmd.Type);

		++StackParams.CurrentHandle;
		if(StackParams.CurrentHandle != Params.ReadHandle)
		{
			// Skip this property.
			if (ERepLayoutCmdType::DynamicArray == Cmd.Type)
			{
				CmdIndex = Cmd.EndCmd - 1;
			}
			continue;
		}
		const FRepParentCmd& Parent = Params.Parents[Cmd.ParentIndex];
		if (ERepLayoutCmdType::DynamicArray == Cmd.Type)
		{
			// 处理TArray的数据同步
		}
		else
		{
			// 简单属性的数据同步
		}
	}
}

解析时根据读取出来的Handler获取对应的FRepLayoutCmd,这里很神奇的是客户端并没有一个直接的HandlerCmdIndex的映射,而是遍历所有的Cmd来处理的,非常的浪费CPU。得到了FRepLayoutCmd之后再根据FRepLayoutCmd::Type是否是DynamicArray走两个不同的处理机制:

  1. 如果不是DynamicArray,直接调用ReceivePropertyHelper,这里又区分了两种情况:

    1. 如果此属性不带属性变化通知,即Property宏内没有声明ReplicatedUsing,则直接调用反序列化方法即可
    Cmd.Property->NetSerializeItem(Bunch, Bunch.PackageMap, Data + SwappedCmd);
    
    1. 如果此属性提供了属性变化通知函数,即Property宏声明了ReplicatedUsing=xxx,则需要先保存当前旧的属性值,再从网络数据中获取新的属性值,然后构造通知回调:
    // 先保存之前的值到ShadowData中
    StoreProperty(Cmd, ShadowData + Cmd, Data + SwappedCmd);
    
    // 从Bunch中获取当前Property的新数据
    Cmd.Property->NetSerializeItem(Bunch, Bunch.PackageMap, Data + SwappedCmd);
    UE_LOG(LogRepProperties, VeryVerbose, TEXT("ReceivePropertyHelper: NetSerializeItem (WithRepNotify)"));
    
    // 如果属性有变化 则将此Property加入到待通知集合中
    if (Parent.RepNotifyCondition == REPNOTIFY_Always || !PropertiesAreIdentical(Cmd, ShadowData + Cmd, Data + SwappedCmd, NetSerializeLayouts))
    {
    	RepNotifies->AddUnique(Parent.Property);
    }
    
  2. 如果是DynamicArray,则准备开始递归,先构造好递归时所用的Param

    FScriptArray* ShadowArray = (FScriptArray*)(StackParams.ShadowData + Cmd).Data;
    FScriptArray* ObjectArray = (FScriptArray*)(StackParams.ObjectData + Cmd).Data;
    
    // Setup a new Stack State for our array.
    FReceivePropertiesStackParams ArrayStackParams{
    	nullptr,
    	nullptr,
    	nullptr,
    	CmdIndex + 1,
    	Cmd.EndCmd - 1,
    	StackParams.RepNotifies
    };
    
    // These buffers will track the dynamic array memory.
    FRepObjectDataBuffer ObjectArrayBuffer = StackParams.ObjectData;
    FRepShadowDataBuffer ShadowArrayBuffer = StackParams.ShadowData;
    

    然后读取中当前TArray传递过来的新的数组大小:

    uint16 ArrayNum = 0;
    Params.Bunch << ArrayNum;
    

    如果此时发现两者数组不等,则客户端对应的TArray将调用Resize接口使两者数组大小相等,如果此属性还定义了ReplicatedUsing这个属性修改回调,则执行RepNotifies->AddUnique(Parent.Property)将当前TArray对应的Property加入到回调属性集合中。 完成上面这步之后,开始遍历新的TArray里的所有元素,尝试从Bunch中获取属性变化信息,这里就会递归的调用ReceiveProperties_r:

    const int32 ObjectArrayNum = ObjectArray->Num();
    for (int32 i = 0; i < ObjectArrayNum; ++i)
    {
    	// 获取当前Item的数据偏移
    	const int32 ElementOffset = i * Cmd.ElementSize;
    
    	ArrayStackParams.ObjectData = ObjectArrayBuffer + ElementOffset;
    	ArrayStackParams.ArrayElementOffset = ElementOffset;
    
    	ArrayStackParams.ShadowData = (ShadowArrayBuffer && i < ShadowArray->Num()) ? (ShadowArrayBuffer + ElementOffset) : nullptr;
    	ArrayStackParams.RepNotifies = ArrayStackParams.ShadowData ? StackParams.RepNotifies : nullptr;
    
    	if (!ReceiveProperties_r(Params, ArrayStackParams))
    	{
    		return false;
    	}
    }
    

    处理结束之后,Bunch里下一个Handler应该是0,代表一个TArray的相关数据结束:

    // Make sure we've hit the array terminator.
    if (0 != Params.ReadHandle)
    {
    	UE_LOG(LogRep, Warning, TEXT("ReceiveProperties_r: Failed to receive property, Array Property Improperly Terminated - Property=%s, Parent=%d, CmdIndex=%d, ReadHandle=%d"), *Parent.CachedPropertyName.ToString(), Cmd.ParentIndex, CmdIndex, Params.ReadHandle);
    	return false;
    }
    

在一个Bunch里的属性同步数据都解析并且属性更新到最新值之后,相关属性的修改通知回调才会被执行:

void FObjectReplicator::PostReceivedBunch()
{
	// Call RepNotifies
	CallRepNotifies(true);
}

上面的函数简单的转发到FRepLayout::CallRepNotifies,这里会遍历所有之前添加的有修改且需要通知回调的属性,找到对应的回调UFunction进行执行,根据回调参数的数量来决定是否更新对应的ShadowData:

void FRepLayout::CallRepNotifies(FReceivingRepState* RepState, UObject* Object) const
{
	if (RepState->RepNotifies.Num() == 0)
	{
		return;
	}

	FRepShadowDataBuffer ShadowData(RepState->StaticBuffer.GetData());
	FRepObjectDataBuffer ObjectData(Object);

	for (FProperty* RepProperty : RepState->RepNotifies)
	{
		UFunction* RepNotifyFunc = Object->FindFunction(RepProperty->RepNotifyFunc);
		const FRepParentCmd& Parent = Parents[RepProperty->RepIndex];
		const int32 NumParms = RepNotifyFunc->NumParms;

		switch (NumParms)
		{
			case 0:
			{
				Object->ProcessEvent(RepNotifyFunc, nullptr);
			}
			case 1:
			{
				FRepShadowDataBuffer PropertyData = ShadowData + Parent;
				Object->ProcessEvent(RepNotifyFunc, PropertyData);
				RepProperty->CopyCompleteValue(ShadowData + Parent, ObjectData + Parent);
			}
			case 2:
			{
				// some codes
			}
		}
	}
	RepState->RepNotifies.Empty();
	RepState->RepNotifyMetaData.Empty();
}

UObject 指针类型同步

当一个UObject指针作为属性向下同步时,会调用这个FPorperty的序列化函数NetSerializeItem

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;
}

这里的UPackageMap::SerializeObject是一个虚方法,具体的实现在其子类UPackageMapClient之中。当序列化这个Object指针时,将其替换为对应的FNetworkGuid这个整数向下同步,反序列化时则读取这个FNetworkGuid并查询GuidCache这个映射结构来获取对应的Object指针:

bool UPackageMapClient::SerializeObject( FArchive& Ar, UClass* Class, UObject*& Object, FNetworkGUID *OutNetGUID)
{
	if(Ar.IsSaving())
	{
		FNetworkGUID NetGUID = GuidCache->GetOrAssignNetGUID( Object );

		// Write out NetGUID to caller if necessary
		if (OutNetGUID)
		{
			*OutNetGUID = NetGUID;
		}

		// Write object NetGUID to the given FArchive
		InternalWriteObject( Ar, NetGUID, Object, TEXT( "" ), NULL );
	}
	else if (Ar.IsLoading())
	{
		// ----------------	
		// Read NetGUID from stream and resolve object
		// ----------------	
		NetGUID = InternalLoadObject(Ar, Object, 0);

		// Write out NetGUID to caller if necessary
		if (OutNetGUID)
		{
			*OutNetGUID = NetGUID;
		}
		
	}
}

这里可能有一种异常情况,即NetGuid对应的ActorChannel还没有创建,导致无法获取对应的UObject,此时会将这个NetGuid加入到待处理集合中:

if ( NetGUID.IsValid() && bShouldTrackUnmappedGuids && !GuidCache->IsGUIDBroken( NetGUID, false ) )
{
	if ( Object == nullptr )
	{
		TrackedUnmappedNetGuids.Add( NetGUID );
	}
	else if ( NetGUID.IsDynamic() )
	{
		TrackedMappedDynamicNetGuids.Add( NetGUID );
	}
}

然后在上层的接收属性同步的接口ReceivePropertyHelper中,判断当前属性解析是否遇到了一些待处理的NetGuid:

if (GuidReferencesMap)
{
	const int32 AbsOffset = ElementOffset + SwappedCmd.Offset;

	// Loop over all de-serialized network guids and track them so we can manage their pointers as their replicated reference goes in/out of relevancy
	const TSet<FNetworkGUID>& TrackedUnmappedGuids = Bunch.PackageMap->GetTrackedUnmappedGuids();
	const TSet<FNetworkGUID>& TrackedDynamicMappedGuids = Bunch.PackageMap->GetTrackedDynamicMappedGuids();

	const bool bHasUnmapped = TrackedUnmappedGuids.Num()> 0;

	FGuidReferences* GuidReferences = GuidReferencesMap->Find(AbsOffset);

	if (TrackedUnmappedGuids.Num() > 0 || TrackedDynamicMappedGuids.Num()> 0)
	{
		
		if (GuidReferences == nullptr || bOutGuidsChanged)
		{
			// First time tracking these guids (or guids changed), so add (or replace) new entry
			GuidReferencesMap->Add(AbsOffset, FGuidReferences(Bunch, Mark, TrackedUnmappedGuids, TrackedDynamicMappedGuids, Cmd.ParentIndex, CmdIndex));
			bOutGuidsChanged = true;
		}
	}
}

如果出现了待处理的NetGuid,则会将对应属性的Offset,Cmd,ParentCmd等相关信息写入FObjectReplicator::GuidReferencesMap之中,等到合适的时机去读取这个GuidReferencesMap重新尝试。UE并没有选择在Actor创建时响应式的更新相关Uobject*属性,而是在NetDriver::Tick中执行FObjectReplicator::UpdateUnmappedObjects,不断查找每个属性unmappedGUID对应Actor是否已创建并和GUID建立关联。如果找到了,就仿照ReceivePropertyHelper代码重新执行一遍反序列化流程,执行完成之后,对应属性的RepNotify也会被执行。

// FObjectReplicator::UpdateUnmappedObjects
FNetDeltaSerializeInfo Parms;
Parms.Object = Object;
Parms.Connection = Connection;
Parms.bInternalAck = Connection->IsInternalAck();
Parms.Map = Connection->PackageMap;
Parms.NetSerializeCB = &NetSerializeCB;

Parms.bUpdateUnmappedObjects = true;

// Let the rep layout update any unmapped properties
LocalRepLayout.UpdateUnmappedObjects(ReceivingRepState, Connection->PackageMap, Object, Parms, bCalledPreNetReceive, bSomeObjectsWereMapped, bOutHasMoreUnmapped);

bSomeObjectsWereMapped |= Parms.bOutSomeObjectsWereMapped;
bOutHasMoreUnmapped |= Parms.bOutHasMoreUnmapped;
bCalledPreNetReceive |= Parms.bCalledPreNetReceive;

if (bCalledPreNetReceive)
{
	// If we mapped some objects, make sure to call PostNetReceive (some game code will need to think this was actually replicated to work)
	PostNetReceive();

	UpdateGuidToReplicatorMap();
}

CallRepNotifies(false);

假如我们有一个Tarray<AActor*> 的同步属性, 这个属性被修改并发送对应Diff数据到客户端之后, 客户端首先走第一轮属性同步更新TArray的大小以及设置好每个Item的指针值。如果某个Item对应的AActor*暂时无法根据传递过来的NetworkGuid查找到,则对应Item的指针值为nullptr,并记录到GuidReferencesMap之中。完成上面的步骤之后会触发设置好的属性回调函数,此时我们唯一能确定的是此回调函数执行时数组的最新大小是正确的,而内部元素的指针值不保证与服务器一样。 如果内部那些没有被同步到的指针被重新定位到了,则对应Item的值会被正确的修改并再次调用回调函数。由于不同的AActor客户端收到的时机无法得到顺序上的保证,每个Item的指针绑定时机是不确定的,可能跨越多次NetDriver::Tick从而导致出现多次属性修改回调,这个在编写客户端属性回调逻辑时需要非常注意。

Pushmodel介绍

UE的属性同步的时候,会对当前UObject上的所有属性进行一次属性diff操作,如果diff操作发现某个属性的最新值与shadown buffer里的记录值不一样,则会使用diff出来的changelist加入到这个UObject的所有连接上的Object Replicator里。这种遍历对比的方法对于使用者来说非常友好,因为任何对同步属性的修改都不需要去考虑如何同步到其他客户端。同步属性与非同步属性对于业务来说基本透明,顶多需要关心一下同步属性的最新值到达客户端之后的OnRep回调触发。但是这种易用性是有代价的,因为需要对所有的属性都做一次diff操作。当一个UObject上的属性数量达到十几个的时候,这部分的消耗会无法忽视。然后Actor上可以挂载非常多的ActorComponent,每次同步Actor的时候,属性对比不仅仅需要计算Actor自身的属性,而且还要计算所有挂载在这个Actor上的ActorComponent上的属性。当项目进行到中期,随着Actor/ActorComponent数量越来愈多,单一Actor上的所有ActorComponent的同步属性数量轻轻松松超过100,甚至以百为单位。特别是Actor数量也变得很多的额时候,属性对比的消耗也从之前的无法忽视变得无法接受,所以UE 4.25中引入了PushModel来尝试解决这个全量属性对比引发的性能问题。

优化大量属性的diff消耗,最重要的是要避免那些没有被修改过的属性的diff, 默认情况下会对所有属性执行CompareParentPropertyHelper:

for (int32 ParentIndex = 0; ParentIndex < SharedParams.Parents.Num(); ++ParentIndex)
{
	CompareParentPropertyHelper(ParentIndex, SharedParams, StackParams);
}

PushModel系统的作用就是对每一个属性提供一个是否修改过的标记位,这样在属性对比之前会首先查询这个标记位是否被置为1,如果不是1则跳过,这部分逻辑封装在一个IsPropertyDirty函数里。 有了这个快速判断属性是否被修改的标记位之后,前述的属性遍历diff代码就变成了这样:

UE_LOG(LogRepCompares, VeryVerbose, TEXT("CompareParentProperties: Default"));

for (int32 ParentIndex = 0; ParentIndex < SharedParams.Parents.Num(); ++ParentIndex)
{
	if (IsPropertyDirty(ParentIndex, bRecentlyCollectedGarbage, SharedParams, StackParams))
	{
		CompareParentPropertyHelper(ParentIndex, SharedParams, StackParams);
	}
}

前述的标记位判定其实是对于IsPropertyDirty这个函数的过于简化,实现上要考虑的东西比一个bit多多了:

#if WITH_PUSH_MODEL
	static bool IsPropertyDirty(
		const int32 ParentIndex,
		const bool bRecentlyCollectedGarbage,
		const FComparePropertiesSharedParams& SharedParams,
		FComparePropertiesStackParams& StackParams)
	{
		return SharedParams.bForceCompareProperties ||
			!(*SharedParams.PushModelProperties)[ParentIndex] || // non-push model properties are always considered dirty			
			SharedParams.PushModelState->IsPropertyDirty(ParentIndex) ||
			(bRecentlyCollectedGarbage &&
				EnumHasAnyFlags(SharedParams.Parents[ParentIndex].Flags, ERepParentFlags::HasObjectProperties | ERepParentFlags::IsNetSerialize));
	}
#endif // WITH_PUSH_MODEL	

这里我们重点关注PushModel相关的变量:

  1. SharedParams.PushModelProperties是一个bit数组,代表对应的属性字段是否支持PushModel
  2. SharedParams.PushModelState 内部维护了一个bit数组PropertyDirtyStates,代表对应的属性字段在上次对比之后是否被修改过
bool IsPropertyDirty(const uint16 RepIndex) const
{
	return PropertyDirtyStates[RepIndex];
}

从这两个bit数组可以看出,一个属性如果想利用PushModel系统来避免不必要的属性diff,需要做两个事情:为当前属性开启PushModel的支持,以及修改之后通知PushModel

为一个属性开启PushModel的支持很简单,不需要在声明同步属性的时候做额外的修改,只需要在GetLifetimeReplicatedProps注册同步属性的时候带上一个额外参数:

void AExampleActor::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
	// PlayMode1被注册为了一个不支持pushmodel的属性
	DOREPLIFETIME(AExampleActor, PlayMode1);
	// PlayMode2 被注册为了一个支持pushmodel的属性
	FDoRepLifetimeParams Params;
	Params.bIsPushBased = true;
	DOREPLIFETIME_WITH_PARAMS_FAST(AExampleActor, PlayMode2, Params);
};

其实DOREPLIFETIMEDOREPLIFETIME_WITH_PARAMS_FAST这两个宏的差异就是一个使用了默认初始化的FDoRepLifetimeParams,一个使用了手动赋值的FDoRepLifetimeParams

在一个UClass被加载的时候,会执行所有同步属性的收集来生成对应的FRepLayout,这里会根据对应的FDoRepLifetimeParams是否开启了bIsPushBased来决定PushModelProperties这个bit数组对应的位置是否为1:

void FRepLayout::InitFromClass(
	UClass* InObjectClass,
	const UNetConnection* ServerConnection,
	const ECreateRepLayoutFlags CreateFlags)
{
	SCOPE_CYCLE_COUNTER(STAT_RepLayout_InitFromObjectClass);

	const bool bIsPushModelEnabled = IS_PUSH_MODEL_ENABLED();
	// 省略很多代码
#if WITH_PUSH_MODEL
	PushModelProperties.Init(false, Parents.Num());
#endif
	// Setup lifetime replicated properties
	for (int32 i = 0; i < LifetimeProps.Num(); i++)
	{
		const int32 ParentIndex = LifetimeProps[i].RepIndex;
		// 省略很多代码
		if (!EnumHasAnyFlags(Parents[ParentIndex].Flags, ERepParentFlags::IsCustomDelta))
		{
			// 省略很多代码
			++NumberOfLifetimeProperties;
#if WITH_PUSH_MODEL
			if (bIsPushModelEnabled && LifetimeProps[i].bIsPushBased)
			{
				++NumberOfLifetimePushModelProperties;
				PushModelProperties[ParentIndex] = true;
			}
#endif
		}
	}
}

这样就完成了一个属性的PushModel支持注册,剩下的另外一个工作就是每次修改对应属性的时候通知PushModel系统去修改PropertyDirtyStates的对应bit。这部分工作在UE里可以使用宏来操作,每次在赋值所在位置加上一个MARK_PROPERTY_DIRTY_FROM_NAME宏即可,至于在赋值前还是在赋值后并不重要,只要在修改的同一帧里加上这个宏即可,多次执行的结果与执行一次的结果是一样的:

MARK_PROPERTY_DIRTY_FROM_NAME(AExampleActor, PlayMode2, this);
PlayMode2 = 1;

这个宏展开之后,就会变成这样的代码,这里的AExampleActor::ENetFields_Private::PlayMode2UHT自动为每个同步属性都会生成的索引:

{ 
	const UEPushModelPrivate::FNetPushObjectId PrivatePushId(this->GetNetPushId()); 
	UEPushModelPrivate::MarkPropertyDirty(this, PrivatePushId, (int32)AExampleActor::ENetFields_Private::PlayMode2); 
}

内部调用的MarkPropertyDirty负责真正的执行bit赋值操作:

void UEPushModelPrivate::MarkPropertyDirty(const UObject* Object, const FNetPushObjectId ObjectId, const int32 RepIndex)
{
	// 省略无关代码
	{
		PushObjectManager.MarkPropertyDirty(ObjectId.GetLegacyPushObjectId(), RepIndex);
	}
}
void FPushModelObjectManager_CustomId::MarkPropertyDirty(const FNetLegacyPushObjectId ObjectId, const int32 RepIndex)
{
	const int32 ObjectIndex = ObjectId;
	if (LIKELY(PerObjectStates.IsValidIndex(ObjectIndex)))
	{
		// The macros will take care of filtering out invalid objects, so we don't need to check here.
		PerObjectStates[ObjectIndex].MarkPropertyDirty(static_cast<uint16>(RepIndex));
	}
}

void FPushModelPerObjectState::MarkPropertyDirty(const uint16 RepIndex)
{
	DirtiedThisFrame[RepIndex] = true;
	bHasDirtyProperties = true;
}

最后被修改的bit数组是FPushModelPerObjectState::DirtiedThisFrame, 并不是我们所期望的FPushModelPerNetDriverState::PropertyDirtyStates数组,不要慌。在属性同步Tick的开头会调用PushDirtyStateToNetDrivers将当前的DirtiedThisFrame推送到PropertyDirtyStates上,然后将DirtiedThisFrame清空:

/**
 * Pushes the current dirty state of the Push Model Object to each of the Net Driver States.
 * and then reset the dirty state.
 */
void FPushModelPerObjectState::PushDirtyStateToNetDrivers()
{
	if (bHasDirtyProperties)
	{
		for (FPushModelPerNetDriverState& NetDriverObject : PerNetDriverStates)
		{
			NetDriverObject.MarkPropertiesDirty(DirtiedThisFrame);
		}

		ResetBitArray(DirtiedThisFrame);
		bHasDirtyProperties = false;
	}
}
void FPushModelPerNetDriverState::MarkPropertiesDirty(const TBitArray<>& OtherBitArray)
{
	BitwiseOrBitArrays(OtherBitArray, PropertyDirtyStates);
	bHasDirtyProperties = true;
}

所以我们只要在cpp代码里将所有修改这个PushModel属性的地方加上MARK_PROPERTY_DIRTY_FROM_NAME,就完成了修改属性的手动标记工作,执行属性diff之前会获取这个bit数组副本来加速未修改属性的过滤操作,从而降低了不必要的属性diff开销。

但是仅仅在cpp代码里对这些属性做修改标记是不够的,因为属性还可以在蓝图中被修改。为了覆盖到所有对这些PushModel属性修改,UE还对蓝图里的属性设置节点做了MarkPropertyDirty支持。蓝图编译器在编译所有的SetOutRef节点时,如果项目启用了PushModel特性,会额外插入一个不可见的MarkDirtyNode节点。

void FKCHandler_VariableSet::Transform(FKismetFunctionContext& Context, UEdGraphNode* Node)
{
	// Expands node out to include a (local) call to the RepNotify function if necessary
	UK2Node_VariableSet* SetNotify = Cast<UK2Node_VariableSet>(Node);

	// If property is HasFieldNotificationBroadcast, then the net code and broadcast will be executed in native code.
	if (SetNotify && !SetNotify->HasFieldNotificationBroadcast())
	{
		// 省略很多无关代码

		if (SetNotify->IsNetProperty())
		{
			/**
			 * This code is for property dirty tracking.
			 * It works by injecting in extra nodes while compiling that will call UNetPushModelHelpers::MarkPropertyDirtyFromRepIndex.
			 * See FKCPushModelHelpers::ConstructMarkDirtyNodeForProperty for node generation.
			 */
			if (FProperty * Property = SetNotify->GetPropertyForVariable())
			{
				if (UEdGraphNode * MarkPropertyDirtyNode = FKCPushModelHelpers::ConstructMarkDirtyNodeForProperty(Context, Property, Node->FindPinChecked(UEdGraphSchema_K2::PN_Self)))
				{
					// Hook up our exec pins.
					UEdGraphPin* OldThenPin = Node->FindPinChecked(UEdGraphSchema_K2::PN_Then);
					UEdGraphPin* NewThenPin = MarkPropertyDirtyNode->FindPinChecked(UEdGraphSchema_K2::PN_Then);
					UEdGraphPin* NewInPin = MarkPropertyDirtyNode->FindPinChecked(UEdGraphSchema_K2::PN_Execute);

					NewThenPin->CopyPersistentDataFromOldPin(*OldThenPin);
					OldThenPin->BreakAllPinLinks();
					OldThenPin->MakeLinkTo(NewInPin);
				}
			}
		}
	}
}

这里会生成一个中间节点UK2Node_CallFunction,插入到当前的VariableSet节点与其后续节点之间。

UEdGraphNode* FKCPushModelHelpers::ConstructMarkDirtyNodeForProperty(FKismetFunctionContext& Context, FProperty* RepProperty, UEdGraphPin* PropertyObjectPin)
{
	static const FName MarkPropertyDirtyFuncName(TEXT("MarkPropertyDirtyFromRepIndex"));
	static const FName ObjectPinName(TEXT("Object"));
	static const FName RepIndexPinName(TEXT("RepIndex"));
	static const FName PropertyNamePinName(TEXT("PropertyName"));

	// Create the node that will call MarkPropertyDirty.
	UK2Node_CallFunction* MarkPropertyDirtyNode = Context.SourceGraph->CreateIntermediateNode<UK2Node_CallFunction>();
	MarkPropertyDirtyNode->FunctionReference.SetExternalMember(MarkPropertyDirtyFuncName, UNetPushModelHelpers::StaticClass());
	MarkPropertyDirtyNode->AllocateDefaultPins();

	// Create the Pins for RepIndex, PropertyName, and Object.
	UEdGraphPin* RepIndexPin = MarkPropertyDirtyNode->FindPinChecked(RepIndexPinName);
	RepIndexPin->DefaultValue = FString::FromInt(RepProperty->RepIndex);

	UEdGraphPin* PropertyNamePin = MarkPropertyDirtyNode->FindPinChecked(PropertyNamePinName);
	PropertyNamePin->DefaultValue = RepProperty->GetFName().ToString();
}

这个UK2Node_CallFunction节点会执行MarkPropertyDirtyFromRepIndex这个暴露给蓝图的函数:

UFUNCTION(BlueprintCallable, Category = "Networking", Meta=(BlueprintInternalUseOnly = "true", HidePin = "Object|RepIndex|PropertyName"))
static ENGINE_API void MarkPropertyDirtyFromRepIndex(UObject* Object, int32 RepIndex, FName PropertyName);

void UNetPushModelHelpers::MarkPropertyDirtyFromRepIndex(UObject* Object, int32 RepIndex, FName PropertyName)
{
#if WITH_PUSH_MODEL
	if (Object && IS_PUSH_MODEL_ENABLED())
	{
		UClass* Class = Object->GetClass();
		if (Class->HasAnyClassFlags(CLASS_ReplicationDataIsSetUp))
		{
			if (RepIndex < INDEX_NONE || RepIndex >= Class->ClassReps.Num())
			{
				UE_LOG(LogNet, Warning, TEXT("UNetPushModelHelpers::MarkPropertyDirtyFromRepIndex: Invalid Rep Index. Class %s RepIndex %d"), *Class->GetPathName(), RepIndex);
			}
			else
			{
#if WITH_PUSH_VALIDATION_SUPPORT
				checkf(!UEPushModelPrivate::bCheckPushBPRepIndexAgainstName || Class->ClassReps[RepIndex].Property->GetFName() == PropertyName,
					TEXT("Property and RepIndex don't match! Object=%s, RepIndex=%d, InPropertyName=%s, FoundPropertyName=%s"),
						*Object->GetPathName(), RepIndex, *PropertyName.ToString(), *(Class->ClassReps[RepIndex].Property->GetName()));
#endif
	
				MARK_PROPERTY_DIRTY_UNSAFE(Object, RepIndex);
			}
		}
	}
#endif
}

这里绝大部分的代码都是在做一些开关和合法性的校验,最终执行的是MARK_PROPERTY_DIRTY_UNSAFE这个宏:

// Marks a property dirty by RepIndex without doing additional rep index validation.
#define MARK_PROPERTY_DIRTY_UNSAFE(Object, RepIndex) CONDITIONAL_ON_OBJECT_NET_ID_DYNAMIC(Object, UEPushModelPrivate::MarkPropertyDirty(Object, PrivatePushId, RepIndex))

// Marks a property dirty by UProperty*, validating that it's actually a replicated property.
#define MARK_PROPERTY_DIRTY(Object, Property) CONDITIONAL_ON_REP_INDEX_AND_OBJECT_NET_ID(Object, Property, UEPushModelPrivate::MarkPropertyDirty(Object, PrivatePushId, Property->RepIndex))

// Marks a property dirty, given the Class Name, Property Name, and Object. This will fail to compile if the Property or Class aren't valid.
#define MARK_PROPERTY_DIRTY_FROM_NAME(ClassName, PropertyName, Object) CONDITIONAL_ON_OBJECT_NET_ID(Object, UEPushModelPrivate::MarkPropertyDirty(Object, PrivatePushId, GET_PROPERTY_REP_INDEX(ClassName, PropertyName)))

这个宏与我们之前使用的MARK_PROPERTY_DIRTY_FROM_NAME宏其实大同小异,省去了GET_PROPERTY_REP_INDEX部分,这部分负责用宏拼接出来属性的索引,而MARK_PROPERTY_DIRTY_UNSAFE我们已经知道属性索引了。

所以蓝图在修改同步属性的时候,如果发现项目里开启了PushModel的支持,则修改之后会自动调用UEPushModelPrivate::MarkPropertyDirty来增加属性修改标记,这样就做到了属性自动标脏。

如果一个UObject上的所有同步属性都开启了PushModelFRepLayout::InitFromClass来收集同步属性的时候会将这个FRepLayout标记为ERepLayoutFlags::FullPushSupport:

// FRepLayout::InitFromClass
#if WITH_PUSH_MODEL
	if (bIsPushModelEnabled && ((NumberOfLifetimePushModelProperties > 0) || (NumberOfFastArrayPushModelProperties > 0)))
	{
		const bool bFullPushProperties = (NumberOfLifetimeProperties == NumberOfLifetimePushModelProperties);

		if (bFullPushProperties)
		{
			Flags |= ERepLayoutFlags::FullPushProperties;
		}

		Flags |= (bFullPushProperties && (NumberOfFastArrayProperties == NumberOfFastArrayPushModelProperties)) ?
			ERepLayoutFlags::FullPushSupport :
			ERepLayoutFlags::PartialPushSupport;
	}
#endif

那么在做属性对比的时候可以走更快的路径,只遍历修改bit数组PushModelState里对应bit1的属性,而不是之前的遍历所有的同步属性:

// CompareParentProperties 函数

// If we have full push model property support, then we only need to check properties that are actually dirty.
else if (EnumHasAnyFlags(SharedParams.Flags, ERepLayoutFlags::FullPushProperties) && !bRecentlyCollectedGarbage)
{
	UE_LOG(LogRepCompares, VeryVerbose, TEXT("CompareParentProperties: Full push properties: Has Dirty: %d"), !!SharedParams.PushModelState->HasDirtyProperties());

	for (TConstSetBitIterator<> It = SharedParams.PushModelState->GetDirtyProperties(); It; ++It)
	{
		CompareParentPropertyHelper(It.GetIndex(), SharedParams, StackParams);
	}
}

FFastArray介绍

PushModel的引入可以有效的降低不必要的属性对比消耗,所以推荐项目组里尽量将所有的同步属性都注册为支持PushModel的,这样FullPushSupport可以尽最大的可能去优化属性同步的效率。不过对于频繁修改的TArray容器,即使引入PushModel也收效甚微。因为容器内的元素很多的时候,即使只修改其中一个元素,在进行属性对比的时候执行的是容器内所有元素的对比,这个在容器内元素比较多的时候会显得格外突出。如果可以将MARK_DIRTY的力度从整个TArray缩小到TArray里的一个元素的话,对应的属性对比的性能应该可以极大的提高。基于这种思想, UE提供了支持元素级别MARK_DIRTYFFastArray

使用FFastArray比使用TArray复杂了很多,首先我们需要为数组里的元素定义一个继承自FFastArraySerializerItemUSTRUCT:

/** Step 1: Make your struct inherit from FFastArraySerializerItem */
USTRUCT()
struct FExampleItemEntry : public FFastArraySerializerItem
{
	GENERATED_USTRUCT_BODY()
	// Your data:
	UPROPERTY()
	int32 ExampleIntProperty;
	UPROPERTY()
	float ExampleFloatProperty;
	/** Optional functions you can implement for client side notification of changes to items */
	void PreReplicatedRemove();
	void PostReplicatedAdd();
	void PostReplicatedChange();
};

在这个FExampleItemEntry里我们除了可以提供要同步的属性字段定义之外,还可以提供Item被修改时的客户端同步回调:

  1. PreReplicatedRemove这个代表的是当前元素删除之前的通知
  2. PostReplicatedAdd 这个代表的是当前元素被添加到FFastArray时的通知
  3. PostReplicatedChange 这个代表的是当前元素里字段被修改的通知

有了FExampleItemEntry之后,我们才能声明对应的FFastArray:

/** Step 2: You MUST wrap your TArray in another struct that inherits from FFastArraySerializer */
USTRUCT()
struct FExampleArray: public FFastArraySerializer
{
	GENERATED_USTRUCT_BODY()
	UPROPERTY()
	TArray<FExampleItemEntry> Items; /** Step 3: You MUST have a TArray named Items of the struct you made in step 1. */
	/** Step 4: Copy this, replace example with your names */
	bool NetDeltaSerialize(FNetDeltaSerializeInfo & DeltaParms)
	{
	return FastArrayDeltaSerialize<FExampleItemEntry>( Items, DeltaParms );
	}
};

这里声明了NetDeltaSerialize函数,实现上直接转接到FastArrayDeltaSerialize,配合TStructOpsTypeTraits一起使用,来通知属性管理系统执行当前结构体的属性diff的时候使用的是FFastArray的对比逻辑:

/** Step 5: Copy and paste this struct trait, replacing FExampleArray with your Step 2 struct. */
template<>
struct TStructOpsTypeTraits< FExampleArray > : public TStructOpsTypeTraitsBase
{
	enum
	{
		WithNetDeltaSerializer = true,
	};
};

然后在操作FFastArraySerializer时,会提供MarkItemDirtyMarkArrayDirty接口来细粒度的对内部的元素进行操作记录:

/** Base struct for wrapping the array used in Fast TArray Replication */
USTRUCT()
struct FFastArraySerializer
{
	GENERATED_USTRUCT_BODY()

	FFastArraySerializer()
		: IDCounter(0)
		, ArrayReplicationKey(0)
#if WITH_PUSH_MODEL
		, OwningObject(nullptr)
		, RepIndex(INDEX_NONE)
#endif // WITH_PUSH_MODEL
		, CachedNumItems(INDEX_NONE)
		, CachedNumItemsToConsiderForWriting(INDEX_NONE)
		, DeltaFlags(EFastArraySerializerDeltaFlags::None)
	{
		SetDeltaSerializationEnabled(true);
	}

	~FFastArraySerializer() {}

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

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

	/** Counter used to track array replication. */
	UPROPERTY(NotReplicated)
	int32 ArrayReplicationKey;

	/** List of items that need to be re-serialized when the referenced objects are mapped */
	TMap<int32, FFastArraySerializerGuidReferences> GuidReferencesMap;

	/** List of items that need to be re-serialized when the referenced objects are mapped.*/
	TMap<int32, FGuidReferencesMap> GuidReferencesMap_StructDelta;

#if WITH_PUSH_MODEL
	// Object that is replicating this fast array
	UObject* OwningObject;

	// Property index of this array in the owning object's replication layout
	int32 RepIndex;
#endif // WITH_PUSH_MODEL

	/** This must be called if you add or change an item in the array */
	void MarkItemDirty(FFastArraySerializerItem & Item);
	/** This must be called if you just remove something from the array */
	void MarkArrayDirty();
	// 省略其他函数声明
}

不过这两个接口并不会自动的调用,而是需要在每次做修改操作的时候来手动的调用:

//增加元素
int index = Items.Add(FExampleItemEntry());
MarkItemDirty(Items[index]);
//修改元素
Items[index].ExampleIntProperty = NewExampleIntProperty;
MarkItemDirty(Items[index]);
//删除元素
Items.RemoveAt(index);
MarkArrayDirty();

其实更好的做法时直接将Items作为protected成员,对外只提供Add,Update,Remove接口。

每次执行MarkArrayDirty的时候,这个FFastArraySerializer存储的ArrayReplicationKey就会自增,并通知属性PushModel管理器去标记当前的FastArray已经被修改了:


void MarkArrayDirty()
{
	ItemMap.Reset();		// This allows to clients to add predictive elements to arrays without affecting replication.
	IncrementArrayReplicationKey();

	// Invalidate the cached item counts so that they're recomputed during the next write
	CachedNumItems = INDEX_NONE;
	CachedNumItemsToConsiderForWriting = INDEX_NONE;
}
void IncrementArrayReplicationKey()
{
	ArrayReplicationKey++;
	if (ArrayReplicationKey == INDEX_NONE)
	{
		ArrayReplicationKey++;
	}

#if WITH_PUSH_MODEL
	if (OwningObject != nullptr && RepIndex != INDEX_NONE)
	{
		MARK_PROPERTY_DIRTY_UNSAFE(OwningObject, RepIndex);
	}
#endif // WITH_PUSH_MODEL
}

这里的ArrayReplicationKey的作用就是标记当前FastArray的数据版本号,用来在属性对比的时候与ShadowBuffer里存储的ArrayReplicationKey做对比,所以这个属性被声明为不参与属性同步。

然后在修改或者添加一个Item的时候,需要执行MarkItemDirty, 这里ItemReplicationID是作为当前Item的唯一标识符来使用的,如果为INDEX_NONE代表是新添加的元素,此时利用Array里的IDCounter自增创建来初始化,之后就不再被修改:

void MarkItemDirty(FFastArraySerializerItem & Item)
{
	if (Item.ReplicationID == INDEX_NONE)
	{
		Item.ReplicationID = ++IDCounter;
		if (IDCounter == INDEX_NONE)
		{
			IDCounter++;
		}
	}

	Item.ReplicationKey++;
	MarkArrayDirty();
}

然后Item.ReplicationKey代表的是当前Item的修改次数,每次修改或者添加的时候都执行自增操作。

了解了这些辅助字段的定义之后,我们就大概可以猜到FFastArray时如何快速的做属性diff的:

  1. 首先判断当前FFastArrayArrayReplicationKey是否有改变,如果没有代表当前FFastArray没有变化,可以跳过对比
  2. 然后遍历所有的Item,判断其ReplicationKey是否有改变,如果没有则这个Item不需要处理属性对比,如果有则执行真正的属性对比

由于数组中删除一个元素会导致后续的所有元素都向前移位,这样会导致很多Item都会生成属性diff,导致属性对比的消耗和下发的diff结果数据大小都激增,所以在FFastArray里对一个Item做属性对比的时候其对比目标并不是ShadowBuffer里同位置的Item,而是ShadowBuffer里拥有同样的ReplicationIDItem。为了方便的通过ReplicationID找到对应的Item所在的索引,FFastArraySerializer上构造了一个TMap<int32, int32> ItemMap;的成员变量来加速查询。

有了这个ReplicationIDItem的唯一标识符之后,在FFastArraySerializer里删除一个元素可以进一步优化,没必要走TArrayRemove操作,因为这样会导致后续所有元素被移动。取而代之的是使用了RemoveAtSwap操作,直接与最后一个元素做Swap然后Pop,这样就避免了大量元素的移动。

同时由于属性同步的时候会合并多次操作的数据diff进行下发,这样会导致客户端的元素移动回放顺序可能与服务端不一样,并最终导致客户端的TArray与服务端的TArray里元素的顺序不一样。所以业务在使用FFastArray的时候,不能依赖于ItemTArray里的索引,只能把TArray当作一个TSet来使用,真正的Item标识符只能使用ReplicationID

FastArrayDeltaSerialize的实现其实远比这里的描述复杂,代码量太大,相关代码有2000多行,过于高级,因此本文不去展示其细节,有兴趣的读者可以自己去看源代码。

UE 属性同步总结

前面我们使用了将近20页的篇幅来介绍了UE自带的属性同步系统的底层实现机制,总体同步流程可以概括为如下几步:

  1. 客户端服务端都从UClass构建对应的FRepLayout来登记所有的参与网络同步的属性
  2. 根据FRepLayout创建FRepParentCmd数组和FRepLayoutCmd数组,用来记录每个底层属性的大小、偏移值、类型值、序列化函数、比较函数等信息
  3. 根据FRepLayout创建ShadowBuffer
    1. 服务端用ShadowBuffer记录比较前所有属性的值,属性Diff完成之后使用属性的最新值修改此ShadowBuffer
    2. 客户端用ShadowBuffer去记录所有带修改回调的属性的同步前的值, 属性回放完成后使用属性的最新值修改此ShadowBuffer
  4. 服务器在FrameTick的末尾,对所有的Actor执行CompareProperties来进行属性Diff,根据对比结果生成Changed数组,然后使用FRepLayout::SendProperties生成若干ID连续的ChangeHistory,然后每个ActorChannel计算需要往客户端发送有序IDFRepLayout::ChangeHistory集合,打包为一个或者多个不可靠Bunch进行发送
  5. 客户端接收到Bunch之后,如果Bunch内有属性同步数据,则会找到对应ActorChannelFRepLayoutReceiveProperties进行属性更新,更新完成之后再调用相关属性的更新回调函数

由于整个系统比较庞大,篇幅所限本文将不去介绍属性同步中的相关高级特性,其中值得注意的高级特性包括:

  1. PushModel,用来手动标记一些属性不参与Diff流程,而是业务层手动标记其为dirty状态,这样可以节省很多Diff时的Cpu
  2. TFastArray, TArrayRemove一个ItemChanged数组会加入该Item及之后所有Item的索引,此时不仅会浪费很多时间在执行Diff上,更重要的是浪费了很多带宽,TFastArray通过实现NetDeltaSerialize来跳过通用的属性Diff,内部提供了机制来优化对Remove的处理以减少带宽
  3. WithNetSharedSerialization, FRepLayout::SendProperties在给每个对应的ActorChannel进行数据打包时会将同一个属性多次序列化,使用了WithNetSharedSerialization之后就会在同一帧中对同一个属性只序列化第一次,这个序列化结果可以在多个ActorChannel中共享