fix(render): Phase A8.F — camera collision no longer corrupts the damped eye (wall-press vibration)
Visual verification showed the camera vibrating/bouncing when pressed against a wall. Cause: the sweep wrote its clamped result back into _dampedEye, so the next frame's damping lerped from the wall toward the target and the sweep re-clamped it — a per-frame feedback loop. Retail keeps viewer_sought_position (damped, uncollided) separate from viewer (the published collided eye). Fix: collide into a separate publishedEye for Position/View/fade and leave _dampedEye as the clean sought position. New regression test Update_CollisionDoesNotCorruptDampedState (clamp-then-release → full recovery). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
05161399de
commit
aae5300fea
2 changed files with 58 additions and 10 deletions
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue