前言

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放回人物

第三篇GameMode,GameState,PlayerState

GameMode 全局服务器 管理游戏规则和流程

GameState 全局服务器+客户端复制 全局游戏状态

PlayerState 单一玩家服务器+客户端复制 单个玩家状态 死亡销毁

一、同步

帧同步和状态同步

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

需要说明的是,由于状态同步的安全性比帧同步高很多,且对网络延迟有较大容忍,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结构体 HitCollisionBoxes

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基本使用

四.其他工程类问题

  1. 哪些属性需要条件复制
属性类型判断 应采用的复制方式 使用条件(如适用)
1. 所有玩家都需要知道的全局或核心状态
(如:位置、血量、得分、旗帜归属)
无条件复制
(DOREPLIFETIME)
COND_None (默认)
2. 只有控制该角色的玩家自己需要知道的数据
(如:拾取提示、私有UI数据)
条件复制
(DOREPLIFETIME_CONDITION)
COND_OwnerOnly
3. 其他玩家需要知道,但所有者自己已有更好数据源的状态
(如:由客户端预测的角色位置、速度)
条件复制
(DOREPLIFETIME_CONDITION)
COND_SkipOwner
4. 仅供其他玩家观看的视觉表现数据
(如:面部动画、次级骨骼物理)
条件复制
(DOREPLIFETIME_CONDITION)
COND_SimulatedOnlyCOND_SkipOwner
5. 仅在生成时确定,永不改变的数据
(如:初始外观配置)
条件复制
(DOREPLIFETIME_CONDITION)
COND_InitialOnly

网络属性复制指南

1. 核心概念

在 Unreal Engine 的状态同步方案中,属性复制 是将服务器的权威状态(如血量、位置、弹药数)自动、高效地同步到所有相关客户端的关键机制。其核心原则是 “按需同步”,旨在平衡状态一致性与网络性能。

2. 需要网络复制的属性

通常,满足以下一个或多个条件的属性应考虑进行网络复制:

条件 说明 示例
核心游戏状态 直接影响游戏逻辑、胜负判定或所有玩家必须感知一致的状态。 角色生命值 (Health)、游戏状态 (bDisableGameplay)、得分、目标归属权。
视觉表现基础 构成其他玩家角色或世界对象视觉表现基础的数据。 角色的位置旋转缩放动画状态
需客户端响应的状态变化 属性变化时,客户端需要执行额外的本地逻辑(如更新UI、播放音效)。 使用 ReplicatedUsing 的场景:Health变化时更新血条UI,Ammo变化时更新弹药显示。
需共享的非瞬时数据 与瞬时的“事件”(适合RPC)不同,这是持续存在的、需要保持一致性的“状态”。 弹药数量 (CurrentAmmo)、技能冷却时间、载具油量、可交互物体的进度。

3. 不需要或应谨慎复制的属性

以下类型的属性通常不应需谨慎考虑进行网络复制:

条件 说明 优化建议
可本地计算或推导的数据 客户端可以根据已复制的权威数据自行计算出的值。 例如,客户端根据速度和上一帧位置预测的移动轨迹。避免同步以减少带宽。
纯本地、无关紧要的临时数据 只对单个客户端有意义,且不影响其他玩家体验或游戏逻辑的数据。 例如,本地粒子特效的播放进度、与游戏逻辑无关的UI动画状态。
庞大且变化频繁的数据 同步开销极大,可能导致网络拥堵。 例如,复杂变形网格体的所有顶点数据。应寻求只同步关键参数,在客户端重建。
所有者私有的输入/意图数据 仅与控制该角色的玩家相关,其他玩家无需感知。 使用条件复制(如COND_OwnerOnly)处理。若完全无需同步,则不在服务器存储。

4. 决策流程

您可以为任意属性参照以下流程图进行决策:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
graph TD
A[评估一个属性] --> B{它是否影响所有玩家的<br>核心游戏状态或视觉一致性}
B -->|是| C[需要网络复制]
C --> D{属性变化时 客户端需要执行<br>额外逻辑 如UI 声音 }
D -->|是| E[使用 ReplicatedUsing]
D -->|否| F[使用 Replicated]
B -->|否| G{它是否只对<br>特定客户端有意义}
G -->|是 仅所有者需要| H[条件复制 COND_OwnerOnly]
G -->|是 除所有者外| I[条件复制 COND_SkipOwner]
G -->|否 纯本地数据| J[不需要网络复制]

subgraph 最终操作
E
F
H
I
J
end

5. 关键函数与宏总结

实现属性复制需使用以下核心函数与宏:

函数/宏 用途 位置
GetLifetimeReplicatedProps 核心注册函数,重写以声明需要复制的属性列表。 Actor 类的 .cpp文件中。
DOREPLIFETIME 宏,用于无条件复制属性到所有客户端。 GetLifetimeReplicatedProps函数内调用。
DOREPLIFETIME_CONDITION 宏,用于有条件复制属性,是网络优化的关键。 GetLifetimeReplicatedProps函数内调用。
OnRep_函数 ReplicatedUsing配对使用的回调函数,在属性同步到客户端后触发本地逻辑。 在 Actor 类中声明和实现,需标记 UFUNCTION()
SetIsReplicated(true) 在组件构造函数中调用,启用自定义 Actor 组件的网络复制功能。 自定义 UActorComponent派生类的构造函数中。

总结:合理运用属性复制是构建高效、响应迅速的多人游戏体验的基础。应优先保证核心状态的一致性,再利用 ReplicatedUsing和条件复制进行优化,最终剔除所有不必要的网络传输。

五 Lag Compensation

延迟补偿 参考 UE多人联机射击游戏中的延迟补偿技术的代码实现 - 知乎 本章为上面第二章的复习和重新梳理

服务器调和的步骤总结如下:

  1. 客户端进行同步(移动)操作
  2. 客户端发送RPC并保存该RPC的信息(RPC的id 和 位置等信息)
  3. 接收到服务器回包
  4. 根据回包进行矫正
  5. 从先前存储的数据中,丢弃旧的已被服务器处理/收到服务器回包 的数据包
  6. 重新应用未被服务器处理的所有数据(通常是增量数据)

也就是 客户端预测,服务端修正。

对于简单例子来说:

对这些情景中联机同步的功能做C/S的权责划分。

  • 武器在地上,玩家可以拾取时,武器附近有碰撞体,进入该碰撞体弹出UI提示玩家拾取:在客户端和服务器都本地检测碰撞即可
  • 玩家实际拾取的操作:客户端调用RPC,以免多个玩家同时拾取同一把武器造成问题。
  • 连续开火时武器子弹的散射效果的同步:开枪的本地客户端将计算好的、经散射处理后的最终HitTarget,通过RPC发送给服务器,服务器再多播到各个其他客户端。

服务器预测

弹药预测

对于属性同步来说:

  1. 在开火时先判断是否是权威端,是的话再消耗弹药。这种情况下,客户端开火后,弹药数量的更新有明显延迟。
  2. 取消权威端判断,所有机器都消耗弹药。此时客户端本地先对数值做了更改,之后由于服务器端修改后会对属性进行同步,因此连发射击时,可以在UI的弹药量上观察到数字的跳变(比如弹药量为11,连开2枪到9,服务器的属性同步回包稍后到达,就会从9跳到10再跳回9)

由此可见,属性同步由于其特性,只用1个变量进行属性同步的话,很难实现延迟补偿。这个例子中我们将使用RPC进行实现。

在RPC中能改变弹药量的有:

  1. 开火 SpendRound() (注:前文有提到过)
  2. 换弹 AddAmmo()

对于弹药量来说,不需要具体的id,只需要开了多少枪这个计数,所以只需1个int32就可以实现,在代码中就是Sequence变量。

(注:RPC:处理瞬时事件。动画、音效这类有持续时间、但结束后不需持续保存的状态,用RPC触发非常合适。它确保了事件在发生时刻对所有相关方是同步的。

属性回调:状态持久。即使有玩家在宝箱打开后才进入游戏或走近宝箱,由于 bIsOpened属性是同步的,该玩家一看到宝箱,客户端就会根据同步来的 true值,通过 OnRep回调直接将其显示为“已打开”状态,无需额外逻辑。

)

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
// 记录未被处理的服务器弹药请求数量
int32 Sequence = 0;

// 开火消耗弹药
void AWeapon::SpendRound()
{
Ammo = FMath::Clamp(Ammo - 1, 0, MagCapacity);
SetHUDAmmo();
if (HasAuthority())
{
ClientUpdateAmmo(Ammo);
}
else
{
++Sequence;
}
}

// UFUNCTION(Client, Reliable)
void AWeapon::ClientUpdateAmmo_Implementation(int32 ServerAmmo)
{
if (HasAuthority()) return;
Ammo = ServerAmmo;
--Sequence;
Ammo -= Sequence;
SetHUDAmmo();
}

// 装弹
void AWeapon::AddAmmo(int32 AmmoToAdd)
{
Ammo = FMath::Clamp(Ammo + AmmoToAdd, 0, MagCapacity);
SetHUDAmmo();
ClientAddAmmo(AmmoToAdd);
}

// UFUNCTION(Client, Reliable)
void AWeapon::ClientAddAmmo_Implementation(int32 AmmoToAdd)
{
if (HasAuthority()) return;
Ammo = FMath::Clamp(Ammo + AmmoToAdd, 0, MagCapacity);
BlasterOwnerCharacter = BlasterOwnerCharacter == nullptr ? Cast<ABlasterCharacter>(GetOwner()) : BlasterOwnerCharacter;
if (BlasterOwnerCharacter && BlasterOwnerCharacter->GetCombat() && IsFull())
{
BlasterOwnerCharacter->GetCombat()->JumpToShotgunEnd();
}
SetHUDAmmo();
}

瞄准

原本的瞄准仅通过一个Replicated的布尔值来完成,客户端快速按下鼠标右键瞄准后又快速松开,此时客户端表现上应该只进行1次缩放,但实际进行了2次缩放,因为存在延迟,鼠标松开后过一段时间分别收到服务器的2个回包。

为了解决该问题,可以客户端本地记录一次瞄准状态,并在属性同步的回调中使用客户端本地的值。

省流脏标记。

// 首先,bAiming是服务端同步的值,正常来说,客户端这个值和服务端是一致的
// 但是如果存在网络延迟,那么客户端的bAiming值可能会比服务端的bAiming值要慢一步
// 那么这里我们采取的策略是,这个瞄准状态我们在客户端向服务器同步的时候,我们先记录下客户端的瞄准状态
// 然后,我们在客户端用的就是这个记录的状态,这样在收到服务端的同步值的时候,我们就不用再去改变客户端的瞄准状态了
// 从而避免了因为网络延迟导致的瞄准状态的不同步,而导致一次点击,瞄准状态来回切换的情况

换弹数据与动画

原本的客户端换弹动画是在CombatState枚举值更新后在OnRep中进行播放的,最终是调用HandleReload播放角色的换弹动画蒙太奇。如果直接在ServerReload(RPC调用)前直接在客户端本地调用HandleReload,客户端就会出现动画重复播放的情况。

知识点回顾:OnRep函数在C++中,对于服务器来说需要手动调用。对于蓝图中,对于服务器发生属性同步时会自动调用OnRep。

延迟补偿组件

维护角色位置的历史记录队列

帧历史记录(Frame History),帧历史记录需要包含特定的信息,比如角色位置和该位置对应的时间戳。

常见维护的HitBox 为头部+其他身体

还可以维护胶囊体和半高的数值

帧历史存储的每帧数据结构

  • FBoxInformation:

位置 大小 旋转

  • FFramePackage:

用于存储每一帧的数据(存储命中框信息的数据结构) HitBoxInfo 是命中盒体的名称

  • HitCollisionBoxes:

角色类中需要有一个类似的TMap<FName, UBoxComponent*> HitCollisionBoxes 类比FFramePackage

  • SaveFramePackage

延迟补偿组件中,保存帧包数据函数 ULagCompensationComponent::SaveFramePackage

(注:前文有代码)

缓存帧历史数据包的容器

  • FrameHistory

为了Cache历史帧数据,准备了一个双向链表容器 –> TDoubleLinkedList

该容器有一定时间(假设400ms)的历史数据帧,可以类比为ECS的E的处理列表

有:

1
2
3
4
5
6
float MaxRecordTime = 4.f;   // 为了方便Debug和当前效果展示,我们先设置记录历史时间长度为4s

void ULagCompensationComponent::TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction)

void ULagCompensationComponent::SaveFramePackage()

img

回溯算法

为了在服务器端进行回溯,需要开火位置、击中位置、受击角色、受击时间

其中由于客户端和服务器有时间同步机制,HitTime很容易得到服务器的命中时刻时间。

  • FServerSideRewindResult

存储受击信息,是否命中盒是否爆头

这边是用HitCharacter 受击角色拿数据

1
2
3
4
FServerSideRewindResult ULagCompensationComponent::ServerSideRewind(ABlasterCharacter* HitCharacter, const FVector_NetQuantize& TraceStart, const FVector_NetQuantize& HitLocation, float HitTime)

FFramePackage ULagCompensationComponent::GetFrameToCheck(ABlasterCharacter* HitCharacter, float HitTime)

这边使用了 InterpBetweenFrames,ConfirmHit

获取到2个相邻所需的帧后,对数据通过插值计算,以得到命中时刻的数据

ConfirmHit:

  1. 缓存当前碰撞盒位置
  2. 将碰撞盒Trans设置为到历史帧数据的Trans
  3. 关闭网格体的碰撞
  4. 启用头部碰撞盒,并设置碰撞通道进行检测。
  5. 若头部未命中,则关闭头部碰撞,并启用其他碰撞盒+设置碰撞通道。完成后再对身体部位进行检测。
  6. 无论是否命中,在函数返回前都要重置碰撞盒Trans为当前帧的Trans,并恢复碰撞盒及网格碰撞体为最初的状态

调用回溯算法

首先,客户端命中后,肯定要将数据发送给服务器,这一步的RPC实现如下,通过角色装备的武器以及是否是爆头应用对应武器的攻击特定身体部位的伤害值。

1
2
void ULagCompensationComponent::ServerScoreRequest_Implementation(ABlasterCharacter* HitCharacter, const FVector_NetQuantize& TraceStart, const FVector_NetQuantize& HitLocation, float HitTime)

Fire函数是主控客户端开火后,在LocalFire中调过来的,ServerFire主要是权威性判断和多播

在Fire计算 DTime

回溯前后的游戏表现分析

防作弊

在下图中,我们在服务器上对弹药量和伤害值都做了校验,但是对于开火延迟这一点,就可能存在安全漏洞。假设原本射击间隔为0.1s,那么持续射击时就会每秒调用10次RPC告知服务器执行武器射击,此时没有检查客户端是否过于频繁地发出RPC调用,因为RPC并不能保证在特定时间内到达服务器,数据包可能延迟或丢失,即使是可靠RPC,其在数据包丢失时会发送确认信息,若客户端未收到服务器的确认响应,客户端会重新发送该RPC,因此当服务器接收到这个RPC时,无法确定接收到RPC的顺序是正确的,因为可能刚好中途某个包丢失了,但是丢失的这个数据包前后的数据包都被正常接收了,这是一个很有挑战性的反作弊场景。

img

在UE引擎中,可以在UFUNCTION中加入WithValidation进行数据的校验,如下所示,该宏标记会自动绑定原函数名+_Validate后缀的函数,该函数与原函数的参数类型数量一致,当收到该RPC调用时,会先调用_Validate函数进行校验,校验通过才执行_Implementation,否则就断开与数据异常的客户端的连接。

1
2
3
4
5
6
7
8
9
10
11
UFUNCTION(Server, Reliable, WithValidation)
ServerFire(HitTarget, Damage)

bool ServerFire_Validate(HitTarget, Damage)
{
if (Damage >= TooMuchDamage)
{
return false;
}
return true;
}

更好的做法是直接不从客户端发送伤害数据给服务器,服务器直接校验武器装备应当造成的伤害值,并在服务器端使用正确的数值进行伤害计算。

因此,对于依赖于客户端发送数据的那些游戏机制,且这些数据被篡改会影响游戏玩法,那么上述这种验证函数的方式就会非常有用。如果客户端压根不发送这部分数据,则不需要验证函数。

Furthermore

这篇文章到此主要内容就讲完了。上面的回溯算法可以称得上是一个比较严肃的实现,但离产品级还是有不少距离的,由此可以想象多人联机游戏有多难做,游戏开发的任何东西只要沾上了联机和网络都会变得极端复杂。

其他进阶挑战比如:

  • 装弹的各个阶段状态实现客户端预测
  • 对火箭筒和手雷进行回溯判定
  • 由于延迟的存在,即使在服务器上回溯判定命中通过了,在其他客户端上你的弹道轨迹也可能视觉上看没有击中对应的角色,此时可以考虑调整该弹道轨迹的位置,以指向其命中的目标,实现更精确的视觉命中效果。

其他插件

  • 网络预测插件 Network Prediction Plugin

网络预测插件(Network Prediction Plugin, NPP)插件:Epic官方的一个插件方案,原作者离职了,但是未来很可能被UE各个系统所使用。其拆分了用于网络同步的固定帧数SimulationTick与游戏线程可变帧数Tick,以便解决客户端与服务器帧数不一样的问题,这个问题在社招面试时经常会被问到。除此之外,下一代移动插件Mover 2.0也在基于NPP开发,所以虽然目前相关插件还很不完善,但还是值得学习一下的。

  • ReplicationGraph与Iris

ReplicationGraph是之前堡垒之夜为了解决UE原生网络同步消耗过大所提供的方案,已经被验证过可以很好地使用。Iris则是UE为下一代网络同步框架所取的名字,其整合了目前很多的官方推荐优化手段,比如Push Model、相关性休眠性等。根据Epic官方(Unreal Fest Orlando 2025)的说法,其实去年就已经在堡垒之夜上实装Iris了,但是之前对此没有公布,Iris跟Mover 2.0一样目前的资料还是很少,对于不在UE游戏工作室全职工作的同学来说,目前(2025.10.19)不建议在Iris和Mover 2.0上花费过多的时间学习。

  • GAS预测与回滚

[UFSH2025]《漫威争锋》基于GAS的多人战斗框架开发分享:干货很多,介绍了如何对GAS新增中间层抽象以便减少一些GA、GC、GE的数量,以及怎么做预测和回滚

  • CMC组件

UE的角色移动组件里对网络相关的处理非常复杂,是个很好的UE网络同步的学习案例,当然本身也很复杂,技术细节非常多,社招也经常会问这方面的内容。

  • 瓦罗兰特/无畏契约专场

Peeking into VALORANT’s Netcode:瓦罗兰特官方介绍游戏如何为竞技网络游戏营造更公平的环境,包括用数学语言描述了拐角优势(peeker advantage)问题(哦伟大的数学建模),同时也介绍了fixed simulation update的优势

VALORANT’s Performance Requirements | Inside Unreal:瓦的服务器性能优化细节,视频生动展示了各种技术的实现效果。

VALORANT’s foundation is Unreal Engine:视频的文字版。

六 断线重连 Reconnection

断线重连在多人在线游戏中非常重要,尤其是在长时间游戏过程中客户端可能会掉线。

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

1. 断线重连的基础原理

在 UE 中,属性同步的基本机制是:服务器管理数据状态的唯一性,客户端负责显示数据的状态。当一个客户端断线重连时,服务器会自动将该客户端的必要状态数据(如位置、生命值、装备等)重新同步给客户端,这使得客户端可以迅速恢复到断线前的状态。

断线重连的主要步骤

  1. 服务器持续同步玩家状态:服务器上的 Actor(如 PlayerStatePawn)通过属性同步机制,将玩家状态数据不断同步给所有相关客户端。
  2. 客户端断线检测:当客户端断开连接时,服务器端 APlayerController 会检测到 OnLogout 事件。
  3. 客户端重连后状态恢复:当客户端重新连接时,服务器将玩家对应的 PlayerController 和其他相关 Actor 状态重新同步给客户端。玩家会恢复到上次连接时的状态。

2. 使用 PlayerStatePawn 属性同步玩家状态

为了实现有效的断线重连,我们可以将玩家的状态数据保存在 PlayerStatePawn 这两个 Actor 中,因为它们都可以通过属性同步机制来保证服务器与客户端的数据一致。

  • PlayerState:通常用于保存不易频繁改变的数据,如玩家的名字、分数、等级、经验等。
  • Pawn:用于保存玩家的当前状态,如位置、速度、生命值、装备、技能冷却等。

在 C++ 或蓝图中,可以将这些属性设置为 Replicated(已复制),这样它们会自动在服务器和客户端之间同步。

3. 属性同步的细节处理

在断线重连过程中,有几点关键的细节需要注意:

  • 自动恢复流程:当客户端重新连接到服务器时,所有标记为 Replicated 的属性都会自动从服务器同步到客户端。你无需在客户端上编写复杂的逻辑来恢复玩家状态,因为 UE 的网络同步机制会帮你完成大部分工作。
  • 属性同步的条件设置:对于一些不需要所有客户端都知道的状态,可以设置条件同步。例如,设置某个属性只同步给 Actor 的所有者(COND_OwnerOnly),减少网络带宽的消耗。
  • 使用 RepNotify 处理同步变化:有时,我们需要在属性同步后执行一些特定的逻辑(如更新 UI)。这时可以使用 RepNotify 属性。当属性变化时,会自动调用对应的 OnRep_属性名 函数。

4. 优化与常见问题

虽然属性同步简化了开发工作,但在实际项目中仍需注意一些常见问题和优化策略:

  • 优化属性同步频率:对于不常改变的数据(如装备信息),可以降低同步频率(Net Update Frequency)来减少不必要的带宽使用。
  • 使用增量同步(Delta Replication):如果同步的数据量很大,但变化不频繁,可以使用 UE 的增量同步功能,只有变化的数据会被发送。
  • 避免重复同步:在某些情况下,断线重连时可能会出现属性重复同步的现象。确保每个属性的初始化和同步流程都是有序且唯一的。
  • 使用网络调试工具:在进行断线重连和属性同步的开发和测试时,可以使用 UE 提供的网络调试工具(如 Net ProfilerNet Pkt Lag 等)来分析网络数据包,优化同步策略。

模拟网络延迟和丢包

进入游戏界面,按下键盘的“~”键,打开控制台界面,输入net会自动列出跟网络相关的命令

img

1、Net pktLag = ,模拟延迟,单位是毫秒
2、Net PktLagVariance = 300,在模拟延迟的基础上,再上下浮动300毫秒。加上这个就会出现移动瞬移卡顿的效果
3、Net PKtLoss =,丢包,单位是百分比,Net PKtLoss=90就是90%会丢包,也会出现移动瞬移卡顿
4、Net PktOrder = 1,乱序发包,会出现一定的移动瞬移,但不太明显
5、Net PktDup = ,重复发包,单位是百分比,Net PktDup=20表示20%会出现重复发包。

5. 断线重连的示例流程

以下是一个简化的流程图,展示了如何在一个多人游戏中通过属性同步来实现断线重连。

1.玩家连接服务器

    • PlayerControllerPlayerState 在服务器上创建,并初始化。
    • 各种状态(如位置、生命值)通过 Replicated 属性自动同步到客户端。

2.玩家断线

    • 服务器检测到 OnLogout 事件,将玩家数据保存在 PlayerState 中。

3.玩家重连

    • 服务器检测到 OnLogin 事件,重新分配 PlayerController 给玩家。
    • 服务器自动同步 PlayerState 中的保存数据到新客户端。

4.玩家继续游戏

    • 玩家状态(位置、生命值等)已从服务器恢复,玩家可以无缝继续游戏。

六、网络同步优化策略

  • 调整同步频率:通过降低不重要 Actor 的同步频率来减少带宽占用。
  • 基于细节层次的复制:根据玩家视角和距离,动态调整 Actor 的同步精度。

参考

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

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

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

UE网络与同步的个人笔记 | 漫宿

UE多人联机射击游戏中的延迟补偿技术的代码实现 - 知乎

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