acdream/src/AcDream.App/Rendering/CameraFrameController.cs
Erik 4873c10673 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>
2026-08-24 09:16:41 +02:00

126 lines
5.2 KiB
C#

using AcDream.App.Combat;
using AcDream.App.Input;
using AcDream.App.Update;
using AcDream.Core.Rendering;
namespace AcDream.App.Rendering;
/// <summary>
/// Owns the per-update fly/chase camera publication tail.
/// </summary>
/// <remarks>
/// The local object phase normally publishes the player before inbound
/// traffic. When the player is first created by that inbound pass, the local
/// frame publishes its initial root here and the spatial reconciler refreshes
/// child/effect anchors before either chase camera samples it.
/// </remarks>
internal sealed class CameraFrameController : ICameraFramePhase
{
private readonly CameraController _camera;
private readonly IInputCaptureSource _capture;
private readonly ICameraFrameInputSource _input;
private readonly ILocalPlayerPresentationRuntime _player;
private readonly IChaseCameraSource _chase;
private readonly RetailLocalPlayerFrameController _localFrame;
private readonly ILiveSpatialReconcilePhase _spatialReconciler;
private readonly ICombatCameraTargetSource _combatTarget;
public CameraFrameController(
CameraController camera,
IInputCaptureSource capture,
ICameraFrameInputSource input,
ILocalPlayerPresentationRuntime player,
IChaseCameraSource chase,
RetailLocalPlayerFrameController localFrame,
ILiveSpatialReconcilePhase spatialReconciler,
ICombatCameraTargetSource combatTarget)
{
_camera = camera ?? throw new ArgumentNullException(nameof(camera));
_capture = capture ?? throw new ArgumentNullException(nameof(capture));
_input = input ?? throw new ArgumentNullException(nameof(input));
_player = player ?? throw new ArgumentNullException(nameof(player));
_chase = chase ?? throw new ArgumentNullException(nameof(chase));
_localFrame = localFrame ?? throw new ArgumentNullException(nameof(localFrame));
_spatialReconciler = spatialReconciler
?? throw new ArgumentNullException(nameof(spatialReconciler));
_combatTarget = combatTarget ?? throw new ArgumentNullException(nameof(combatTarget));
}
public void Tick(UpdateFrameTiming timing)
{
if (_capture.DevToolsWantCaptureKeyboard || !_input.IsAvailable)
return;
if (_camera.IsFlyMode)
{
FlyCameraInput input = _input.CaptureFly();
_camera.Fly.Update(
timing.SimulationDeltaSeconds,
input.Forward,
input.Left,
input.Backward,
input.Right,
input.Up,
input.Down,
input.Boost);
return;
}
PlayerMovementController? controller = _player.Controller;
ChaseCamera? legacy = _chase.Legacy;
RetailChaseCamera? retail = _chase.Retail;
if (!_player.CanPresentPlayer || controller is null || legacy is null)
return;
if (CameraDiagnostics.UseRetailChaseCamera && retail is not null)
{
ChaseCameraAdjustmentInput input = _input.CaptureChaseAdjustment();
float adjustment = CameraDiagnostics.CameraAdjustmentSpeed
* timing.SimulationDeltaSecondsSingle;
if (input.ZoomIn)
retail.AdjustDistance(-adjustment);
if (input.ZoomOut)
retail.AdjustDistance(+adjustment);
if (input.Raise)
retail.AdjustPitch(+adjustment * 0.02f);
if (input.Lower)
retail.AdjustPitch(-adjustment * 0.02f);
}
if (!_localFrame.TryGetPresentationAfterNetwork(out var playerFrame))
return;
if (!playerFrame.AdvancedBeforeNetwork)
_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: cameraDt);
retail?.Update(
result.RenderPosition,
controller.Yaw,
playerVelocity: controller.BodyVelocity,
isOnGround: result.IsOnGround,
contactPlaneNormal: controller.ContactPlane.Normal,
dt: cameraDt,
cellId: controller.CellId,
selfEntityId: controller.LocalEntityId,
trackedTargetPoint: _combatTarget.GetTrackedTargetPoint());
}
}