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:
parent
dc317a321b
commit
ab74d0328d
1 changed files with 71 additions and 7 deletions
|
|
@ -187,8 +187,38 @@ public sealed class GameWindow : IDisposable
|
|||
public System.Numerics.Vector3 ObservedVelocity;
|
||||
/// <summary>Server-supplied world velocity from UpdatePosition (HasVelocity flag).</summary>
|
||||
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>
|
||||
/// Soft-snap window in seconds: after an UpdatePosition arrives for a
|
||||
/// 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 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.Rotation = rot;
|
||||
|
||||
|
|
@ -1629,6 +1665,24 @@ public sealed class GameWindow : IDisposable
|
|||
drState.LastServerRot = rot;
|
||||
drState.LastServerPosTime = now;
|
||||
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.
|
||||
|
|
@ -3416,11 +3470,12 @@ public sealed class GameWindow : IDisposable
|
|||
|| mlo == 0x0F || mlo == 0x10;
|
||||
if (isLocomotion)
|
||||
{
|
||||
var predicted = ae.Entity.Position + worldVel * dt;
|
||||
// Cap prediction radius around last server pos. Over
|
||||
// DeadReckonMaxPredictSeconds we must not drift more
|
||||
// than 1 RunAnimSpeed × run-rate away from server
|
||||
// truth, so cap at |worldVel| * max time.
|
||||
// Integrate from the separate DeadReckonedPos — NOT
|
||||
// from Entity.Position, which may be carrying a
|
||||
// decaying soft-snap residual. This keeps the
|
||||
// integration clean and the residual applied as a
|
||||
// pure render-time offset.
|
||||
var predicted = drState.DeadReckonedPos + worldVel * dt;
|
||||
float maxDrift = worldVel.Length() * DeadReckonMaxPredictSeconds;
|
||||
var fromServer = predicted - drState.LastServerPos;
|
||||
if (fromServer.LengthSquared() > maxDrift * maxDrift && maxDrift > 1e-3f)
|
||||
|
|
@ -3428,15 +3483,24 @@ public sealed class GameWindow : IDisposable
|
|||
// Clamp back toward last server position.
|
||||
var clamped = drState.LastServerPos +
|
||||
System.Numerics.Vector3.Normalize(fromServer) * maxDrift;
|
||||
ae.Entity.Position = clamped;
|
||||
drState.DeadReckonedPos = clamped;
|
||||
}
|
||||
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
|
||||
// (TurnRight / TurnLeft / any cycle with baked-in spin), rotate
|
||||
// the entity's quaternion around the omega axis by |omega|*dt.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue