Mapz's Blog

可以递归的函数指针

UE4:虚幻竞技场的延迟补偿机制

UE4引擎采用的是状态同步的网络同步方式,虚幻竞技场为了优化客户端体验,减少不一致的情况,使用了延迟补偿机制

在 PlayerController 中,定义了一些补偿相关的参数

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
class UNREALTOURNAMENT_API AUTPlayerController : public AUTBasePlayerController
{
//..... 略
//-----------------------------------------------
// Perceived latency reduction
/** Used to correct prediction error. */
UPROPERTY(EditAnywhere, Replicated, GlobalConfig, Category=Network)
float PredictionFudgeFactor;

/** Negotiated max amount of ping to predict ahead for. */
UPROPERTY(BlueprintReadOnly, Category=Network, Replicated)
float MaxPredictionPing;

/** user configurable desired prediction ping (will be negotiated with server. */
UPROPERTY(BlueprintReadOnly, GlobalConfig, Category=Network)
float DesiredPredictionPing;

UPROPERTY(GlobalConfig, EditAnywhere, Category = Debug)
bool bIsDebuggingProjectiles;

/** Propose a desired ping to server */
UFUNCTION(reliable, server, WithValidation)
virtual void ServerNegotiatePredictionPing(float NewPredictionPing);

/** Console command to change prediction ping */
UFUNCTION(Exec)
virtual void Predict(float NewDesiredPredictionPing);

/** Return amount of time to tick or simulate to make up for network lag */
virtual float GetPredictionTime();

/** How long fake projectile should sleep before starting to simulate (because client ping is greater than MaxPredictionPing). */
virtual float GetProjectileSleepTime();

/** List of fake projectiles currently out there for this client */
UPROPERTY()
TArray<class AUTProjectile*> FakeProjectiles;

//..... 略
}

在客户端的 PlayerController 会通过 ServerNegotiatePredictionPing RPC 在在服务器上设置 MaxPredictionPing

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void AUTPlayerController::BeginPlay()
{
bSpectatorMouseChangesView = false;
Super::BeginPlay();
if (Role < ROLE_Authority)
{
ServerNegotiatePredictionPing(DesiredPredictionPing);
}
}

void AUTPlayerController::ServerNegotiatePredictionPing_Implementation(float NewPredictionPing)
{
MaxPredictionPing = FMath::Clamp(NewPredictionPing, 0.f, UUTGameEngine::StaticClass()->GetDefaultObject<UUTGameEngine>()->ServerMaxPredictionPing);
}

通过 GetPredictionTime 函数,可以取得需要补偿的时间

1
2
3
4
5
6
float AUTPlayerController::GetPredictionTime()
{
// exact ping is in msec, divide by 1000 to get time in seconds
//if (Role == ROLE_Authority) { UE_LOG(UT, Warning, TEXT("Server ExactPing %f"), PlayerState->ExactPing); }
return (PlayerState && (GetNetMode() != NM_Standalone)) ? (0.0005f*FMath::Clamp(PlayerState->ExactPing - PredictionFudgeFactor, 0.f, MaxPredictionPing)) : 0.f;
}

再查看 Character 的代码

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
class UNREALTOURNAMENT_API AUTCharacter : public ACharacter, public IUTTeamInterface
{
//...略
/** Stored past positions of this player. Used for bot aim error model, and for server side hit resolution. */
UPROPERTY()
TArray<FSavedPosition> SavedPositions;

/** Maximum interval to hold saved positions for. */
UPROPERTY()
float MaxSavedPositionAge;

/** Mark the last saved position as where a shot was spawned so can synch firing to client position. */
virtual void NotifyPendingServerFire();

/** Called by CharacterMovement after movement */
virtual void PositionUpdated(bool bShotSpawned);

/** Returns this character's position PredictionTime seconds ago. */
UFUNCTION(BlueprintCallable, Category = Pawn)
virtual FVector GetRewindLocation(float PredictionTime, AUTPlayerController* DebugViewer=nullptr);

/** Max time server will look back to found client synchronized shot position. */
UPROPERTY(EditAnyWhere, Category = "Weapon")
float MaxShotSynchDelay;

UPROPERTY(BlueprintReadOnly, Category = Pawn)
class AUTGameVolume* LastGameVolume;

/** Returns most recent position with bShotSpawned. */
virtual FVector GetDelayedShotPosition();
virtual FRotator GetDelayedShotRotation();

/** Return true if there's a recent delayed shot */
virtual bool DelayedShotFound();

/** returns a simplified set of SavedPositions containing only the latest position for a given frame (i.e. each element has a unique Time)
* @param bStopAtTeleport - removes any positions prior to and including the most recent teleportation
*/
void GetSimplifiedSavedPositions(TArray<FSavedPosition>& OutPositions, bool bStopAtTeleport) const;
//...略
}

可以看到角色上使用 TArray 存储了一组 FSavedPosition 信息

包括了位置,旋转,速度,是否发出了射击,时间戳等

定义如下

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
USTRUCT(BlueprintType)
struct FSavedPosition
{
GENERATED_USTRUCT_BODY()

FSavedPosition() : Position(FVector(0.f)), Rotation(FRotator(0.f)), Velocity(FVector(0.f)), bTeleported(false), bShotSpawned(false), Time(0.f), TimeStamp(0.f) {};

FSavedPosition(FVector InPos, FRotator InRot, FVector InVel, bool InTeleported, bool InShotSpawned, float InTime, float InTimeStamp) : Position(InPos), Rotation(InRot), Velocity(InVel), bTeleported(InTeleported), bShotSpawned(InShotSpawned), Time(InTime), TimeStamp(InTimeStamp) {};

/** Position of player at time Time. */
UPROPERTY()
FVector Position;

/** Rotation of player at time Time. */
UPROPERTY()
FRotator Rotation;

/** Keep velocity also for bots to use in realistic reaction time based aiming error model. */
UPROPERTY()
FVector Velocity;

/** true if teleport occurred getting to current position (so don't interpolate) */
UPROPERTY()
bool bTeleported;

/** true if shot was spawned at this position */
UPROPERTY()
bool bShotSpawned;

/** Current server world time when this position was updated. */
float Time;

/** Client timestamp associated with this position. */
float TimeStamp;
};

更新位置信息 TArray 的地方是

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void AUTCharacter::PositionUpdated(bool bShotSpawned)
{
const float WorldTime = GetWorld()->GetTimeSeconds();
if (GetCharacterMovement())
{
new(SavedPositions)FSavedPosition(GetActorLocation(), GetViewRotation(), GetCharacterMovement()->Velocity, GetCharacterMovement()->bJustTeleported, bShotSpawned, WorldTime, (UTCharacterMovement ? UTCharacterMovement->GetCurrentSynchTime() : 0.f));
}

// maintain one position beyond MaxSavedPositionAge for interpolation
if (SavedPositions.Num() > 1 && SavedPositions[1].Time < WorldTime - MaxSavedPositionAge)
{
SavedPositions.RemoveAt(0);
}
}

可以看到加入新位置的时候会移除超过保存时间限制的位置

调用栈

1
2
+ AUTCharacter::PositionUpdated(bool bShotSpawned)
+ UUTCharacterMovement::PerformMovement()

可见每帧会保存一个当前帧的数据,其中 bShotSpawned 参数传入当前帧是否会发射射击,是否射击的状态在武器类上维护

下面我们继续查看抛射物如何做预测

1
2
调用 AUTProjectile* AUTWeapon::FireProjectile()
调用 AUTProjectile* AUTWeapon::SpawnNetPredictedProjectile(TSubclassOf<AUTProjectile> ProjectileClass, FVector SpawnLocation, FRotator SpawnRotation)

在客户端上,如果当前延迟超过了最大预测延迟,会在等待一段超出最大预测延迟的时间后,射出模拟子弹,避免延迟过高的情况下,补偿产生的副作用

1
GetWorldTimerManager().SetTimer(SpawnDelayedFakeProjHandle, this, &AUTWeapon::SpawnDelayedFakeProjectile, SleepTime, false);

如果当前延迟没有超过最大延迟补偿

在服务器上,则会直接对抛射物和它的 MovememtComponent 执行一次 tick

tick 传入的时间就是补偿的时间

从而达到补偿的目的

客户端上则会执行 InitFakeProjectile 初始化一个模拟抛射物

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
if (Role == ROLE_Authority)
{
NewProjectile->HitsStatsName = HitsStatsName;
if ((CatchupTickDelta > 0.f) && NewProjectile->ProjectileMovement)
{
// server ticks projectile to match with when client actually fired
// TODO: account for CustomTimeDilation?
if (NewProjectile->PrimaryActorTick.IsTickFunctionEnabled())
{
NewProjectile->TickActor(CatchupTickDelta * NewProjectile->CustomTimeDilation, LEVELTICK_All, NewProjectile->PrimaryActorTick);
}
NewProjectile->ProjectileMovement->TickComponent(CatchupTickDelta * NewProjectile->CustomTimeDilation, LEVELTICK_All, NULL);
NewProjectile->SetForwardTicked(true);
if (NewProjectile->GetLifeSpan() > 0.f)
{
NewProjectile->SetLifeSpan(0.1f + FMath::Max(0.01f, NewProjectile->GetLifeSpan() - CatchupTickDelta));
}
}
else
{
NewProjectile->SetForwardTicked(false);
}
}
else
{
NewProjectile->InitFakeProjectile(OwningPlayer);
NewProjectile->SetLifeSpan(FMath::Min(NewProjectile->GetLifeSpan(), 2.f * FMath::Max(0.f, CatchupTickDelta)));
}
}

对于其他同步下来的抛射物,客户端也会在同步到位置信息或者 BeginPlay 的时候,直接给予一个延迟补偿

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
void AUTProjectile::PostNetReceiveLocationAndRotation()
{
//...略
// forward predict to get to position on server now
if (!bFakeClientProjectile)
{
AUTPlayerController* MyPlayer = Cast<AUTPlayerController>(InstigatorController ? InstigatorController : GEngine->GetFirstLocalPlayerController(GetWorld()));
if (MyPlayer)
{
float CatchupTickDelta = MyPlayer->GetPredictionTime();
if ((CatchupTickDelta > 0.f) && ProjectileMovement)
{
ProjectileMovement->TickComponent(CatchupTickDelta, LEVELTICK_All, NULL);
}
}
}
//...略
}

void AUTProjectile::BeginPlay()
{
//...略
if (Role == ROLE_Authority)
{
//...略
}
else
{
AUTPlayerController* MyPlayer = Cast<AUTPlayerController>(InstigatorController ? InstigatorController : GEngine->GetFirstLocalPlayerController(GetWorld()));
if (MyPlayer)
{
// Move projectile to match where it is on server now (to make up for replication time)
float CatchupTickDelta = MyPlayer->GetPredictionTime();
if (CatchupTickDelta > 0.f)
{
CatchupTick(CatchupTickDelta);
}
//...略
}
}
//...略
}

那么 Rewind 机制是在哪儿使用的咧?
我们查找寻回角色历史位置的函数

1
2
3
/** Returns this character's position PredictionTime seconds ago. */
UFUNCTION(BlueprintCallable, Category = Pawn)
virtual FVector GetRewindLocation(float PredictionTime, AUTPlayerController* DebugViewer=nullptr);

在武器的发射函数中

1
void AUTWeapon::FireShot()

有抛射物模式和快速检测命中模式两种类型的发射
如果是抛射物,执行的上面提到的通过 TickActor 和 TickMovement 来补偿的方式
如果是快速检测命中
则会执行射线检测

射线检测会遍历所有的 Character
然后对每个 Character 在其当前位置(客户端),或者补偿位置(服务器),也就是通过 GetRewindLocation 传入补偿时间获得的历史位置
来检查位置和射线的最近距离
再把最近距离和胶囊体大小比较来判断是否击中

如果是客户端,会先快速在客户端本地做一次无补偿的射线检测
如果有射中目标,则会通过

1
2
UFUNCTION(Server, Unreliable, WithValidation)
void ServerHitScanHit(AUTCharacter* HitScanChar, uint8 HitScanEventIndex);

上报服务器客户端命中的目标和命中事件的 Index

同时服务器上也会做一次带补偿的射线检测

如果客户端有上报数据,那么服务器上的对于客户端上报的命中数据会有一些额外的优惠(更容易被判定为击中)