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

@ -94,11 +94,23 @@ internal sealed class CameraFrameController : ICameraFramePhase
_spatialReconciler.Reconcile();
MovementResult result = playerFrame.Movement;
// #429 defect 2: the chase camera smooths toward the PRESENTED player
// position, which lives on the retail 30 Hz object clock (see
// PlayerMovementController.PresentedDeltaSeconds). Integrating the
// damping with wall dt made the camera step full wall time on long
// frames while the presented player under-advanced against the
// quantum — measured ~1 m of camera/player decoherence in one frame,
// the felt run-hitch. Retail ties the camera to the physics-update
// callback (SmartBox::PlayerPhysicsUpdatedCallback 0x00452d60), i.e.
// the same clock as the body; the presented delta restores that.
// Manual zoom/pitch adjustment above stays on wall dt — it is a
// user-input rate, not target chasing.
float cameraDt = controller.PresentedDeltaSeconds;
legacy.Update(
result.RenderPosition,
controller.Yaw,
isOnGround: result.IsOnGround,
dt: timing.SimulationDeltaSecondsSingle);
dt: cameraDt);
retail?.Update(
result.RenderPosition,
@ -106,7 +118,7 @@ internal sealed class CameraFrameController : ICameraFramePhase
playerVelocity: controller.BodyVelocity,
isOnGround: result.IsOnGround,
contactPlaneNormal: controller.ContactPlane.Normal,
dt: timing.SimulationDeltaSecondsSingle,
dt: cameraDt,
cellId: controller.CellId,
selfEntityId: controller.LocalEntityId,
trackedTargetPoint: _combatTarget.GetTrackedTargetPoint());

View file

@ -1,75 +0,0 @@
using System.Diagnostics;
using System.Globalization;
using System.Numerics;
namespace AcDream.App.Rendering;
/// <summary>
/// TEMPORARY #429 apparatus. One CSV row per rendered frame:
/// <c>seconds,camX,camY,camZ,playerX,playerY,playerZ</c>, where seconds is a
/// Stopwatch-derived monotonic time and player is the frame's
/// <c>PlayerViewPosition</c> — the presented local-player position driving
/// lighting/visibility this frame. Off unless
/// <c>ACDREAM_PROBE_PLAYER_PRESENT=&lt;path&gt;</c> is set (one null check per
/// frame). Analysis: a hitch frame shows the player's per-frame delta
/// collapsing to ~0 or doubling; whether the SAME frame's dt is smooth
/// separates a presentation-phase bug (pack frame graph sampling a stale
/// snapshot) from genuine frame-pacing spikes. Strip with the #429 fix.
/// </summary>
internal sealed class PlayerPresentationProbe : IDisposable
{
private readonly StreamWriter _writer;
private readonly long _startTimestamp = Stopwatch.GetTimestamp();
private int _linesSinceFlush;
private PlayerPresentationProbe(StreamWriter writer)
{
_writer = writer;
_writer.WriteLine("seconds,camX,camY,camZ,playerX,playerY,playerZ");
}
internal static PlayerPresentationProbe? CreateFromEnvironment()
{
string? path = Environment.GetEnvironmentVariable("ACDREAM_PROBE_PLAYER_PRESENT");
if (string.IsNullOrWhiteSpace(path))
return null;
try
{
return new PlayerPresentationProbe(new StreamWriter(path, append: false));
}
catch (Exception ex)
{
Console.Error.WriteLine(
$"[player-present] probe file '{path}' could not be opened: {ex.Message}");
return null;
}
}
internal void Observe(in Vector3 cameraPosition, in Vector3 playerViewPosition)
{
double seconds = (Stopwatch.GetTimestamp() - _startTimestamp)
/ (double)Stopwatch.Frequency;
_writer.WriteLine(string.Create(
CultureInfo.InvariantCulture,
$"{seconds:F6},{cameraPosition.X:F4},{cameraPosition.Y:F4},{cameraPosition.Z:F4},{playerViewPosition.X:F4},{playerViewPosition.Y:F4},{playerViewPosition.Z:F4}"));
if (++_linesSinceFlush >= 240)
{
_linesSinceFlush = 0;
_writer.Flush();
}
}
public void Dispose()
{
try
{
_writer.Flush();
_writer.Dispose();
}
catch
{
// A probe must never turn teardown fallible.
}
}
}

View file

@ -450,10 +450,6 @@ internal sealed class RuntimeWorldFrameEnvironmentPreparation
private readonly HashSet<uint> _visibleCells = [];
private bool _visibleCellsValid;
/// <summary>TEMPORARY #429 — see <see cref="PlayerPresentationProbe"/>.</summary>
private readonly PlayerPresentationProbe? _playerPresentProbe =
PlayerPresentationProbe.CreateFromEnvironment();
public RuntimeWorldFrameEnvironmentPreparation(
RuntimeOptions options,
WorldTimeService worldTime,
@ -489,10 +485,6 @@ internal sealed class RuntimeWorldFrameEnvironmentPreparation
activeDayGroup,
camera.Position);
// TEMPORARY #429 apparatus — one CSV row per frame; off unless
// ACDREAM_PROBE_PLAYER_PRESENT names a file. Strip with the fix.
_playerPresentProbe?.Observe(camera.Position, roots.PlayerViewPosition);
UpdateSunFromSky(foundation.Sky, roots.PlayerInsideCell);
_lighting.UpdateViewerLight(roots.PlayerViewPosition);
_lighting.Tick(camera.Position);

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)
{

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]