From 86df9478d4c07f46acf9c165fec053d3bbe8a0a1 Mon Sep 17 00:00:00 2001 From: Mateus Rodrigues Date: Fri, 8 May 2026 23:31:42 -0300 Subject: [PATCH] feat(client): snapshot interpolation no proxy remoto para suavizar movimento MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- Source/ZMMO/Game/Entity/ZMMOPlayerProxy.cpp | 219 ++++++++++++++------ Source/ZMMO/Game/Entity/ZMMOPlayerProxy.h | 83 +++++++- 2 files changed, 226 insertions(+), 76 deletions(-) diff --git a/Source/ZMMO/Game/Entity/ZMMOPlayerProxy.cpp b/Source/ZMMO/Game/Entity/ZMMOPlayerProxy.cpp index bbeecf5..98737c3 100644 --- a/Source/ZMMO/Game/Entity/ZMMOPlayerProxy.cpp +++ b/Source/ZMMO/Game/Entity/ZMMOPlayerProxy.cpp @@ -97,7 +97,121 @@ void AZMMOPlayerProxy::BeginPlay() void AZMMOPlayerProxy::Tick(const float 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(FPlatformTime::Seconds() * 1000.0); + const int64 ServerNowMs = LocalNowMs - ServerClockOffsetMs; + const int64 RenderMs = ServerNowMs - static_cast(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(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(1.0, static_cast(B.ServerTimeMs - A.ServerTimeMs)); + InterpAlpha = static_cast(FMath::Clamp( + static_cast(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(); + + // 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) @@ -108,84 +222,54 @@ void AZMMOPlayerProxy::SetZMMOIdentity(const int64 InEntityId, const EZMMOEntity void AZMMOPlayerProxy::ApplyEntitySnapshot(const FZMMOEntitySnapshot& Snapshot) { - const FRotator NewRotation(0.0f, Snapshot.YawDeg, 0.0f); - - // V0: o servidor envia Z=spawnZ fixo (ADR 0039). ADR 0041 passou a Z a - // vir do cliente do dono (`GetActorLocation().Z`), pelo que o snapshot - // 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()) + // Estabiliza o offset de relogio na primeira amostra. Subsequente nao + // re-sincroniza para evitar saltos visuais; um esquema mais sofisticado + // (ema do offset) cabe quando tivermos ping/RTT estabilizado por sessao. + if (SnapshotBuffer.Num() == 0) { - const FVector TraceStart = AdjustedPos + FVector(0.0f, 0.0f, 1000.0f); - const FVector TraceEnd = AdjustedPos - FVector(0.0f, 0.0f, 4000.0f); - FHitResult Hit; - FCollisionQueryParams Params(TEXT("ZMMOProxyGround"), false, this); - if (World->LineTraceSingleByChannel(Hit, TraceStart, TraceEnd, ECC_Visibility, Params)) + const int64 LocalNowMs = static_cast(FPlatformTime::Seconds() * 1000.0); + ServerClockOffsetMs = LocalNowMs - Snapshot.ServerTimeMs; + } + + FZMMOProxySnapshot S; + S.PosCm = Snapshot.PositionCm; + S.VelCmS = Snapshot.VelocityCmS; + S.bGrounded = Snapshot.bGrounded; + S.ServerTimeMs = Snapshot.ServerTimeMs; + + // Insercao ordenada defensiva (UDP pode trocar a ordem de pacotes). + int32 InsertAt = SnapshotBuffer.Num(); + for (int32 i = SnapshotBuffer.Num() - 1; i >= 0; --i) + { + if (SnapshotBuffer[i].ServerTimeMs < S.ServerTimeMs) { - const float HalfHeight = GetCapsuleComponent() - ? GetCapsuleComponent()->GetScaledCapsuleHalfHeight() - : 96.0f; - AdjustedPos.Z = Hit.Location.Z + HalfHeight; + InsertAt = i + 1; + break; } - } - - const FVector NewVel = Snapshot.VelocityCmS; - - // Aceleração derivada entre snapshots (espelha ZeusInputBridgeComponent.cpp:443-486). - // O AnimBP do Quinn usa Acceleration!=0 nas formulas de ShouldMove e - // Jump_Start. Como proxies não têm input, derivamos a accel a partir da - // variação de velocidade entre snapshots consecutivos. - FVector DerivedAccel = FVector::ZeroVector; - if (bHasPreviousSnapshot && PreviousSnapshotServerTimeMs > 0) - { - const double DeltaSec = static_cast( - Snapshot.ServerTimeMs - PreviousSnapshotServerTimeMs) / 1000.0; - if (DeltaSec > KINDA_SMALL_NUMBER) + if (SnapshotBuffer[i].ServerTimeMs == S.ServerTimeMs) { - DerivedAccel = (NewVel - PreviousVisualVelocity) / static_cast(DeltaSec); - DerivedAccel = DerivedAccel.GetClampedToMaxSize(8000.0f); + // Dedupe: substitui pelo mais recente (este). + SnapshotBuffer[i] = S; + InsertAt = INDEX_NONE; + break; } + InsertAt = i; } - 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) + if (InsertAt != INDEX_NONE) { - const FVector2D VelDir = Vel2D.GetSafeNormal(); - const FVector2D FallbackAccel2D = VelDir * MinAccelFallbackCmS2; - LastDerivedAccelerationCmS2.X = FallbackAccel2D.X; - LastDerivedAccelerationCmS2.Y = FallbackAccel2D.Y; - LastDerivedAccelerationCmS2.Z = 0.0f; + SnapshotBuffer.Insert(S, InsertAt); + } + while (SnapshotBuffer.Num() > SnapshotBufferCapacity) + { + SnapshotBuffer.RemoveAt(0); } - PreviousVisualVelocity = NewVel; - PreviousSnapshotServerTimeMs = Snapshot.ServerTimeMs; - bHasPreviousSnapshot = true; - - SetActorLocationAndRotation(AdjustedPos, NewRotation); - LastSnapshotPosition = AdjustedPos; - VisualVelocity = NewVel; + AuthoritativePosCm = Snapshot.PositionCm; + LastSnapshotPosition = Snapshot.PositionCm; bHasFirstSnapshot = true; - // Estado de queda/pulo replicado via `bGrounded` (eco do bit IsFalling do - // cliente local — ver `EInputAxisFlags::IsFalling` e CoreServerApp). Sem - // este passo o `MovementMode` do proxy ficaria sempre em MOVE_Walking - // (definido no construtor) e o AnimBP nunca tocaria a animação de queda. + // `bGrounded` e evento — aplicar `MovementMode` imediatamente para que o + // pulo nao espere `InterpolationDelayMs` para mudar a state machine do AnimBP. if (UCharacterMovementComponent* CMC = GetCharacterMovement()) { const EMovementMode DesiredMode = Snapshot.bGrounded ? MOVE_Walking : MOVE_Falling; @@ -195,7 +279,6 @@ void AZMMOPlayerProxy::ApplyEntitySnapshot(const FZMMOEntitySnapshot& Snapshot) } } - RefreshAnimationDrivers(); ApplyCollisionPolicy(); } diff --git a/Source/ZMMO/Game/Entity/ZMMOPlayerProxy.h b/Source/ZMMO/Game/Entity/ZMMOPlayerProxy.h index 0273d28..14c5c3a 100644 --- a/Source/ZMMO/Game/Entity/ZMMOPlayerProxy.h +++ b/Source/ZMMO/Game/Entity/ZMMOPlayerProxy.h @@ -29,6 +29,29 @@ * Velocity+Acceleration estejam populados quando o `NativeUpdateAnimation` * 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) class ZMMO_API AZMMOPlayerProxy : public ACharacter, public IZMMOEntityInterface { @@ -63,30 +86,74 @@ protected: UPROPERTY(VisibleInstanceOnly, BlueprintReadOnly, Category = "ZMMO|Entity") 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") FVector LastSnapshotPosition = FVector::ZeroVector; /** - * Velocidade visual derivada para o AnimBP. Espelha `Snapshot.VelocityCmS` - * e e re-imposta no `CMC->Velocity` em cada `Tick` para manter os drivers - * de animacao alimentados entre updates de rede. + * Velocidade visual derivada para o AnimBP. Espelha a velocidade + * *interpolada* (nao o snapshot bruto) e e re-imposta no `CMC->Velocity` + * em cada `Tick` para manter os drivers de animacao alimentados entre + * updates de rede. */ UPROPERTY(VisibleInstanceOnly, BlueprintReadOnly, Category = "ZMMO|Entity") 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. */ - int64 PreviousSnapshotServerTimeMs = 0; + /** Tamanho maximo do buffer (em snapshots). Cobre ~270 ms a 30 Hz. */ + 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 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 * o AnimBP (`ShouldMove`/`Jump_Start` exigem Acceleration!=0). */ FVector LastDerivedAccelerationCmS2 = FVector::ZeroVector; + /** Velocidade visual do tick anterior (para a derivada `Acceleration = dV/dt`). */ + FVector PreviousVisualVelocity = FVector::ZeroVector; + bool bHasPreviousSnapshot = false; + /** Ultimo log de diagnostico (s, monotonic). Log emitido 1x/s no Tick. */ + double LastDiagLogSec = 0.0; + private: /** Reaplica `CMC->Velocity` (com clamp de Z em walking) e injeta a * aceleracao derivada no CMC custom. Chamado em `Tick` (pre-mesh) e em