diff --git a/src/AcDream.App/Rendering/RetailChaseCamera.cs b/src/AcDream.App/Rendering/RetailChaseCamera.cs index 0470311..79de8f4 100644 --- a/src/AcDream.App/Rendering/RetailChaseCamera.cs +++ b/src/AcDream.App/Rendering/RetailChaseCamera.cs @@ -143,19 +143,26 @@ public sealed class RetailChaseCamera : ICamera } // 5b. Spring-arm collision (A8.F). Retail SmartBox::update_viewer - // (0x00453ce0) sweeps viewer_sphere from the head-pivot to the - // desired eye and uses the stopped position. Keeps the eye out of - // walls so the A8.F camera-cell + portal side-tests stay stable. - // A null probe or disabled flag leaves the eye unchanged. + // (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; if (CameraDiagnostics.CollideCamera && CollisionProbe is not null) - _dampedEye = CollisionProbe.SweepEye(pivotWorld, _dampedEye, cellId, selfEntityId); + publishedEye = CollisionProbe.SweepEye(pivotWorld, _dampedEye, cellId, selfEntityId); - // 6. Publish renderer surface. - Position = _dampedEye; - View = Matrix4x4.CreateLookAt(_dampedEye, _dampedEye + _dampedForward, new Vector3(0f, 0f, 1f)); + // 6. Publish renderer surface (from the collided eye; rotation stays the + // smoothly-damped look direction toward the pivot). + Position = publishedEye; + View = Matrix4x4.CreateLookAt(publishedEye, publishedEye + _dampedForward, new Vector3(0f, 0f, 1f)); - // 7. Auto-fade translucency. - float d = Vector3.Distance(_dampedEye, pivotWorld); + // 7. Auto-fade translucency — uses the published (collided) eye so the + // player fades once the eye is pulled in close. + float d = Vector3.Distance(publishedEye, pivotWorld); PlayerTranslucency = ComputeTranslucency(d); } diff --git a/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs b/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs index 325115b..8a3063b 100644 --- a/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs @@ -531,4 +531,45 @@ public class RetailChaseCameraTests Assert.Equal(pulledIn, cam.Position); Assert.Equal(1f, cam.PlayerTranslucency, 3); } + + // Probe that clamps the eye to a fixed point on the FIRST call, then + // releases (returns the requested eye unchanged) on later calls. + private sealed class ClampThenReleaseProbe : ICameraCollisionProbe + { + public int Calls; + public Vector3 ClampEye; + public Vector3 SweepEye(Vector3 pivot, Vector3 desiredEye, uint cellId, uint selfEntityId) + { + Calls++; + return Calls == 1 ? ClampEye : desiredEye; + } + } + + [Fact] + public void Update_CollisionDoesNotCorruptDampedState() + { + // Regression for the wall-press vibration: the sweep must NOT write its + // clamped result back into the damped "sought" eye (retail keeps + // viewer_sought_position separate from viewer). Frame 1 clamps the eye + // near the pivot; frame 2 releases. With the damp state kept clean, the + // published eye returns straight to the (constant) target on frame 2; if + // it were corrupted, frame 2 would only lerp ~7.5% back from the clamp + // and stay pinned near it. + CameraDiagnostics.CollideCamera = true; + var probe = new ClampThenReleaseProbe { ClampEye = new Vector3(0f, 0f, 2f) }; + var cam = new RetailChaseCamera { CollisionProbe = probe }; + + void Step() => cam.Update( + playerPosition: Vector3.Zero, playerYaw: 0f, playerVelocity: Vector3.Zero, + isOnGround: true, contactPlaneNormal: Vector3.UnitZ, dt: 1f / 60f, + cellId: 0x100, selfEntityId: 0x5); + + Step(); // frame 1: clamps to (0,0,2) + Step(); // frame 2: releases + + // Constant pose → target eye ≈ (-2.5, 0, 2.25). Full recovery means + // Position.X is near the target (< -2), not pinned near the clamp (X≈0). + Assert.True(cam.Position.X < -2f, + $"published eye should fully recover to the target after release, got {cam.Position}"); + } }