前言

UE 中网络系统是重要一个部分,其中包括网络预测更为重要。DS就需要基于此去处理部分信息。

零、开篇

省流

第一篇使用 关键字回忆

  1. 帧同步和状态同步,UE5的CS架构(如DS)是状态同步
  2. Player角色枚举ENetRole
  3. RPC :[Reliable]、HasAuthority()检测宇宙是否权威、xxx_Implementation执行、xxx_Validate 校验
  4. 属性复制 : [Replicated]、[ReplicatedUsing = OnRep_xxx ] 、注册属性复制 GetLifetimeReplicatedProps()–[DOREPLIFETIME]
  5. 组件属性复制:CombatComponent->SetIsReplicated(true); 构造函数中SetIsReplicatedByDefault(true);

第二篇预测

  1. 客户端提前预测:Ammo的属性复制和AmmoNetSequence(预测变更子弹数)
  2. 服务器权威 对表 HitBox 回滚判定
  • 写入对表:计算时间 DTime = STime - CTime = (RspSTime + Trip1Time) - CTime = (RspSTime + Trip2Time * 0.5) - CTime = (RspSTime + (NowCTime - ReqCTime)* 0.5) - CTime

  • 对表在一定时间需要更新,可能会有DTime的误差

  • HitBox(双端链表)存储:在一定时间中的FramePackage;

  • 用射击时间HitTime去查找Hitbox中TarPos,一般会在俩个HitBox中间(使用插值处理or选择更近的hitbox);

  • 回溯与判定:SetPos人物到TarPos->判定->SetPos放回人物

一、同步

帧同步和状态同步

【网络同步】浅析帧同步和状态同步 - 知乎

需要说明的是,由于状态同步的安全性比帧同步高很多,且对网络延迟有较大容忍,FPS普遍采用状态同步的方案,而UE作为射击游戏发家的引擎,其提供的rpc也是为状态同步设计的,所以本笔记和项目是基于状态同步方案实现的,此外本笔记所有功能基于AActor,其他类型(F类)可能需要另外实现。

ue角色的联机属性:

拆分了权威服务器后,我们宇宙中的角色就可以分成三种:

  • 权威服务器(server)上的角色,该角色不管是不是这个宇宙真正的主人(即房主),都是最权威的,权威服务器的运行代码说B左移50米,不管 B宇宙中的真B 此刻可能是往右移了50米,所有宇宙的B都是左移50米,且B宇宙中的真B也会因”不可抗力”左移到权威服务器说它应该在的位置。
  • 普通客户端(client)上的正在操纵的角色,也就是A宇宙中的A,B宇宙中的B。
  • 普通客户端(client)上的复制角色,也就是A宇宙中的BCD,B宇宙中的ACD

对此,UE构建了个枚举enum类ENetRole:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
UENUM(BlueprintType)
enum ENetRole : int
{
/** No role at all. */
ROLE_None UMETA(DisplayName = "None"),
/* Locally simulated proxy of this actor. */
//普通客户端(client)上的复制角色*/
ROLE_SimulatedProxy UMETA(DisplayName = "Simulated Proxy"),
/* Locally autonomous proxy of this actor. */
//普通客户端(client)上的正在操纵的角色*/
ROLE_AutonomousProxy UMETA(DisplayName = "Autonomous Proxy"),
/* Authoritative control over the actor. */
//权威服务器(server)上的角色
ROLE_Authority UMETA(DisplayName = "Authority"),
ROLE_MAX UMETA(Hidden),
};

我们可以使用Actor的GetLocalRole()来确定当前Character的ENetRole,由于这是个枚举类,我们甚至可以写出

if (GetLocalRole() > ENetRole::ROLE_SimulatedProxy)

来排除掉ROLE_None 和复制人。

此外需要强调的是,UE中的actor默认是关闭网络复制功能的,如果某个actor类需要使用网络功能(即后面的RPC和OnRep等),请设置

bReplicates = true;

但是Apawn类是默认开启的,我们可以在其构造函数中找的这句,ACharacter类继承自Apawn也是默认开启

现在我们角色三六九等划分清楚了,是时候该让不同宇宙同步了。

RPC

https://dev.epicgames.com/documentation/zh-cn/unreal-engine/remote-procedure-calls-in-unreal-engine

  • Client在此Actor的所属客户端连接上执行RPC。
  • Server在服务器上执行RPC。
  • Remote在连接的远程端执行RPC。
  • NetMulticast在服务器上以及与Actor相关的所有当前连接的客户端上执行RPC。

上述为元数据说明符,可添加在UFUNCTION()里面:

​ UFUNCTION(Server, Reliable, WithValidation)

​ void ServerFire(const FVector_NetQuantize& TraceHitTarget, float FireDelay);

在这个样例里,无论在权威宇宙还是普通宇宙(当然普通宇宙必须持有该actor),如果该actor的ServerFire()被调用了, 那么权威宇宙就会收到信号,并开始执行ServerFire里的逻辑,并且只有权威宇宙执行。

同理,如果添加的是Client,则是只有持有该Actor的宇宙执行ServerFire。

如果是Remote,则是另一端(无论是权威还是普通)执行。

如果是NetMulticast, 则是所有宇宙执行ServerFire, 也就是广播。此时注意,只有权威宇宙调用能广播,如果是普通宇宙调用ServerFire,则只有它自己执行(其他宇宙不鸟它)。

直接贴项目代码Fire:

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

//某个普通客户端 或 服务器上控制的角色A触发开火
void UCombatComponent::Fire() {
if (EquippedWeapon && FatherCharacter) {
CrosshairHitTarget = EquippedWeapon->bUseScatter ? EquippedWeapon->TraceEndWithScatter(CrosshairHitTarget) : CrosshairHitTarget;
//执行一些本地开火逻辑,如播放开火动画, 但如果在服务器上开火,则有serverfire或multicast处理
//为什么:网络有延迟,当前玩家按下开火就应该开火, 但坐在服务器上的玩家没延迟,这也算一种客户端预测,后续文章会讲
if (!FatherCharacter->HasAuthority()) LocalFire(CrosshairHitTarget);
ServerFire(CrosshairHitTarget, EquippedWeapon->FireDelay);//让服务器执行ServerFire
}

void UCombatComponent::ServerFire_Implementation(const FVector_NetQuantize& TraceHitTarget, float FireDelay) {
MulticastFire(TraceHitTarget);//服务器广播这个角色A开火
}

//所有客户端和服务器的A被广播的开火逻辑
void UCombatComponent::MulticastFire_Implementation(const FVector_NetQuantize& TraceHitTarget) {
if (FatherCharacter && FatherCharacter->IsLocallyControlled() && !FatherCharacter->HasAuthority()) {
return; //说明当前宇宙是调用fire的那个人,已经在Fire里面LocalFire过了
}
LocalFire(TraceHitTarget);//当前是其他宇宙的复制人,或者是服务器的权威人,执行一些本地开火逻辑(复用)
}

RPC底层

UE4属性同步(四)rpc实现 - 知乎

Reliable

RPC通信默认是不可靠的,即如果数据包被丢弃,则不执行该RPC。

而如果某些函数是重要的,比如ServerFire告知服务器这个玩家开火,我们需要添加Reliable来标记可靠“将重新发送该RPC,直到它被接收方确认。在确认该RPC之前,将暂停所有后续RPC执行”。

当然,如果学过TCP和UDP, 就知道可靠传输代价会更高一些。

WithValidation

RPC还提供了验证功能,需要我们在cpp中添加_Validate实现:

1
2
3
4
5
6
7
8
9
10
11

//上面是ServerFire_Implementation,validate需要额外实现

bool UCombatComponent::ServerFire_Validate(const FVector_NetQuantize& TraceHitTarget, float FireDelay) {
if (EquippedWeapon) {
//判断开火间隔有没有被玩家篡改成一秒六棍,如果差别在0.01f以上说明被篡改过
bool bNearlyEqual = FMath::IsNearlyEqual(EquippedWeapon->FireDelay, FireDelay, 0.01f);
return bNearlyEqual;
}
return true;
}

上面是一个简单的验证的实现,上线项目需要检测更多的属性。

注意,我们需要确保当前AActor派生的类被设置为在派生的Actor的构造函数内复制:

bReplicates = true;

属性复制

  • Replicated属性为属性复制提供了指定特定条件的选项,将属性复制限制在特定连接上。你也可以设置自定义复制条件,为属性复制定义自己的逻辑。
  • ReplicatedUsing属性需要你提供RepNotify函数,当相关属性被复制时,客户端就会调用该函数。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
.h
UPROPERTY(Replicated)
bool bDisableGameplay = false;

UPROPERTY(ReplicatedUsing = OnRep_Health)
float Health = 100.f;

UFUNCTION()
void OnRep_Health(float LastHealth);

virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;

注意,绑定的方法**必须**有UFUNCTION(),我们还必须要 override GetLifetimeReplicatedProps 并添加宏调用,以在派生的Actor实例的生命周期内复制需要的属性

void ABlasterCharacter::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const {
Super::GetLifetimeReplicatedProps(OutLifetimeProps);

DOREPLIFETIME(ABlasterCharacter, Health);
DOREPLIFETIME(ABlasterCharacter, bDisableGameplay);
DOREPLIFETIME_CONDITION(ABlasterCharacter, OverlappingWeapon, COND_OwnerOnly);

}

眼尖的人一下就能看见这里OverlappingWeapon似乎有一点不同,这里就是条件复制。默认情况是将属性复制到所有A的复制体和它自己,但这里我们可以添加条件 COND_OwnerOnly 并用宏 DOREPLIFETIME_CONDITION 使 OverlappingWeapon 的变化仅复制到Actor的所有者(即仅A宇宙的A身上)。可定义的条件有很多,请参考官方文档:

https://dev.epicgames.com/documentation/zh-cn/unreal-engine/replicate-actor-properties-in-unreal-engine

某个结构体整体会被复制,但里面可能有个开销很大的数据类我们不想复制,我们可以使用UPROPERTY(NotReplicated) 来指定该数据不复制。

Component 复制

我的项目中,我的CombatComponent是挂在Character下的组件,自建的组件默认是不开启网络复制的,该组件需要网络复制,则需要:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
  Combat->SetIsReplicated(true);

结合文档,我们看到如果是静态Actor组件,即在Actor结构函数中创建的Actor组件,例如:

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

则应该在Actor组件结构函数中设置

UMyActorComponent::UMyActorComponent() {
SetIsReplicatedByDefault(true);
}

此外,运动组件是默认开启网络复制的

二、预测

(99+ 封私信 / 80 条消息) UE多人联机入门笔记(二) 客户端预测和服务器倒带 - 知乎

延迟

科普向:竞技游戏的延迟与延迟补偿_游戏热门视频

简单来说,由于物理上的限制,客户端上的数据不可能一瞬间到达权威服务器(用上一篇文章的类比,权威宇宙),所以在这里引入Ping的概念,Ping测量从源主机发送到目标计算机并返回到源主机的消息的往返时间,如果Ping值为100ms, 则指的是从一个测试ping的数据包从客户端发到服务器并立刻返回到客户端花费的时间为100ms。 换句话说,如果A玩家的Ping为100ms,那么A玩家在某年某月某日12.00.00. 0000(时.分.秒.毫秒)发射了一颗子弹,服务器则会在某年某月某日12.00.00. 0050收到A发射了一颗子弹的消息,假设是一瞬间计算出了结果(事实上应该要花费至少一帧的时间),那么A玩家在某年某月某日12.00.00. 0100 才会收到产生的结果,这就是网络延迟。

这里需要强调,从客户端到服务器 和 从服务器到客户端 的时间不一定是相同的(比如发过去走的是一条网络线路,往回走可能该线路崩了得走另一条),但为了方便计算我们还是按ping的1/2计算单程时间,大部分情况下来往时间也确实是相同的。

而这种延迟在客户端和服务端造成了很大的困惑,我们先来看客户端:

客户端预测

FPS游戏中,在玩家的延时都不一样的情况下是如何做到游戏的同步性的? - 知乎

那么我们如何使客户端能及时响应输入,且还兼顾权威服务器的验证需求呢?方案是我们把每一次上传的消息包都打上时间戳,并在本地把历史存起来。

A 进行移动

时刻 A 服务器
0ms {posA(10,0,0), 0ms } 发往服务器 -
50ms {posA(20,0,0), 50ms } 发往服务器 收到 {posA(10,0,0), 0ms }
100ms - 收到 {posA(20,0,0), 50ms } 抛弃 {posA(10,0,0), 0ms }

既然如此,为什么我们要比对,直接让客户端想干啥干啥更好吗?这里就牵扯到延迟带来的纠正问题。

时间回退到第50ms,此时此时服务器收到了A的带着0ms时间戳的位移到(10,0,0)的消息,然而期间其他玩家也有操作,B创建了一堵墙挡住了A的前进,该消息在第30ms传到了服务器,此时在50ms,服务器判定A移动到(10,0,0)不合法,并告知玩家A你应仍在(0,0,0),带着0的时间戳返回给A。

时间推进到第100ms(当然B造了一堵墙的消息可能在第80ms通知给了A,这个就看设定的服务器通信频率了),A收到带着0时间戳的(0,0,0),并于自己在0时间戳的信息进行了比对,发现不合法,就需要纠正了。

粗暴点的做法就是直接挪到(0,0,0),并清空历史重新记录,玩家看到有堵墙在那被挡住了,也能理解这种纠正(当然100ms也就是0.1s,玩家感知上也不会太有问题)。

玩家按键操作之后,客户端把该输入发给服务器,同时客户端本地直接处理该输入、产生部分结果/表现,而不用等待服务器的返回,这就是客户端预测

引自FPS游戏中,在玩家的延时都不一样的情况下是如何做到游戏的同步性的?

客户端预测代码案例

  • 属性复制

此时Ammo使用属性复制,在服务器那边因为延迟,在50ms子弹数才减少到25, 客户端在第100ms收到该消息,子弹数从20蹦跶到25.

1
2
3
4
5
UPROPERTY(EditAnywhere, ReplicatedUsing = OnRep_Ammo)
int32 Ammo;

UFUNCTION()
void OnRep_Ammo();
  • RPC
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
.h
//子弹数
UPROPERTY(EditAnywhere)int32 Ammo = 30;

//通知A宇宙的A更改手中子弹数
UFUNCTION(Client, Reliable)
void ClientUpdateAmmo(int32 ServerAmmo);
//由Fire调用,服务器,客户端均会执行
void SpendRound();
//预测序列号,当然说成是预测变更子弹数更好理解
int32 AmmoNetSequence = 0;
=======================================
.cpp

void AWeapon::SpendRound() {
//保证子弹数量大于0,小于最大子弹数MagCapacity
Ammo = FMath::Clamp(Ammo - 1, 0, MagCapacity);
//更新UI
SetHUDAmmo();
//是否在权威服务器上
if (HasAuthority()) {
ClientUpdateAmmo(Ammo);
} else {
++AmmoNetSequence;
}
}

void AWeapon::ClientUpdateAmmo_Implementation(int32 ServerAmmo) {
if (HasAuthority()) return;
Ammo = ServerAmmo;
--AmmoNetSequence;
Ammo -= AmmoNetSequence; // Ensure Ammo is always positive
SetHUDAmmo();
}

RPC timeline

时刻 A AmmoNetSequence UI 服务器 服务器回调 A服务器接收 A接收后
0ms 调用Fire(),并触发SpendRound() AmmoNetSequence++ 更新ui中子弹数为29(原有30) - - - -
50ms A本地A又开了两枪 AmmoNetSequence = 3 A本地ui子弹数为27 收到A 0ms调用Fire(),并触发SpendRound() 通知A客户端更新Ammo子弹数为29,调用ClientUpdateAmmo(29); - -
100ms A相比50ms又开了两枪 AmmoNetSequence为5 ui子弹数为25 - - 收到回调,设置Ammo为29 AmmoNetSequence– ,AmmoNetSequence为4
50ms 不合法 同50ms 同50ms 同50ms 收到A 0ms消息 ,判断Fire不合法 通知A不合法,ClientUpdateAmmo(30); - -
100ms不合法 同100ms 同100ms A本地ui子弹数为26(0ms 的fire 不合法) - - 收到回调,设置Ammo为30 AmmoNetSequence,AmmoNetSequence为5

服务器倒带

  • 主要作用

服务器记录一段时间内每个人的历史位置和Pose,收到A的射击事件时,服务器把A之外的所有玩家都挪回到一个恰当的历史位置去,使服务器上其他人的位置与A看到的其他人位置基本一致。

对表

我们马上想到,当今世界的网络时间是联网同步的,所以获取玩家的设备的本地时间就能对齐了吧!

想法很美好,但先不论时区问题,如果玩家自己关闭了全球同步,并手动调快时间了1年,这时候以此同步就会出现很奇怪的结果(不要觉得这种操作很奇怪,极限竞速地平线就可以通过这种手段, 调到限时活动时间段从而拿到限时活动的活动车)。

UE是自带计时器的,我们能拿到自世界生成后的时间 :

1
GetWorld()->GetTimeSeconds();

把权威服务器(房主)的时间同步给其他客户端(普通玩家)就可以了,不可能有其他客户端比房主早,并且在中途加入的玩家也要进行同步。

可以重写APlayerController::ReceivedPlayer

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
.h

virtual void ReceivedPlayer() override; // Sync with server clock time as soon as the player is received

//Sync time between client and server

// Request the current server time, passing in the client's time when the request was made
UFUNCTION(Server, Reliable)
void ServerRequestServerTime(float TimeOfClientRequest);

// Reports the current server time back to the client in response to the ServerRequestServerTime call
UFUNCTION(Client, Reliable)
void ClientReportServerTime(float TimeOfClientRequest, float TimeServerReceivedClientRequest);

//记录server的GetWorld()->GetTimeSeconds() 和 client的差
float ClientServerDelta = 0.f; // The difference between the client and server time, used to sync time

APlayerController::ReceivedPlayer 文档中解释:“Called after this PlayerController’s viewport/net connection is associated with this player controller”,可以用于同步。在ReceivedPlayer中调用ServerRequestServerTime,向权威服务器申请时间,因为网络有延迟,我们要带上申请时自己的时间过去,服务器收到后把客户端发送带的时间戳和自己的时间再转发回去:

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
cpp


void ABlasterPlayerController::ReceivedPlayer() {
Super::ReceivedPlayer();
// Sync with server clock time as soon as the player is received
//是宇宙A的A的controller
if (IsLocalController()) {
float ClientRequestTime = GetWorld()->GetTimeSeconds();
//带上自己的发送时间戳去问服务器
ServerRequestServerTime(ClientRequestTime);
}
}

void ABlasterPlayerController::ServerRequestServerTime_Implementation(float TimeOfClientRequest) {
float ServerTimeOfReceipt = GetWorld()->GetTimeSeconds();
//带上传过来的客户端的询问的时间和自己的时间返回给客户端
ClientReportServerTime(TimeOfClientRequest, ServerTimeOfReceipt);
}

void ABlasterPlayerController::ClientReportServerTime_Implementation(float TimeOfClientRequest, float TimeServerReceivedClientRequest) {
//来回跑一趟的时间
float RoundTripTime = GetWorld()->GetTimeSeconds() - TimeOfClientRequest;
SingleTripTime = RoundTripTime * 0.5f; // Half of the round trip time
//此时服务器上的时间
float CurrentServerTime = TimeServerReceivedClientRequest + SingleTripTime;
ClientServerDelta = CurrentServerTime - GetWorld()->GetTimeSeconds();
}

时间推进,传回到客户端上的TimeServerReceivedClientRequest已经不是当前服务器的时间了,所以我们要算一下单程的时间,添加到TimeServerReceivedClientRequest,即CurrentServerTime ,再用该时间减去客户端世界时间,即服务器和客户端的时间差ClientServerDelta。

这样时间就对齐了,但高枕无忧了吗? 如果有玩家的配置太烂,World的时钟的误差可能会越来越大,所以我们每隔一段时间就得同步一下时间:

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

void ABlasterPlayerController::Tick(float DeltaTime) {
Super::Tick(DeltaTime);

CheckTimeSync(DeltaTime);
}

void ABlasterPlayerController::CheckTimeSync(float DeltaTime) {
TimeSyncRunningTime += DeltaTime;
//每隔TimeSyncFrequency同步一下时间,这里更推荐用计时器
if (IsLocalController() && TimeSyncRunningTime >= TimeSyncFrequency) {
ServerRequestServerTime(GetWorld()->GetTimeSeconds());
TimeSyncRunningTime = 0.f;
}
}

//public,获取当前服务器时间
float ABlasterPlayerController::GetServerTime() {
if (HasAuthority()) {
return GetWorld()->GetTimeSeconds();
}
return GetWorld()->GetTimeSeconds() + ClientServerDelta;
}

HitBox

HixBox选择

存储每一个人碰撞箱的位置信息

对于碰撞箱的判定,若对于判定要求不高或者服务器的配置不高,可以只用一个胶囊体去代表历史位置

img

但是对于严格判定,并且服务器配置好的情况下,要将hitbox列入游戏平衡。我们就要在骨骼上绑定碰撞箱。

img

Hitbox结构体

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
.h

TMap<FName, class UBoxComponent*> HitCollisionBoxes;

UPROPERTY(EditAnywhere)
class UBoxComponent* head;

UPROPERTY(EditAnywhere)
UBoxComponent* pelvis;
//后续省略

.cpp

head = CreateDefaultSubobject<UBoxComponent>(TEXT("head"));
head->SetupAttachment(GetMesh(), FName("head"));
HitCollisionBoxes.Add(FName("head"), head);

pelvis = CreateDefaultSubobject<UBoxComponent>(TEXT("pelvis"));
pelvis->SetupAttachment(GetMesh(), FName("pelvis"));
HitCollisionBoxes.Add(FName("pelvis"), pelvis);

//省略其他的hitbox
//设置每个hitbox的碰撞属性
for (auto Box : HitCollisionBoxes)
{
if (Box.Value)
{
Box.Value->SetCollisionObjectType(ECC_HitBox);
Box.Value->SetCollisionResponseToAllChannels(ECollisionResponse::ECR_Ignore);
Box.Value->SetCollisionResponseToChannel(ECC_HitBox, ECollisionResponse::ECR_Block);
Box.Value->SetCollisionEnabled(ECollisionEnabled::NoCollision);
}
}

ULagCompensationComponent

ULagCompensationComponent组件来处理服务器倒带逻辑。

该组件开启tick,并在每一帧中存储该玩家所有hitbox的信息,信息用struct抽象。

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
.h

//每个box的信息
USTRUCT(BlueprintType)
struct FBoxInformation
{
GENERATED_BODY()
//box的location
UPROPERTY()
FVector Location;
//box的rotation
UPROPERTY()
FRotator Rotation;
//box的尺寸
UPROPERTY()
FVector BoxExtent;
//三者拼起来就能还原box的位置和大小
};

//每帧存储的信息
USTRUCT(BlueprintType)
struct FFramePackage
{
GENERATED_BODY()
//当前帧的时间戳
UPROPERTY()
float Time;
//FBoxInformation 拼起来的当前角色当前帧的所有hitbox信息
UPROPERTY()
TMap<FName, FBoxInformation> HitBoxInfo;
//当前帧属于哪个角色
UPROPERTY()
ABlasterCharacter* Character;
};

//双向链表存FFramePackage,便于开头去掉过期的帧,结尾加上最新帧
TDoubleLinkedList<FFramePackage> FrameHistory;
//最大倒带时间,超过就不回滚了并且删除记录
UPROPERTY(EditAnywhere)
float MaxRecordTime = 4.f;
//是否开启倒带逻辑

每次tick需要判定FrameHistory的最古老的一帧是否过期(超过MaxRecordTime),也就是服务器倒带限制的时间,不照顾那些超级高ping战士(csgo设置为200-300ms,瓦降到了140ms被红迪说不照顾非洲兄弟)

  • 这段代码实现了一个典型的客户端-服务器架构下的延迟补偿系统,主要作用包括:
  1. 帧数据记录:在服务器端定期保存角色及其碰撞框的状态快照
  2. 历史数据管理:维护一个有时间限制的帧历史记录(由MaxRecordTime控制)
  3. 数据清理:自动移除过期的历史帧数据以控制内存使用
  • 注意事项

    • 该组件仅在拥有权限(服务器端)运行,通过HasAuthority()检查保证

    • 使用双向链表(TDoubleLinkedList)存储帧历史,便于从两端快速操作

    • 碰撞框信息包括位置、旋转和尺寸,为后续的命中检测提供完整数据

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
/**
* @brief ULagCompensationComponent的构造函数
* 初始化组件并设置Tick功能
*/
ULagCompensationComponent::ULagCompensationComponent()
{
// 设置此组件每帧更新
PrimaryComponentTick.bCanEverTick = true;
}

/**
* @brief 每帧调用的函数
* @param DeltaTime 距离上一帧的间隔时间
* @param TickType ticking类型
* @param ThisTickFunction 触发此次Tick的函数信息
*/
void ULagCompensationComponent::TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) {
// 调用父类的TickComponent
Super::TickComponent(DeltaTime, TickType, ThisTickFunction);

// 如果父角色不存在或没有权限,则直接返回
if (FatherCharacter == nullptr || !FatherCharacter->HasAuthority()) return;

// 保存当前帧的数据包
SaveFramePackagePerTick();
}

/**
* @brief 生成并存储当前帧的FFramePackage
* 该方法负责管理帧历史记录,确保不超过最大记录时间
*/
void ULagCompensationComponent::SaveFramePackagePerTick() {
// 安全检查:确保父角色存在且拥有权限
if (FatherCharacter == nullptr || !FatherCharacter->HasAuthority()) return;

// 如果帧历史记录数量小于等于1,直接添加新帧
if (FrameHistory.Num() <= 1) {
FFramePackage ThisFrame;
SaveFramePackage(ThisFrame);
FrameHistory.AddHead(ThisFrame);
} else {
// 计算当前链表中记录的总时间长度
float HistoryLength = FrameHistory.GetHead()->GetValue().Time - FrameHistory.GetTail()->GetValue().Time;

// 删除超过最大记录时间的旧帧数据
while (HistoryLength > MaxRecordTime) {
FrameHistory.RemoveNode(FrameHistory.GetTail());
HistoryLength = FrameHistory.GetHead()->GetValue().Time - FrameHistory.GetTail()->GetValue().Time;
}

// 创建并保存当前帧数据
FFramePackage ThisFrame;
SaveFramePackage(ThisFrame);
FrameHistory.AddHead(ThisFrame);

// 调试用:显示帧包信息(已注释)
//ShowFramePackage(ThisFrame, FColor::Red);
}
}

/**
* @brief 保存帧数据包的具体实现
* @param Package 要填充数据的帧包引用
* 该方法收集角色所有碰撞框的当前位置、旋转和尺寸信息
*/
void ULagCompensationComponent::SaveFramePackage(FFramePackage& Package) {
// 确保FatherCharacter有效
FatherCharacter = FatherCharacter ? FatherCharacter : Cast<ABlasterCharacter>(GetOwner());

if (FatherCharacter)
{
// 记录当前时间戳
Package.Time = GetWorld()->GetTimeSeconds();
// 记录角色引用
Package.Character = FatherCharacter;

// 遍历角色所有的碰撞框
for (auto& BoxPair : FatherCharacter->HitCollisionBoxes)
{
FBoxInformation BoxInformation;
// 记录碰撞框的位置
BoxInformation.Location = BoxPair.Value->GetComponentLocation();
// 记录碰撞框的旋转
BoxInformation.Rotation = BoxPair.Value->GetComponentRotation();
// 记录碰撞框的尺寸
BoxInformation.BoxExtent = BoxPair.Value->GetScaledBoxExtent();

// 将碰撞框信息添加到帧包中
Package.HitBoxInfo.Add(BoxPair.Key, BoxInformation);
}
}
}

回滚判定

现在我们的A朝B开了一枪,触发了Fire():

解决多人游戏中网络延迟导致命中判定不公平的关键技术。当客户端射击时,它会将射击信息(包括命中目标、起点、终点和一个时间戳)发送给服务器。服务器根据这个时间戳,将游戏状态回退到客户端射击的那个瞬间,再进行命中判定,以确保结果的公平性

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
// HitTarget为本次射击的终点位置,通常由客户端计算(起点 + 射击方向 * 有效射程)
void AHitScanWeapon::Fire(const FVector& HitTarget) {
// 调用父类的Fire函数,执行基础的射击逻辑(如播放动画、消耗弹药等)
Super::Fire(HTarget);

// 获取持有此武器的Pawn(角色)
APawn* OwnerPawn = Cast<APawn>(GetOwner());
// 安全检查:如果持有者无效,则直接返回
if (OwnerPawn == nullptr) return;
// 获取控制该角色的控制器(用于后续的伤害应用和权限判断)
AController* InstigatorController = OwnerPawn->GetController();

// 从武器骨骼网格体上找到名为"MuzzleFlash"的插槽(通常是枪口位置)
const USkeletalMeshSocket* MuzzleFlashSocket = GetWeaponMesh()->GetSocketByName("MuzzleFlash");
if (MuzzleFlashSocket) {
// 获取该插槽在当前武器网格上的变换(位置和旋转)
FTransform SocketTransform = MuzzleFlashSocket->GetSocketTransform(GetWeaponMesh());
// 子弹的生成起点就是枪口插槽的世界坐标位置
FVector Start = SocketTransform.GetLocation();

// 声明一个命中结果结构体,用于存储射线检测的结果
FHitResult FireHit;
// 进行武器射线检测:从Start到HitTarget,检测命中的物体,结果存入FireHit
WeaponTraceHit(Start, HitTarget, FireHit);

// 尝试将命中的Actor转换为ABlasterCharacter(判断是否击中玩家角色)
ABlasterCharacter* HitCharacter = Cast<ABlasterCharacter>(FireHit.GetActor());
// 如果击中了角色并且有有效的控制器
if (HitCharacter && InstigatorController) {
// 判断是否应由服务器直接授权造成伤害:
// 条件是:1) 未开启服务器回退功能 或 2) 当前是本地控制的角色(避免自欺欺人)
bool bCauseAuthDamage = !bUseServerSideRewind || OwnerPawn->IsLocallyControlled();

// 权限检查:如果当前在服务器端且满足授权伤害条件
if (HasAuthority() && bCauseAuthDamage) {
// 服务器端直接应用伤害逻辑(例如调用ApplyDamage函数)
// 此处具体伤害应用代码被省略
}

// 客户端特定逻辑:如果当前在客户端,且武器开启了服务器回退功能
if (!HasAuthority() && bUseServerSideRewind) {
// 初始化或获取持有者角色(延迟初始化,避免重复转换)
BlasterOwnerCharacter = BlasterOwnerCharacter ? BlasterOwnerCharacter : Cast<ABlasterCharacter>(OwnerPawn);
// 初始化或获取持有者的玩家控制器
BlasterOwnerController = BlasterOwnerController ? BlasterOwnerController : Cast<ABlasterPlayerController>(InstigatorController);

// 安全检查:确保角色、控制器和延迟补偿组件都有效
if (BlasterOwnerCharacter && BlasterOwnerController && BlasterOwnerCharacter->GetLagCompensation()) {
// 获取延迟补偿组件
ULagCompensationComponent* LagCompensation = BlasterOwnerCharacter->GetLagCompensation();
// 向服务器发送得分请求,进行服务器端回退验证
LagCompensation->ServerScoreRequest(
HitCharacter, // 被击中的角色
Start, // 子弹的起始位置(枪口)
HitTarget, // 子弹的目标终点
// 计算射击发生时的服务器时间:当前服务器时间减去网络单程延迟
BlasterOwnerController->GetServerTime() - BlasterOwnerController->SingleTripTime,
this // 当前武器实例
);
}
}
}
}
}

这里有个细节:当前A客户端上的B因为延迟,其实是在单程延迟(50ms)时间前B在服务器上的位置,所以判定的时间要BlasterOwnerController->GetServerTime()- BlasterOwnerController->SingleTripTime。

视角转到服务器上的ULagCompensationComponent:

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
.h
//仅服务器运行且可靠
UFUNCTION(Server, Reliable)
void ServerScoreRequest(
ABlasterCharacter* HitCharacter,
const FVector_NetQuantize& TraceStart,
const FVector_NetQuantize& HitLocation,
float HitTime,
class AWeapon* DamageCauser
);

struct FServerSideRewindResult
{
GENERATED_BODY()
//确定命中
UPROPERTY()
bool bHitConfirmed;
//是爆头命中
UPROPERTY()
bool bHeadShot;
};

.cpp

void ULagCompensationComponent::ServerScoreRequest_Implementation(ABlasterCharacter* HitCharacter, const FVector_NetQuantize& TraceStart, const FVector_NetQuantize& HitLocation, float HitTime, AWeapon* DamageCauser) {
//检查是否命中,命中结果存储在Confirm 中
FServerSideRewindResult Confirm = ServerSideRewind(HitCharacter, TraceStart, HitLocation, HitTime);

if (FatherCharacter && HitCharacter && DamageCauser && Confirm.bHitConfirmed)
{
const float Damage = Confirm.bHeadShot ? DamageCauser->GetHeadShotDamage() : DamageCauser->GetDamage();

UGameplayStatics::ApplyDamage(
HitCharacter,
Damage,
FatherCharacter->Controller,
DamageCauser,
UDamageType::StaticClass()
);
}
}

FServerSideRewindResult ULagCompensationComponent::ServerSideRewind(ABlasterCharacter* HitCharacter, const FVector_NetQuantize& TraceStart, const FVector_NetQuantize& HitLocation, float HitTime) {
//找到对应的存储帧
FFramePackage FrametoCheck = GetFrameToCheck(HitCharacter, HitTime);
//返回命中验证
return ConfirmHit(FrametoCheck, HitCharacter, TraceStart, HitLocation);
}



GetFrameToCheck 和 ConfirmHit的实现细节:

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
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
/**
* @brief 根据命中时间和目标角色,从帧历史记录中确定需要进行碰撞检测的帧数据
* @param HitCharacter 被命中的角色
* @param HitTime 命中的时间点(通常经过延迟补偿调整)
* @return 用于命中检测的帧数据包
*/
FFramePackage ULagCompensationComponent::GetFrameToCheck(ABlasterCharacter* HitCharacter, float HitTime)
{
// 基础安全检查:确保命中目标和其帧历史数据有效
bool bReturn =
HitCharacter == nullptr ||
HitCharacter->GetLagCompensation() == nullptr ||
HitCharacter->GetLagCompensation()->FrameHistory.GetHead() == nullptr ||
HitCharacter->GetLagCompensation()->FrameHistory.GetTail() == nullptr;
if (bReturn) return FFramePackage(); // 如果任一条件不满足,返回空包

// 初始化返回的帧数据包和插值标志
FFramePackage FrameToCheck;
bool bShouldInterpolate = true;

// 获取目标角色的帧历史记录链表
const TDoubleLinkedList<FFramePackage>& History = HitCharacter->GetLagCompensation()->FrameHistory;
const float OldestHistoryTime = History.GetTail()->GetValue().Time; // 链表中最旧帧的时间戳
const float NewestHistoryTime = History.GetHead()->GetValue().Time; // 链表中最新帧的时间戳

// 情况1:命中时间早于记录的最旧时间(已过期),无法进行有效检测
if (OldestHistoryTime > HitTime)
{
return FFramePackage();
}
// 情况2:命中时间恰好等于最旧帧的时间
if (OldestHistoryTime == HitTime)
{
FrameToCheck = History.GetTail()->GetValue();
bShouldInterpolate = false; // 无需插值,直接使用精确帧
}
// 情况3:命中时间等于或晚于最新帧的时间(如客户端预测领先),使用最新帧
if (NewestHistoryTime <= HitTime)
{
FrameToCheck = History.GetHead()->GetValue();
bShouldInterpolate = false;
}

// 通过链表遍历,寻找命中时间所处的区间(OlderTime < HitTime < YoungerTime)
TDoubleLinkedList<FFramePackage>::TDoubleLinkedListNode* Younger = History.GetHead(); // 较年轻的帧(时间戳更大/更新)
TDoubleLinkedList<FFramePackage>::TDoubleLinkedListNode* Older = Younger; // 较年老的帧(时间戳更小/更旧)
while (Older->GetValue().Time > HitTime)
{
if (Older->GetNextNode() == nullptr) break; // 防止越界
Older = Older->GetNextNode(); // 移动Older指针到下一个更旧的帧
if (Older->GetValue().Time > HitTime)
{
Younger = Older; // 移动Younger指针,确保Younger始终是时间戳大于HitTime的最新帧
}
}
// 情况4:幸运地找到了时间戳与命中时间完全匹配的帧
if (Older->GetValue().Time == HitTime)
{
FrameToCheck = Older->GetValue();
bShouldInterpolate = false;
}
// 情况5:命中时间处于两个已记录帧之间,需要进行插值计算以获得近似帧数据
if (bShouldInterpolate)
{
FrameToCheck = InterpBetweenFrames(Older->GetValue(), Younger->GetValue(), HitTime);
}

FrameToCheck.Character = HitCharacter; // 记录角色引用
return FrameToCheck;
}

/**
* @brief 在服务器端验证客户端报告的命中是否有效(服务器端回退的核心确认逻辑)
* @param Package 通过GetFrameToCheck获取的历史帧数据包
* @param HitCharacter 被命中的角色
* @param TraceStart 射线检测的起点(通常为枪口位置)
* @param HitLocation 客户端报告的命中点
* @return 包含是否命中以及是否爆头的结果结构体
*/
FServerSideRewindResult ULagCompensationComponent::ConfirmHit(const FFramePackage& Package, ABlasterCharacter* HitCharacter, const FVector_NetQuantize& TraceStart, const FVector_NetQuantize& HitLocation) {
if (HitCharacter == nullptr) return FServerSideRewindResult(); // 安全检查

FFramePackage CurrentFrame;
// 步骤1:缓存角色当前所有碰撞盒的实时位置和状态
CacheBoxPositions(HitCharacter, CurrentFrame);
// 步骤2:将角色的所有碰撞盒移动(回退)到历史帧数据包中记录的位置
MoveBoxes(HitCharacter, Package);
// 步骤3:禁用角色网格体的碰撞,防止当前(未回退的)角色模型干扰射线检测
EnableCharacterMeshCollision(HitCharacter, ECollisionEnabled::NoCollision);

// 步骤4:首先仅启用头部的碰撞盒进行射线检测(优先检测爆头)
UBoxComponent* HeadBox = HitCharacter->HitCollisionBoxes[FName("head")];
HeadBox->SetCollisionEnabled(ECollisionEnabled::QueryAndPhysics);
HeadBox->SetCollisionResponseToChannel(ECC_HitBox, ECollisionResponse::ECR_Block);

FHitResult ConfirmHitResult;
// 计算射线终点:从起点延伸到超过客户端报告命中点一定距离(例如1.25倍),确保检测容错性
const FVector TraceEnd = TraceStart + (HitLocation - TraceStart) * 1.25f;
UWorld* World = GetWorld();
if (World) {
// 执行射线检测
World->LineTraceSingleByChannel(
ConfirmHitResult,
TraceStart,
TraceEnd,
ECC_HitBox // 使用自定义的命中盒碰撞通道
);
// 步骤5:分析检测结果
if (ConfirmHitResult.bBlockingHit) { // 射线被头部碰撞盒阻挡
// 命中头部,还原碰撞盒位置并重新启用角色网格体碰撞
ResetHitBoxes(HitCharacter, CurrentFrame);
EnableCharacterMeshCollision(HitCharacter, ECollisionEnabled::QueryAndPhysics);
return FServerSideRewindResult{ true, true }; // 返回结果:命中且爆头
} else {
// 步骤6:未命中头部,则启用角色所有其他部位的碰撞盒,再次进行检测
for (auto& HitBoxPair : HitCharacter->HitCollisionBoxes) {
if (HitBoxPair.Value != nullptr) {
HitBoxPair.Value->SetCollisionEnabled(ECollisionEnabled::QueryAndPhysics);
HitBoxPair.Value->SetCollisionResponseToChannel(ECC_HitBox, ECollisionResponse::ECR_Block);
}
}
World->LineTraceSingleByChannel(
ConfirmHitResult,
TraceStart,
TraceEnd,
ECC_HitBox
);
if (ConfirmHitResult.bBlockingHit) { // 射线被某个身体部位碰撞盒阻挡
// 命中身体,还原碰撞盒位置并重新启用角色网格体碰撞
ResetHitBoxes(HitCharacter, CurrentFrame);
EnableCharacterMeshCollision(HitCharacter, ECollisionEnabled::QueryAndPhysics);
return FServerSideRewindResult{ true, false }; // 返回结果:命中但非爆头
}
}
}
// 步骤7:未命中任何碰撞盒,同样需要还原状态
ResetHitBoxes(HitCharacter, CurrentFrame);
EnableCharacterMeshCollision(HitCharacter, ECollisionEnabled::QueryAndPhysics);
return FServerSideRewindResult{ false, false }; // 返回结果:未命中
}

CacheBoxPositions等细节函数较为简单

三、联机中的GameMode,GameState,PlayerState

UE多人联机入门笔记(三) 联机中的GameMode,GameState,PlayerState - 知乎

GameMode

GameMode身为一场游戏的唯一逻辑操纵者身兼重任,在功能实现上有许多的接口,但主要可以分为以下几大块……省略 4.多人游戏的步调同步,在多人游戏的时候,我们常常需要等所有加入的玩家连上之后,载入地图完毕后才能一起开始逻辑 -《InsideUE4》GamePlay架构(七)GameMode和GameState

本工程是多人射击游戏,采用AGameMode,这里需要特地强调:Game Mode 不会复制到加入多人游戏的远程客户端;它只存在于服务器上,因此本地客户端可看到之前使用过的留存 Game Mode 类(或蓝图);但无法访问实际的实例并检查其变量,确定游戏进程中已发生哪些变化。这也是我们为什么有Game State, Game State的数据会被复制到所有远程客户端。

GameState

Game State 负责启用客户端监控游戏状态。从概念上而言,Game State 应该管理所有已连接客户端已知的信息(特定于 Game Mode 但不特定于任何个体玩家)。它能够追踪游戏层面的属性,如已连接玩家的列表、夺旗游戏中的团队得分、开放世界游戏中已完成的任务,等等。

虚幻引擎中的 Game Mode 和 Game State | 虚幻引擎 5.5 文档 | Epic Developer Community

Game State 并非追踪玩家特有内容(如夺旗比赛中特定玩家为团队获得的分数)的最佳之处,因为它们由 Player State 更清晰地处理。

可以看出工程里的GameState只承担了计分排行榜的职责,实际上我们可以将更新每个玩家显示倒计时的职责也给它,统一为countDowntime,Gamemode 只需要在 OnMatchStateSet() 里更新GameState的countDowntime 并让 GameState去处理更新每个controller。

所以这里的逻辑设置时灵活的,引用大钊老师的话:

关于使用,开发者可以自定义GameState子类来存储本GameMode的运行过程中产生的数据(那些想要replicated的!),如果是GameMode游戏运行的一些数据,又不想要所有的客户端都可以看到,则也可以写在GameMode的成员变量中。

PlayerState

游戏里一个全局的玩家逻辑实体,而PlayerController代表的就是玩家的意志,PlayerState代表的是玩家的状态。 APlayerState用来保存玩家的游戏数据,GameState把当前Server的PlayerState都收集了过来,方便访问使用

如果套用MVC模型的话,那么这里PlayerState应该代表的M,玩家手中的金币,子弹数量等数据。而GameState的 PlayerArray 存的就是所有Player的 PlayerState。

PlayerState使用可以参考 UE4游戏框架中PlayerState基本使用

参考

UE多人联机入门笔记(一) - 知乎

Unreal Engine 网络同步:属性同步、RPC、断线重连与高级技巧 - 知乎

硬核向:延迟补偿具体实现算法_哔哩哔哩_bilibili