diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index d7ed12f..1dadda6 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -187,8 +187,38 @@ public sealed class GameWindow : IDisposable
public System.Numerics.Vector3 ObservedVelocity;
/// Server-supplied world velocity from UpdatePosition (HasVelocity flag).
public System.Numerics.Vector3? ServerVelocity;
+
+ ///
+ /// 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.
+ ///
+ public System.Numerics.Vector3 DeadReckonedPos;
+
+ ///
+ /// 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.
+ ///
+ public System.Numerics.Vector3 SnapResidual;
}
+ /// 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.
+ private const float SnapResidualDecayRate = 8.0f;
+ ///
+ /// 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.
+ ///
+ private const float SnapHardSnapThreshold = 5.0f;
+
///
/// 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.