属性级脏标记:让 UE 只同步真正变化的属性
属性级脏标记:让 UE 只同步真正变化的属性
在一些大型多人项目里,团队会对 UE4 原生的属性同步规则做一层扩展,引入「属性级脏标记」(这里叫它 NetDirty 系统)。核心目标是把同步粒度从 Actor 级细化到单个属性级。
解决的问题
UE 原生基于 ActorChannel 的同步流程里,只要一个被标记为需要同步的 Actor 有任意一个属性变化,整个 Actor 就会被拉去做相关性检查和比对——哪怕其他属性根本没动。
属性级脏标记提供另一种可能:Actor 的某个属性变化时,只同步这个变化的属性,跳过未变化属性的无效比对,从而大幅降低带宽和比对开销。
在「客户端连接多、同步对象多、属性变动频繁」的项目里,这套系统优化效果明显。值得一提的是,UE5 的新同步框架 Iris 在理念上也是类似思路——只同步 Actor 中发生变化的数据,做架构选型时可以拿来对照。
用法示例
下面以「把某对象的某个属性标记为脏」的代码流程为例。
1. 在头文件的 UCLASS 宏上加 NetDirty 标记,声明这个 class 拥有属性级脏同步能力。RPC 函数和需要同步的属性,声明方式与原生 UE 完全一致:
UCLASS(NetDirty)
class GAMECORE_API APickup_LootContainer : public APickup_Container, public IInteractableInterface
{
GENERATED_BODY()
// ...
protected:
UPROPERTY(Replicated, ReplicatedUsing = OnRep_bIsContainerOwned)
bool bIsContainerOwned = false;
UPROPERTY(Replicated, ReplicatedUsing = OnRep_ContainerOwnInfo)
FContainerOwnInfo ContainerOwnInfo;
// ...
};
2. 在 cpp 里,某个被改动的属性需要同步时,调用生成出来的 Mark##属性名##Dirty() 把它标记为脏:
void APickup_LootContainer::SetIsContainerOwned(bool InbNew)
{
bIsContainerOwned = InbNew;
MarkbIsContainerOwnedDirty();
}
void APickup_LootContainer::SetContainerOwnInfo(TArray<uint64> InPlayerIdArray, float InStartTime, float InOwnDuration)
{
ContainerOwnInfo.PlayerIdArray = InPlayerIdArray;
ContainerOwnInfo.StartTime = InStartTime;
ContainerOwnInfo.OwnDuration = InOwnDuration;
MarkContainerOwnInfoDirty();
// ...
}
3. 这些 Mark...Dirty() 函数是在 .generated.h 里生成的,可以打开反射文件查看:
#define PickupLootContainer_h_36_DIRTY_SETTER \
static const int kbIsContainerOwnedIndex = 77; \
void MarkbIsContainerOwnedDirty(){ \
NETDIRTY_MARK_PROPERTY_DIRTY(this, bIsContainerOwned); \
} \
void SetbIsContainerOwnedAndMarkDirty(bool value){ \
NETDIRTY_SET_AND_MARK_PROPERTY_DIRTY(this, bIsContainerOwned, value); \
} \
static const int kContainerOwnInfoIndex = 78; \
void MarkContainerOwnInfoDirty(){ \
NETDIRTY_MARK_PROPERTY_DIRTY(this, ContainerOwnInfo); \
} \
// ...
生成规则:改 UHT
这些函数从哪来?要在反射代码生成器(UHT,CodeGenerator.cpp / FNativeClassHeaderGenerator)里给 Class 新增一个扩展标志 EClassExtraFlags::CLASS_Extra_NetDirty。生成时,根据 Class 是否带 NetDirty 标记、以及变量本身是否是引擎支持的基础类型,决定要不要给它生成 Mark%sDirty() 和 Set%sAndMarkDirty():
void FNativeClassHeaderGenerator::ExportClassFromSourceFileInner(
FOutputDevice& OutGeneratedHeaderText,
FOutputDevice& OutCpp,
FOutputDevice& OutDeclarations,
FClass* Class,
const FUnrealSourceFile& SourceFile)
{
// ...
if (Class->ClassExtraFlags & EClassExtraFlags::CLASS_Extra_NetDirty)
{
FUHTStringBuilder DirtyBitsDeclText;
Class->SetUpRuntimeReplicationData();
int32 Index = SuperClass ? SuperClass->ClassReps.Num() : 0;
for (; Index < Class->ClassReps.Num(); Index++)
{
FProperty* Property = Class->ClassReps[Index].Property;
check(Property->PropertyFlags & CPF_Net);
FString Name = Property->GetName();
FString RealPropertyName = GetRealPropertyName(Property, Name);
FString Extended;
FString CppType = Property->GetCPPType(&Extended);
CppType += Extended;
DirtyBitsDeclText.Logf(TEXT("static const int k%sIndex = %d;\r\n"), *RealPropertyName, Index);
DirtyBitsDeclText.Logf(TEXT("void Mark%sDirty(){\r\n"), *Name);
DirtyBitsDeclText.Logf(TEXT("\tNETDIRTY_MARK_PROPERTY_DIRTY(this, %s);\r\n"), *RealPropertyName);
DirtyBitsDeclText.Log(TEXT("}\r\n"));
if (NeedSetterProperty(Property))
{
DirtyBitsDeclText.Logf(TEXT("void Set%sAndMarkDirty(%s value){\r\n"), *Name, *CppType);
DirtyBitsDeclText.Logf(TEXT("\tNETDIRTY_SET_AND_MARK_PROPERTY_DIRTY(this, %s, value);\r\n"), *RealPropertyName);
DirtyBitsDeclText.Log(TEXT("}\r\n"));
}
}
FString DirtySetterMacroName = SourceFile.GetGeneratedMacroName(ClassData, TEXT("_DIRTY_SETTER"));
WriteMacro(OutGeneratedHeaderText, DirtySetterMacroName, DirtyBitsDeclText);
ClassNoPureDeclsMacroCalls.Logf(TEXT("\t%s\r\n"), *DirtySetterMacroName);
ClassMacroCalls.Logf(TEXT("\t%s\r\n"), *DirtySetterMacroName);
}
// ...
}
注意这里用 static const int k%sIndex 把每个属性映射到一个属性索引——后面标脏、按位记录脏属性都靠它。
运行时:宏展开后的标脏流程
把生成的宏继续展开追下去,核心落在 NetDirtySystemInterface.h / NetDirtySystem.cpp(这套系统自己的实现),以及对原生 RepLayout 的扩展上。
NetDirtySystemInterface.h 定义了反射文件里那两个关键宏,并对外暴露接口:
namespace UE4DirtySystemPrivate
{
ENGINE_API void MarkObjectNetDirty(FNetDirtySystem& NetDirtySystem, const UObject* Object, int32 PropertyIndex = -1);
// ...
}
#define REP_PROPERTY_INDEX(Property) k##Property##Index
#define NETDIRTY_MARK_PROPERTY_DIRTY(Object, Property) \
if (GNetDirtySystem != NULL) UE4DirtySystemPrivate::MarkObjectNetDirty(*GNetDirtySystem, Object, Object->REP_PROPERTY_INDEX(Property));
#define NETDIRTY_SET_AND_MARK_PROPERTY_DIRTY(Object, Property, Value) \
if (Property != Value) { Property = Value; NETDIRTY_MARK_PROPERTY_DIRTY(Object, Property); }
NetDirtySystem.cpp 是标脏的核心:把属性被标脏的帧序号存为时间戳,并在 FrameDirtyInfo 里记下包含该脏属性的 Actor。注意它会沿着 GetOuter() 链往上走,subobject 变脏也会把外层 Actor 标脏:
namespace UE4DirtySystemPrivate
{
void MarkObjectNetDirty(FNetDirtySystem& NetDirtySystem, const UObject* Object, int32 PropertyIndex)
{
check(Object != NULL);
NetDirtySystem.MarkObjectDirty(Object, PropertyIndex);
}
}
// ...
void FNetDirtySystem::MarkObjectDirty(const UObject* Object, int32 PropertyIndex)
{
// ...
FFrameDirtyInfo& FrameDirtyInfo = GetCurrentFrameDirtyInfo();
const int32 FrameCounter = ThisFrameCounter;
for (; Object != NULL; Object = Object->GetOuter())
{
auto* ChangeListMgr = NetDriver->ReplicationChangeListMap.Find(Object);
FObjectState* ObjectState = NULL;
if (ChangeListMgr && ChangeListMgr->IsValid() && ChangeListMgr->IsObjectValid())
{
ObjectState = &((*ChangeListMgr)->GetDirtyState());
}
// ...
ObjectState->DirtyFrameCounter = FrameCounter;
if (bDirtyActor)
{
FrameDirtyInfo.AddDirtyActor(Object);
break;
}
}
}
最后在 UNetDriver::TickFlush() 执行完服务器同步 ServerReplicateActors() 之后,清空 FrameDirtyInfo,下一帧重新记录。
对 RepLayout 的扩展
脏状态本身挂在原生 FRepChangelistState 上新增的结构体里。关键数据:时间戳 DirtyFrameCounter、状态累加器 VersionFrameCounter、标记「具体哪个属性脏」的位数组 DirtyProperties,以及子对象脏状态:
struct FReplicationObjectDirtyState
{
int32 DirtyFrameCounter;
int32 VersionFrameCounter;
TSharedPtr Subobjects;
#if WITH_NETDIRTY_PROPERTY
TBitArray<> DirtyProperties;
bool AllPropertiesDirty;
bool RepFlagChangeDirty;
bool EnablePropertyBitArray;
#endif
#if WITH_NETDIRTY_CHECK
TSharedPtr StaticBuffer;
#endif
};
构造 FRepChangelistState 时,创建脏对象并通过 AddNetworkObject() 注册进脏系统,析构时 RemoveNetworkObject() 移除;首次注册时把位数组全部初始化为脏,保证第一次同步走全量:
FRepChangelistState::FRepChangelistState(
const TSharedRef<const FRepLayout>& InRepLayout,
const uint8* Source,
const UObject* InRepresenting,
FCustomDeltaChangelistState* InDeltaChangelistState):
CustomDeltaChangelistState(InDeltaChangelistState),
HistoryStart(0), HistoryEnd(0), CompareIndex(0),
StaticBuffer(InRepLayout->CreateShadowBuffer(Source))
{
#if WITH_NETDIRTY
ObjectKey = FObjectKey(InRepresenting);
#if !WITH_NETDIRTY_SUBOBJECT
UE4DirtySystemPrivate::AddNetworkObject(ObjectKey);
#else
UE4DirtySystemPrivate::AddNetworkObject(ObjectKey, RepDirtyState);
#if WITH_NETDIRTY_PROPERTY
if (RepDirtyState.EnablePropertyBitArray)
{
RepDirtyState.RepFlagChangeDirty = true;
RepDirtyState.AllPropertiesDirty = true;
RepDirtyState.DirtyProperties.Init(true, InRepLayout->GetParentsNum());
}
#endif
#endif
#endif
}
其他辅助接口
NetDirtySystem.cpp 里还有几个值得一提的函数:
AddNetworkObject():给同步对象加脏标记,记录标记帧并增加引用计数。StatPropertyDirty():脏状态的校验与统计,辅助调试。TryMarkInitialDormancyActorDirty():与 UE 的 Actor 休眠机制配合,唤醒休眠 Actor 时补上脏标记,避免漏同步。