PlayerInfo高频同步解决方案

描述

需求目的

分析一个常见的需求: “在 1P 客户端显示 3PTransform ”。

显然,在客户端存在 3PPawn 时,可以直接取 PawnTransform;但出于性能考虑,会进行各种 AOI 机制,在较远距离时客户端会将 3PPawn 裁剪掉,只留下 PlayerState(或者某个不被剪裁的数据 Channel) 用于同步。

一个直观的想法是将 Transform 直接通过对应的 PlayerState 属性同步给所有客户端;但出于性能考虑,对于同步一般会开启 PushModel;这种高频字段会频繁将 PlayerState 对应 ActorChannelMarkDirty,导致 PushModel 功能基本失效,频繁进行同步的 Diff 等大开销的操作;

所以需要一个机制对这种情况进行优化。

核心思路

对于 DS,创建一个 Channel 专门用于同步 Player 的高频变化信息,如 LocationRotation 等;

对于同步的信息,进行适当的同步降频(不需要每帧同步)、字节压缩(舍弃部分精度,精确到 float 没有意义);

同时为了保证 Client 的信息相对正确(同步降频会导致 Location 不连续),在 1P Client 进行信息的预测插值;

实现

Replicator

创建一个 Actor - PlayerSyncInfoReplicator 专门用于打包所有 Player 高频 Info(这里特指 LocationRotation) 进行数据同步,在合适的地方进行初始化(比如 ReplicationGraph 初始化后);

Replicator 包括一个 FPlayerSyncInfoContainer 结构体用于打包数据、进行数据的收集、自定义序列化;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void APlayerSyncInfoReplicator::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);

// 不使用 PushModel,始终进行同步操作
FDoRepLifetimeParams Params;
DOREPLIFETIME_WITH_PARAMS(APlayerSyncInfoReplicator, PlayerSyncInfoContainer, Params);
}

void APlayerSyncInfoReplicator::PreReplication(IRepChangedPropertyTracker& ChangedPropertyTracker)
{
// 收集数据
PlayerSyncInfoContainer.PreReplication();
}

Define Data & Byte Compression

对于每一个 Player,需要收集 FSyncInfo,这里记录了一个 SyncLocation : FVector_NetQuantizeYaw

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
USTRUCT()
struct FSyncInfo
{
GENERATED_BODY()

FSyncInfo() = default;
FSyncInfo(const FVector_NetQuantize& InSyncLocation) : SyncLocation(InSyncLocation) { }
bool NetSerialize(FArchive& Ar, class UPackageMap* Map, bool& bOutSuccess);

public:
FVector_NetQuantize SyncLocation;
float Yaw;
};


template<>
struct TStructOpsTypeTraits< FSyncInfo > : public TStructOpsTypeTraitsBase2< FSyncInfo >
{
enum
{
WithNetSerializer = true,
WithNetSharedSerialization = true,
};
};

FVector_NetQuantize 是一个自定义的类型,进行了对 FVector 的数据压缩。

由于表达一个 float 所需要的 Bit 比较多,但大部分情况不需要同步到 float 这么高精度的数据),比如一个有限范围的大世界, Location 显然不需要那么高的精度表示:

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
USTRUCT()
struct FVector_NetQuantize : public FVector
{
GENERATED_USTRUCT_BODY()

FORCEINLINE FVector_NetQuantize() {}

explicit FORCEINLINE FVector_NetQuantize(EForceInit E) : FVector(E)
FORCEINLINE FVector_NetQuantize( float InX, float InY, float InZ ) : FVector(InX, InY, InZ) {}
FORCEINLINE FVector_NetQuantize( const FVector &InVec )
{
FVector::operator=(InVec);
}

bool NetSerialize(FArchive& Ar, class UPackageMap* Map, bool& bOutSuccess)
{
bOutSuccess = SerializePackedVector<1, 20>(*this, Ar);
return true;
}
};

template<>
struct TStructOpsTypeTraits< FVector_NetQuantize > : public TStructOpsTypeTraitsBase2< FVector_NetQuantize >
{
enum
{
WithNetSerializer = true,
WithNetSharedSerialization = true,
};
};

这里除了将 FVector 压缩为一个 20bitFVector_NetQuantize,还可以对 Yaw 这一旋转角度进行压缩:

  1. 定义一个 uint8ByteYaw,存储 Yaw 的压缩版本,通过 FRotator::CompressAxisToByte 将其压缩为一个字节;
  2. 新增一个标志位 NotZero 判断 ByteRaw 是否是 0(不需要同步 Yaw 时候,该值就为 0,可能清空较多),如果为 0,则只需要存储 NotZero 的值(1位),而不是整个 ByteYaw(8位);

对于 FSyncInfo,自定义其 NetSerialize 逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
bool FSyncInfo::NetSerialize(FArchive& Ar, UPackageMap* Map, bool& bOutSuccess)
{
SyncLocation.NetSerialize(Ar, Map, bOutSuccess);

uint8 ByteYaw = 0;
if ( Ar.IsSaving() )
ByteYaw = FRotator::CompressAxisToByte(Yaw);

uint8 NotZero = ByteYaw != 0;
Ar.SerializeBits( &NotZero, 1 );

if ( NotZero ) Ar << ByteYaw;
else ByteYaw = 0;

if( Ar.IsLoading() )
{
Yaw = FRotator::DecompressAxisFromByte(ByteYaw);
}

return true;
}

这里就定义完毕了 FSyncInfo,显然需要一个 FSyncInfoPlayer UID 的映射,打包成一个结构体 FPlayerSyncInfo

一般使用 uint64 来保存 UID,可以简单的用额外的一个 bit 判断 UID 是否 > MAX_uint32,用来节省一些流量(也可以将 uint64 分为 4 个 16bit,用额外的 2bit 来判断在哪一段范围)

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
USTRUCT()
struct FPlayerSyncInfo
{
GENERATED_BODY()
FPlayerSyncInfo() = default;
FPlayerSyncInfo(uint64 UID, const FSyncInfo& SyncInfo)
: UID(UID), SyncInfo(SyncInfo) {};

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

public:
uint64 UID;
FSyncInfo SyncInfo;
};


template<>
struct TStructOpsTypeTraits< FPlayerSyncInfo > : public TStructOpsTypeTraitsBase2< FPlayerSyncInfo >
{
enum
{
WithNetSerializer = true,
WithNetSharedSerialization = true,
};
};

// ----------------------------------------

bool FPlayerSyncInfo::NetSerialize(FArchive& Ar, UPackageMap* Map, bool& bOutSuccess)
{
SyncInfo.NetSerialize(Ar, Map, bOutSuccess);

uint8 UID_IsHigh = UID > MAX_uint32;
Ar.SerializeBits(&IsHigh, 1)

if (UID_IsHigh)
{
Ar << UID;
}
else
{
uint32 UID_LowBits = (uint32)UID;
Ar << UID_LowBits;
UID = (uint64)UID_LowBits;
}

return true;
}

Collect Data

FPlayerSyncInfoContainer

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
USTRUCT()
struct FPlayerSyncInfoContainer
{
GENERATED_BODY()

public:
FPlayerSyncInfoContainer() {};

bool NetSerialize(FArchive& Ar, class UPackageMap* Map, bool& bOutSuccess);
void PreReplication();
#endif

bool operator==(const FPlayerSyncInfoContainer& Other) const
{
return FrameCounter == Other.FrameCounter;
}
bool operator!=(const FPlayerSyncInfoContainer& Other) const
{
return !(*this == Other);
}

private:
void CollectReplicateData();

private:
// 控制同步频率
uint64 FrameCounter = 0;
uint32 PastFrameCounter = 1;

bool bHasCollectDataThisFrame = false;

// 同步信息的数量(对应玩家个数)
uint32 InfoCount = 0;
// Bit 流
TSharedPtr <FNetBitWriter> WriterPtr;

// Replicator
TWeakObjectPtr <class APlayerSyncInfoReplicator> Replicator;
};

template<>
struct TStructOpsTypeTraits<FPlayerSyncInfoContainer> : public TStructOpsTypeTraitsBase2<FPlayerSyncInfoContainer>
{
enum
{
WithNetSerializer = true,
WithIdenticalViaEquality = true,
WithCopy = false,
};
};

PreReplication 中进行 CollectReplicateData

通过 CVarPlayerSyncInfoReplicateFrameInternal 来控制同步的频率,记录 PastFrameCounter 用于记录过去了多少帧,用于后续判断;

1
2
3
4
5
6
7
8
9
10
void FPlayerSyncInfoContainer::PreReplication()
{
if (FrameCounter == GFrameCounter) return;

PastFrameCounter = FMath::Clamp<uint32>(GFrameCounter - FrameCounter, 1, CVarPlayerSyncInfoReplicateFrameInternal + 1);
FrameCounter = GFrameCounter;
bHasCollectDataThisFrame = false;

CollectReplicateData();
}

CollectReplicate 中进行数据收集与统计,将收集到的数据字节流写入 Writer

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

void FPlayerSyncInfoContainer::CollectReplicateData()
{
if (!Replicator.IsValid()) return;

if (bHasCollectDataThisFrame) return;
bHasCollectDataThisFrame = true;

if (!WriterPtr.IsValid())
{
WriterPtr = MakeShared<FNetBitWriter>();
WriterPtr->SetAllowResize(true);
}
else
{
WriterPtr.Get()->Reset();
InfoCount = 0;
}

FNetBitWriter* Writer = WriterPtr.Get();
bool bOutSuccess = true;

const auto SerializePlayerSyncInfo = [&](const auto& PlayerState)
{
if (!PlayerState.IsValid()) return;

uint64 UID = PlayerState->GetUID();
FVector Location = INVALID_LOCATION
float Yaw = 0;
if (APawn* PlayerPawn = PlayerState->GetPawn(); IsValid(PlayerPawn))
{
const FTransform& Transform = PlayerPawn->GetTransform();
Location = Transform.GetLocation();
Yaw = Transform.Rotator().Yaw;
}

FVector_NetQuantize SyncLocation(Location.X, Location.Y, Location.Z);
FPlayerSyncInfo PlayerSyncInfo(UID, {SyncLocation, Yaw});
PlayerSyncInfo.NetSerialize(*Writer, nullptr, bOutSuccess);
InfoCount++;
};


for (const auto& PlayerState : PlayerArray)
{
SerializePlayerSyncInfo(PlayerState);
}
}

Serialize

对于每一个 PlayerConnection,判断其是否满足同步帧数限制,每次 NetSerialize ,将 ConnectionDriver->NextReplicateFrameCount -= PlayerSyncInfoContainer.PastFameCounter,若 <= 0 则重置其 NextReplicateFrameCountCVarPlayerSyncInfoReplicateFrameInternal,并认为本次需要对该 Connection 进行同步;若需要同步,将 Writer 中的数据进行序列化;

在反序列化时,找到对应的 Player,将数据设置到其 PlayerState 中;

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
bool FPlayerSyncInfoContainer::NetSerialize(FArchive& Ar, class UPackageMap* Map, bool& bOutSuccess)
{
if (Ar.IsSaving())
{
auto PackageMapClient = Cast<UPackageMapClient>(Map);
auto Connection = PackageMapClient->GetConnection();
auto ConnectionDriver = Connection->GetReplicationConnectionDriver();

bool bNeedReplicate = false;

if (ConnectionDriver->NextReplicateFrameCount <= 0)
{
bNeedReplicate = true;
ConnectionDriver->NextReplicateFrameCount = CVarPlayerSyncInfoReplicateFrameInternal
}


if (bNeedReplicate)
{
Ar.SerializeIntPacked(InfoCount);
Ar.SerializeBits(WriterPtr->GetData(), WriterPtr->GetNumBits());
}
else
{
uint32 Count = 0;
Ar.SerializeIntPacked(Count);
}

bOutSuccess = true;
}
else
{
// 将收集到的数据同步到客户端,在客户端进行数据的反序列化,
uint32 Count = 0;
Ar.SerializeIntPacked(Count);
if (Count == 0) return true;

// 记录当前收包累计次数,用于预测等
const uint64 FrameNum = ++GDSPlayerSyncInfoReplicateFrameNum;
for (int Index = 0; Index < Count; Index++)
{
FPlayerSyncInfo PlayerSyncInfo;
PlayerSyncInfo.NetSerialize( Ar, Map, bOutSuccess );

uint64 UID = PlayerSyncInfo.UID;
if (auto PlayerState = GET_PLAYERSTATE_BY_UID(UID))
{
PlayerState->SetPlayerSyncInfo(PlayerSyncInfo);
}
}
}

return true;
}

Client Predict

对于 Client,会间隔收到 3PPlayerSyncInfo,同时在收包的时候记录了帧号。

对于比如 Location 这样的数据,显然需要保证其连续性。

于是可以根据这些信息做一个简单的预测:

  1. Client 存在 Pawn 时,直接使用 Pawn 的位置,并且强制更新预测与期望;
  2. 在收到一个新包 CurrentBack 时,使用 PrePack -> CurrentBackLocationDiff / PreFrameInternal 计算出一个新的预测速度 PredictCalcDeltaSyncLocationVelocity,同时可以根据实际情况 * SpeedFactor 来进行适当修改;
  3. 每次 GetSyncLocation 时尝试更新预测信息,在当前帧 CurrentFrame,利用 PredictCalcDeltaSyncLocationVelocity * FrameInternal 来外推当前所在 Location,这里的 FrameInternal 是从 上一次预测帧 LastPredictFrame 开始经过的帧数,预测完毕更新 LastPredictFrame
  4. 如果当前位置和预测期望位置太远,则直接设置为期望位置,不进行插值;
flowchart LR

A(PrePack)
B(CurrentBack)
C(NextBack)

Last([LastPredictFrame])
Now([CurrentFrame])

A---->|PreFrameInternal| B
B-->|Internal|Last
Last-->|Internal|Now
Now-..->C
1
2
3
4
5
6
7
8
9
10
11
12
13
14

void APlayerState::SetSyncLocation(FVector InSyncLocation, int32 CurrentFrameNum)
{
// --- Predict
int64 DeltaFrameCount = UKismetSystemLibrary::GetFrameCount() - SyncLocationFrameCount;
float SpeedUpFactor = 1.05f;

PredictCalcDeltaSyncLocationVelocity = DeltaFrameCount <= 0 ? 0.0f : FVector::Dist(InSyncLocation, ActorSyncLocation) / DeltaFrameCount * SpeedUpFactor;

// --- Update
ActorSyncLocation = InSyncLocation;
SyncLocationUpdateFrameNum = CurrentFrameNum;
SyncLocationFrameCount = UKismetSystemLibrary::GetFrameCount();
}
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
FVector APlayerState::GetSyncLocation()
{
if (APawn* Pawn = GetPawn(); IsValid(Pawn))
{
FVector Location = Pawn->GetTransform().GetLocation();
ActorSyncLocation.Set(Loc.X, Loc.Y, Loc.Z);
UpdatePredictSyncLocation(ActorSyncLocation, true);
return ActorSyncLocation;
}

// 判断是否正常发包,若新的 PlayerSyncInfoPack 中无该 PlayerState 则不采用预测信息
if (GDSPlayerSyncInfoReplicateFrameNum == SyncLocationUpdateFrameNum && SyncLocationUpdateFrameNum != 0)
{
UpdatePredictSyncLocation(ActorSyncLocation);
return PredictSyncLocation;
}

return InvalidSyncLocation;
}


void APlayerState::UpdatePredictSyncLocation(FVector InLocation, bool bForce)
{
int64 CurrentFrame = UKismetSystemLibrary::GetFrameCount();

// 强制更新预测信息
if (bForce == true)
{
LastPredictCalcLocationFrameCount = CurrentFrame;
PredictSyncLocation = InLocation;
PredictCalcDeltaSyncLocationVelocity = 0.0f;
return;
}

int64 DeltaFrame = CurrentFrame - LastPredictCalcLocationFrameCount;
if (DeltaFrame <= 0) return;

// 预测速度为 0 / 距离超过 DistanceLimit,直接传送
if ( FMath::IsNearlyEqual(PredictCalcDeltaSyncLocationVelocity, 0.0f) || FVector::DistSquared(PredictSyncLocation, InLocation) >= PredictSyncLocationDistanceLimitSquared)
{
PredictSyncLocation = InLocation;
}
else
{
PredictSyncLocation += DeltaFrame * (InLocation - PredictSyncLocation).GetSafeNormal() * PredictCalcDeltaSyncLocationVelocity;
}

LastPredictCalcLocationFrameCount = CurrentFrame;
}

这样,就可以得到一个相对丝滑的 Location 信息。