From cd974b29bccf27de2a66e898195fb1ee8c0f0325 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 8 Jun 2026 14:59:59 +0200 Subject: [PATCH] =?UTF-8?q?fix(camera):=20rest-snap=20render=20position=20?= =?UTF-8?q?=E2=80=94=20kills=20the=20indoor=20doorway=20standing-still=20f?= =?UTF-8?q?licker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause (pinned live, flap-churn.log at the Holtburg cottage doorway): the physics body is byte-stable at rest (rawPlayer = 1 distinct value), but PlayerMovementController.ComputeRenderPosition's Lerp(prev, curr, alpha) dithers the render position by microns — the two physics-tick snapshots lag the settled body (per-frame resolve edge-settles the resting sphere against the doorframe after the last tick wrote curr) while the leftover-accumulator alpha varies every frame. The grazing- doorframe camera-collision sweep (PhysicsCameraCollisionProbe.SweepEye) amplifies that ~1000x into a ~1.3 mm eye jitter (eye 17 distinct, RenderPosition 15 distinct) that trips the PortalVisibilityBuilder clip -> the standing-still flicker (blue void / grass over the cellar entrance) the user reported. Fix: at rest (body velocity below RestVelocityEpsilonSq) render AT the authoritative byte-stable body position instead of interpolating between two stale tick snapshots, so the camera's pivot input is byte-stable and the sweep output stops jittering. Mirrors retail (a resting object renders bit-stable) + the boom convergence snap (RetailChaseCamera.ApplyConvergenceSnap, d2212cf), one layer earlier. Sub-tick interpolation is preserved during motion (velocity above epsilon). This SUPERSEDES the committed bounded-propagation plan: the live pin proved ZERO portal re-enqueue churn during the flap (maxPop=1 across 13k oscillating frames; 0/63k reciprocals ever clipped empty), so the flap was never the churn the spec hypothesized. The ACDREAM_PROBE_PORTAL_CHURN apparatus did its job (refuted the hypothesis before the wrong fix was built); plan/spec/memory updates to follow. TDD: extracted the rest-snap into an internal-static pure ComputeRenderPosition; RED rest- snap test (stale prev!=curr + varying alpha dithers) -> GREEN after the gate; motion test guards interpolation; precondition test confirms a settled body's velocity is below the gate threshold. 29 controller+cellar + 62 camera+portal tests green, no regression. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Input/PlayerMovementController.cs | 25 ++++++- .../Input/PlayerMovementControllerTests.cs | 67 +++++++++++++++++++ 2 files changed, 91 insertions(+), 1 deletion(-) diff --git a/src/AcDream.App/Input/PlayerMovementController.cs b/src/AcDream.App/Input/PlayerMovementController.cs index 5332fe37..d422738f 100644 --- a/src/AcDream.App/Input/PlayerMovementController.cs +++ b/src/AcDream.App/Input/PlayerMovementController.cs @@ -810,7 +810,30 @@ public sealed class PlayerMovementController private Vector3 ComputeRenderPosition() { float alpha = Math.Clamp(_physicsAccum / PhysicsBody.MinQuantum, 0f, 1f); - return Vector3.Lerp(_prevPhysicsPos, _currPhysicsPos, alpha); + return ComputeRenderPosition(_prevPhysicsPos, _currPhysicsPos, _body.Position, _body.Velocity, alpha); + } + + // Render-position rest-snap (2026-06-08, indoor doorway flap). At rest the authoritative + // body position is byte-stable, but the two physics-tick snapshots (prev/curr) can lag it by + // microns — the per-frame resolve edge-settles the resting sphere against doorframe geometry + // after the last tick wrote curr — so Lerp(prev, curr, alpha) with a per-frame-VARYING + // leftover-accumulator alpha dithers the render position by microns. The grazing-doorframe + // camera-collision sweep (PhysicsCameraCollisionProbe.SweepEye) amplifies that ~1000x into a + // ~1.3 mm eye jitter that trips the portal-flood clip → the standing-still indoor flicker + // (pinned live, flap-churn.log: rawPlayer 1 distinct, RenderPosition 15 distinct, eye 17). + // When the body is at rest (velocity below epsilon) render AT the authoritative position so + // the camera's pivot input is byte-stable. Mirrors retail (a resting object renders + // bit-stable) + the boom convergence snap (RetailChaseCamera.ApplyConvergenceSnap, d2212cf), + // one layer earlier. Interpolation between tick snapshots is preserved during motion + // (velocity above epsilon), so sub-tick movement stays smooth. + internal const float RestVelocityEpsilonSq = 1e-4f; // (0.01 m/s)^2 — below this the body is at rest + + internal static Vector3 ComputeRenderPosition( + Vector3 prevPhysicsPos, Vector3 currPhysicsPos, Vector3 bodyPosition, Vector3 bodyVelocity, float alpha) + { + if (bodyVelocity.LengthSquared() < RestVelocityEpsilonSq) + return bodyPosition; // at rest: render at the authoritative byte-stable position + return Vector3.Lerp(prevPhysicsPos, currPhysicsPos, alpha); } public MovementResult Update(float dt, MovementInput input) diff --git a/tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs b/tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs index 1dd41b37..260fd73a 100644 --- a/tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs +++ b/tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs @@ -224,6 +224,73 @@ public class PlayerMovementControllerTests Assert.Equal(result.Position.Z, result.RenderPosition.Z, precision: 4); } + // ── Indoor doorway flap: render-position rest-snap (2026-06-08) ─────────── + // + // Live pin (flap-churn.log, user at the cottage doorway): the physics body is + // byte-stable at rest (rawPlayer = 1 distinct value), but the render position + // (Lerp of the two physics-tick snapshots) jitters ~µm and the camera EYE + // jitters ~1.3 mm — a ~1000x amplification by the grazing-doorframe camera- + // collision sweep, which trips the portal clip → the standing-still flicker. + // The dither is structural: at rest the tick snapshots (_prevPhysicsPos / + // _currPhysicsPos) can lag the settled authoritative Position by microns (door- + // frame edge-settle in the per-frame resolve), so Lerp(prev, curr, alpha) with a + // per-frame-VARYING alpha sweeps a tiny segment instead of holding still. Fix: + // at rest (velocity below epsilon) render AT the authoritative body position — + // byte-identical and alpha-independent. Mirrors retail (a resting object renders + // bit-stable) and the boom convergence snap (RetailChaseCamera.ApplyConvergenceSnap, + // d2212cf), one layer earlier so the camera's pivot input is byte-stable too. + // + // The flat-terrain controller tests above CANNOT reproduce the doorframe-specific + // prev!=curr-at-rest condition (flat terrain collapses prev==curr), so these test + // the pure rest-snap function directly; the end-to-end acceptance is the live + // doorway visual gate. + [Fact] + public void ComputeRenderPosition_AtRestWithStaleEndpoints_SnapsToAuthoritativePosition_NoAlphaDither() + { + // prev lags curr by 30 µm (the live doorframe edge-settle lag); body = the settled + // authoritative position; velocity = 0 (at rest). Two different leftover-accumulator + // alphas must BOTH return the authoritative position, byte-identical (no dither). + var prev = new Vector3(155.525116f, 14.225600f, 94f); + var curr = new Vector3(155.525146f, 14.225600f, 94f); + var body = new Vector3(155.525146f, 14.225600f, 94f); + + var lowAlpha = PlayerMovementController.ComputeRenderPosition(prev, curr, body, Vector3.Zero, alpha: 0.15f); + var highAlpha = PlayerMovementController.ComputeRenderPosition(prev, curr, body, Vector3.Zero, alpha: 0.93f); + + Assert.Equal(body, lowAlpha); // byte-identical to the authoritative position + Assert.Equal(lowAlpha, highAlpha); // alpha-independent at rest (no dither) + } + + [Fact] + public void ComputeRenderPosition_Moving_InterpolatesBetweenTickSnapshots() + { + // Guard the no-over-fire half: while moving (velocity well above the rest epsilon) + // the render position must still interpolate smoothly between the tick snapshots. + var prev = new Vector3(96.0f, 96f, 50f); + var curr = new Vector3(96.3f, 96f, 50f); + var moving = new Vector3(3.12f, 0f, 0f); // walk speed + + var half = PlayerMovementController.ComputeRenderPosition(prev, curr, curr, moving, alpha: 0.5f); + + Assert.Equal(96.15f, half.X, precision: 3); // midpoint — interpolation preserved + } + + [Fact] + public void Update_AtRest_BodyVelocityBelowRenderRestSnapThreshold() + { + // Precondition for the render-position rest-snap: a settled grounded body's velocity must + // be below RestVelocityEpsilonSq, else ComputeRenderPosition's gate never fires at rest and + // the doorway flicker persists. kill_velocity on grounded contact drives it to zero. + var engine = MakeFlatEngine(); + var controller = new PlayerMovementController(engine); + controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001); + for (int i = 0; i < 60; i++) controller.Update(1f / 60f, new MovementInput()); + + Assert.True( + controller.BodyVelocity.LengthSquared() < PlayerMovementController.RestVelocityEpsilonSq, + $"resting body velocity {controller.BodyVelocity.Length()} m/s must be below the rest-snap threshold"); + } + [Fact] public void Update_RunForward_MoveFasterThanWalk() {