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:
parent
7272235a22
commit
48aaab811c
5 changed files with 571 additions and 56 deletions
|
|
@ -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 (0x00456fcd–0x00457035): 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 0x00456fcd–0x00457035).
|
||||
/// 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);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue