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

@ -344,6 +344,60 @@ public sealed class PlayerMovementController
/// </summary>
internal bool AdvancedObjectQuantumLastTick { get; private set; }
/// <summary>
/// #429 defect 2: how far the PRESENTED position's own clock advanced in
/// the last tick, in seconds. The presented position
/// (<see cref="RenderPosition"/>) lives on the retail 30 Hz object clock —
/// a lerp between the last two simulated quanta whose alpha clamps at 1 —
/// so near-quantum-length host frames alias against the 33.3 ms quantum
/// and the presented position under-advances relative to wall time. Any
/// consumer that smooths toward the presented position (the chase camera)
/// must integrate THIS delta, not the host frame's wall dt, or the two
/// visibly decohere on long frames (measured at ~1 m in one frame, the
/// felt run-hitch). Retail ties its camera to the physics-update callback
/// (<c>SmartBox::PlayerPhysicsUpdatedCallback</c> 0x00452d60) — the same
/// clock — which is the behavior this delta restores.
/// </summary>
public float PresentedDeltaSeconds { get; private set; }
/// <summary>
/// Seconds spanned by the prev→curr snapshot pair the presentation lerp
/// interpolates across — the length of the LAST simulated quantum, which
/// the retail clock makes variable (MinQuantum..MaxQuantum). Maintained by
/// <see cref="ComputePresentedDelta"/>, the single per-tick chokepoint.
/// </summary>
private float _lastQuantumSeconds = PhysicsBody.MinQuantum;
private float ComputePresentedDelta(
float wallDt,
double pendingBeforeSeconds,
in RetailObjectQuantumBatch batch)
{
if (batch.Discarded)
{
// The lerp base was reset to the current body position — the
// presented position snapped. Hand the camera the wall step so it
// snaps along rather than freezing mid-teleport.
_lastQuantumSeconds = PhysicsBody.MinQuantum;
return wallDt;
}
// Presented time = t(curr) lastInterval + min(pending, lastInterval):
// continuous across quantum fires, long-run rate 1 with bounded jitter.
// The per-tick advance is the simulated seconds plus the phase change.
float previousInterval = _lastQuantumSeconds;
float simulated = 0f;
if (batch.Count > 0)
{
simulated = batch.FullSteps * PhysicsBody.MaxQuantum + batch.Remainder;
_lastQuantumSeconds = batch.GetQuantum(batch.Count - 1);
}
float interval = _lastQuantumSeconds;
float before = Math.Min((float)pendingBeforeSeconds, previousInterval);
float after = Math.Min((float)_objectClock.PendingSeconds, interval);
float delta = simulated + (after - interval) - (before - previousInterval);
return Math.Max(delta, 0f);
}
/// <summary>
/// Returns retail's canonical outbound <c>Position</c>: the physics body's
/// carried cell id plus its landblock-local frame origin. Retail
@ -1043,6 +1097,7 @@ public sealed class PlayerMovementController
{
EnsurePublishedForRuntimeOperation();
AdvancedObjectQuantumLastTick = false;
PresentedDeltaSeconds = 0f;
if (float.IsFinite(elapsedSeconds) && elapsedSeconds > 0f)
_simTimeSeconds += elapsedSeconds;
_objectClock.Deactivate();
@ -2188,8 +2243,16 @@ public sealed class PlayerMovementController
private Vector3 ComputeRenderPosition()
{
// #429 defect 2 (residual): the prev→curr snapshot pair spans the LAST
// SIMULATED QUANTUM, whose length is variable — the retail clock
// simulates everything above MinQuantum in one step, so a long host
// frame produces a 34-100 ms quantum. Normalizing the interpolation by
// the fixed MinQuantum made the presented position freeze the frame a
// long quantum fired (alpha reset to 0 across a bigger gap) and then
// replay at gap/MinQuantum speed — the residual whole-view hiccup
// after the camera-clock fix. Normalize by the actual interval.
float alpha = Math.Clamp(
(float)(_objectClock.PendingSeconds / PhysicsBody.MinQuantum),
(float)(_objectClock.PendingSeconds / _lastQuantumSeconds),
0f,
1f);
return Vector3.Lerp(_prevPhysicsPos, _currPhysicsPos, alpha);
@ -2206,6 +2269,7 @@ public sealed class PlayerMovementController
{
EnsurePublishedForRuntimeOperation();
AdvancedObjectQuantumLastTick = false;
PresentedDeltaSeconds = 0f;
if (!float.IsFinite(dt) || dt <= 0f)
{
return CapturePresentationResult() with
@ -2216,12 +2280,14 @@ public sealed class PlayerMovementController
}
_simTimeSeconds += dt;
double pendingBeforeSeconds = _objectClock.PendingSeconds;
bool reactivated = _objectClock.Activate();
_body.TransientState |= TransientStateFlags.Active;
RetailObjectQuantumBatch batch = reactivated
? default
: _objectClock.Advance(dt);
AdvancedObjectQuantumLastTick = batch.Count > 0;
PresentedDeltaSeconds = ComputePresentedDelta(dt, pendingBeforeSeconds, in batch);
if (batch.Discarded)
{
_prevPhysicsPos = _body.Position;
@ -2326,6 +2392,7 @@ public sealed class PlayerMovementController
{
EnsurePublishedForRuntimeOperation();
AdvancedObjectQuantumLastTick = false;
PresentedDeltaSeconds = 0f;
// Reject a malformed host-frame duration at the controller boundary.
// The retail object clock cannot sanitize state that input/jump/yaw
// code already mutated; in particular Infinity would never converge
@ -2641,12 +2708,15 @@ public sealed class PlayerMovementController
// stale gaps above HugeQuantum. Every admitted quantum executes the
// whole object update below; animation never runs on a render-only
// fragment.
double pendingBeforeSeconds = _objectClock.PendingSeconds;
bool reactivated = _objectClock.Activate();
_body.TransientState |= TransientStateFlags.Active;
RetailObjectQuantumBatch quantumBatch = reactivated
? default
: _objectClock.Advance(dt);
AdvancedObjectQuantumLastTick = quantumBatch.Count > 0;
PresentedDeltaSeconds =
ComputePresentedDelta(dt, pendingBeforeSeconds, in quantumBatch);
bool justLanded = false;
if (quantumBatch.Discarded)
{