fix(camera): rest-snap render position — kills the indoor doorway standing-still flicker
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) <noreply@anthropic.com>
This commit is contained in:
parent
b3a9884dff
commit
cd974b29bc
2 changed files with 91 additions and 1 deletions
|
|
@ -810,7 +810,30 @@ public sealed class PlayerMovementController
|
||||||
private Vector3 ComputeRenderPosition()
|
private Vector3 ComputeRenderPosition()
|
||||||
{
|
{
|
||||||
float alpha = Math.Clamp(_physicsAccum / PhysicsBody.MinQuantum, 0f, 1f);
|
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)
|
public MovementResult Update(float dt, MovementInput input)
|
||||||
|
|
|
||||||
|
|
@ -224,6 +224,73 @@ public class PlayerMovementControllerTests
|
||||||
Assert.Equal(result.Position.Z, result.RenderPosition.Z, precision: 4);
|
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]
|
[Fact]
|
||||||
public void Update_RunForward_MoveFasterThanWalk()
|
public void Update_RunForward_MoveFasterThanWalk()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue