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:
parent
9601ef39c3
commit
d2212cfaea
3 changed files with 688 additions and 2 deletions
|
|
@ -605,4 +605,102 @@ public class RetailChaseCameraTests
|
|||
Assert.True(cam.Position.X < -2f,
|
||||
$"published eye should fully recover to the target after release, got {cam.Position}");
|
||||
}
|
||||
|
||||
// ── Convergence snap (Part 1: kills the at-rest boom drift) ────────
|
||||
|
||||
[Fact]
|
||||
public void ConvergenceSnap_StepBelowEpsilon_FreezesAtCurrent()
|
||||
{
|
||||
// Both the translation step and the rotation step are below the retail snap
|
||||
// thresholds (0.0004 m / 0.0002) → freeze: return the CURRENT damped state,
|
||||
// not the candidate. This is the exact fixed point retail's UpdateCamera reaches.
|
||||
var damped = new Vector3(5f, 6f, 7f);
|
||||
var forward = Vector3.Normalize(new Vector3(1f, 0f, 0f));
|
||||
var candidate = damped + new Vector3(0.0001f, 0f, 0f); // 0.1 mm step < 0.4 mm
|
||||
var candFwd = forward; // no rotation step
|
||||
|
||||
var (eye, fwd, frozen) = RetailChaseCamera.ApplyConvergenceSnap(damped, forward, candidate, candFwd);
|
||||
|
||||
Assert.True(frozen);
|
||||
Assert.Equal(damped, eye); // exact — returns the input, freezing the drift
|
||||
Assert.Equal(forward, fwd);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvergenceSnap_TranslationStepAboveEpsilon_ReturnsCandidate()
|
||||
{
|
||||
var damped = new Vector3(5f, 6f, 7f);
|
||||
var forward = Vector3.Normalize(new Vector3(1f, 0f, 0f));
|
||||
var candidate = damped + new Vector3(0.01f, 0f, 0f); // 1 cm step ≫ 0.4 mm
|
||||
var candFwd = forward;
|
||||
|
||||
var (eye, fwd, frozen) = RetailChaseCamera.ApplyConvergenceSnap(damped, forward, candidate, candFwd);
|
||||
|
||||
Assert.False(frozen);
|
||||
Assert.Equal(candidate, eye); // still converging → apply the lerp step
|
||||
Assert.Equal(candFwd, fwd);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvergenceSnap_RotationStepAboveEpsilon_ReturnsCandidate()
|
||||
{
|
||||
// Translation has converged but the heading is still turning — retail does NOT
|
||||
// freeze unless BOTH are close (it returns the interpolated frame). So a small
|
||||
// translation step must NOT freeze while the forward is still rotating.
|
||||
var damped = new Vector3(5f, 6f, 7f);
|
||||
var forward = Vector3.Normalize(new Vector3(1f, 0f, 0f));
|
||||
var candidate = damped + new Vector3(0.0001f, 0f, 0f); // sub-epsilon translation
|
||||
var candFwd = Vector3.Normalize(new Vector3(1f, 0.05f, 0f)); // ~0.05 rad turn ≫ 0.0002
|
||||
|
||||
var (_, _, frozen) = RetailChaseCamera.ApplyConvergenceSnap(damped, forward, candidate, candFwd);
|
||||
|
||||
Assert.False(frozen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_AtRestAfterConvergence_BoomFreezesAtExactFixedPoint()
|
||||
{
|
||||
// The retail UpdateCamera snap freezes the boom at an exact fixed point once the
|
||||
// per-frame step falls below ~0.4 mm. Without it, Vector3.Lerp asymptotes forever
|
||||
// — the eye dithers sub-millimetre every frame and walks across the portal plane,
|
||||
// flipping the viewer cell (the indoor flicker). Hold a pose DIFFERENT from the
|
||||
// init pose so the boom has to converge over many frames; with collision OFF
|
||||
// (Position == _dampedEye), two consecutive post-convergence frames must be
|
||||
// BIT-IDENTICAL. (At frame 120, α≈0.075, displacement ~7 m, the un-snapped step is
|
||||
// ~5e-5 m ≈ tens of float ULP — distinguishably nonzero — so this is a real RED.)
|
||||
bool savedAlign = CameraDiagnostics.AlignToSlope;
|
||||
bool savedColl = CameraDiagnostics.CollideCamera;
|
||||
float savedT = CameraDiagnostics.TranslationStiffness;
|
||||
float savedR = CameraDiagnostics.RotationStiffness;
|
||||
try
|
||||
{
|
||||
CameraDiagnostics.AlignToSlope = false; // deterministic heading
|
||||
CameraDiagnostics.CollideCamera = false; // Position == _dampedEye
|
||||
CameraDiagnostics.TranslationStiffness = 0.45f;
|
||||
CameraDiagnostics.RotationStiffness = 0.45f;
|
||||
|
||||
var cam = new RetailChaseCamera { Distance = 2.61f, Pitch = 0.291f };
|
||||
|
||||
// Frame 1 at pose A: init snaps the damped eye to A's target.
|
||||
cam.Update(Vector3.Zero, 0.5f, Vector3.Zero, true, Vector3.UnitZ, 1f / 60f);
|
||||
|
||||
// Hold pose B for many frames → the boom lerps A's target → B's target.
|
||||
var posB = new Vector3(5f, 5f, 0f);
|
||||
for (int i = 0; i < 120; i++)
|
||||
cam.Update(posB, 0.5f, Vector3.Zero, true, Vector3.UnitZ, 1f / 60f);
|
||||
|
||||
Vector3 a = cam.Position;
|
||||
cam.Update(posB, 0.5f, Vector3.Zero, true, Vector3.UnitZ, 1f / 60f);
|
||||
Vector3 b = cam.Position;
|
||||
|
||||
Assert.Equal(a, b); // exact — frozen, not dithering
|
||||
}
|
||||
finally
|
||||
{
|
||||
CameraDiagnostics.AlignToSlope = savedAlign;
|
||||
CameraDiagnostics.CollideCamera = savedColl;
|
||||
CameraDiagnostics.TranslationStiffness = savedT;
|
||||
CameraDiagnostics.RotationStiffness = savedR;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue