fix(render): Part 1 — camera boom convergence snap (kills the at-rest viewer-cell flicker trigger)

Port retail CameraManager::UpdateCamera's convergence snap (0x00456fcd):
once the per-frame lerp step is below 0.0004 m AND the rotation within
0.000199999995, freeze the damped eye at an exact fixed point instead of
Vector3.Lerp's endless sub-mm asymptote. The drift was walking the 3rd-person
eye across the vestibule/room portal plane at rest, flipping the per-frame
viewer-cell resolve 0170<->0171 -> the indoor grey/texture flicker. The
collided-eye firewall (separate publishedEye local) is already present.

Adds ApplyConvergenceSnap static (TDD: 3 unit tests + 1 integration freeze
test) + SnapEpsilon/RotCloseEpsilon. App suite 183 -> 187, all green.

Plan: docs/superpowers/plans/2026-06-05-indoor-viewer-cell-flicker-fix.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-05 15:56:04 +02:00
parent 9601ef39c3
commit d2212cfaea
3 changed files with 688 additions and 2 deletions

View file

@ -77,6 +77,16 @@ public sealed class RetailChaseCamera : ICamera
public const float PitchMin = -0.7f;
public const float PitchMax = 1.4f;
// Retail CameraManager::UpdateCamera convergence-snap thresholds (decomp
// acclient_2013_pseudo_c.txt, 0x00456fcd0x00457035). SnapEpsilon = 2 ×
// 0.000199999995 m ≈ 0.0004 m — the per-frame translation step below which retail
// freezes the boom at an exact fixed point (0x00456fe1). RotCloseEpsilon =
// 0.000199999995 — the Frame::close_rotation tolerance (0x00456fdd). Without the
// snap, Vector3.Lerp asymptotes forever and the boom drifts at rest, walking the eye
// across a portal plane and flipping the viewer cell → the indoor flicker.
private const float SnapEpsilon = 0.000199999995f * 2f;
private const float RotCloseEpsilon = 0.000199999995f;
// ── Damped state ────────────────────────────────────────────────
private readonly Vector3[] _velocityRing = new Vector3[5];
@ -146,8 +156,14 @@ public sealed class RetailChaseCamera : ICamera
{
float tAlpha = ComputeDampingAlpha(CameraDiagnostics.TranslationStiffness, dt);
float rAlpha = ComputeDampingAlpha(CameraDiagnostics.RotationStiffness, dt);
_dampedEye = Vector3.Lerp(_dampedEye, targetEye, tAlpha);
_dampedForward = Vector3.Normalize(Vector3.Lerp(_dampedForward, targetForward, rAlpha));
Vector3 candidateEye = Vector3.Lerp(_dampedEye, 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);
}
// 5b. Spring-arm collision (A8.F). Retail SmartBox::update_viewer
@ -369,6 +385,26 @@ public sealed class RetailChaseCamera : ICamera
return a;
}
/// <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),
/// 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)
{
bool translationConverged = Vector3.Distance(candidateEye, dampedEye) < SnapEpsilon;
bool rotationConverged = Vector3.Distance(candidateForward, dampedForward) < RotCloseEpsilon;
if (translationConverged && rotationConverged)
return (dampedEye, dampedForward, true); // freeze: exact fixed point
return (candidateEye, candidateForward, false);
}
/// <summary>
/// Low-pass filter for a single mouse axis. Mirrors retail's
/// <c>CameraSet::FilterMouseInput</c>: if last sample was within