Mapz's Blog

可以递归的函数指针

关于 Iris 自定义序列化

由于 Iris 的同步逻辑发生了变更,所以从前的使用 TStructOpsTypeTraits WithNetSerializer Trait

并定义

1
bool NetSerialize(FArchive& Ar, class UPackageMap* Map, bool& bOutSuccess);

的方式也被废弃

我们需要使用新的方式来实现对象的自定义序列化

Iris 使用船新的 FNetSerializer 来进行自定义序列化

如果我们声明了 WithNetSerializer 的 Trait

但是又没有注册 Iris 的 FNetSerializer

会在启动后 Log Warning

1
Generating descriptor for struct XXX that has custom serialization.

提示你要重新搞个 Serializer,如果没有搞的话

会使用默认的 StructSerializer 来进行处理

默认的 StructSerializer 会遍历所有的 Property 来使用其 Member Serializer 来进行同步

但是由于我们在使用旧版的自定义序列化的时候,通常会有其他的逻辑,不光是同步,所以使用默认的 Iris 同步一般会出现逻辑问题

所以大概率仍需要重新编写 NetSerializer

NetSerializer 主要做的事情大致可以看做下面除去传输过程外的内容

游戏数据->压缩装箱->序列化->[传输过程]->反序列化->开箱解压缩->游戏数据

下面我们以 FHitResultNetSerializer 为例,继续从自定义同步的实现来入手分析 Iris 这部分的工作原理

由于系统中使用了很多编译期逻辑

所以阅读代码需要掌握一些编译期模版元编程的知识

FHitResultNetSerializer 的实现

HitResultNetSerializer 用于实现 HitResult 的同步

NetSerializer 的定义

先查看头文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// Copyright Epic Games, Inc. All Rights Reserved.

#pragma once

#include "Iris/Serialization/NetSerializer.h"
#include "HitResultNetSerializer.generated.h"

USTRUCT()
struct FHitResultNetSerializerConfig : public FNetSerializerConfig
{
GENERATED_BODY()
};

namespace UE::Net
{

UE_NET_DECLARE_SERIALIZER(FHitResultNetSerializer, ENGINE_API);

}

先是继承 FNetSerializerConfig 实现一个 Config

而后使用宏 UE_NET_DECLARE_SERIALIZER 定义一个 NetSerializer 以及导出

其中宏定义

1
2
3
4
5
6
7
8
/** Declare a serializer. */
#define UE_NET_DECLARE_SERIALIZER(SerializerName, Api) struct Api SerializerName ## NetSerializerInfo \
{ \
static const UE::Net::FNetSerializer Serializer; \
static uint32 GetQuantizedTypeSize(); \
static uint32 GetQuantizedTypeAlignment(); \
static const FNetSerializerConfig* GetDefaultConfig(); \
};

其中声明了一个 FHitResultNetSerializerNetSerializerInfo 的 struct,其成员为

  • static 的 FNetSerializer,这是通用的 Serializer 的内部数据结构,会根据实现类的信息在编译期设置内容
  • 获取压缩装箱后的类型内存大小的函数
  • 获取压缩装箱后的类型内存对齐的函数
  • 获取同步 Config 的函数

NetSerializer 的静态初始化

那么这些内容是如何初始化的呢?

我们接着查看源文件

1
2
3
4
5
6
7
8
9
namespace UE::Net
{

struct FHitResultNetSerializer
{
...
}
}
UE_NET_IMPLEMENT_SERIALIZER(FHitResultNetSerializer);

声明了我们在头文件中使用的那个 FHitResultNetSerializer 实现类

然后使用宏 UE_NET_IMPLEMENT_SERIALIZER 实现其初始化

1
2
3
4
5
6
/** Implement a serializer using the struct named SerializerName. */
#define UE_NET_IMPLEMENT_SERIALIZER(SerializerName) const UE::Net::FNetSerializer SerializerName ## NetSerializerInfo::Serializer = UE::Net::TNetSerializer<SerializerName>::ConstructNetSerializer(TEXT(#SerializerName)); \
uint32 SerializerName ## NetSerializerInfo::GetQuantizedTypeSize() { return UE::Net::TNetSerializerBuilder<SerializerName>::GetQuantizedTypeSize(); }; \
uint32 SerializerName ## NetSerializerInfo::GetQuantizedTypeAlignment() { return UE::Net::TNetSerializerBuilder<SerializerName>::GetQuantizedTypeAlignment(); }; \
const FNetSerializerConfig* SerializerName ## NetSerializerInfo::GetDefaultConfig() { return UE::Net::TNetSerializerBuilder<SerializerName>::GetDefaultConfig(); };

首先是内部 Serializer 的初始化

使用了模版

1
UE::Net::TNetSerializer<FHitResultNetSerializer>::ConstructNetSerializer("FHitResultNetSerializer"); 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
template<typename NetSerializerImpl>
class TNetSerializer
{
public:
static constexpr FNetSerializer ConstructNetSerializer(const TCHAR* Name)
{
TNetSerializerBuilder<NetSerializerImpl> Builder;
Builder.Validate();

FNetSerializer Serializer = {};
Serializer.Version = Builder.GetVersion();
Serializer.Traits = Builder.GetTraits();

Serializer.Serialize = Builder.GetSerializeFunction();
Serializer.Deserialize = Builder.GetDeserializeFunction();
Serializer.SerializeDelta = Builder.GetSerializeDeltaFunction();
Serializer.DeserializeDelta = Builder.GetDeserializeDeltaFunction();
Serializer.Quantize = Builder.GetQuantizeFunction();
Serializer.Dequantize = Builder.GetDequantizeFunction();
Serializer.IsEqual = Builder.GetIsEqualFunction();
Serializer.Validate = Builder.GetValidateFunction();
Serializer.CloneDynamicState = Builder.GetCloneDynamicStateFunction();
Serializer.FreeDynamicState = Builder.GetFreeDynamicStateFunction();
Serializer.CollectNetReferences = Builder.GetCollectNetReferencesFunction();

Serializer.DefaultConfig = Builder.GetDefaultConfig();

Serializer.QuantizedTypeSize = Builder.GetQuantizedTypeSize();
Serializer.QuantizedTypeAlignment = Builder.GetQuantizedTypeAlignment();

Serializer.ConfigTypeSize = Builder.GetConfigTypeSize();
Serializer.ConfigTypeAlignment = Builder.GetConfigTypeAlignment();

Serializer.Name = Name;
return Serializer;
}
};

此模版类的用处是使用 NetSerializer 的实现类,来静态初始化 NetSerizlierInfo 中的 Serializer 内容

初始化过程都是在编译期执行的

查看 Builder 的模版类,这是静态初始化的具体逻辑内容

1
2
3
4
5
template<typename NetSerializerImpl>
class TNetSerializerBuilder
{
...
}

其中使用了大量的编译期逻辑来给不同类型的 Serializer 赋值不同的参数

例如 Version ,各种 Traits 等

1
2
3
// Version check
template<typename U> static ETrueType TestHasVersion(typename TEnableIf<std::is_same_v<decltype(&FVersion::Version), decltype(&U::Version)>>::Type*);
template<typename> static EFalseType TestHasVersion(...);

NetSerializer 的内容简析

我们来看看 FHitResultNetSerializer 的内容

Version

自定义序列化的版本号

1
2
// Version
static const uint32 Version = 0;

编译期如果有这个成员,则 Builder 中 TestHasVersion 为 True

则 Builder 的 Etraits 中的 HasVersion 会设置为 True

1
2
3
4
5
enum ETraits : unsigned
{
HasVersion = unsigned(decltype(TestHasVersion<NetSerializerImpl>(nullptr))::Value),
...
}

从而 Builder 的 GetVersion 函数可得其值

1
2
3
4
5
template<typename T = void, typename U = typename TEnableIf<HasVersion, T>::Type, bool V = true>
static constexpr uint32 GetVersion() { return NetSerializerImpl::Version; }

template<typename T = void, typename U = typename TEnableIf<!HasVersion, T>::Type, char V = 0>
static constexpr uint32 GetVersion() { return ~0U; }

然后在静态初始化的时候,设置到类型的 NetSerializerInfo 的 NetSerialzier 中

当前 Version 参数在同步中我没有找到使用的位置

预计是用于同步数据的版本管理

bIsForwardingSerializer

是否是转发序列化器

1
static constexpr bool bIsForwardingSerializer = true; // Triggers asserts if a function is missing

如果有这个成员

在 Builder 中设置后,获取函数为

1
2
3
4
5
template<typename T = void, typename U = typename TEnableIf<IsForwardingSerializerIsBool, T>::Type, bool V = true>
static constexpr bool IsForwardingSerializer() { return NetSerializerImpl::bIsForwardingSerializer; }

template<typename T = void, typename U = typename TEnableIf<!IsForwardingSerializerIsBool, T>::Type, char V = 0>
static constexpr bool IsForwardingSerializer() { return false; }

如果设置为 true,则需要在 NetSerialzer 实现中实现下面所有的检查所检查的成员,否则编译会失败

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
template<typename T = void, typename U = typename TEnableIf<IsForwardingSerializer(), T>::Type, bool V = true>
static void ValidateForwardingSerializer()
{
static_assert(HasSerialize, "Forwarding FNetSerializer must implement Serialize.");
static_assert(HasDeserialize, "Forwarding FNetSerializer must implement Deserialize.");
static_assert(HasSerializeDelta, "Forwarding FNetSerializer must implement SerializeDelta.");
static_assert(HasDeserializeDelta, "Forwarding FNetSerializer must implement DeserializeDelta.");
static_assert(HasQuantize, "Forwarding FNetSerializer must implement Quantize.");
static_assert(HasDequantize, "Forwarding FNetSerializer must implement Dequantize.");
static_assert(HasIsEqual, "Forwarding FNetSerializer must implement IsEqual.");
static_assert(HasValidate, "Forwarding FNetSerializer must implement Validate.");
static_assert(HasCloneDynamicState, "Forwarding FNetSerializer must implement CloneDynamicState.");
static_assert(HasFreeDynamicState, "Forwarding FNetSerializer must implement FreeDynamicState.");
static_assert(HasCollectNetReferences, "Forwarding FNetSerializer must implement CollectNetReferences.");
}

template<typename T = void, typename U = typename TEnableIf<!IsForwardingSerializer(), T>::Type, char V = 0>
static void ValidateForwardingSerializer()
{
}

bHasCustomNetReference

是否有自定义的 NetReference,如果有的话,可以自己收集需要同步的 NetReference

1
static constexpr bool bHasCustomNetReference = true;

如果设置了此参数,会在 Validate 静态检查

1
static_assert(!HasCustomNetReference() || (HasCustomNetReference() && HasCollectNetReferences), "FNetSerializer with bHasCustomNetReference = true must implement CollectNetReferences method.");

如果序列化器没有实现 CollectNetReferences 函数,则会编译失败

bHasDynamicState

是否有动态 State
State 是序列化器用到的数据 Buffer
如果有动态 State 意味着需要手动处理 State 的拷贝和释放

1
static constexpr bool bHasDynamicState = true;

如果设置了此参数,会在 NetSerializer 中增加 Trait

1
Traits |= (HasDynamicState() ? ENetSerializerTraits::HasDynamicState : ENetSerializerTraits::None);

并且必须添加 CloneDynamicState 和 FreeDynamicState 成员函数

1
2
static_assert(!HasDynamicStateIsBool || (HasFreeDynamicState && HasCloneDynamicState), "FNetSerializer must implement CloneDynamicState and FreeDynamicState when it has dynamic state.");

定义需要同步的内容数据结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// Types
enum EReplicationFlags : uint32
{
BlockingHit = 1U,
StartPenetrating = BlockingHit << 1U,
ImpactPointEqualsLocation = StartPenetrating << 1U,
ImpactNormalEqualsNormal = ImpactPointEqualsLocation << 1U,
InvalidItem = ImpactNormalEqualsNormal << 1U,
InvalidFaceIndex = InvalidItem << 1U,
NoPenetrationDepth = InvalidFaceIndex << 1U,
InvalidElementIndex = NoPenetrationDepth << 1U,
InvalidMyItem = InvalidElementIndex << 1U,
};

static constexpr uint32 ReplicatedFlagCount = 9U;

struct FQuantizedType
{
alignas(16) uint8 HitResult[316];
uint32 ReplicationFlags;
};

typedef FHitResult SourceType;
typedef FQuantizedType QuantizedType;
typedef FHitResultNetSerializerConfig ConfigType;

static const ConfigType DefaultConfig;

Flags 用于标记需要同步的内容
而后面的 FQuantizedType 用于存储压缩后的数据
其中使用了一个 uint8 数组来存储压缩后的数据,其大小是通过计算原 Struct 所需的最大内存来计算的
而后面的 ReplicationFlags 是用于存储标记位的
SourceType 是原始数据类型
QuantizedType 是装箱后的数据类型
ConfigType 是同步配置类型

实现的成员函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static void Serialize(FNetSerializationContext&, const FNetSerializeArgs& Args);
static void Deserialize(FNetSerializationContext&, const FNetDeserializeArgs& Args);

static void SerializeDelta(FNetSerializationContext&, const FNetSerializeDeltaArgs& Args);
static void DeserializeDelta(FNetSerializationContext&, const FNetDeserializeDeltaArgs& Args);

static void Quantize(FNetSerializationContext&, const FNetQuantizeArgs& Args);
static void Dequantize(FNetSerializationContext&, const FNetDequantizeArgs& Args);

static bool IsEqual(FNetSerializationContext&, const FNetIsEqualArgs& Args);
static bool Validate(FNetSerializationContext&, const FNetValidateArgs& Args);

static void CloneDynamicState(FNetSerializationContext&, const FNetCloneDynamicStateArgs&);
static void FreeDynamicState(FNetSerializationContext&, const FNetFreeDynamicStateArgs&);

static void CollectNetReferences(FNetSerializationContext&, const FNetCollectReferencesArgs&);

Serialize

用于从 Internal Buffer 到 比特流的 序列化数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
void FHitResultNetSerializer::Serialize(FNetSerializationContext& Context, const FNetSerializeArgs& Args)
{
// 注 :Args.Source 是指向序列化数据的指针
const QuantizedType& Value = *reinterpret_cast<QuantizedType*>(Args.Source);

FNetBitStreamWriter* Writer = Context.GetBitStreamWriter();

// 注 :直接写入标记位
const uint32 ReplicationFlags = Value.ReplicationFlags;
Writer->WriteBits(ReplicationFlags, FHitResultNetSerializer::ReplicatedFlagCount);

// We need to manually serialize the properties as there are some replicated properties that don't need to be replicated depending on the data
// as is the case with some of the bools that are covered by the ReplicationFlags.

const FReplicationStateDescriptor* Descriptor = StructNetSerializerConfigForHitResult.StateDescriptor.GetReference();
const FReplicationStateMemberDescriptor* MemberDescriptors = Descriptor->MemberDescriptors;
const FReplicationStateMemberSerializerDescriptor* MemberSerializerDescriptors = Descriptor->MemberSerializerDescriptors;
const FReplicationStateMemberDebugDescriptor* MemberDebugDescriptors = Descriptor->MemberDebugDescriptors;

const uint32 MemberCount = Descriptor->MemberCount;

// Initalize mask as all dirty
uint32 MemberMaskStorage = ~0U;
// 注 :获得需要同步的可选 Struct 成员
FNetBitArrayView MemberMask = GetMemberChangeMask(&MemberMaskStorage, MemberCount, ReplicationFlags);

for (uint32 MemberIndex = 0; MemberIndex < MemberCount; ++MemberIndex)
{
if (!MemberMask.GetBit(MemberIndex))
{
continue;
}

// 注 : 序列化需要同步的成员
const FReplicationStateMemberDescriptor& MemberDescriptor = MemberDescriptors[MemberIndex];
const FReplicationStateMemberSerializerDescriptor& MemberSerializerDescriptor = MemberSerializerDescriptors[MemberIndex];

UE_NET_TRACE_DYNAMIC_NAME_SCOPE(MemberDebugDescriptors[MemberIndex].DebugName, *Context.GetBitStreamWriter(), Context.GetTraceCollector(), ENetTraceVerbosity::Verbose);
UE_NET_TRACE_DYNAMIC_NAME_SCOPE(MemberSerializerDescriptor.Serializer->Name, *Context.GetBitStreamWriter(), Context.GetTraceCollector(), ENetTraceVerbosity::VeryVerbose);

FNetSerializeArgs MemberSerializeArgs;
MemberSerializeArgs.NetSerializerConfig = MemberSerializerDescriptor.SerializerConfig;
MemberSerializeArgs.Source = reinterpret_cast<NetSerializerValuePointer>(&Value.HitResult) + MemberDescriptor.InternalMemberOffset;
// 注 :序列化成员
MemberSerializerDescriptor.Serializer->Serialize(Context, MemberSerializeArgs);
}
}

Deserialize

用于从头 比特流 反序列化数据 到 Internal Buffer

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
void FHitResultNetSerializer::Deserialize(FNetSerializationContext& Context, const FNetDeserializeArgs& Args)
{
QuantizedType& Target = *reinterpret_cast<QuantizedType*>(Args.Target);

FNetBitStreamReader* Reader = Context.GetBitStreamReader();

// 注:先读取标记位
const uint32 ReplicationFlags = Reader->ReadBits(FHitResultNetSerializer::ReplicatedFlagCount);
Target.ReplicationFlags = ReplicationFlags;

// We need to manually serialize the properties as there are some replicated properties that don't need to be replicated depending on the data
// as is the case with some of the bools that are covered by the ReplicationFlags.
const FReplicationStateDescriptor* Descriptor = StructNetSerializerConfigForHitResult.StateDescriptor.GetReference();
const FReplicationStateMemberDescriptor* MemberDescriptors = Descriptor->MemberDescriptors;
const FReplicationStateMemberSerializerDescriptor* MemberSerializerDescriptors = Descriptor->MemberSerializerDescriptors;
const FReplicationStateMemberDebugDescriptor* MemberDebugDescriptors = Descriptor->MemberDebugDescriptors;

const uint32 MemberCount = Descriptor->MemberCount;

// Initalize mask as all dirty
uint32 MemberMaskStorage = ~0U;
// 获取要反序列化的内容
FNetBitArrayView MemberMask = GetMemberChangeMask(&MemberMaskStorage, MemberCount, ReplicationFlags);

for (uint32 MemberIndex = 0; MemberIndex < MemberCount; ++MemberIndex)
{
if (!MemberMask.GetBit(MemberIndex))
{
continue;
}

const FReplicationStateMemberDescriptor& MemberDescriptor = MemberDescriptors[MemberIndex];
const FReplicationStateMemberSerializerDescriptor& MemberSerializerDescriptor = MemberSerializerDescriptors[MemberIndex];

UE_NET_TRACE_DYNAMIC_NAME_SCOPE(MemberDebugDescriptors[MemberIndex].DebugName, *Context.GetBitStreamReader(), Context.GetTraceCollector(), ENetTraceVerbosity::Verbose);
UE_NET_TRACE_DYNAMIC_NAME_SCOPE(MemberSerializerDescriptor.Serializer->Name, *Context.GetBitStreamReader(), Context.GetTraceCollector(), ENetTraceVerbosity::VeryVerbose);

FNetDeserializeArgs MemberDeserializeArgs;
MemberDeserializeArgs.NetSerializerConfig = MemberSerializerDescriptor.SerializerConfig;
MemberDeserializeArgs.Target = reinterpret_cast<NetSerializerValuePointer>(&Target.HitResult) + MemberDescriptor.InternalMemberOffset;
MemberSerializerDescriptor.Serializer->Deserialize(Context, MemberDeserializeArgs);
}
}

SerializeDelta 和 DeserializeDelta

用于序列化和反序列化 Delta 数据

可以在前面的 State 的基础上,做增量序列化

这样可以只同步变化内容,进一步减少同步所需数据量

1
2
3
4
5
6
7
8
9
10
// For now just use normal serialization for delta as this struct typically is a one off.
void FHitResultNetSerializer::SerializeDelta(FNetSerializationContext& Context, const FNetSerializeDeltaArgs& Args)
{
NetSerializeDeltaDefault<Serialize>(Context, Args);
}

void FHitResultNetSerializer::DeserializeDelta(FNetSerializationContext& Context, const FNetDeserializeDeltaArgs& Args)
{
NetDeserializeDeltaDefault<Deserialize>(Context, Args);
}

阅读模版函数 NetSerializeDeltaDefault 和 NetDeserializeDeltaDefault

1
2
3
4
5
6
7
8
9
10
11
12
13
template<NetSerializeFunction Serialize>
void
NetSerializeDeltaDefault(FNetSerializationContext& Context, const FNetSerializeDeltaArgs& Args)
{
Serialize(Context, Args);
};

template<NetDeserializeFunction Deserialize>
void
NetDeserializeDeltaDefault(FNetSerializationContext& Context, const FNetDeserializeDeltaArgs& Args)
{
Deserialize(Context, Args);
}

可知本 Serializer 中直接使用了 Serialize 和 Deserialize 来处理 Delta Serialize 的内容,并没有做增量优化

Quantize 和 Dequantize

用于从 External Buffer 到 Internal Buffer 的 “量子化” 和 “逆量子化”
可以理解为装箱和开箱,装箱指把原始数据处理成可用来同步的数据,开箱指把同步数据处理成原始数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
在 HitResult 中,做了一次全量的量子化和逆量子化,并处理了 Flag 的更新
由于 Quantize 是在 Serialize 之前调用的,所以在 Serialize 中可以直接使用量子化后的数据

```cpp
void FHitResultNetSerializer::Quantize(FNetSerializationContext& Context, const FNetQuantizeArgs& Args)
{
const SourceType& SourceValue = *reinterpret_cast<const SourceType*>(Args.Source);
QuantizedType& TargetValue = *reinterpret_cast<QuantizedType*>(Args.Target);

uint32 ReplicationFlags = 0;

// Update flags based on SourceValue
// 注:根据 SourceValue 的内容更新标记位
ReplicationFlags |= SourceValue.ImpactPoint == SourceValue.Location ? EReplicationFlags::ImpactPointEqualsLocation : 0U;
ReplicationFlags |= SourceValue.ImpactNormal == SourceValue.Normal ? EReplicationFlags::ImpactNormalEqualsNormal : 0U;
ReplicationFlags |= SourceValue.MyItem == INDEX_NONE ? EReplicationFlags::InvalidMyItem : 0U;
ReplicationFlags |= SourceValue.Item == INDEX_NONE ? EReplicationFlags::InvalidItem : 0U;
ReplicationFlags |= SourceValue.FaceIndex == INDEX_NONE ? EReplicationFlags::InvalidFaceIndex : 0U;
ReplicationFlags |= (SourceValue.PenetrationDepth == 0.0f) ? EReplicationFlags::NoPenetrationDepth : 0U;
ReplicationFlags |= SourceValue.ElementIndex == INDEX_NONE ? EReplicationFlags::InvalidElementIndex : 0U;
ReplicationFlags |= SourceValue.bBlockingHit ? EReplicationFlags::BlockingHit : 0U;
ReplicationFlags |= SourceValue.bStartPenetrating ? EReplicationFlags::StartPenetrating : 0U;

TargetValue.ReplicationFlags = ReplicationFlags;

// We do a full quantize even though we wont necessarily serialize them.
// 注:做了一次 Full Quantize
FNetQuantizeArgs HitResultQuantizeArgs = {};
HitResultQuantizeArgs.NetSerializerConfig = &StructNetSerializerConfigForHitResult;
HitResultQuantizeArgs.Source = Args.Source;
HitResultQuantizeArgs.Target = NetSerializerValuePointer(&TargetValue.HitResult);
StructNetSerializer->Quantize(Context, HitResultQuantizeArgs);
}

void FHitResultNetSerializer::Dequantize(FNetSerializationContext& Context, const FNetDequantizeArgs& Args)
{
const QuantizedType& SourceValue = *reinterpret_cast<const QuantizedType*>(Args.Source);
SourceType& TargetValue = *reinterpret_cast<SourceType*>(Args.Target);

const uint32 ReplicatonFlags = SourceValue.ReplicationFlags;

// Dequantize all and fixup afterwards
FNetDequantizeArgs HitResultQuantizeArgs = {};
HitResultQuantizeArgs.NetSerializerConfig = &StructNetSerializerConfigForHitResult;
HitResultQuantizeArgs.Source = NetSerializerValuePointer(&SourceValue.HitResult);
HitResultQuantizeArgs.Target = Args.Target;
StructNetSerializer->Dequantize(Context, HitResultQuantizeArgs);

if (ReplicatonFlags & EReplicationFlags::ImpactPointEqualsLocation)
{
TargetValue.ImpactPoint = TargetValue.Location;
}

if (ReplicatonFlags & EReplicationFlags::ImpactNormalEqualsNormal)
{
TargetValue.ImpactNormal = TargetValue.Normal;
}

if (ReplicatonFlags & EReplicationFlags::InvalidMyItem)
{
TargetValue.MyItem = INDEX_NONE;
}

if (ReplicatonFlags & EReplicationFlags::InvalidItem)
{
TargetValue.Item = INDEX_NONE;
}

if (ReplicatonFlags & EReplicationFlags::InvalidFaceIndex)
{
TargetValue.FaceIndex = INDEX_NONE;
}

if (ReplicatonFlags & EReplicationFlags::NoPenetrationDepth)
{
TargetValue.PenetrationDepth = 0.f;
}

if (ReplicatonFlags & EReplicationFlags::InvalidElementIndex)
{
TargetValue.ElementIndex = INDEX_NONE;
}

// Calculate distance
TargetValue.Distance = (TargetValue.ImpactPoint - TargetValue.TraceStart).Size();

// Set the bBlockingHit and bStartPenetrating from the flags
TargetValue.bBlockingHit = (ReplicatonFlags & EReplicationFlags::BlockingHit) != 0U ? 1 : 0;
TargetValue.bStartPenetrating = (ReplicatonFlags & EReplicationFlags::StartPenetrating) != 0U ? 1 : 0;
}

IsEqual

用于对比两个 Internal Buffer 的内容是否相同,用于确认对象是否 Dirty 从而确认是否需要同步
这里分别对比了 State 是 装箱 后的数据的情况和 State 是原始数据的情况

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
bool FHitResultNetSerializer::IsEqual(FNetSerializationContext& Context, const FNetIsEqualArgs& Args)
{
if (Args.bStateIsQuantized)
{
const QuantizedType& Value0 = *reinterpret_cast<const QuantizedType*>(Args.Source0);
const QuantizedType& Value1 = *reinterpret_cast<const QuantizedType*>(Args.Source1);

if (Value0.ReplicationFlags != Value1.ReplicationFlags)
{
return false;
}

// Do a per member compare of relevant members
const FReplicationStateDescriptor* Descriptor = StructNetSerializerConfigForHitResult.StateDescriptor.GetReference();
const FReplicationStateMemberDescriptor* MemberDescriptors = Descriptor->MemberDescriptors;
const FReplicationStateMemberSerializerDescriptor* MemberSerializerDescriptors = Descriptor->MemberSerializerDescriptors;
const FReplicationStateMemberDebugDescriptor* MemberDebugDescriptors = Descriptor->MemberDebugDescriptors;

const uint32 MemberCount = Descriptor->MemberCount;

// Initalize mask as all dirty
uint32 MemberMaskStorage = ~0U;
FNetBitArrayView MemberMask = GetMemberChangeMask(&MemberMaskStorage, MemberCount, Value0.ReplicationFlags);

FNetIsEqualArgs MemberArgs;
MemberArgs.Version = 0;
MemberArgs.bStateIsQuantized = Args.bStateIsQuantized;

for (uint32 MemberIt = 0; MemberIt < MemberCount; ++MemberIt)
{
if (!MemberMask.GetBit(MemberIt))
{
continue;
}

const FReplicationStateMemberDescriptor& MemberDescriptor = MemberDescriptors[MemberIt];
const FReplicationStateMemberSerializerDescriptor& MemberSerializerDescriptor = MemberSerializerDescriptors[MemberIt];
const FNetSerializer* Serializer = MemberSerializerDescriptor.Serializer;

MemberArgs.NetSerializerConfig = MemberSerializerDescriptor.SerializerConfig;
const uint32 MemberOffset = MemberDescriptor.InternalMemberOffset;
MemberArgs.Source0 = reinterpret_cast<NetSerializerValuePointer>(&Value0.HitResult) + MemberDescriptor.InternalMemberOffset;
MemberArgs.Source1 = reinterpret_cast<NetSerializerValuePointer>(&Value1.HitResult) + MemberDescriptor.InternalMemberOffset;

if (!Serializer->IsEqual(Context, MemberArgs))
{
return false;
}
}
}
else
{
FNetIsEqualArgs HitResultEqualArgs = Args;
HitResultEqualArgs.NetSerializerConfig = &StructNetSerializerConfigForHitResult;
HitResultEqualArgs.Source0 = NetSerializerValuePointer(Args.Source0);
HitResultEqualArgs.Source1 = NetSerializerValuePointer(Args.Source1);

if (!StructNetSerializer->IsEqual(Context, HitResultEqualArgs))
{
return false;
}
}

return true;
}

Validate

校验正确性,我暂时没有找到使用的地方

FreeDynamicState 和 CloneDynamicState

用于自定义动态的 State 数据的拷贝和释放

释放一般调用于 NetBlob 的释放的时候

例如发送收到临时的传输,比如 Unreliable 信息的传输,就会在发送或者收到后立即释放其 State

又例如在 Array 中,如果有元素移除,也会释放其 State

而克隆会在需要拷贝 State 的时候调用

例如发送信息后,会把当前的 State 拷贝一份作为基准,用于后续的 Delta 比较

具体实现内容略

CollectNetReferences

用于收集需要同步的内容

例如发送和接收 RPC 的参数

发送数据的时候

HitResult 内此函数只做了转发,所以内容略去

注册同步类型到自定义 Serializer

我们可以看到在 Serializer 的实现中,声明了一个内部类

并指定了一个静态成员用于注册类型

这个内部类用于处理注册 Serializer 到系统中,以及初始化相关 Serializer 的相关参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
struct FHitResultNetSerializer
{
...
private:
// 用于注册的内部类
class FNetSerializerRegistryDelegates final : private UE::Net::FNetSerializerRegistryDelegates
{
public:
virtual ~FNetSerializerRegistryDelegates();

private:
// 委托的挂载会在和卸载会在父类的构造函数和析构函数中自动执行,这里只需要重写两个虚函数即可
virtual void OnPreFreezeNetSerializerRegistry() override;
virtual void OnPostFreezeNetSerializerRegistry() override;
};
...
// 静态成员
static FHitResultNetSerializer::FNetSerializerRegistryDelegates NetSerializerRegistryDelegates;
...
};
...
// 创建内部类的静态成员实例
FHitResultNetSerializer::FNetSerializerRegistryDelegates FHitResultNetSerializer::NetSerializerRegistryDelegates;


具体的注册过程如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
// 注册一个名称,这个名称和 Struct 的名称一致
static const FName PropertyNetSerializerRegistry_NAME_HitResult("HitResult");

// 使用这个宏,会实现一个 FPropertyNetSerializerInfo ,其中包含其类型的序列化名称和其 Serializer
UE_NET_IMPLEMENT_NAMED_STRUCT_NETSERIALIZER_INFO(PropertyNetSerializerRegistry_NAME_HitResult, FHitResultNetSerializer);

// 在 FNetSerializerRegistryDelegates 的析构函数中,我们注销 NetSerializer Info
FHitResultNetSerializer::FNetSerializerRegistryDelegates::~FNetSerializerRegistryDelegates()
{
// 这个宏用于注销类型的序列化器,必须依托 UE_NET_IMPLEMENT_NAMED_STRUCT_NETSERIALIZER_INFO
UE_NET_UNREGISTER_NETSERIALIZER_INFO(PropertyNetSerializerRegistry_NAME_HitResult);
}

// 在 OnPreFreezeNetSerializerRegistry 中,我们注册 NetSerializer Info
void FHitResultNetSerializer::FNetSerializerRegistryDelegates::OnPreFreezeNetSerializerRegistry()
{
// 这个宏用于注册类型的序列化器,必须依托 UE_NET_IMPLEMENT_NAMED_STRUCT_NETSERIALIZER_INFO
UE_NET_REGISTER_NETSERIALIZER_INFO(PropertyNetSerializerRegistry_NAME_HitResult);
}

// 在 OnPostFreezeNetSerializerRegistry 中,我们可以配置序列化器
// 并初始化一些需要使用的内容
// 通常是写自己的 StateDescriptor
// Desriptor 属性包括
// 是否包括 Super Class 的 Descriptor
// GetLifeTimeProperties 的属性 (class 适用)
// 是否启用 FastArraySerializer
// 等
void FHitResultNetSerializer::FNetSerializerRegistryDelegates::OnPostFreezeNetSerializerRegistry()
{
// Setup serializer
{
// In this case we want to build a descriptor based on the struct members rather than the serializer we try to register
FReplicationStateDescriptorBuilder::FParameters Params;
Params.SkipCheckForCustomNetSerializerForStruct = true;

const UStruct* HitResultStruct = FHitResult::StaticStruct();

// Had do comment this out as the size differs between editor and non-editor builds.
//if (HitResultStruct->GetStructureSize() != 240 || HitResultStruct->GetMinAlignment() != 8)
//{
// LowLevelFatalError(TEXT("%s Size: %d Alignment: %d"), TEXT("FHitResult layout has changed. Need to update FHitResultNetSerializer."), HitResultStruct->GetStructureSize(), HitResultStruct->GetMinAlignment());
//}

// 设置 StateDescriptor
StructNetSerializerConfigForHitResult.StateDescriptor = FReplicationStateDescriptorBuilder::CreateDescriptorForStruct(HitResultStruct, Params);
const FReplicationStateDescriptor* Descriptor = StructNetSerializerConfigForHitResult.StateDescriptor.GetReference();
check(Descriptor != nullptr);
HitResultStateTraits = Descriptor->Traits;

// 用于检查 Serializer 是否适配当前版本的 Struct,这里粗暴的使用了 MemberCount
if (Descriptor->MemberCount > 32U)
{
LowLevelFatalError(TEXT("%s Has more than 32 replicated members."), TEXT("FHitResult has changed. Need to update FHitResultNetSerializer."), Descriptor->MemberCount);
}

// Build property -> Member index lookup table
FName PropertyNames[EPropertyName::PropertyName_ConditionallyReplicatedPropertyCount];

PropertyNames[PropertyName_FaceIndex] = FName("FaceIndex");
PropertyNames[PropertyName_Distance] = FName("Distance");
PropertyNames[PropertyName_ImpactPoint] = FName("ImpactPoint");
PropertyNames[PropertyName_ImpactNormal] = FName("ImpactNormal");
PropertyNames[PropertyName_PenetrationDepth] = FName("PenetrationDepth");
PropertyNames[PropertyName_MyItem] = FName("MyItem");
PropertyNames[PropertyName_Item] = FName("Item");
PropertyNames[PropertyName_ElementIndex] = FName("ElementIndex");
PropertyNames[PropertyName_bBlockingHit] = FName("bBlockingHit");
PropertyNames[PropertyName_bStartPenetrating] = FName("bStartPenetrating");

uint32 FoundPropertyMask = 0;

// Find all replicated properties of interest.
// 这里遍历所有同步成员,制作了一个 PropertyToMemberIndex,可用于标记 MemberMark ,标识哪一个成员发生了变化,并只同步变化的内容
// 使用见 GetMemberChangeMask 和 Serializer 函数
const FProperty*const*MemberProperties = Descriptor->MemberProperties;
for (uint32 PropertyIndex = 0; PropertyIndex != EPropertyName::PropertyName_ConditionallyReplicatedPropertyCount; ++PropertyIndex)
{
for (const FProperty*const& MemberProperty : MakeArrayView(MemberProperties, Descriptor->MemberCount))
{
const SIZE_T MemberIndex = &MemberProperty - MemberProperties;
if (MemberProperty->GetFName() == PropertyNames[PropertyIndex])
{
FoundPropertyMask |= 1U << PropertyIndex;

FHitResultNetSerializer::PropertyToMemberIndex[PropertyIndex] = MemberIndex;
break;
}
}
}

if (FoundPropertyMask != (1U << EPropertyName::PropertyName_ConditionallyReplicatedPropertyCount) - 1U)
{
LowLevelFatalError(TEXT("%s"), TEXT("Couldn't find expected replicated members in FHitResult."));
}

// 校验空间大小是否正确
// Validate our assumptions regarding quantized state size and alignment.
constexpr SIZE_T OffsetOfHitResult = offsetof(FQuantizedType, HitResult);
if ((sizeof(FQuantizedType::HitResult) < Descriptor->InternalSize) || (((OffsetOfHitResult/Descriptor->InternalAlignment)*Descriptor->InternalAlignment) != OffsetOfHitResult))
{
LowLevelFatalError(TEXT("FQuantizedType::HitResult has size %u but requires size %u and alignment %u."), uint32(sizeof(FQuantizedType::HitResult)), uint32(Descriptor->InternalSize), uint32(Descriptor->InternalAlignment));
}
}

// Verify traits
// 校验生成的 Traits 是否正确
ValidateForwardingNetSerializerTraits(&UE_NET_GET_SERIALIZER(FHitResultNetSerializer), HitResultStateTraits);
}

至此,我们的 HitResult 的自定义同步编写和注册完成了

结论

在新的架构下,编写自定义序列化,功能更加齐全,也可以全流程掌控和优化

但是当前版本中

编写自定义序列化的工作,变得异常复杂,成本陡增,甚至需要专人去做维护

有时候我们只是希望省去几个字节的流量,却要编写一大堆公式化的内容

并且由于结构体和 Serializer 内容的逻辑分开了

维护也产生了困难

例如结构体发生改变后,Serializer 并没有更新,有可能在没有任何提示的情况下,出现未知的问题

希望在 Iris 继续迭代后

能在用户侧简化 NetSerializer 的编写或者定制

写在前面

Iris 从进入 Roadmap 到现在已经过了一段时间了,但是网上相关内容不多,正值 5.2 版本正式发布, Lyra 范例项目也更新了可使用 Iris 的版本

这里根据 Lyra 的配置方式,在作者的项目中启用成功

下面我会将成功启动同步的配置方式列出,再逐一的探索解释每个配置的用途

希望对大家有所帮助

以下内容全部基于 UE 5.2.0 版本

阅读时需有一些 UE 网络同步相关的基础知识

启用 Iris Replication

编译时

uproject 文件中,添加 plugin

1
2
3
4
5
6
7
8
9

{

"Name": "Iris",

"Enabled": true

}

Build.cs 文件中,添加调用

1
2
3

SetupIrisSupport

配置

DefaultEngine.ini

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91

[/Script/Engine.Engine]

!IrisNetDriverConfigs=ClearArray

+IrisNetDriverConfigs=(NetDriverDefinition="你的NetDriver名",NetDriverName="你的NetDriver名",NetDriverWildcardName="你的NetDriver名*",bEnableIris=true)


[/Script/Engine.NetDriver]

+ChannelDefinitions=(ChannelName=DataStream, ClassName=/Script/Engine.DataStreamChannel, StaticChannelIndex=2, bTickOnCreate=true, bServerOpen=true, bClientOpen=true, bInitialServer=true, bInitialClient=true)


[/Script/IrisCore.PartialNetObjectAttachmentHandlerConfig]

MaxPartCount=4096


[/Script/IrisCore.NetBlobHandlerDefinitions]

+NetBlobHandlerDefinitions=(ClassName=NetRPCHandler)

+NetBlobHandlerDefinitions=(ClassName=PartialNetObjectAttachmentHandler )

+NetBlobHandlerDefinitions=(ClassName=NetObjectBlobHandler)


[/Script/IrisCore.DataStreamDefinitions]

+DataStreamDefinitions=(DataStreamName=NetToken, ClassName=/Script/IrisCore.NetTokenDataStream, DefaultSendStatus=EDataStreamSendStatus::Send, bAutoCreate=true)

+DataStreamDefinitions=(DataStreamName=Replication, ClassName=/Script/IrisCore.ReplicationDataStream, DefaultSendStatus=EDataStreamSendStatus::Send, bAutoCreate=true)


[/Script/IrisCore.NetObjectPrioritizerDefinitions]

+NetObjectPrioritizerDefinitions=(PrioritizerName=Default, ClassName=/Script/IrisCore.SphereNetObjectPrioritizer, ConfigClassName=/Script/IrisCore.SphereNetObjectPrioritizerConfig)

+NetObjectPrioritizerDefinitions=(PrioritizerName=PlayerState, ClassName=/Script/IrisCore.NetObjectCountLimiter, ConfigClassName=/Script/Engine.PlayerStateCountLimiterConfig)


[/Script/IrisCore.NetObjectFilterDefinitions]

+NetObjectFilterDefinitions=(FilterName=Spatial, ClassName=/Script/IrisCore.NetObjectGridFilter, ConfigClassName=/Script/IrisCore.NetObjectGridFilterConfig)

+NetObjectFilterDefinitions=(FilterName=NotRouted, ClassName=/Script/IrisCore.FilterOutNetObjectFilter, ConfigClassName=/Script/IrisCore.FilterOutNetObjectFilterConfig)


[/Script/IrisCore.ReplicationStateDescriptorConfig]

+SupportsStructNetSerializerList=(StructName=GameplayCueParameters)

+SupportsStructNetSerializerList=(StructName=GameplayAbilityTargetData_LocationInfo)

+SupportsStructNetSerializerList=(StructName=GameplayAbilityTargetData_ActorArray)

+SupportsStructNetSerializerList=(StructName=GameplayAbilityTargetData_SingleTargetHit)

+SupportsStructNetSerializerList=(StructName=LyraGameplayAbilityTargetData_SingleTargetHit)

+SupportsStructNetSerializerList=(StructName=NetLevelVisibilityTransactionId)

+SupportsStructNetSerializerList=(StructName=Vector2D)

+SupportsStructNetSerializerList=(StructName=GameplayDebuggerNetPack)


[/Script/IrisCore.ObjectReplicationBridgeConfig]

DefaultSpatialFilterName=Spatial

!FilterConfigs=ClearArray

+FilterConfigs=(ClassName=/Script/Engine.LevelScriptActor, DynamicFilterName=NotRouted) ; Not needed

+FilterConfigs=(ClassName=/Script/Engine.Actor, DynamicFilterName=None))

+FilterConfigs=(ClassName=/Script/Engine.Info, DynamicFilterName=None)

+FilterConfigs=(ClassName=/Script/Engine.PlayerState, DynamicFilterName=None)

+FilterConfigs=(ClassName=/Script/Engine.Pawn, DynamicFilterName=Spatial))

+FilterConfigs=(ClassName=/Script/EntityActor.SimObject, DynamicFilterName=None))

+PrioritizerConfigs=(ClassName=/Script/Engine.PlayerState, PrioritizerName=PlayerState)

+DeltaCompressionConfigs=(ClassName=/Script/Engine.Pawn))

+DeltaCompressionConfigs=(ClassName=/Script/Engine.PlayerState))

自定义 Replication Bridge

DefaultEngine.ini

1
2
3
4
5

[Script/你的NetDriver]

ReplicationBridgeClassName=你的ReplicationBridge类

不配置则使用 UActorReplicationBridge

默认使用 ReplicateSubObjectList

DefaultEngine.ini

1
2
3
4
5

[SystemSettings]

net.SubObjects.DefaultUseSubObjectReplicationList=1

如果没有设置这个,则会使用默认版本的 ReplicateSubobjects 函数来同步

因为 Iris 使用 SubObjectReplicationList 来同步,所以如果没有配置的话,会弹出 Ensure

并 Log Warning LogIrisBridge

启动时

命令行参数添加

1
2
3

-UseIrisReplication=1

或者

控制台修改

1
2
3

net.Iris.UseIrisReplication1

或者在 PostEngineInit 回调中加入

1
2
3
4
5
6
7
8
9

#ifUE_WITH_IRIS

// 设置 Iris 使用 Iris Replication

UE::Net::SetUseIrisReplication(true);

#endif

启动项目后,开始成功同步

配置类型分析

现在我们来分析配置中用到的类型,从而为学习 Iris 同步的实现方式找一个抓手

配置项同步桥配置 UObjectReplicationBridgeConfig

此类型用于每种不同的类型的过滤和优先级等配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

[/Script/IrisCore.ObjectReplicationBridgeConfig]

DefaultSpatialFilterName=Spatial

!FilterConfigs=ClearArray

+FilterConfigs=(ClassName=/Script/Engine.LevelScriptActor, DynamicFilterName=NotRouted) ; Not needed

+FilterConfigs=(ClassName=/Script/Engine.Actor, DynamicFilterName=None))

+FilterConfigs=(ClassName=/Script/Engine.Info, DynamicFilterName=None)

+FilterConfigs=(ClassName=/Script/Engine.PlayerState, DynamicFilterName=None)

+FilterConfigs=(ClassName=/Script/Engine.Pawn, DynamicFilterName=Spatial))

+FilterConfigs=(ClassName=/Script/EntityActor.SimObject, DynamicFilterName=None))

+PrioritizerConfigs=(ClassName=/Script/Engine.PlayerState, PrioritizerName=PlayerState)

+DeltaCompressionConfigs=(ClassName=/Script/Engine.Pawn))

+DeltaCompressionConfigs=(ClassName=/Script/Engine.PlayerState))

从配置内容来看,此类似于 Replication Graph 中初始化各 Actor 的同步节点,同步优先级等内容

用于控制不同类型的对象,在同步的时候同步到哪一些连接,以及同步优先级,压缩等内容

现在进一步查看类型里面的可配置成员

同步轮询周期

用于控制类型查询是否需要同步的帧数周期

1
2
3
4
5
6
7

// 同步轮询周期配置

UPROPERTY(Config)

TArray<FObjectReplicationBridgePollConfig> PollConfigs;

其中具体配置有

  • ClassName:使用此配置的类型,Actor 以及 UObject 不允许配置
  • PollFramePeriod:轮询间隔帧数
  • bIncludeSubclasses:是否对子类用相同配置

类型过滤器配置

用于控制类型的角色需要同步到哪些连接,例如分块同步

1
2
3
4
5
6
7

// 过滤器配置

UPROPERTY(Config)

TArray<FObjectReplicationBridgeFilterConfig> FilterConfigs;

其中具体配置有

  • ClassName:使用此配置的类型
  • DynamicFilterName:过滤器名称

同步优先级配置

用于配置类型同步的优先级,例如距离优先等

1
2
3
4
5
6
7

// 同步优先级配置

UPROPERTY(Config)

TArray<FObjectReplicationBridgePrioritizerConfig> PrioritizerConfigs;

其中具体配置有

  • ClassName:使用此配置的类型
  • PrioritizerName:使用的优先级控制器名称,Default 会使用默认 spatial prioritizer

增量同步压缩配置

用于控制是否开启类型的增量同步压缩

1
2
3
4
5
6
7

// 增量压缩相关配置

UPROPERTY(Config)

TArray<FObjectReplicationBridgeDeltaCompressionConfig> DeltaCompressionConfigs;

其中具体的配置有

  • ClassName:使用此配置的类型
  • bEnableDeltaCompression:是否使用增量压缩(子类都会使用此配置)

默认分块同步的过滤器名称

由此可知,Iris 的默认同步过滤逻辑还是分块

1
2
3
4
5

UPROPERTY(Config)

FName DefaultSpatialFilterName;

需要的 NetDriverChannel 名称

必须要使用的 NetDriverChannel 名称,按注释必须要有这个配置的 Channel 才能让此配置生效,但我没有找到这个逻辑生效的地方

1
2
3
4
5

UPROPERTY(Config)

FName RequiredNetDriverChannelClassName;


配置项过滤器定义 UNetObjectFilterDefinitions

此类型用于配置过滤器列表的定义

1
2
3
4
5
6
7

[/Script/IrisCore.NetObjectFilterDefinitions]

+NetObjectFilterDefinitions=(FilterName=Spatial, ClassName=/Script/IrisCore.NetObjectGridFilter, ConfigClassName=/Script/IrisCore.NetObjectGridFilterConfig)

+NetObjectFilterDefinitions=(FilterName=NotRouted, ClassName=/Script/IrisCore.FilterOutNetObjectFilter, ConfigClassName=/Script/IrisCore.FilterOutNetObjectFilterConfig)

其中

  • FilterName :上面 UObjectReplicationBridgeConfig 中用到的过滤器 Name
  • ClassName :过滤器类型
  • ConfigClassName :过滤器配置的类型

过滤器基类 UNetObjectFilter

过滤器类型用于处理那些对象同步到哪些连接的问题

类型中声明了一些可覆写的空函数

包括初始化,添加连接,移除连接,添加同步对象,移除同步对象,以及过滤相关的内容

我们可以通过实现自己的过滤逻辑来自定义同步规则,类似于 Replication Graph 中的同步节点编写

下面我们看一些具体的实现类型

默认分块同步过滤器 UNetObjectGridFilter

实现目的应该是同 Replication Graph 的分块同步

使用网络裁切距离和位置,确定对象可以被同步的 Grid Cell 列表

过滤所有过滤器 UFilterOutNetObjectFilter

过滤掉所有东西,不同步

不过滤任何内容 UNopNetObjectFilter

不过滤任何东西,全部同步


过滤器配置基类 UNetObjectFilterConfig

这是每种过滤器配置的积累,单独提出一个类型,可以方便的在 DefaultEngine.ini 中进行配置

当前有以下类型

分块同步配置 UNetObjectGridFilterConfig

用于分块同步过滤器,配置分块大小,最大默认同步距离等

过滤任何内容 UFilterOutNetObjectFilterConfig

无需配置,占位,其实无用

不过滤任何内容 UNopNetObjectFilterConfig

无需配置,占位,其实无用


配置项优先级控制器定义 UNetObjectPrioritizerDefinitions

用于定义项目中的优先级控制器

1
2
3
4
5
6
7

[/Script/IrisCore.NetObjectPrioritizerDefinitions]

+NetObjectPrioritizerDefinitions=(PrioritizerName=Default, ClassName=/Script/IrisCore.SphereNetObjectPrioritizer, ConfigClassName=/Script/IrisCore.SphereNetObjectPrioritizerConfig)

+NetObjectPrioritizerDefinitions=(PrioritizerName=PlayerState, ClassName=/Script/IrisCore.NetObjectCountLimiter, ConfigClassName=/Script/Engine.PlayerStateCountLimiterConfig)

其中配置项

  • PrioritizerName :配置的优先级处理器名称,用于配置 Replication Bridge
  • ClassName :同步器的类型
  • ConfigClassName :同步器的配置类型

优先级处理器基类 UNetObjectPrioritizer

用于处理同步对象的优先级

同过滤器基类,有一些空的虚函数,可自行扩展

我们来看一些具体类型

ULocationBasedNetObjectPrioritizer

基于位置的优先级处理器,没有实际处理优先级的部分,只是处理了位置信息的设置,所以只作为基类存在

USphereNetObjectPrioritizer

ULocationBasedNetObjectPrioritizer 的子类

会根据不同个数的 View 位置来进行不同的优先级设置

总体来说是根据和 View 的距离来设置不同的优先级,在一定范围内则优先级高,在外则低,一定距离外非常低

USphereWithOwnerBoostNetObjectPrioritizer

USphereNetObjectPrioritizer 的子类

在其基础上,对于 Connection 的 Owning Object 附加一定的优先级

UNetObjectCountLimiter

根据配置的对象个数来限制同步频率

有两种模式

  • RoundRobin : 每次同步轮流同步后面的 N 个对象
  • Fill :每次同步的时候,同步最久没有被同步的 N 个对象

UPlayerStateCountLimiterConfig

UNetObjectCountLimiter 的子类,作为同步 PlayerState 的 Limiter

没有具体逻辑,只是对父类的一些参数做特化


优先级处理器配置类 UNetObjectPrioritizerConfig

用于配置优先级处理器的 Config,与过滤器 Config 类似

每种优先级处理器都有其专用的类型

具体的子类此略


配置项数据流定义 UDataStreamDefinitions

Iris 使用数据流 DataStream 的方式来在 Bunch 的基础上传输数据

在这个里面,我们定义项目中使用到的数据流类型

在启用 Iris 之后,数据同步内容均经由 ReplicationDataStream 来传输

其中配置如下

1
2
3
4
5
6
7

[/Script/IrisCore.DataStreamDefinitions]

+DataStreamDefinitions=(DataStreamName=NetToken, ClassName=/Script/IrisCore.NetTokenDataStream, DefaultSendStatus=EDataStreamSendStatus::Send, bAutoCreate=true)

+DataStreamDefinitions=(DataStreamName=Replication, ClassName=/Script/IrisCore.ReplicationDataStream, DefaultSendStatus=EDataStreamSendStatus::Send, bAutoCreate=true)

  • DataStreamName :数据流名称
  • ClassName : 数据流的类
  • DefaultSendStatus:创建时默认的发送状态
  • bAutoCreate:是否为每个连接自动创建,如果不是 true 则需要手动创建

配置项 NetBlob 处理器定义 UNetBlobHandlerDefinitions

Iris 在传输上使用数据块 FNetBlob 来传输

每个 NetBlob 会有一个 FNetBlobCreationInfo CreationInfo 来标识其创建 Flag 和 Blob 的类型

以便使用特定的 Blob 处理器来处理

此处配置 NetBlob 的处理器定义列表

1
2
3
4
5
6
7
8
9

[/Script/IrisCore.NetBlobHandlerDefinitions]

+NetBlobHandlerDefinitions=(ClassName=NetRPCHandler)

+NetBlobHandlerDefinitions=(ClassName=PartialNetObjectAttachmentHandler )

+NetBlobHandlerDefinitions=(ClassName=NetObjectBlobHandler)

其中

  • UNetPRCHandler 用于处理 RPC
  • PartialNetObjectAttachmentHandler 用于处理分块大型 Blob
  • UNetObjectBlobHandler 用于处理 NetObjectBlob

使用的同步 Channel DataStreamChannel

Iris 使用 DataSteam Channel 用于同步数据流

所以需要添加配置

1
2
3
4
5

[/Script/Engine.NetDriver]

+ChannelDefinitions=(ChannelName=DataStream, ClassName=/Script/Engine.DataStreamChannel, StaticChannelIndex=2, bTickOnCreate=true, bServerOpen=true, bClientOpen=true, bInitialServer=true, bInitialClient=true)

在 NetDriver 上添加 DataStreamChannel 以开启通道


配置项自定义序列化 UReplicationStateDescriptorConfig

配置 Struct 是否可以使用 Iris 默认的 StructNetSerializer

因为 Iris 在序列化 Struct 的时候,会默认使用 StructNetSerializer

如果需要自定义序列化的话,用从前的 TStructOpsTypeTraits WithNetSerializer Trait 是不行的

需要自己去使用 UE_NET_IMPLEMENT_SERIALIZER 来定义自定义序列化处理器

如果有使用了 WithNetSerializer Trait 自定义序列化的内容,却没有用新的方式去实现的话,会提示 Warning

所以这个配置仅用于使用了 NetSerialize Trait

但是可以使用 Iris 自己创建的同步信息

不想要提示 Warning 的情况

如果提示 Warning 了,可检查对应 Struct 的同步功能是否正常,正常则加入此配置中

否则需要重新写自己的对应类型的 Serializer

配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

[/Script/IrisCore.ReplicationStateDescriptorConfig]

+SupportsStructNetSerializerList=(StructName=GameplayCueParameters)

+SupportsStructNetSerializerList=(StructName=GameplayAbilityTargetData_LocationInfo)

+SupportsStructNetSerializerList=(StructName=GameplayAbilityTargetData_ActorArray)

+SupportsStructNetSerializerList=(StructName=GameplayAbilityTargetData_SingleTargetHit)

+SupportsStructNetSerializerList=(StructName=LyraGameplayAbilityTargetData_SingleTargetHit)

+SupportsStructNetSerializerList=(StructName=NetLevelVisibilityTransactionId)

+SupportsStructNetSerializerList=(StructName=Vector2D)

+SupportsStructNetSerializerList=(StructName=GameplayDebuggerNetPack)


结尾

本文通过对成功启动 Iris 的配置的内容分析

揭开了 Iris 机制的冰山一角

后续我将继续分享 Iris 同步的其他内容

什么是 Gauntlet

Gauntlet 官方文档

简而言之,Gauntlet 提供支持来启动多个实例来进行测试,例如常见的 Dedicated Server 测试,需要一个服务器和多个客户端

但是 Gauntlet 并不关心测试的内容,只提供一个需要开发者实现的 GauntletTestController 来控制测试的流程

所以 Gauntlet 的侧重点在于 组织单个或多个实例 ,而非测试的 内容和形式(包括使用的测试框架,测试的内容等)

具体的测试可以由开发者自己编写的 GauntletTestController 以 任意方式 来测试 任意内容

本文的开发环境

操作系统:windows 11

引擎:UE 5.0 源码版本

IDE:Rider for Unreal

Gauntlet 的原理

两个部分

Gauntlet 分为两个部分

  • 游戏外自动化部分,属于 AutomationTool
  • 游戏内 Runtime 部分,属于 Gauntlet 插件,通过 Gauntlet Test Controller 驱动游戏内逻辑

其中游戏外部分,控制游戏实例的参数和启动

游戏内部分,控制具体的测试逻辑

什么是 AutomationTool

AutomationTool 官方文档

AutomationTool 可简称 UAT

是虚幻引擎中用于自动化流水线的一套工具,主要用于 Build 项目和测试等,这套工具由 C# 编写

执行

1
"%EnginePath%\Engine\Build\BatchFiles\RunUAT"

即可执行 Automation 命令,命令的基类为

1
public abstract class BuildCommand : CommandUtils

开发者也可以自己添加 Automation 项目

在其中添加相关 Automation 中需要的自定义类型,或者继承 BuildCommand 为 UAT 添加自定义命令

Automation 项目都需要以 XXX.Automation 来命名,且放在 [ProjectDir]\Build 或 [EngineDir]\Source\Programs 目录下

Build 时需要使用源码版本的引擎,从而在每次 Build 的时候重新生成自定义 Automation 项目的 dll

Gauntlet.Automation 游戏外自动化部分

Gauntlet 正是 Automation 中的一个项目,也就是上文所说的游戏外自动化部分

RunUnreal 命令

Gauntlet 项目提供了 RunUnreal 命令来进行 Gauntlet 测试

1
public class RunUnreal : BuildCommand

命令入口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

public override ExitCode Execute()
{
Globals.Params = new Gauntlet.Params(this.Params);

UnrealTestOptions ContextOptions = new UnrealTestOptions();
// 附加载各种参数到 Context 中
AutoParam.ApplyParamsAndDefaults(ContextOptions, Globals.Params.AllArguments);

if (string.IsNullOrEmpty(ContextOptions.Project))
{
throw new AutomationException("No project specified. Use -project=ShooterGame etc");
}

ContextOptions.Namespaces = "Gauntlet.UnrealTest,UnrealGame,UnrealEditor";
ContextOptions.UsesSharedBuildType = true;
// 开始测试
return RunTests(ContextOptions);
}

测试参数 Context

1
public class UnrealTestOptions : TestExecutorOptions,  IAutoParamNotifiable

其中就包含了上面所说的启动多个实例需要用到的包括 Platform 类型、Build 类型、包文件地址、多个实例的 Build Target 类型、Role 类型(客户端还是服务器)、加载的地图之类的参数

RunTest

1
public virtual ExitCode RunTests(UnrealTestOptions ContextOptions)

包含测试逻辑, ExitCode 中会返回测试是否通过

执行函数时,会先根据我们传入的 Context 来查找存在的符合条件的可执行文件,然后打开一个或多个游戏实例来进行测试

ExecuteTests

1
bool ExecuteTests(UnrealTestOptions Options, IEnumerable<ITestNode> TestList)

具体测试逻辑入口,准备好实例的 Context 信息后,会传入测试列表,执行这个函数开始测试

测试的具体CSharp类型

1
public abstract class BaseTest : ITestNode

这个就是测试的基类,包含了测试所需的基本逻辑链条,在实际使用中,需要使用其子类

1
2
public abstract class UnrealTestNode<TConfigClass> : BaseTest, IDisposable
where TConfigClass : UnrealTestConfiguration, new()

来派生出开发者自己的测试类型,并在其中指定 Gauntlet Controller 和游戏实例配置等信息

这个类型中,还包括一些测试结果相关的功能,例如输入测试报告等

在实际使用的章节我们会继续提到这个类型

我们现在只需要知道,测试的列表,是通过 UAT 的命令行传入的

启动实例

1
protected bool PrepareUnrealApp()

在测试 Node 开始测试的时候,这个函数会被执行,来启动游戏实例

在这个函数中,我们会发现上文所说的 两个部分 连接起来的秘密

1
2
3
// add controllers
SessionRole.CommandLineParams.Add("gauntlet",
TestRole.Controllers.Count > 0 ? string.Join(",", TestRole.Controllers) : null);

这里,把 Gauntlet Test Controller 通过命令行的形式,加入到了游戏实例的启动参数中!

也就是说游戏实例启动后,一定是去获取了 gauntlet=xxx,xxx,xxx 参数,来获得需要执行的 gauntlet test controller 列表

这个我们在后面再继续聊

当 Controller 准备好以后,最终执行了

1
UnrealApp = new UnrealSession(Context.BuildInfo, SessionRoles) { Sandbox = Context.Options.Sandbox };

来启动实例

我们可以看到,这个实例是以 UnrealSession 的形式存在的

UnrealSession

1
public class UnrealSession : IDisposable

Unreal Session 用于启动、监控、关闭游戏实例

Gauntlet 游戏内插件部分

可以看到,插件非常简单,基本就只有一个 Gauntlet Test Controller

并提供了两个示例 BootTest 和 Error Test

加载 Gauntlet 命令行的位置

GauntletModule.cpp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
void FGauntletModuleImpl::OnPostEngineInit()
{
FCoreUObjectDelegates::PostLoadMapWithWorld.AddRaw(this, &FGauntletModuleImpl::InnerPostMapChange);
FCoreUObjectDelegates::PreLoadMap.AddRaw(this, &FGauntletModuleImpl::InnerPreMapChange);

FParse::Value(FCommandLine::Get(), TEXT("gauntlet.screenshotperiod="), ScreenshotPeriod);
FParse::Value(FCommandLine::Get(), TEXT("gauntlet.heartbeatperiod="), HeartbeatPeriod);
// 加载 Controllers
LoadControllers();

float kTickRate = 1.0f;
FParse::Value(FCommandLine::Get(), TEXT("gauntlet.tickrate="), kTickRate);


TickHandle = FTSTicker::GetCoreTicker().AddTicker(FTickerDelegate::CreateLambda([this, kTickRate](float TimeDelta)
{
// ticker passes in frame-delta, not tick delta...
InnerTick(kTickRate);
return true;
}),
kTickRate);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
void FGauntletModuleImpl::LoadControllers()
{
FString ControllerString;

FParse::Value(FCommandLine::Get(), TEXT("gauntlet="), ControllerString, false);

if (ControllerString.Len())
{
TArray<FString> ControllerNames;

ControllerString.ParseIntoArrayWS(ControllerNames, TEXT(","));

TSet<FString> ControllersToCreate;

for (const FString& Name : ControllerNames)
{
UClass* TestClass = nullptr;

for (TObjectIterator<UClass> It; It; ++It)
{
UClass* Class = *It;

FString FullName = Name;

// Search for SomethingTestController and ControllerSomethingTest. The last is legacy
FString PartialName1 = Name + TEXT("Controller");
FString PartialName2 = FString(TEXT("Controller")) + Name;

if (Class->IsChildOf<UGauntletTestController>())
{
FString ClassName = Class->GetName();
if (ClassName == FullName || ClassName.EndsWith(PartialName1) || ClassName.EndsWith(PartialName2))
{
// Gauntlet has a couple of test classes, so need to differentiate between "GauntletFooTest" and "GameFooTest".
// that will both be launched via -gauntlet=FooTest
bool GauntletDefault = ClassName.StartsWith(TEXT("Gauntlet"));

TestClass = Class;

// If not gauntlet stop searching
if (!GauntletDefault)
{
break;
}
}
}
}

checkf(TestClass, TEXT("Could not find class for controller %s"), *Name);

UGauntletTestController* NewController = NewObject<UGauntletTestController>(GetTransientPackage(), TestClass);

check(NewController);

UE_LOG(LogGauntlet, Display, TEXT("Added Gauntlet controller %s"), *Name);

// Important - add the controller first! Some controllers may trigger GC's which would
// then result in them being collected...
Controllers.Add(NewController);
}
}

for (auto Controller : Controllers)
{
Controller->OnInit();
}
}

可以看到,在 PostEngineInit 后,开始 LoadControllers

然后读取了命令行,并使用其名称,去获得 C++ 的相应类型

然后执行其 OnInit

GauntletTestController 简析

声明

1
2
UCLASS()
class GAUNTLET_API UGauntletTestController : public UObject

里面是一些简单的状态控制,几个处理函数都需要自己去实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
virtual void    OnInit() {}

/**
* Called prior to a map change
*/
virtual void OnPreMapChange() {}

/**
* Called after a map change. GetCurrentMap() will now return the new map
*/
virtual void OnPostMapChange(UWorld* World) {}

/**
* Called periodically to let the controller check and control state
*/
virtual void OnTick(float TimeDelta) {}

/**
* Called when a state change is applied to the module. States are game-driven.
* GetCurrentState() == OldState until this function returns
*/
virtual void OnStateChange(FName OldState, FName NewState) {}

正所谓什么都没写,就有无限可能(狗头。

前面说的可以测试任何内容就是这个意思,你可以通过写一个自己的 TestController 来添加任何逻辑和接入任何测试框架。

从上面的 OnInit 那里就可以简单推测出,整个 TestController 是由 GauntletModuleImpl 来驱动的,事实呢也是如此,上面几个函数的调用都是在 GauntletModuleImpl 中。

从零开始的Gauntlet实战

现在我们已经了解了 Gauntlet 的基本原理,那么现在我们从零开始使用 Gauntlet 搭建一个简单的测试

版本是 UE 5.0 源码编译版本

搭建源码环境

因为编译 Automation 项目需要源码版本的引擎,所以需要搭建源码环境

创建一个新的游戏项目

这里我们选择 First Person 模版,创建一个 C++ 项目,取名 GauntletTutorial

然后把项目依赖的引擎版本设置为源码,编译启动编辑器

打开 Plugin 设置,启用 Gauntlet 插件

因为我们测试的时候需要启动 Server 和 Client 实例,所以我们需要在项目中添加 Server Build Target 以及 Client Build Target

详见 官网文档说明

我们这里只需要正确添加 GauntletTutorialServer.Target.cs 和 GauntletTutorialClient.Target.cs 两个文件即可

制作 Automation 工程

创建项目

官网文档说明

注:5.0 以前的版本和 5.0 以后的版本使用的 .Net 依赖不同,创建方式略有不同

我这里 IDE 使用 Rider ,使用 VS 的同学应该也差不太多

选择 File -> New Solution -> .Net/.Net Core -> Class Library

填入你喜欢的项目名称,记住之前说过的两个规则

  1. 项目以 XXX.Automation 命名
  2. 项目放在 [ProjectDir]\Build 下

我们这里在 [ProjectDir]\Build 目录下创建一个 Gauntlet 目录,把我们的项目放进去

然后 Framework 选择 netcoreapp3.1

勾上 Put Solution and project in the same directory

点击创建生成项目

编写一个测试

原理部分讲到,我们的每个测试都是继承自

1
2
public abstract class UnrealTestNode<TConfigClass> : BaseTest, IDisposable
where TConfigClass : UnrealTestConfiguration, new()

的一个子类

其中,TConfigClass 是我们测试使用的配置类型,继承自 UnrealTestConfiguration

我们现在来创建一个自己的测试

修改默认创建的 Class1 ,改名为 GauntletTutorialTest,并使用 UnrealTestConfig 类型作为配置,类声明如下

1
public class GauntletTutorialTest : UnrealTestNode<UnrealTestConfig>

可以看到 IDE 提示缺少依赖,我们需要引入 Gauntlet.Automation 项目作为依赖

项目目录右键点击 Dependence ,打开 Reference

点击 Add From

选择 [EnginePath]\unrealengine\Engine\Binaries\DotNET\AutomationScripts\netcoreapp3.1\Gauntlet.Automation.dll

然后修改代码,最终如下

1
2
3
4
5
6
7
8
9
10
11
using Gauntlet;
using UnrealGame;
namespace GauntletTutorial.Automation
{
public class GauntletTutorialTest : UnrealTestNode<UnrealTestConfig>
{
public GauntletTutorialTest(UnrealTestContext inContext) : base(inContext)
{
}
}
}

我们现在创建出了一个新的测试,但如何设置测试参数呢?答案是重写函数 GetConfiguration()

1
2
3
4
/// <summary>
/// Returns information about how to configure our Unreal processes. For the most part the majority
/// of Unreal tests should only need to override this function
/// </summary>

此函数的说明:大部分情况下,我们只需要重写这个函数就可以了

现在我们大致写一个测试配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public override UnrealTestConfig GetConfiguration()
{
UnrealTestConfig config = base.GetConfiguration();

// 添加一个客户端和一个服务器
UnrealTestRole clientRole = config.RequireRole(UnrealTargetRole.Client);
UnrealTestRole serverRole = config.RequireRole(UnrealTargetRole.Server);

// 设置一个最长测试时间(单位是秒,时间到了后会结束测试)
config.MaxDuration = 60 * 5;

// 超时后,测试结果是成功还是失败
config.MaxDurationReachedResult = EMaxDurationReachedResult.Success;

// 给每个 Role 设定 GauntletTestController
// 这里使用官方提供的 ErrorTest 来做一个测试
clientRole.Controllers.Add("GauntletTestControllerErrorTest");
serverRole.Controllers.Add("GauntletTestControllerErrorTest");

// 可以通过 Map 来修改测试地图
// config.Map = "TestMap";

// 也可以通过 Role 的 Map Override 来修改地图
// clientRole.MapOverride = "ClientMapOverride";

return config;
}

另外除了这些配置,我们可以看到 UnrealTestConfig 和其父类中还有很多注解为 AutoParam 的变量

这些变量都可以通过 RunUnreal 的命令行参数来自动获取并赋值

在测试中,我们指定每个 Role 都执行系统自带的 GauntletTestControllerErrorTest 来测试

至此一个简单的测试就完成了

运行编写好的测试

通过之前的原理部分,我们知道,Gauntlet Automation 是去查找打好的可执行文件来运行的,所以运行前,应该保证你测试中的 Role 实例已经打好包了,例如 Server 就要打好服务器包

在 [ProjectDir] 下新建一个 Scripts 目录

打包

新建一个批处理 Pack.bat 来进行打包

1
2
3
4
5
6
7
8
@echo on

set ProjectPath=项目目录
set EnginePath=源码版引擎目录

"%EnginePath%\Engine\Build\BatchFiles\RunUAT" BuildCookRun -project=%ProjectPath%\GauntletTutorial.uproject -platform=Win64 -configuration=Development -build -cook -pak -stage -server -client

pause

执行后,会打出 Client 和 Server 包,因为是源码构建,可能会花比较久的时间

执行 RunUnreal

再新建一个批处理 RunGauntlet.bat

1
2
3
4
5
6
7
8
@echo on

set ProjectPath=项目目录
set StagingDir=%ProjectPath%\Saved\StagedBuilds\
set EnginePath=源码版引擎目录

"%EnginePath%\Engine\Build\BatchFiles\RunUAT" RunUnreal -project=%ProjectPath%\GauntletTutorial.uproject -build=%StagingDir% -platform=Win64 -configuration=development -test=GauntletTutorial.Automation.GauntletTutorialTest -scriptdir=%ProjectPath%
pause

其中 platform 和 configuration 一定更要和打包配置一致,否则会找不到目标可执行文件

build 参数选择打出来的包的目录

test 参数填入 Gauntlet Automation 测试的全名

运行后,我们可以看到,按照配置启动了一个客户端和一个服务器,如下

然后测试结果是失败

原因是 GauntletTestControllerErrorTest 默认逻辑会在启动后立刻释放一个 check 让测试失败

传入命令行参数到 Automation 项目和游戏实例中

查看 UGauntletTestControllerErrorTest 的源码,我们可知这个测试会接受命令行参数,从而调用 check , ensure 等

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
void UGauntletTestControllerErrorTest::OnInit()
{
ErrorDelay = 0;
ErrorType = TEXT("check");

FParse::Value(FCommandLine::Get(), TEXT("errortest.delay="), ErrorDelay);
FParse::Value(FCommandLine::Get(), TEXT("errortest.type="), ErrorType);
}

void UGauntletTestControllerErrorTest::OnTick(float TimeDelta)
{
if (GetTimeInCurrentState() > ErrorDelay)
{
if (ErrorType == TEXT("ensure"))
{
UE_LOG(LogGauntlet, Display, TEXT("Issuing ensure as requested"));
ensureMsgf(false, TEXT("Ensuring false...."));
EndTest(-1);
}
else if (ErrorType == TEXT("check"))
{
UE_LOG(LogGauntlet, Display, TEXT("Issuing failed check as requested"));
checkf(false, TEXT("Asserting as requested"));
}
else if (ErrorType == TEXT("fatal"))
{
UE_LOG(LogGauntlet, Fatal, TEXT("Issuing fatal error as requested"));
}
else if (ErrorType == TEXT("gpf"))
{
#ifndef PVS_STUDIO
UE_LOG(LogGauntlet, Display, TEXT("Issuing GPF as requested"));
int* Ptr = (int*)0;
CA_SUPPRESS(6011);
*Ptr = 42; // -V522
#endif // PVS_STUDIO
}
else
{
UE_LOG(LogGauntlet, Error, TEXT("No recognized error request. Failing test"));
EndTest(-1);
}
}
}

那么我们怎么样才能从 Gauntlet 传入命令行参数到游戏呢?

官方例子 Nullrhi 参数

下面我们参考 UnrealTestConfiguration 是如何把 Nullrhi 这个参数传入游戏中的

我们回到 Automation 项目中,查看类型 我们使用的配置类型 UnrealTestConfig 的基类 Gauntlet.UnrealTestConfiguration

其中有一个函数是

1
public virtual void ApplyToConfig(UnrealAppConfig AppConfig, UnrealSessionRole ConfigRole, IEnumerable<UnrealSessionRole> OtherRoles)

这个函数的作用就是组装测试的配置,其中 UnrealAppConfig 就是实例启动的相关参数

在 ApplyToConfig 函数中有这几行代码

1
2
3
4
if (Nullrhi)
{
AppConfig.CommandLine += " -nullrhi";
}

程序读取了 Nullrhi 这个成员变量,并判断如果为 True 则在 AppConfig 的命令行参数,也就是实例启动的命令行参数中加入 “ -nullrhi” ,从而让客户端不进行渲染

1
2
3
4
5
6
/// <summary>
/// Use a nullrhi for tests
/// </summary>
///
[AutoParam(false)]
protected bool Nullrhi { get; set; }

Nullrhi 成员使用了注解 [AutoParam(false)]

这个注解的作用是直接从 RunUnreal 的命令行获取参数进行初始化,这个命令行传入有两种写法

我们下面来尝试用这两种写法来使用 nullrhi 参数

修改我们的 RunGauntlet.bat

方法一

1
2
3
4
5
6
7
8
9
10
11
12
@echo on

set ProjectPath=项目目录
set StagingDir=%ProjectPath%\Saved\StagedBuilds\
set EnginePath=引擎目录

"%EnginePath%\Engine\Build\BatchFiles\RunUAT" RunUnreal -project=%ProjectPath%\GauntletTutorial.uproject ^
-build=%StagingDir% -platform=Win64 -configuration=development ^
-test=GauntletTutorial.Automation.GauntletTutorialTest ^
-nullrhi ^
-scriptdir=%ProjectPath%
pause

这里面,我们直接把 nullrhi 当做 RunUnreal 的命令行参数传入

方法二

1
2
3
4
5
6
7
8
9
10
11
@echo on

set ProjectPath=项目目录
set StagingDir=%ProjectPath%\Saved\StagedBuilds\
set EnginePath=引擎目录

"%EnginePath%\Engine\Build\BatchFiles\RunUAT" RunUnreal -project=%ProjectPath%\GauntletTutorial.uproject ^
-build=%StagingDir% -platform=Win64 -configuration=development ^
-test=GauntletTutorial.Automation.GauntletTutorialTest(nullrhi) ^
-scriptdir=%ProjectPath%
pause

这里面,我们可以在 test 参数中的的 Gauntlet 测试类型后打一个括号,传入我们的 nullrhi 参数

不管是哪种方式,修改后运行批处理,可以发现客户端没有渲染了,可知是 nullrhi 已经传入了游戏中,其完整流程为

  1. 通过命令行,参数被 AutoParam 传给了测试的配置类中的成员变量
  2. 在配置类的 ApplyToConfig 函数中,通过判断成员变量的值,并在 AppConfig 的 Commandline 中加入到游戏命令行

ApplyToConfig 也不光是可以修改命令行,其他用法也可以根据官方代码学习

尝试传入 ErrorTest 所需参数

我们使用的 Controller 中需要传入命令行参数 errortest.delay 和 errotest.type

于是乎,我们按照上面 nullrhi 的做法,先写一个新的配置类型,继承 UnrealTestConfiguration

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
using System;
using System.Collections.Generic;
using Gauntlet;

namespace GauntletTutorial.Automation
{
public class GauntletTutorialConfig : UnrealTestConfiguration
{
[AutoParam(5)]
private float ErrorDelay { get; set; }

[AutoParam]
private String ErrorType { get; set; }

public override void ApplyToConfig(UnrealAppConfig AppConfig, UnrealSessionRole ConfigRole,
IEnumerable<UnrealSessionRole> OtherRoles)
{
base.ApplyToConfig(AppConfig,ConfigRole,OtherRoles);
if (ErrorDelay > 0)
{
AppConfig.CommandLine += " -errortest.delay=" + ErrorDelay;
AppConfig.CommandLine += " -errortest.type=" + ErrorType;
}
}

}
}

然后修改我们的 GauntletTutorialTest 的泛型参数为刚创建的新配置类型

1
2
3
4
5
6
7
8
9
...
public class GauntletTutorialTest : UnrealTestNode<GauntletTutorialConfig>
{
public GauntletTutorialTest(UnrealTestContext inContext) : base(inContext)
{
}

public override GauntletTutorialConfig GetConfiguration()
...

然后修改 RunGauntlet.bat 加入参数 ErrorDelay 和 ErrorType

1
2
3
4
5
6
7
8
9
10
11
@echo on

set ProjectPath=项目目录
set StagingDir=%ProjectPath%\Saved\StagedBuilds\
set EnginePath=引擎目录

"%EnginePath%\Engine\Build\BatchFiles\RunUAT" RunUnreal -project=%ProjectPath%\GauntletTutorial.uproject ^
-build=%StagingDir% -platform=Win64 -configuration=development ^
-test=GauntletTutorial.Automation.GauntletTutorialTest(ErrorDelay=20,ErrorType=ensure) ^
-scriptdir=%ProjectPath%
pause

重新启动 RunGauntlet.bat ,发现 20 秒后,提示 ensure ,返回测试失败,我们的参数成功传入到游戏中

至此,我们的整个 Gauntlet 基本流程就算完成了

实战:实现一个基本的测试需求

需求

我们现在需要测试在 N 个客户端开启后登入一个服务器,确定所有客户端正确登入服务器则测试成功

创建测试配置类型

创建新的测试配置类型,其中指定一个参数 ClientCount,通过 RunUnreal 命令行传入,用于启动 N 个 Client 实例

然后用 AppConfig 通过命令行参数 -ClientCountToCheck 传入游戏中

可以看到我们这里使用的基类 Config 为 EpicGameTestConfig

使用这个的原因是里面包含了让客户端自动连接服务器的逻辑

LoginTestConfig.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
using System.Collections.Generic;
using Gauntlet;

namespace GauntletTutorial.Automation
{
public class LoginTestConfig : EpicGameTestConfig
{
[AutoParam(1)]
public int ClientCount { get; set; }

public override void ApplyToConfig(UnrealAppConfig AppConfig, UnrealSessionRole ConfigRole, IEnumerable<UnrealSessionRole> OtherRoles)
{
base.ApplyToConfig(AppConfig, ConfigRole, OtherRoles);
AppConfig.CommandLine += " -ClientCountToCheck=" + ClientCount;
}
}
}

创建测试类型

在测试中,我们直接把配置中的 ClientCount 取出来,创建相应数量的客户端

然后服务器设置 TestController 为 GauntletTestLoginController,客户端不需要 Controller 所以不设置

设置 NoMCP 为 true,这样可以跳过 Gauntlet 内部客户端登录服务器的账号验证等步骤

如 60 秒还没测试成功,则判断为失败

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
using System.Collections.Generic;
using Gauntlet;

namespace GauntletTutorial.Automation
{
public class LoginTest : UnrealTestNode<LoginTestConfig>
{
public LoginTest(UnrealTestContext inContext) : base(inContext)
{
}

public override LoginTestConfig GetConfiguration()
{
LoginTestConfig config = base.GetConfiguration();
UnrealTestRole server = config.RequireRole(UnrealTargetRole.Server);
server.Controllers.Add("GauntletTestLoginController");
// 直接启动配置数量的客户端
config.RequireRoles(UnrealTargetRole.Client, config.ClientCount);
// 60 秒后超时,判定为测试失败
config.MaxDuration = 60;
config.MaxDurationReachedResult = EMaxDurationReachedResult.Failure;
config.NoMCP = true;
return config;
}
}
}

创建 GauntletController

首先,在游戏项目中的 GauntletTutorial.build.cs 中

PublicDependencyModuleNames 中加入 “Gauntlet”

然后创建新 C++ 类型 UGauntletTestLoginController

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// Fill out your copyright notice in the Description page of Project Settings.

#pragma once

#include "CoreMinimal.h"
#include "GauntletTestController.h"
#include "GauntletTestLoginController.generated.h"

/**
*
*/
UCLASS()
class GAUNTLETTUTORIAL_API UGauntletTestLoginController : public UGauntletTestController
{
GENERATED_BODY()

virtual void OnInit() override;
virtual void OnTick(float TimeDelta) override;

UFUNCTION()
void OnClientLogin(AGameModeBase* GameMode, APlayerController* NewPlayer);

int32 CurrentClientCount;
int32 ClientCountShouldLogin;
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// Fill out your copyright notice in the Description page of Project Settings.


#include "GauntletTestLoginController.h"
#include "GameFramework/GameModeBase.h"

void UGauntletTestLoginController::OnInit()
{
Super::OnInit();
CurrentClientCount = 0;

FParse::Value(FCommandLine::Get(), TEXT("ClientCountToCheck="), ClientCountShouldLogin);
FGameModeEvents::GameModePostLoginEvent.AddUObject(this,&UGauntletTestLoginController::OnClientLogin);

}

void UGauntletTestLoginController::OnTick(float TimeDelta)
{
Super::OnTick(TimeDelta);
// 全部都登入啦
if (CurrentClientCount == ClientCountShouldLogin)
{
EndTest(0);
}
}

void UGauntletTestLoginController::OnClientLogin(AGameModeBase* GameMode, APlayerController* NewPlayer)
{
CurrentClientCount++;
}

我们注册全局登入事件,然后判断登录的客户端数量是否能达到配置的人数

如果进入的数量和配置的数量相等了,则结束测试,返回代表成功的结果0

编写测试批处理

在 [ProjectDir]\Scripts 目录下创建 RunLoginTest.bat ,test 传入 LogintTest, 并传入参数 ClientCount 5

1
2
3
4
5
6
7
8
9
10
11
@echo on

set ProjectPath=项目目录
set StagingDir=%ProjectPath%\Saved\StagedBuilds\
set EnginePath=引擎目录

"%EnginePath%\Engine\Build\BatchFiles\RunUAT" RunUnreal -project=%ProjectPath%\GauntletTutorial.uproject ^
-build=%StagingDir% -platform=Win64 -configuration=development ^
-test=GauntletTutorial.Automation.LoginTest(ClientCount=5) ^
-scriptdir=%ProjectPath%
pause

打包,运行测试

运行 Pack.bat 打包

然后运行 RunLoginTest.bat 开启测试

可以看到当 6 个 device 都启动后,测试显示成功

然后查看 Server 的日志

日志在 [Engin]\GauntletTemp\DeviceCache\Win64[LocalDevice”N”]\UserDir\Saved\Logs 中

这里的服务器大概率应该是 LocalDevice0,其他的客户端应该是 LocalDevice1 ~ 5

日志中显示 5 个客户端登录成功,我们的需求完成

调试 RunUnreal 和 游戏实例

我们在学习和测试中,经常会有需要调试 Gauntlet Automation 或者在特定实例调试的情况

调试 RunUnreal

这时我们只需在 RunUAT 命令行参数中加入 -waitfordebugger

再运行 Gauntlet 测试,就会在启动时等待调试器接入

然后我们在 Automation 项目代码中打好断点,然后在 Rider 中选择 Run -> AttachToProcess

然后过滤输入 Automation ,在进程中选中 dotnet(AutomationTool.dll) 开始调试即可

调试特定游戏实例

我们知道游戏中传入参数 -waitfordebugger 就可以等待调试器接入

所以我们只需按照之前传参到特定实例的方法传入参数

然后在游戏项目中打好断点,attach 到游戏进程即可

相关学习和参考

现在我们已经了解了 Gauntlet 的基本使用

如果需要了解和学习更多的用法,可以

  • 查看 Gauntlet Automation 项目中相关源代码,学习借鉴其中的用法
  • 查看相关测官网文档
  • 查看一些使用案例

可供参考的项目

DaedalicTestAutomation 插件

Github 地址

这个插件写了一套测试框架,然后也集成了使用 Gauntlet 来启动测试的方式,很有参考价值

ShooterGame 示例项目

官方示例项目中,包含了一些 Gauntlet Automation 和 Gauntlet Test Controller 的使用实例

本文项目的 Github 仓库

Github 仓库地址

常见控制消息与参数列表

Hello

1
DEFINE_CONTROL_CHANNEL_MESSAGE(Hello, 0, uint8, uint32, FString); // initial client connection message

客户端发起登录初始化

参数:大小端 本地客户端的网络版本 网络加密Token

Upgrade

1
DEFINE_CONTROL_CHANNEL_MESSAGE(Upgrade, 2, uint32); // server tells client their version is incompatible

告诉客户端版本不匹配

参数:服务器版本号

Challenge

1
DEFINE_CONTROL_CHANNEL_MESSAGE(Challenge, 3, FString); // server sends client challenge string to verify integrity

服务器让客户端开始登录

参数:当前时间

Login

1
DEFINE_CONTROL_CHANNEL_MESSAGE(Login, 5, FString, FString, FUniqueNetIdRepl, FString); // client requests to be admitted to the game

客户端发起登录

参数:默认客户端反馈”0” 客户端登录Url字符串 PlayerID 平台名称

Welcome

1
DEFINE_CONTROL_CHANNEL_MESSAGE(Welcome, 1, FString, FString, FString); // server tells client they're ok'ed to load the server's level

服务器欢迎客户端登陆

参数:地图名称 GameMode名称 重定向URL

NetSpeed

1
DEFINE_CONTROL_CHANNEL_MESSAGE(Netspeed, 4, int32); // client sends requested transfer rate

客户端请求设置网速

参数:网速

Failure

1
DEFINE_CONTROL_CHANNEL_MESSAGE(Failure, 6, FString); // indicates connection failure

失败,各种意义上的,一般会断开连接

参数:失败原因

Join

1
DEFINE_CONTROL_CHANNEL_MESSAGE(Join, 9); // final join request (spawns PlayerController)

客户端正式加入游戏中

参数:无

JoinSplit

1
DEFINE_CONTROL_CHANNEL_MESSAGE(JoinSplit, 10, FString, FUniqueNetIdRepl); // child player (splitscreen) join request

向服务器发送分屏玩家加入请求

参数:URL 玩家UniqueNetId

EncryptionAck

1
DEFINE_CONTROL_CHANNEL_MESSAGE(EncryptionAck, 21);

向客户端发送收到流量加密请求

参数:无

GameSpecific

1
DEFINE_CONTROL_CHANNEL_MESSAGE(GameSpecific, 20, uint8, FString); // custom game-specific message routed to UGameInstance for processing

自由定义的命令,会在 UGameInstance::HandleGameNetControlMessage 中处理

参数:命令的Byte 命令的字符串

登录流程控制消息顺序

  • 客户端

    • 发送 Hello 到服务器
  • 服务器收到 Hello

    • 判断两边的网络版本是否匹配,不匹配则发送 Upgrade 到客户端
    • 版本匹配则查看是否有加密 Token
      • 如果没有,则发送 Challenge 到客户端(5.0 以及以前)
      • 如果没有,则判断是否强制开启加密(5.1 以及以后)
        • 如果强制开启加密,则发送 Failure 到客户端,原因是缺少 Encrypt Token
        • 如果没有开启,则发送 Challenge 到客户端
      • 如果有,则调用 UGameInstance::ReceivedNetworkEncryptionToken 解析 Token
        • 如果解析成功
          • 开启加密,向客户端发送 EncryptionAck 到客户端
          • 发送 Challenge 到客户端
        • 解析失败
          • 发送 Failure 到客户端
  • 客户端收到 EncryptionAck

    • 客户端执行 UGameInstance::ReceivedNetworkEncryptionAck ,客户端开启加密
  • 客户端收到 Challenge

    • 组装一些参数(玩家昵称 ,还有 GetGameLoginOptions)到 URL,并和平台名和 PlayerID 等一起发送消息 Login 到服务器
  • 服务器收到 Login

    • 调用 GameMode->PreLogin,使用 Login 消息传入的 PlayerID
      • PreLogin 没有 Error Message
        • 调用 AuthorityGameMode->GameWelcomePlayer
        • 向客户端发送 Welcome
      • PreLogin 有 Error Message,则向客户端发送 Failure
  • 客户端收到 Welcome

    • 发送 NetSpeed 消息到服务器
  • 服务器收到 NetSpeed

    • 设置 Connection 的 NetSpeed
  • 客户端收到 Failure

    • 关闭网络连接
  • 客户端加载地图完成

    • 发送 Join 到服务器
  • 服务器收到 Join

    • 创建 PlayerController
      • 创建失败:发送 Failure
      • 创建成功:标记玩家状态为 Join,开始 ClientTravel

这篇文章主要记录一些平时想到和看到的 UE Tips

RPC 实现调用 XXX_Implementation 和 XXX_Validate 的方式

其实是 UHT 里面自动生成的 execXXX 函数中调用 Validate 和 Implementation 的逻辑,所以 XXX_Implementation 不是 UFUNCTION 也很正常,因为真正的 UFUNCTION 是根据 RPC 宏自动生成的 execXXX,所以如果要魔改逻辑,只能改 UHT 源码了

CallInEditor

一个 UFUNCTION 宏中,如果加上宏参数

1
CallInEditor

那么会在编辑器的 Property 面板中生成一个同名按钮,点击后可以执行这个函数

同样的,如果是蓝图类,在 CustomEvent 节点的属性面板中也有一个 CallInEditor 的勾选框

效果是一样的

但是有一个限制,就是函数不能带参数,否则不会生成 Property 面板按钮

使用 ini 重新设置控制台变量 CVar 的值

在 DefaultEngine.ini 中,添加

1
2
[SystemSettings]
xxx.yyy = zzz

其中 xxx.yyy 是控制台变量的名称,zzz 为覆盖默认值

Map Check

Map Check 可以帮助你更好的检查地图中的 Actor 是否存在错误或者不适当的配置

执行主菜单的->Build->MapCheck 即可执行检查当前地图(UE5下)

1
2
3
4
5
/**
* Function that gets called from within Map_Check to allow this actor to check itself
* for any potential errors and register them with map check dialog.
*/
virtual void CheckForErrors();

在你自己的 Actor 派生类重写 CheckForErrors ,在其中添加检查逻辑即可

检查逻辑可以通过

1
FMessageLog("MapCheck")

来生成编辑器 Console 提示,其还可以 Add 各种 Token 来进行帮助跳转

其中 ActorToken 点击可跳转 Actor,ActionToken 点击可以执行一个自定义的命令,AssetNameToken 点击可跳转相关资源,等待

网络部分

  • 控制命令新增 NMT_Ping,衍生出 NetPing.h 相关内容

  • Iris 同步系统(实验性)

  • SubObject 新同步特性

    • ActorComponent 新增函数 ReadyForReplication
    • FReplicatedComponentsInfo 类型
    • 现在 Actor 会自己管理一个 ReplicatedComponent 的列表
  • 流量加密

    • NetDriver 中增加了两个可以覆盖的函数,DoesSupportEncryption IsEncryptionRequired,需要强制开启加密就不需要搞魔法了
  • Replication Graph

    • 性能分析中增加了 RedundantRepCPUTimeAccumulated ,无用 CPU 耗时,详见 FReplicationGraphCSVTracker
    • 为每个节点增加了 virtual void Serialize(FArchive& Ar); 序列化函数,用于数据内存大小的 Tracking
    • FStreamingLevelActorListCollection 符号现在导出了,可在外部使用
    • 针对超出距离的 Actor 增加了一个 OutOfRangeList,和 DestructDistance 区分,没有到销毁距离,但是到了 OutOfRanged 的,先移入一个列表中

系统部分

  • UClass 新增 TryFindTypeSlow 函数
  • TCppStructOps 把 Traits 的判断放在了编译期

本身我们在 uclass 中,去创建一个 BlueprintNativeEvent 的函数的时候

可以有一个默认 c++ 实现,然后蓝图中,可以选择性的使用蓝图函数 override c++ 的实现

可是在 uinterface 中使用 BlueprintNativeEvent 的话

如果在蓝图中手动 implement 接口的话,会把函数的 override 蓝图空函数自动创建出来,并且呢,删都删不掉呢

默认的 c++ 实现就变得访问不到啦

个人倾向于是个 Bug,并且是改不动这种


顺便,一个继承了 A 接口的 B 类型 UObject

它的 CDO 如果 Cast <A>,会得到一个 nullptr

很反直觉

最近尝试服务器客户端代码分离,于是只在服务器上存在的 GameMode 类型,理所当然的被分到服务器专属模块中

但是打出包后,客户端连接上服务器后,任然会去加载 GameMode 类型,但由于找不到,会导致报错或崩溃

于是顺着 Debug 了下服务器发包

发现 AGameStateBase 中,有

1
2
UPROPERTY(Transient, BlueprintReadOnly, Category=GameState, ReplicatedUsing=OnRep_GameModeClass)
TSubclassOf<AGameModeBase> GameModeClass;

作为一个变量同步到客户端

查找了下,这个变量在客户端上并没有什么卵用

不理解为啥 Epic 会把这个东西设置为同步

于是在

1
GetLifetimeReplicatedProps

中果断使用宏

1
DISABLE_REPLICATED_PROPERTY

关闭 GameModeClass 的同步

顺利解决问题

最近需要修改打出的 exe 的名称

在网上找了半天都只是说需要修改 uproject 的名称

有没有办法在不修改 uproject 的名称的前提下,修改打出的包名呢

答案是肯定的

  1. 修改 xxx.Target.cs 的 xxx 部分
  2. 修改其中的类名和 xxx 一致
  3. 打出来的 exe 名称就是 xxx.exe

导致的问题

Gauntlet 测试的时候,如果按 Path 传入打好的可执行文件,命名和 uproject 不同可能导致无法找到 Role 相应的 exe

例如 项目名称为 AAA 的时候,寻找 Role 为 Server 的包,会寻找名称为 AAAServer.exe 的可执行文件

如果随意修改打出的 exe 名称为 BBB ,则会找不到 Role 为 Server 的包

结论

修改打出的 exe 名称,还是需要和 uproject 名称结合使用,因为 UAT 里面一些功能项目名和 exe 有部分耦合(万恶的 Gauntlet)

例如 AAA.uproject 最好把 Server Target 命名为 AAAServer

Client Target 命名为 AAAClient

Game Target 命名为 AAA 或 AAAGame

Editor Target 命名为 AAAEditor