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:
parent
0330fcd0d1
commit
4873c10673
5 changed files with 110 additions and 100 deletions
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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=<path></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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue