fix #180: port retail's stateful camera sought-position - the sweep target converges onto the wall contact

The camera-collision sweep strobed the eye 0.27m every ~5-10 frames while
the compressed chase boom moved along corridor walls (pulledIn 0.27<->0.53
on ~1.4mm input drift): RetailChaseCamera re-demanded the FULL-length ideal
boom from scratch each frame, so the pivot->eye ray re-rolled the same
knife-edge r+-eps graze on the double-faced slabs every frame, and its two
first-contact solutions tear-interleaved at ~1700fps into the #176
"stripes/triangles".

Retail never re-rolls that ray. CameraManager::UpdateCamera (0x00456660)
interpolates FROM THE CURRENT SWEPT VIEWER toward the desired pose
(interpolate_origin/rotation, stiffness 0.45 x dt x 10, clamped) and the
result becomes viewer_sought_position (SmartBox::PlayerPhysicsUpdatedCallback
0x00452d60); update_viewer (0x00453ce0) sweeps pivot->SOUGHT. Pressed against
a wall the sweep ray extends one interpolation step past the contact
(sub-mm at high fps), so a bistable graze can move the eye by at most that
step - the strobe is structurally impossible. A 0.4mm/2e-4 dead-band parks
the sought exactly on the viewer when converged (0x00456fcd-0x00457035).

- RetailChaseCamera: _dampedEye -> _soughtEye + _publishedEye (retail's two
  Positions); lerp base = the published (swept) viewer; sweep targets the
  sought; total-fallback (ViewerCellId==0) resets the sought like
  set_viewer(player_pos, 1). The old "collision must NOT feed back into the
  damped state" comment had the coupling backwards - what stays clean is the
  transient desired pose, not the sought.
- SweepEye untouched (faithful update_viewer port, exonerated by the #180
  investigation).
- Tests: the old pin asserting instant full re-extension after a clamp
  (the divergence itself) replaced with four retail pins: gradual
  re-extension, sweep-target-converges-onto-contact, total-fallback
  re-extends from the player, wall-press glide stability.
- Pseudocode doc: docs/research/2026-07-06-camera-sought-position-pseudocode.md
  (UpdateCamera tail incl. the sought derivation + set_viewer reset semantics
  + Frame interpolate/close_rotation).
- Register: AD-37 (forward-vector nlerp vs quaternion slerp), AD-38
  (init-at-full-extension vs retail re-extend-from-player) - both
  pre-existing, identified during the decomp reading.

Suites green (Core 2599+2skip / App 729+2skip / UI 425 / Net 385).
Pending: autonomous visual verify + user gate (#180 + the #176 re-gate).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-06 16:49:01 +02:00
parent 7272235a22
commit 48aaab811c
5 changed files with 571 additions and 56 deletions

View file

@ -8,8 +8,12 @@ namespace AcDream.App.Rendering;
/// Retail-faithful chase camera. Ports the chase-cam behavior from the
/// 2013 acclient (<c>CameraManager</c> + <c>CameraSet</c>, decomp at
/// <c>docs/research/named-retail/acclient_2013_pseudo_c.txt:95505</c>):
/// exponential damping toward a target pose, 5-frame velocity-averaged
/// slope-aligned heading frame, mouse-input low-pass filter.
/// a STATEFUL sought position that converges from the current swept
/// viewer toward the desired boom pose (<c>CameraManager::UpdateCamera</c>
/// 0x00456660 → <c>viewer_sought_position</c>, the #180 fix), 5-frame
/// velocity-averaged slope-aligned heading frame, mouse-input low-pass
/// filter. Pseudocode:
/// <c>docs/research/2026-07-06-camera-sought-position-pseudocode.md</c>.
///
/// <para>
/// Sits behind <see cref="CameraDiagnostics.UseRetailChaseCamera"/>
@ -97,11 +101,23 @@ public sealed class RetailChaseCamera : ICamera
private const float SnapEpsilon = 0.000199999995f * 2f;
private const float RotCloseEpsilon = 0.000199999995f;
// ── Damped state ────────────────────────────────────────────────
// ── Stateful camera state (retail SmartBox's two Positions) ─────
//
// _soughtEye = retail viewer_sought_position — the persisted sweep
// TARGET, re-derived each frame from the current swept
// viewer (NOT from itself).
// _publishedEye = retail viewer — the swept, published eye; the base
// of next frame's interpolation (SmartBox::
// PlayerPhysicsUpdatedCallback passes &this->viewer
// into UpdateCamera, 0x00452d75).
// _dampedForward = the sought's look direction. Sweeps translate but
// never rotate, so the viewer's rotation is always the
// previous sought rotation — one field serves both.
private readonly Vector3[] _velocityRing = new Vector3[5];
private int _velocityCount;
private Vector3 _dampedEye;
private Vector3 _soughtEye;
private Vector3 _publishedEye;
private Vector3 _dampedForward = new(1f, 0f, 0f);
private bool _initialised;
@ -155,10 +171,18 @@ public sealed class RetailChaseCamera : ICamera
Vector3 targetEye = pivotWorld + forward * (-horizontal) + up * vertical;
Vector3 targetForward = Vector3.Normalize(pivotWorld - targetEye);
// 5. Exponential damping (independent translation + rotation rates).
// 5. Stateful sought position (#180). Retail CameraManager::UpdateCamera
// (0x00456660) interpolates FROM THE CURRENT SWEPT VIEWER toward the
// desired pose and assigns the result to viewer_sought_position
// (SmartBox::PlayerPhysicsUpdatedCallback 0x00452d60) — the sweep
// target converges onto whatever the collision produced last frame
// and re-extends gradually. The full-length ideal boom is never swept
// directly. Pseudocode:
// docs/research/2026-07-06-camera-sought-position-pseudocode.md.
if (!_initialised)
{
_dampedEye = targetEye;
_soughtEye = targetEye;
_publishedEye = targetEye; // start converged (AD-38: retail re-extends from the player)
_dampedForward = targetForward;
_initialised = true;
}
@ -166,36 +190,48 @@ public sealed class RetailChaseCamera : ICamera
{
float tAlpha = ComputeDampingAlpha(CameraDiagnostics.TranslationStiffness, dt);
float rAlpha = ComputeDampingAlpha(CameraDiagnostics.RotationStiffness, dt);
Vector3 candidateEye = Vector3.Lerp(_dampedEye, targetEye, tAlpha);
// interpolate_origin(viewer.frame → desired, t) — the lerp base is the
// VIEWER (0x00456fae), not the previous sought. The forward base is the
// viewer's rotation ≡ the previous sought forward (sweeps never rotate).
Vector3 candidateEye = Vector3.Lerp(_publishedEye, targetEye, tAlpha);
Vector3 candidateForward = Vector3.Normalize(Vector3.Lerp(_dampedForward, targetForward, rAlpha));
// Retail UpdateCamera convergence snap (0x00456fcd): freeze at an exact fixed
// point once the lerp step is sub-epsilon, instead of dithering forever. This is
// the at-rest flicker fix — see ApplyConvergenceSnap + SnapEpsilon.
(_dampedEye, _dampedForward, _) =
ApplyConvergenceSnap(_dampedEye, _dampedForward, candidateEye, candidateForward);
// Retail UpdateCamera dead-band (0x00456fcd0x00457035): once the step
// off the viewer is sub-epsilon in translation AND rotation, the sought
// parks EXACTLY ON the viewer — an exact fixed point instead of an
// asymptote. Kills the at-rest drift AND the residual micro-jitter when
// pressed against a wall. See ApplyConvergenceSnap + SnapEpsilon.
(_soughtEye, _dampedForward, _) =
ApplyConvergenceSnap(_publishedEye, _dampedForward, candidateEye, candidateForward);
}
// 5b. Spring-arm collision (A8.F). Retail SmartBox::update_viewer
// (0x00453ce0) keeps TWO states: viewer_sought_position (the damped
// desired eye) and viewer (the published eye = set_viewer(curr_pos)).
// The collision produces the PUBLISHED eye each frame but must NOT
// feed back into the damped state — writing the clamped result into
// _dampedEye makes next frame's lerp start from the wall and fight
// the clamp, which shows up as visible oscillation/vibration when the
// eye is pressed against a wall. So collide into a separate local and
// leave _dampedEye as the clean, uncollided sought position.
Vector3 publishedEye = _dampedEye;
// 5b. Spring-arm collision (A8.F / #180). Retail SmartBox::update_viewer
// (0x00453ce0) sweeps the viewer_sphere pivot → viewer_sought_position
// and publishes the swept result as the viewer (set_viewer(curr_pos, 0)
// — the sought is NOT reset on success). Pressed against a wall, the
// sweep ray extends only one interpolation step past the contact, so a
// knife-edge r±ε graze can move the eye by at most that step (sub-mm at
// high fps) instead of re-solving the full-length boom with its 0.27 m
// bistable contact pair — the #180 strobe fix.
Vector3 publishedEye = _soughtEye;
// The viewer cell defaults to the player cell (collision off / null probe); the sweep
// overwrites it with the swept cell (retail viewer_cell). Always set so GameWindow has a
// robust per-frame "which cell is the camera in?" answer.
ViewerCellId = cellId;
if (CameraDiagnostics.CollideCamera && CollisionProbe is not null)
{
var swept = CollisionProbe.SweepEye(pivotWorld, _dampedEye, cellId, selfEntityId, playerPosition);
var swept = CollisionProbe.SweepEye(pivotWorld, _soughtEye, cellId, selfEntityId, playerPosition);
publishedEye = swept.Eye;
ViewerCellId = swept.ViewerCellId;
// Total-failure fallback = retail set_viewer(player_pos, reset_sought=1)
// (update_viewer :92886 and the cell==0 bail :92775 — both surface here as
// ViewerCellId == 0): the sought resets to the returned position and
// re-extends from there.
if (swept.ViewerCellId == 0)
_soughtEye = swept.Eye;
}
// Retail viewer — the base of next frame's interpolation (step 5).
_publishedEye = publishedEye;
// 6. Publish renderer surface (from the collided eye; rotation stays the
// smoothly-damped look direction toward the pivot).
@ -396,22 +432,23 @@ public sealed class RetailChaseCamera : ICamera
}
/// <summary>
/// Retail <c>CameraManager::UpdateCamera</c> convergence snap (decomp 0x00456fcd).
/// After the per-frame lerp, if the translation step from <paramref name="dampedEye"/>
/// to <paramref name="candidateEye"/> is below <see cref="SnapEpsilon"/> AND the
/// rotation step is below <see cref="RotCloseEpsilon"/>, retail returns the input
/// position unchanged — an exact fixed point. Returns <c>frozen=true</c> with the
/// current state in that case; otherwise <c>frozen=false</c> with the candidate.
/// Both conditions are required (retail couples origin + rotation in the snap test),
/// Retail <c>CameraManager::UpdateCamera</c> dead-band (decomp 0x00456fcd0x00457035).
/// After the per-frame lerp, if the translation step from <paramref name="viewerEye"/>
/// (the interpolation base = the current swept viewer) to <paramref name="candidateEye"/>
/// is below <see cref="SnapEpsilon"/> AND the rotation step is below
/// <see cref="RotCloseEpsilon"/>, retail returns the VIEWER unchanged — the sought
/// parks exactly on it (<c>return viewer</c>, 0x00457025). Returns <c>frozen=true</c>
/// with the viewer state in that case; otherwise <c>frozen=false</c> with the candidate.
/// Both conditions are required (retail couples origin + rotation in the test),
/// so the boom keeps converging while the heading is still turning.
/// </summary>
internal static (Vector3 eye, Vector3 forward, bool frozen) ApplyConvergenceSnap(
Vector3 dampedEye, Vector3 dampedForward, Vector3 candidateEye, Vector3 candidateForward)
Vector3 viewerEye, Vector3 viewerForward, Vector3 candidateEye, Vector3 candidateForward)
{
bool translationConverged = Vector3.Distance(candidateEye, dampedEye) < SnapEpsilon;
bool rotationConverged = Vector3.Distance(candidateForward, dampedForward) < RotCloseEpsilon;
bool translationConverged = Vector3.Distance(candidateEye, viewerEye) < SnapEpsilon;
bool rotationConverged = Vector3.Distance(candidateForward, viewerForward) < RotCloseEpsilon;
if (translationConverged && rotationConverged)
return (dampedEye, dampedForward, true); // freeze: exact fixed point
return (viewerEye, viewerForward, true); // park: exact fixed point on the viewer
return (candidateEye, candidateForward, false);
}