- Published on
Unreal Engine 中快速数组序列化器的原理与应用
- Authors
- Name
- Zihan Li
在网络游戏开发中,高效地同步数据对于保证流畅的玩家体验至关重要。数组作为一种常见的数据结构,用于存储例如玩家库存、游戏状态信息等集合数据。然而,对于大型数组而言,传统的复制方式可能会带来显著的性能开销,尤其是在只有少量元素发生变化时,仍然需要传输整个数组,这会浪费带宽并增加 CPU 负担。为了解决这个问题,Unreal Engine 提供了快速数组序列化器(Fast Array Serializer),它是一种专门为包含结构体(UStruct
)的 TArray
设计的自定义 NetDeltaSerialize
实现。本文将深入探讨快速数组序列化器的定义、工作原理、优势、劣势、C++ 实现方法、性能表现、最佳实践以及常见问题。
快速数组序列化器是一种优化网络复制大型结构体数组的机制。其核心目标是通过仅发送自上次复制以来数组发生的更改(即增量或差异),从而显著提高网络传输效率,尤其适用于大型数据集。与每次都可能发送整个数组的标准 TArray
复制不同,快速数组序列化器能够更精细地控制数据的传输,从而减少带宽占用和服务器 CPU 处理时间。该序列化器的一个关键特性是能够高效地处理数组中任意位置的元素移除,避免了传统复制方式中移除中间元素后可能需要重新发送后续所有元素的问题。此外,快速数组序列化器还允许在客户端触发关于数组元素添加和移除的事件,方便客户端进行同步更新和逻辑处理。
快速数组复制的核心在于增量序列化。与传输数组的完整状态不同,系统会比较当前状态和先前发送的“基础状态”,然后仅传输两者之间的差异。为了实现这一点,快速数组中的每个元素都应具有一个 ReplicationID
和一个 ReplicationKey
,这两个成员通常是 int32
类型,定义在 FFastArraySerializerItem
中。ReplicationID
是数组中每个元素的唯一标识符,用于在网络更新中跟踪特定元素。ReplicationKey
的值在元素被标记为“脏”(即已修改)时发生变化。服务器利用这个键来判断哪些元素需要在客户端进行更新。
在服务器端序列化(写入)过程中,快速数组序列化器会将当前数组状态(特别是 ID 到复制键的映射)与旧的基础状态(上次发送的状态)进行比较。如果在当前状态中缺少基础状态中存在的某个元素,则该元素将在传出的网络“数据块”(网络传输的单元)中标记为删除。如果某个元素的 ReplicationKey
发生了变化,则表明该元素已被修改,需要在客户端进行更新。服务器随后会序列化该特定元素的更新数据。
在客户端序列化(读取)过程中,客户端会接收包含已更改和已删除元素信息的网络数据块。客户端维护一个 ReplicationID
到其本地数组索引的映射。当客户端反序列化 ID 时,它会在其本地数组中查找相应的元素。如果收到一个新的 ID 且本地不存在,客户端会创建一个新元素。如果 ID 存在并且包含数据,客户端会更新该元素的属性。如果某个 ID 被标记为删除,客户端会从其数组中移除相应的元素。值得注意的是,快速数组还默认启用了对内部结构体的增量序列化。这意味着当一个元素的 ReplicationKey
发生变化时,只会发送内部结构体中已修改的属性,从而进一步优化带宽使用。
使用快速数组复制的主要优势在于其显著的性能提升。对于包含大量结构体的 TArray
,特别是当只有一小部分元素频繁更改时,快速数组序列化器可以大幅降低服务器 CPU 时间和网络带宽消耗。例如,一项研究表明,当一个包含 10,000 个元素的大型数组发生更改时,服务器 CPU 时间从 3 毫秒降至 0.05 毫秒。当数组内容在复制更新之间保持不变时,快速数组复制产生的性能开销非常小。这得益于其优化的增量复制机制,能够仅发送必要的更新,而不是整个数组。
与标准的 TArray
复制不同,后者在中间元素被移除时可能需要重新发送大部分后续元素,快速数组复制通过仅发送特定元素的“删除”命令来高效处理移除操作。此外,快速数组序列化器还提供了在客户端接收元素添加 (PostReplicatedAdd
)、移除 (PreReplicatedRemove
) 或更改 (PostReplicatedChange
) 通知的功能,这使得客户端能够同步更新并执行自定义逻辑,而无需手动比较数组。对嵌套结构体的自动增量序列化进一步优化了带宽,只有当父数组元素的 ReplicationKey
发生变化时,才会发送这些结构体中已修改的属性。
然而,使用快速数组复制也存在一些缺点和需要权衡的地方。一个关键的权衡是需要手动将数组中的元素标记为“脏”。每当游戏代码中的元素数据发生更改时,都必须使用 MarkItemDirty()
函数。对于元素的移除,则需要调用 MarkArrayDirty()
。如果忘记执行这些操作,更改将不会复制到客户端。另一个需要考虑的方面是,客户端快速数组中元素的顺序可能并不总是与服务器上的顺序完全一致。这是因为系统使用 ID 进行跟踪,客户端处理更新的顺序可能略有不同。如果维护元素的精确顺序对游戏逻辑至关重要,则可能需要额外的机制。此外,与简单地声明一个 TArray
类型的 UPROPERTY
相比,设置和使用快速数组复制涉及更多步骤,并且需要更深入地理解其底层机制,这可能会增加开发者的学习曲线。
要在 C++ 中实现快速数组复制,需要遵循以下步骤。首先,存储在 TArray
中的结构体必须继承自 FFastArraySerializerItem
。这会为结构体提供必要的 ReplicationID
和 ReplicationKey
成员。例如:
USTRUCT()
struct FMyItem : public FFastArraySerializerItem
{
GENERATED_USTRUCT_BODY()
UPROPERTY()
int32 ItemID;
UPROPERTY()
FString ItemName;
// 可选的通知函数
void PreReplicatedRemove(const FFastArraySerializer& Serializer);
void PostReplicatedAdd(const FFastArraySerializer& Serializer);
void PostReplicatedChange(const FFastArraySerializer& Serializer);
};
接下来,需要创建一个包装结构体来包含 TArray
,并且该结构体需要继承自 FFastArraySerializer
。注意,这个 TArray
必须命名为 Items
。例如:
USTRUCT()
struct FMyItemArray : public FFastArraySerializer
{
GENERATED_USTRUCT_BODY()
UPROPERTY()
TArray<FMyItem> Items; // TArray 必须命名为 'Items'
// 实现 NetDeltaSerialize 函数
bool NetDeltaSerialize(FNetDeltaSerializeInfo& DeltaParms) override;
};
然后,需要在包装结构体中实现 NetDeltaSerialize
函数。这个函数应该调用静态的 FastArrayDeltaSerialize
函数,并将你的 Items
数组作为参数传递进去。例如:
bool FMyItemArray::NetDeltaSerialize(FNetDeltaSerializeInfo& DeltaParms)
{
return FastArrayDeltaSerialize<FMyItem>(Items, DeltaParms, *this);
}
为了告知 Unreal Engine 的反射系统你的包装结构体使用了自定义的网络增量序列化器,需要添加一个结构体特性。例如:
template<>
struct TStructOpsTypeTraits<FMyItemArray> : public TStructOpsTypeTraitsBase
{
enum
{
WithNetDeltaSerializer = true,
};
};
在需要使用快速复制数组的 Actor 或组件中,声明一个包装结构体类型的 UPROPERTY
,并标记为 Replicated
。例如:
UPROPERTY(Replicated)
FMyItemArray MyReplicatedArray;
当向 Items
数组添加、修改或移除元素时,必须调用 FMyItemArray
实例上的相应函数。对于添加或修改元素,使用 MarkItemDirty()
并传递对已修改元素的引用。对于移除元素,使用 MarkArrayDirty()
。例如:
void UMyComponent::AddItem(FMyItem NewItem)
{
MyReplicatedArray.Items.Add(NewItem);
MyReplicatedArray.MarkItemDirty(NewItem);
}
void UMyComponent::ModifyItem(int32 Index)
{
if (MyReplicatedArray.Items.IsValidIndex(Index))
{
MyReplicatedArray.Items[Index].ItemName = "Updated Name";
MyReplicatedArray.MarkItemDirty(MyReplicatedArray.Items[Index]);
}
}
void UMyComponent::RemoveItem(int32 Index)
{
if (MyReplicatedArray.Items.IsValidIndex(Index))
{
MyReplicatedArray.Items.RemoveAt(Index);
MyReplicatedArray.MarkArrayDirty();
}
}
最后,在 Actor 或组件的 GetLifetimeReplicatedProps
函数中,使用 DOREPSTRUCT()
宏来启用包装结构体属性的复制。例如:
void AMyActor::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPSTRUCT(AMyActor, MyReplicatedArray);
}
此外,在继承自 FFastArraySerializerItem
的 FMyItem
结构体中,可以选择性地实现 PreReplicatedRemove()
、PostReplicatedAdd()
和 PostReplicatedChange()
函数,以便在客户端发生这些事件时接收回调。
性能基准测试表明,对于大型结构体数组,快速数组复制能够显著降低服务器 CPU 时间,尤其是在只有部分元素发生变化的情况下。例如,对于包含 10,000 个元素的数组,当单个元素发生变化时,服务器 CPU 时间的降低非常明显。当数组内容没有变化时,快速数组复制的开销也很低。其主要优势在于执行高效的增量复制,仅发送必要的更新。通常情况下,客户端在使用快速数组复制和标准复制时,性能差异并不显著。快速数组复制最适合处理包含大量结构体且频繁发生少量变化的场景。对于小型数组或每次更新都完全变化的数组,设置和管理快速数组复制的开销可能不值得。
为了更有效地使用快速数组序列化器,需要遵循一些最佳实践。首先,尽量保持复制的结构体尽可能小,只包含绝对必要的数据,以最大化增量序列化的效率。其次,如果数组元素的顺序对游戏逻辑很重要,不要完全依赖客户端的数组顺序,考虑在数据结构中添加显式的排序机制或使用其他方法来维护顺序。一种方法是包含一个服务器索引并在客户端基于该索引管理顺序。务必确保正确使用 MarkItemDirty()
和 MarkArrayDirty()
函数,在每次添加、修改或移除数组元素后都要调用它们,否则更改将不会被复制。将数组修改封装在自动调用这些“脏”标记函数的函数中是一个好的实践。
在管理大量 Actor 及其相关数据(包括快速复制数组)的复制时,可以考虑使用网络管理器模式。这有助于集中复制逻辑,并通过减少单独复制的 Actor 数量来提高性能。利用项目结构体中的 PreReplicatedRemove()
、PostReplicatedAdd()
和 PostReplicatedChange()
函数来执行客户端逻辑以响应数组更改,可以简化客户端更新并提高响应速度。即使使用快速数组复制,优化复制的数据仍然至关重要,系统仍然需要迭代数据以查找更改。因此,尽量减少复制的数据量,避免复制不必要的信息。对于与数组中的项目相关的静态游戏数据(例如图标、名称、描述),可以考虑使用数据资产,仅复制引用它们的键或 ID,从而减小复制结构体的大小。
在使用快速数组序列化器时,可能会遇到一些常见问题。一个报告的问题是在打包构建中出现“ReceiveFastArrayItem: Invalid property terminator handle”错误。这可能与在打包环境和编辑器中序列化或反序列化属性的方式不一致有关。潜在的解决方案包括仔细检查复制项的结构,并确保所有属性都由复制系统正确处理。最常见的问题可能是忘记在修改数组后调用 MarkItemDirty()
或 MarkArrayDirty()
,导致更改无法复制。务必检查所有修改数组的代码路径,确保正确调用这些函数。确保所有设置步骤都正确完成,包括继承、创建包装结构体、实现 NetDeltaSerialize
和添加结构体特性。如果游戏逻辑依赖于元素的顺序,并且由于顺序无法保证而遇到问题,可能需要在复制的数据中实现自定义的排序或索引机制。考虑在项目结构体中添加 SortOrder
或 Index
属性并进行复制。虽然快速数组序列化器是为 UStruct
设计的,但尝试直接在快速数组中复制 UObject
可能会导致问题。通常最好在 UStruct
中复制与 UObject
相关的数据,然后单独管理 UObject
的生命周期和访问。可以考虑复制 ID 或软对象引用。即使使用快速数组序列化器,如果复制的结构体非常大或复杂,或者数组更改非常频繁,仍然可能遇到性能瓶颈。使用性能分析工具来识别导致问题的特定区域,并考虑进一步优化,例如减少复制的数据量或更新频率。
总而言之,快速数组序列化器是 Unreal Engine 中用于优化网络游戏里大型结构体数组复制的强大工具。通过使用增量序列化和高效处理移除操作,它在处理大型数据集和频繁局部更改的场景中提供了显著的性能优势。客户端事件通知为同步游戏逻辑和 UI 更新提供了便利。然而,它也存在需要显式标记“脏”元素以及无法保证元素顺序的缺点。开发者应在处理频繁更新的大型结构体数组、性能至关重要且需要降低服务器 CPU 负载和网络带宽、数组元素的顺序不严格要求或可以实现自定义排序机制以及客户端逻辑需要响应数组添加、移除或更改时考虑使用快速数组序列化器。如果数组较小、更改不频繁或需要严格的顺序且不能接受额外的开销,则标准的 TArray
复制可能更简单且足够使用。通过理解快速数组序列化器的优点、缺点和实现细节,开发者可以做出明智的决定,何时以及如何使用它来创建更高效和可扩展的 Unreal Engine 网络游戏。