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.