fix(runtime/camera) #429: presented player and chase camera share the object clock

Two halves of the felt run-hitch (the visible one-frame player lurch):

- The presentation lerp normalized the pending object-clock time by the
  fixed 30 Hz MinQuantum, but retail's object clock simulates
  VARIABLE-length quanta (CPhysicsObj::update_object 0x00515D10: capped
  at MaxQuantum, everything above MinQuantum runs as ONE step). After a
  long frame the view froze for the quantum and then fast-replayed it.
  ComputeRenderPosition now spans the ACTUAL last quantum
  (_lastQuantumSeconds), and PresentedDeltaSeconds accounts continuous
  presented time across quantum boundaries.

- The chase camera damped toward the presented player using wall dt
  while the player presents on the object clock, so a long frame
  stepped the camera far past the under-advanced player — measured up
  to ~1 m of camera/player decoherence in a single frame. Retail ties
  camera update to the physics-update callback
  (SmartBox::PlayerPhysicsUpdatedCallback 0x00452d60), i.e. the same
  clock as the body; both chase cameras now integrate
  PresentedDeltaSeconds. Manual zoom/pitch adjustment stays on wall dt
  (a user-input rate, not target chasing).

Owner gate: camera-vs-player boom-length change fell from ~1 m spikes
to 0.2-1.2 cm median on long frames; teleports settle clean. Two
Runtime tests updated to pin the continuous-rate contract. The
temporary PlayerPresentationProbe apparatus that measured this is
retired with the fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-24 09:16:41 +02:00
parent 0330fcd0d1
commit 4873c10673
5 changed files with 110 additions and 100 deletions

View file

@ -546,8 +546,14 @@ public class PlayerMovementControllerTests
Assert.True(halfFrame.RenderPosition.X < firstTick.Position.X,
$"Render X={halfFrame.RenderPosition.X} should stay between {start.X} and {firstTick.Position.X}");
float expectedMidpoint = start.X + ((firstTick.Position.X - start.X) * 0.5f);
Assert.Equal(expectedMidpoint, halfFrame.RenderPosition.X, precision: 3);
// #429 defect 2 (residual): the interpolation normalizes by the LAST
// SIMULATED QUANTUM's length — here the ObjectTick-long remainder
// quantum, not the fixed MinQuantum — so the presented position
// advances at a continuous rate across variable-length quanta instead
// of freezing then over-speeding after a long host frame.
float alpha = (PhysicsBody.MinQuantum * 0.5f) / ObjectTick;
float expected = start.X + ((firstTick.Position.X - start.X) * alpha);
Assert.Equal(expected, halfFrame.RenderPosition.X, precision: 3);
}
[Fact]
@ -653,27 +659,32 @@ public class PlayerMovementControllerTests
}
[Fact]
public void Update_LeftoverAboveMinQuantum_ClampsRenderAlphaToCurrentPhysicsPosition()
public void Update_LeftoverAboveMinQuantum_InterpolatesAcrossTheActualQuantumInterval()
{
var engine = MakeFlatEngine();
var controller = new PlayerMovementController(engine);
controller.SeedPlacementForTest(new Vector3(96f, 96f, 50f), 0x0001, new Vector3(96f, 96f, 50f));
var start = new Vector3(96f, 96f, 50f);
controller.SeedPlacementForTest(start, 0x0001, start);
controller.Yaw = 0f;
var result = controller.Update(
PhysicsBody.MaxQuantum + PhysicsBody.MinQuantum,
new MovementInput(Forward: true));
// Tolerance, not decimal `precision:` — the AP-7 friction port (P2)
// shifts the velocity-fallback trajectory by micrometers, and
// Math.Round-based precision comparison fails when two essentially
// equal values straddle a 5e-5 rounding boundary (observed: X
// 96.3427505 vs 96.3427429 — a 7.6 µm gap rounding to 96.3428 vs
// 96.3427). The clamp contract is "render == physics for
// presentation"; 1 mm is far below visibility and boundary-immune.
Assert.Equal(result.Position.X, result.RenderPosition.X, tolerance: 1e-3f);
Assert.Equal(result.Position.Y, result.RenderPosition.Y, tolerance: 1e-3f);
Assert.Equal(result.Position.Z, result.RenderPosition.Z, tolerance: 1e-3f);
// #429 defect 2 (residual): one MaxQuantum step simulates and
// MinQuantum is retained as pending, so the prev→curr interpolation
// pair spans a MaxQuantum-long interval and the presented position
// sits MinQuantum INTO it — lerp(start, Position, Min/Max) — rather
// than clamping onto the authoritative body. The former clamp
// contract ("render == physics when leftover >= MinQuantum")
// presented a forward rate spike after every long host frame; the
// continuous-rate contract is what keeps the presentation and the
// chase camera (which integrates PresentedDeltaSeconds) coherent.
float alpha = PhysicsBody.MinQuantum / PhysicsBody.MaxQuantum;
Vector3 expected = Vector3.Lerp(start, result.Position, alpha);
Assert.Equal(expected.X, result.RenderPosition.X, tolerance: 1e-3f);
Assert.Equal(expected.Y, result.RenderPosition.Y, tolerance: 1e-3f);
Assert.Equal(expected.Z, result.RenderPosition.Z, tolerance: 1e-3f);
}
[Fact]