feat(client): snapshot interpolation no proxy remoto para suavizar movimento

Implementa o padrao Source/Quake/Unreal: o proxy nao aplica mais a posicao
recebida diretamente — empurra para um buffer ordenado de snapshots e o Tick
interpola entre os dois snapshots que cercam (server_now - InterpolationDelayMs).

- Novo USTRUCT FZMMOProxySnapshot guardado em TArray ordenado por ServerTimeMs
  (insercao defensiva + dedupe; UDP pode trocar ordem).
- ApplyEntitySnapshot vira "push to buffer"; estabelece o offset de relogio
  na primeira amostra (local_now - server_time). Mudanca de bGrounded
  continua aplicada imediatamente (MovementMode) para o pulo nao herdar o
  delay de interpolacao.
- Tick acha o par (A,B), faz Lerp em pos/vel/yaw; extrapolacao curta
  clampada por MaxExtrapolationSeconds quando faltam snapshots futuros.
- Aceleracao derivada agora vem da curva interpolada (mais estavel que a
  derivada snapshot-a-snapshot anterior). Fallback sintetico de accel para
  o AnimBP do Quinn preservado.
- Removido o line-trace de Z (causava oscilacao apos a interpolacao; a Z
  autoritativa do dono ja vem no snapshot, ADR 0041).
- Separacao explicita AuthoritativePosCm vs InterpolatedPosCm, com
  LastSnapshotPosition mantido como alias para compat de Blueprint.
- Defaults: InterpolationDelayMs=100, SnapshotBufferCapacity=8,
  MaxExtrapolationSeconds=0.1 (UPROPERTY EditAnywhere para tunar em PIE).
- Log Verbose 1x/s com renderLagMs, interpAlpha, flag de extrapolacao.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-08 23:31:42 -03:00
parent 385ebf6a7b
commit 86df9478d4
2 changed files with 226 additions and 76 deletions

View File

@@ -97,7 +97,121 @@ void AZMMOPlayerProxy::BeginPlay()
void AZMMOPlayerProxy::Tick(const float DeltaSeconds) void AZMMOPlayerProxy::Tick(const float DeltaSeconds)
{ {
Super::Tick(DeltaSeconds); Super::Tick(DeltaSeconds);
if (SnapshotBuffer.Num() == 0)
{
return;
}
// Tempo do servidor estimado: relogio local em ms - offset estabelecido na
// primeira amostra. RenderMs = tempo "atrasado" usado para procurar o par
// de snapshots a interpolar (snapshot interpolation a la Source/Quake).
const int64 LocalNowMs = static_cast<int64>(FPlatformTime::Seconds() * 1000.0);
const int64 ServerNowMs = LocalNowMs - ServerClockOffsetMs;
const int64 RenderMs = ServerNowMs - static_cast<int64>(InterpolationDelayMs);
// Procura o snapshot B tal que A.t <= RenderMs <= B.t (A = B-1).
int32 IdxB = INDEX_NONE;
for (int32 i = 1; i < SnapshotBuffer.Num(); ++i)
{
if (SnapshotBuffer[i].ServerTimeMs >= RenderMs)
{
IdxB = i;
break;
}
}
FVector NewPos;
FVector NewVel;
float InterpAlpha = 0.0f;
bool bExtrapolating = false;
if (IdxB == INDEX_NONE)
{
// Sem snapshot futuro: extrapolacao curta com a ultima velocidade
// conhecida, clampada por `MaxExtrapolationSeconds` para nao deixar
// o proxy fugir se a rede ficar muda.
const FZMMOProxySnapshot& Last = SnapshotBuffer.Last();
const float ExtrapSec = FMath::Clamp(
static_cast<float>(RenderMs - Last.ServerTimeMs) / 1000.0f,
0.0f, MaxExtrapolationSeconds);
NewPos = Last.PosCm + Last.VelCmS * ExtrapSec;
NewVel = Last.VelCmS;
bExtrapolating = ExtrapSec > KINDA_SMALL_NUMBER;
}
else
{
const FZMMOProxySnapshot& A = SnapshotBuffer[IdxB - 1];
const FZMMOProxySnapshot& B = SnapshotBuffer[IdxB];
const double Span = FMath::Max<double>(1.0, static_cast<double>(B.ServerTimeMs - A.ServerTimeMs));
InterpAlpha = static_cast<float>(FMath::Clamp(
static_cast<double>(RenderMs - A.ServerTimeMs) / Span, 0.0, 1.0));
NewPos = FMath::Lerp(A.PosCm, B.PosCm, InterpAlpha);
NewVel = FMath::Lerp(A.VelCmS, B.VelCmS, InterpAlpha);
}
// Yaw deriva da velocidade interpolada: locomotion mantem face na direcao
// do movimento; em idle preserva o yaw atual para nao "snappar".
FRotator NewRot = GetActorRotation();
if (FVector(NewVel.X, NewVel.Y, 0.0f).SizeSquared() > 1.0f)
{
NewRot.Yaw = FMath::RadiansToDegrees(FMath::Atan2(NewVel.Y, NewVel.X));
}
NewRot.Pitch = 0.0f;
NewRot.Roll = 0.0f;
// Aceleracao derivada da curva interpolada (mais estavel que a derivada
// snapshot-a-snapshot que tinhamos antes; AnimBP do Quinn precisa de
// `Acceleration != 0` para sair de Idle).
FVector DerivedAccel = FVector::ZeroVector;
if (bHasPreviousSnapshot && DeltaSeconds > KINDA_SMALL_NUMBER)
{
DerivedAccel = (NewVel - PreviousVisualVelocity) / DeltaSeconds;
DerivedAccel = DerivedAccel.GetClampedToMaxSize(8000.0f);
}
constexpr float AccelSmoothingAlpha = 0.35f;
LastDerivedAccelerationCmS2 = bHasPreviousSnapshot
? FMath::Lerp(LastDerivedAccelerationCmS2, DerivedAccel, AccelSmoothingAlpha)
: DerivedAccel;
// Fallback sintetico: locomotion a velocidade constante => derivada ~ 0.
// Sem este nudge o AnimBP voltaria para Idle.
const FVector2D Vel2D(NewVel.X, NewVel.Y);
const float Speed2D = Vel2D.Size();
const FVector2D Accel2D(LastDerivedAccelerationCmS2.X, LastDerivedAccelerationCmS2.Y);
constexpr float MinSpeedForFallbackCmS = 50.0f;
constexpr float MinAccelFallbackCmS2 = 200.0f;
const bool bGroundedNow = SnapshotBuffer.Last().bGrounded;
if (bGroundedNow && Speed2D >= MinSpeedForFallbackCmS && Accel2D.Size() < MinAccelFallbackCmS2)
{
const FVector2D VelDir = Vel2D.GetSafeNormal();
const FVector2D FallbackAccel2D = VelDir * MinAccelFallbackCmS2;
LastDerivedAccelerationCmS2.X = FallbackAccel2D.X;
LastDerivedAccelerationCmS2.Y = FallbackAccel2D.Y;
LastDerivedAccelerationCmS2.Z = 0.0f;
}
PreviousVisualVelocity = NewVel;
bHasPreviousSnapshot = true;
InterpolatedPosCm = NewPos;
VisualVelocity = NewVel;
SetActorLocationAndRotation(NewPos, NewRot);
RefreshAnimationDrivers(); RefreshAnimationDrivers();
// Diagnostico 1x/s: render lag, buffer, alpha, extrapolating?
const double NowSec = FPlatformTime::Seconds();
if (NowSec - LastDiagLogSec >= 1.0)
{
LastDiagLogSec = NowSec;
const int64 NewestMs = SnapshotBuffer.Last().ServerTimeMs;
const int64 RenderLagMs = ServerNowMs - NewestMs;
UE_LOG(LogZMMO, Verbose,
TEXT("ZMMOPlayerProxy[%lld] buffer=%d renderLagMs=%lld interpAlpha=%.2f extrap=%d delayMs=%.0f"),
EntityId, SnapshotBuffer.Num(), RenderLagMs, InterpAlpha,
bExtrapolating ? 1 : 0, InterpolationDelayMs);
}
} }
void AZMMOPlayerProxy::SetZMMOIdentity(const int64 InEntityId, const EZMMOEntityType InEntityType) void AZMMOPlayerProxy::SetZMMOIdentity(const int64 InEntityId, const EZMMOEntityType InEntityType)
@@ -108,84 +222,54 @@ void AZMMOPlayerProxy::SetZMMOIdentity(const int64 InEntityId, const EZMMOEntity
void AZMMOPlayerProxy::ApplyEntitySnapshot(const FZMMOEntitySnapshot& Snapshot) void AZMMOPlayerProxy::ApplyEntitySnapshot(const FZMMOEntitySnapshot& Snapshot)
{ {
const FRotator NewRotation(0.0f, Snapshot.YawDeg, 0.0f); // Estabiliza o offset de relogio na primeira amostra. Subsequente nao
// re-sincroniza para evitar saltos visuais; um esquema mais sofisticado
// V0: o servidor envia Z=spawnZ fixo (ADR 0039). ADR 0041 passou a Z a // (ema do offset) cabe quando tivermos ping/RTT estabilizado por sessao.
// vir do cliente do dono (`GetActorLocation().Z`), pelo que o snapshot if (SnapshotBuffer.Num() == 0)
// ja contem a Z autoritativa. Mantemos o line trace local como fallback
// gracioso para os casos em que o snapshot Z fica estranho (terreno
// receiver com buracos, drift entre clientes, packet loss). Sem hit, o
// AdjustedPos.Z fica igual a Snapshot.PositionCm.Z.
FVector AdjustedPos = Snapshot.PositionCm;
if (UWorld* World = GetWorld())
{ {
const FVector TraceStart = AdjustedPos + FVector(0.0f, 0.0f, 1000.0f); const int64 LocalNowMs = static_cast<int64>(FPlatformTime::Seconds() * 1000.0);
const FVector TraceEnd = AdjustedPos - FVector(0.0f, 0.0f, 4000.0f); ServerClockOffsetMs = LocalNowMs - Snapshot.ServerTimeMs;
FHitResult Hit;
FCollisionQueryParams Params(TEXT("ZMMOProxyGround"), false, this);
if (World->LineTraceSingleByChannel(Hit, TraceStart, TraceEnd, ECC_Visibility, Params))
{
const float HalfHeight = GetCapsuleComponent()
? GetCapsuleComponent()->GetScaledCapsuleHalfHeight()
: 96.0f;
AdjustedPos.Z = Hit.Location.Z + HalfHeight;
}
} }
const FVector NewVel = Snapshot.VelocityCmS; FZMMOProxySnapshot S;
S.PosCm = Snapshot.PositionCm;
S.VelCmS = Snapshot.VelocityCmS;
S.bGrounded = Snapshot.bGrounded;
S.ServerTimeMs = Snapshot.ServerTimeMs;
// Aceleração derivada entre snapshots (espelha ZeusInputBridgeComponent.cpp:443-486). // Insercao ordenada defensiva (UDP pode trocar a ordem de pacotes).
// O AnimBP do Quinn usa Acceleration!=0 nas formulas de ShouldMove e int32 InsertAt = SnapshotBuffer.Num();
// Jump_Start. Como proxies não têm input, derivamos a accel a partir da for (int32 i = SnapshotBuffer.Num() - 1; i >= 0; --i)
// variação de velocidade entre snapshots consecutivos.
FVector DerivedAccel = FVector::ZeroVector;
if (bHasPreviousSnapshot && PreviousSnapshotServerTimeMs > 0)
{ {
const double DeltaSec = static_cast<double>( if (SnapshotBuffer[i].ServerTimeMs < S.ServerTimeMs)
Snapshot.ServerTimeMs - PreviousSnapshotServerTimeMs) / 1000.0;
if (DeltaSec > KINDA_SMALL_NUMBER)
{ {
DerivedAccel = (NewVel - PreviousVisualVelocity) / static_cast<float>(DeltaSec); InsertAt = i + 1;
DerivedAccel = DerivedAccel.GetClampedToMaxSize(8000.0f); break;
} }
} if (SnapshotBuffer[i].ServerTimeMs == S.ServerTimeMs)
constexpr float AccelSmoothingAlpha = 0.35f;
LastDerivedAccelerationCmS2 = bHasPreviousSnapshot
? FMath::Lerp(LastDerivedAccelerationCmS2, DerivedAccel, AccelSmoothingAlpha)
: DerivedAccel;
// Fallback visual: se o proxy está em locomotion (grounded + Speed alto)
// mas a accel derivada caiu para ~0 (velocidade constante entre snapshots),
// injetar accel sintética na direção da velocidade. Sem isto, andar em
// linha reta a velocidade constante deixaria Acceleration=0 e o AnimBP
// voltaria para Idle.
const FVector2D Vel2D(NewVel.X, NewVel.Y);
const float Speed2D = Vel2D.Size();
const FVector2D Accel2D(LastDerivedAccelerationCmS2.X, LastDerivedAccelerationCmS2.Y);
constexpr float MinSpeedForFallbackCmS = 50.0f;
constexpr float MinAccelFallbackCmS2 = 200.0f;
if (Snapshot.bGrounded && Speed2D >= MinSpeedForFallbackCmS && Accel2D.Size() < MinAccelFallbackCmS2)
{ {
const FVector2D VelDir = Vel2D.GetSafeNormal(); // Dedupe: substitui pelo mais recente (este).
const FVector2D FallbackAccel2D = VelDir * MinAccelFallbackCmS2; SnapshotBuffer[i] = S;
LastDerivedAccelerationCmS2.X = FallbackAccel2D.X; InsertAt = INDEX_NONE;
LastDerivedAccelerationCmS2.Y = FallbackAccel2D.Y; break;
LastDerivedAccelerationCmS2.Z = 0.0f; }
InsertAt = i;
}
if (InsertAt != INDEX_NONE)
{
SnapshotBuffer.Insert(S, InsertAt);
}
while (SnapshotBuffer.Num() > SnapshotBufferCapacity)
{
SnapshotBuffer.RemoveAt(0);
} }
PreviousVisualVelocity = NewVel; AuthoritativePosCm = Snapshot.PositionCm;
PreviousSnapshotServerTimeMs = Snapshot.ServerTimeMs; LastSnapshotPosition = Snapshot.PositionCm;
bHasPreviousSnapshot = true;
SetActorLocationAndRotation(AdjustedPos, NewRotation);
LastSnapshotPosition = AdjustedPos;
VisualVelocity = NewVel;
bHasFirstSnapshot = true; bHasFirstSnapshot = true;
// Estado de queda/pulo replicado via `bGrounded` (eco do bit IsFalling do // `bGrounded` e evento — aplicar `MovementMode` imediatamente para que o
// cliente local — ver `EInputAxisFlags::IsFalling` e CoreServerApp). Sem // pulo nao espere `InterpolationDelayMs` para mudar a state machine do AnimBP.
// este passo o `MovementMode` do proxy ficaria sempre em MOVE_Walking
// (definido no construtor) e o AnimBP nunca tocaria a animação de queda.
if (UCharacterMovementComponent* CMC = GetCharacterMovement()) if (UCharacterMovementComponent* CMC = GetCharacterMovement())
{ {
const EMovementMode DesiredMode = Snapshot.bGrounded ? MOVE_Walking : MOVE_Falling; const EMovementMode DesiredMode = Snapshot.bGrounded ? MOVE_Walking : MOVE_Falling;
@@ -195,7 +279,6 @@ void AZMMOPlayerProxy::ApplyEntitySnapshot(const FZMMOEntitySnapshot& Snapshot)
} }
} }
RefreshAnimationDrivers();
ApplyCollisionPolicy(); ApplyCollisionPolicy();
} }

View File

@@ -29,6 +29,29 @@
* Velocity+Acceleration estejam populados quando o `NativeUpdateAnimation` * Velocity+Acceleration estejam populados quando o `NativeUpdateAnimation`
* do AnimBP os ler. * do AnimBP os ler.
*/ */
/**
* Snapshot bruto guardado no buffer de interpolacao. Mantido como struct privada
* ao .h porque so o proxy (e debugging local) precisa olhar para ele.
*/
USTRUCT()
struct FZMMOProxySnapshot
{
GENERATED_BODY()
UPROPERTY()
FVector PosCm = FVector::ZeroVector;
UPROPERTY()
FVector VelCmS = FVector::ZeroVector;
UPROPERTY()
bool bGrounded = true;
/** Timestamp do servidor que produziu o snapshot (ms desde Unix epoch). */
UPROPERTY()
int64 ServerTimeMs = 0;
};
UCLASS(Blueprintable, BlueprintType) UCLASS(Blueprintable, BlueprintType)
class ZMMO_API AZMMOPlayerProxy : public ACharacter, public IZMMOEntityInterface class ZMMO_API AZMMOPlayerProxy : public ACharacter, public IZMMOEntityInterface
{ {
@@ -63,30 +86,74 @@ protected:
UPROPERTY(VisibleInstanceOnly, BlueprintReadOnly, Category = "ZMMO|Entity") UPROPERTY(VisibleInstanceOnly, BlueprintReadOnly, Category = "ZMMO|Entity")
bool bHasFirstSnapshot = false; bool bHasFirstSnapshot = false;
/** Ultima posicao recebida por snapshot (mundo, cm). */ /**
* Ultima posicao **autoritativa** recebida do servidor (cm, mundo). Difere
* de `InterpolatedPosCm` (visual) — separar os dois e essencial para
* comparar drift autoritativo vs visual via debug overlay/logs.
*/
UPROPERTY(VisibleInstanceOnly, BlueprintReadOnly, Category = "ZMMO|Entity")
FVector AuthoritativePosCm = FVector::ZeroVector;
/** Posicao **visual** atual (resultado da interpolacao do tick). */
UPROPERTY(VisibleInstanceOnly, BlueprintReadOnly, Category = "ZMMO|Entity")
FVector InterpolatedPosCm = FVector::ZeroVector;
/** Compat: alias para `AuthoritativePosCm` (preserva binding de Blueprint, se houver). */
UPROPERTY(VisibleInstanceOnly, BlueprintReadOnly, Category = "ZMMO|Entity") UPROPERTY(VisibleInstanceOnly, BlueprintReadOnly, Category = "ZMMO|Entity")
FVector LastSnapshotPosition = FVector::ZeroVector; FVector LastSnapshotPosition = FVector::ZeroVector;
/** /**
* Velocidade visual derivada para o AnimBP. Espelha `Snapshot.VelocityCmS` * Velocidade visual derivada para o AnimBP. Espelha a velocidade
* e e re-imposta no `CMC->Velocity` em cada `Tick` para manter os drivers * *interpolada* (nao o snapshot bruto) e e re-imposta no `CMC->Velocity`
* de animacao alimentados entre updates de rede. * em cada `Tick` para manter os drivers de animacao alimentados entre
* updates de rede.
*/ */
UPROPERTY(VisibleInstanceOnly, BlueprintReadOnly, Category = "ZMMO|Entity") UPROPERTY(VisibleInstanceOnly, BlueprintReadOnly, Category = "ZMMO|Entity")
FVector VisualVelocity = FVector::ZeroVector; FVector VisualVelocity = FVector::ZeroVector;
/** Velocidade do snapshot anterior — base para derivar `Acceleration`. */ /**
FVector PreviousVisualVelocity = FVector::ZeroVector; * Atraso de interpolacao em ms. Render-time = wall_now - InterpolationDelayMs,
* expresso no dominio do tempo do servidor. Padrao 100 ms (~3 snapshots a 30 Hz)
* cobre 1 perda + 1 jitter sem extrapolar.
*/
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "ZMMO|Network",
meta = (ClampMin = "0.0", ClampMax = "500.0"))
double InterpolationDelayMs = 100.0;
/** ServerTimeMs do snapshot anterior — `dt` da derivada de Acceleration. */ /** Tamanho maximo do buffer (em snapshots). Cobre ~270 ms a 30 Hz. */
int64 PreviousSnapshotServerTimeMs = 0; UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "ZMMO|Network",
meta = (ClampMin = "2", ClampMax = "32"))
int32 SnapshotBufferCapacity = 8;
/** Limite duro de extrapolacao (s) quando nao ha snapshot futuro. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "ZMMO|Network",
meta = (ClampMin = "0.0", ClampMax = "0.5"))
float MaxExtrapolationSeconds = 0.1f;
/** Buffer ordenado por `ServerTimeMs` ascendente. */
UPROPERTY()
TArray<FZMMOProxySnapshot> SnapshotBuffer;
/**
* Offset entre o relogio local (`FPlatformTime::Seconds * 1000`) e o
* relogio do servidor (`ServerTimeMs` do snapshot). Estimado pela primeira
* amostra como `local_now_ms - server_time_ms`. Subtraindo este offset do
* relogio local obtemos o tempo do servidor estimado.
*/
int64 ServerClockOffsetMs = 0;
/** Aceleracao derivada (com smoothing) escrita no CMC custom para alimentar /** Aceleracao derivada (com smoothing) escrita no CMC custom para alimentar
* o AnimBP (`ShouldMove`/`Jump_Start` exigem Acceleration!=0). */ * o AnimBP (`ShouldMove`/`Jump_Start` exigem Acceleration!=0). */
FVector LastDerivedAccelerationCmS2 = FVector::ZeroVector; FVector LastDerivedAccelerationCmS2 = FVector::ZeroVector;
/** Velocidade visual do tick anterior (para a derivada `Acceleration = dV/dt`). */
FVector PreviousVisualVelocity = FVector::ZeroVector;
bool bHasPreviousSnapshot = false; bool bHasPreviousSnapshot = false;
/** Ultimo log de diagnostico (s, monotonic). Log emitido 1x/s no Tick. */
double LastDiagLogSec = 0.0;
private: private:
/** Reaplica `CMC->Velocity` (com clamp de Z em walking) e injeta a /** Reaplica `CMC->Velocity` (com clamp de Z em walking) e injeta a
* aceleracao derivada no CMC custom. Chamado em `Tick` (pre-mesh) e em * aceleracao derivada no CMC custom. Chamado em `Tick` (pre-mesh) e em