feat(anim): soft-snap residual — hides prediction error on server update

Before: when the dead-reckoner's prediction and the server's
UpdatePosition disagreed, the hard reassignment caused a visible 1-frame
teleport. Even a small 0.3m prediction error (common when velocity ≠
server's ground truth by a bit) looked like a stutter-step.

Now: on each UpdatePosition, we compute the error
    preSnapPos - newServerPos
and stash it as SnapResidual. Each tick the residual decays at
SnapResidualDecayRate (~8/sec, so ~300ms to fade from 1m to 0.05m). The
rendered Entity.Position = authoritative_DeadReckonedPos + residual.

Authoritative position and rendered position are now separated:
- DeadReckonedPos: server truth + velocity*dt integration (used by
  clamp logic, collision, shadow registration — anything that needs
  accuracy).
- Entity.Position: DeadReckonedPos + SnapResidual (what the camera
  sees — smooth blend through prediction errors).

Large errors (> SnapHardSnapThreshold = 5m) are treated as
teleports/rubber-bands and hard-snap with no residual, so a portal
transition doesn't produce a 300ms slow-drift.

No new tests — the visual smoothing is a GPU-side behavior. The
integration tests already cover the authoritative DeadReckonedPos
correctness (via CurrentVelocity scaling + retain-through-link).

659 tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-19 10:46:06 +02:00
parent dc317a321b
commit ab74d0328d

View file

@ -187,8 +187,38 @@ public sealed class GameWindow : IDisposable
public System.Numerics.Vector3 ObservedVelocity; public System.Numerics.Vector3 ObservedVelocity;
/// <summary>Server-supplied world velocity from UpdatePosition (HasVelocity flag).</summary> /// <summary>Server-supplied world velocity from UpdatePosition (HasVelocity flag).</summary>
public System.Numerics.Vector3? ServerVelocity; public System.Numerics.Vector3? ServerVelocity;
/// <summary>
/// Internal dead-reckoned position: the authoritative server pos plus
/// velocity*dt integration since the last update. Each tick this
/// advances; on UpdatePosition it resets to the new server pos.
/// Separated from the publicly visible Entity.Position so the
/// residual-decay logic doesn't mix with the integration state.
/// </summary>
public System.Numerics.Vector3 DeadReckonedPos;
/// <summary>
/// Residual offset the renderer is blending out. When UpdatePosition
/// arrives, we compute (lastRenderedPos - newServerPos) and store it
/// here; each tick the offset decays toward zero while the entity's
/// displayed position = DeadReckonedPos + residual. This hides a
/// sudden teleport when the dead-reckoner and server disagreed.
/// </summary>
public System.Numerics.Vector3 SnapResidual;
} }
/// <summary>Soft-snap decay rate (1/sec). At this rate the residual
/// halves every 1/rate seconds. 8.0 → ~100ms half-life, so even a
/// 2m residual fades within ~300ms without visible snap.</summary>
private const float SnapResidualDecayRate = 8.0f;
/// <summary>
/// When the prediction error exceeds this many meters, we treat the
/// update as a teleport / rubber-band and hard-snap (no soft lerp).
/// Prevents the soft-snap logic from trying to smooth a genuine portal
/// or force-move event.
/// </summary>
private const float SnapHardSnapThreshold = 5.0f;
/// <summary> /// <summary>
/// Soft-snap window in seconds: after an UpdatePosition arrives for a /// Soft-snap window in seconds: after an UpdatePosition arrives for a
/// remote entity, dead-reckoning continues but the "origin" for /// remote entity, dead-reckoning continues but the "origin" for
@ -1579,6 +1609,12 @@ public sealed class GameWindow : IDisposable
var worldPos = new System.Numerics.Vector3(p.PositionX, p.PositionY, p.PositionZ) + origin; var worldPos = new System.Numerics.Vector3(p.PositionX, p.PositionY, p.PositionZ) + origin;
var rot = new System.Numerics.Quaternion(p.RotationX, p.RotationY, p.RotationZ, p.RotationW); var rot = new System.Numerics.Quaternion(p.RotationX, p.RotationY, p.RotationZ, p.RotationW);
// Capture the pre-update render position for the soft-snap residual
// calculation below. Assign entity.Position to the server truth up
// front; if we then compute a snap residual, we restore the rendered
// position by adding the residual back (so the visual doesn't jerk
// for one frame before the residual decay kicks in on the next tick).
System.Numerics.Vector3 preSnapPos = entity.Position;
entity.Position = worldPos; entity.Position = worldPos;
entity.Rotation = rot; entity.Rotation = rot;
@ -1629,6 +1665,24 @@ public sealed class GameWindow : IDisposable
drState.LastServerRot = rot; drState.LastServerRot = rot;
drState.LastServerPosTime = now; drState.LastServerPosTime = now;
drState.ServerVelocity = update.Velocity; drState.ServerVelocity = update.Velocity;
drState.DeadReckonedPos = worldPos; // reset integration from server truth
// Soft-snap: if the displayed position (preSnapPos) was close to
// the authoritative position, convert the error into a residual
// that decays over ~100ms. If it was far (> SnapHardSnapThreshold),
// this IS a teleport — leave residual zero, hard-snap already done.
var snapError = preSnapPos - worldPos;
float mag = snapError.Length();
if (mag > 1e-3f && mag <= SnapHardSnapThreshold)
{
drState.SnapResidual = snapError;
entity.Position = worldPos + snapError; // keep rendered pos unchanged this frame
}
else
{
drState.SnapResidual = System.Numerics.Vector3.Zero;
// entity.Position already = worldPos from hard-snap above
}
} }
// Phase B.3: portal-space arrival detection. // Phase B.3: portal-space arrival detection.
@ -3416,11 +3470,12 @@ public sealed class GameWindow : IDisposable
|| mlo == 0x0F || mlo == 0x10; || mlo == 0x0F || mlo == 0x10;
if (isLocomotion) if (isLocomotion)
{ {
var predicted = ae.Entity.Position + worldVel * dt; // Integrate from the separate DeadReckonedPos — NOT
// Cap prediction radius around last server pos. Over // from Entity.Position, which may be carrying a
// DeadReckonMaxPredictSeconds we must not drift more // decaying soft-snap residual. This keeps the
// than 1 RunAnimSpeed × run-rate away from server // integration clean and the residual applied as a
// truth, so cap at |worldVel| * max time. // pure render-time offset.
var predicted = drState.DeadReckonedPos + worldVel * dt;
float maxDrift = worldVel.Length() * DeadReckonMaxPredictSeconds; float maxDrift = worldVel.Length() * DeadReckonMaxPredictSeconds;
var fromServer = predicted - drState.LastServerPos; var fromServer = predicted - drState.LastServerPos;
if (fromServer.LengthSquared() > maxDrift * maxDrift && maxDrift > 1e-3f) if (fromServer.LengthSquared() > maxDrift * maxDrift && maxDrift > 1e-3f)
@ -3428,15 +3483,24 @@ public sealed class GameWindow : IDisposable
// Clamp back toward last server position. // Clamp back toward last server position.
var clamped = drState.LastServerPos + var clamped = drState.LastServerPos +
System.Numerics.Vector3.Normalize(fromServer) * maxDrift; System.Numerics.Vector3.Normalize(fromServer) * maxDrift;
ae.Entity.Position = clamped; drState.DeadReckonedPos = clamped;
} }
else else
{ {
ae.Entity.Position = predicted; drState.DeadReckonedPos = predicted;
} }
} }
} }
// Render position = dead-reckoned authoritative + residual.
// Residual decays toward zero, so after ~300ms the rendered
// position matches the authoritative truth.
float decay = MathF.Max(0f, 1f - SnapResidualDecayRate * dt);
drState.SnapResidual *= decay;
if (drState.SnapResidual.LengthSquared() < 1e-4f)
drState.SnapResidual = System.Numerics.Vector3.Zero;
ae.Entity.Position = drState.DeadReckonedPos + drState.SnapResidual;
// Rotation integration: if the sequencer's Omega is non-zero // Rotation integration: if the sequencer's Omega is non-zero
// (TurnRight / TurnLeft / any cycle with baked-in spin), rotate // (TurnRight / TurnLeft / any cycle with baked-in spin), rotate
// the entity's quaternion around the omega axis by |omega|*dt. // the entity's quaternion around the omega axis by |omega|*dt.