fix(camera): retail-faithful jump-tracking via contact-plane projection

Original symptom: jumping made the camera swing around the player
vertically — the basis tilted up/down with the player's Z velocity.

Root cause: ComputeHeading used the raw 3D velocity vector as the
heading direction. During a jump, velocity has a substantial Z
component (vy ≈ jump speed), and `normalize((vx, vy, vz))` produced
a heading pointing up. The basis tilted accordingly and the camera
went under/over the player.

Retail's actual ALIGN_WITH_PLANE algorithm (decomp at
acclient_2013_pseudo_c.txt:95644-95795) is different:

  1. Velocity is only used as a gate. If |vx| AND |vy| > epsilon
     (player is moving in XY), proceed; otherwise fall back to the
     LOOK_IN_DIRECTION path (player's facing direction unchanged).
  2. The base heading is `localtoglobalvec(player, (0, 1, 0))` —
     the player's local +Y axis in world space, which in our
     convention is `(cos yaw, sin yaw, 0)`.
  3. Pick a surface normal:
       grounded:  contact_plane.N
       airborne:  (0, 0, 1)  [world up]
  4. Project the base heading onto the plane perpendicular to that
     normal:  projected = forward - normal * dot(forward, normal).
  5. Normalize. Fall back to the base if projection collapses.

Behaviorally:
  * Standing jump (vx≈0, vy≈0):  gate fails → base heading. Camera
    doesn't move with the jump.
  * Running jump (vx, vy, vz all nonzero, airborne):  projects onto
    world up → no-op since base is already horizontal. Camera basis
    stays horizontal; player visibly rises in frame.
  * Walking uphill (grounded, slope normal tilted):  projection
    adds a Z component matching the slope angle. Camera basis tilts
    with the terrain.
  * Walking on flat ground:  projection is a no-op. Camera basis
    horizontal.

Surface changes:
  * RetailChaseCamera.ComputeHeading gains `isOnGround` and
    `contactPlaneNormal` parameters.
  * RetailChaseCamera.Update gains the same two parameters and
    threads them through.
  * GameWindow's two Update call sites pass `result.IsOnGround` and
    `_playerController.ContactPlane.Normal` (already exposed on
    PlayerMovementController — no plumbing change there).
  * Tests: 2 existing heading tests reshaped (Moving* and Uphill);
    2 new tests added (AirborneJumping straight-up + running-jump);
    1 renamed (SlopeAlignDisabled). Net 25 → 27 tests in
    RetailChaseCameraTests; full AcDream.App.Tests: 39 → 41.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-19 09:32:50 +02:00
parent 8f30e13317
commit b7e954e50b
3 changed files with 188 additions and 34 deletions

View file

@ -16,7 +16,10 @@ public class RetailChaseCameraTests
var avgVel = Vector3.Zero;
float yaw = MathF.PI / 4f; // 45°
var h = RetailChaseCamera.ComputeHeading(avgVel, yaw, alignToSlope: true);
var h = RetailChaseCamera.ComputeHeading(
avgVel, yaw,
isOnGround: true, contactPlaneNormal: Vector3.UnitZ,
alignToSlope: true);
Assert.Equal(MathF.Cos(yaw), h.X, 5);
Assert.Equal(MathF.Sin(yaw), h.Y, 5);
@ -24,30 +27,87 @@ public class RetailChaseCameraTests
}
[Fact]
public void Heading_MovingHorizontal_MatchesNormalizedVelocity()
public void Heading_MovingOnFlatGround_HeadingIsHorizontalFacing()
{
// Player moving forward (yaw=0 = +X), on flat ground. Heading
// should be the yaw vector — the projection onto (0,0,1)-normal
// plane is a no-op since the base is already horizontal.
var avgVel = new Vector3(3f, 0f, 0f);
var h = RetailChaseCamera.ComputeHeading(avgVel, yaw: 0f, alignToSlope: true);
var h = RetailChaseCamera.ComputeHeading(
avgVel, yaw: 0f,
isOnGround: true, contactPlaneNormal: Vector3.UnitZ,
alignToSlope: true);
Assert.Equal(1f, h.X, 5);
Assert.Equal(0f, h.Y, 5);
Assert.Equal(0f, h.Z, 5);
}
[Fact]
public void Heading_MovingUphill_HasPositiveZ()
public void Heading_OnUphillSlope_TiltsWithSlope()
{
var avgVel = new Vector3(1f, 0f, 0.5f);
var h = RetailChaseCamera.ComputeHeading(avgVel, yaw: 0f, alignToSlope: true);
Assert.True(h.Z > 0f, $"expected positive Z component, got {h.Z}");
// Player facing +Y (yaw=π/2), walking up a slope rising in +Y.
// Slope normal tilts back-up: (0, -0.5, 0.866) (30° rise).
// Projection of (0,1,0) onto plane perpendicular to (0,-0.5,0.866):
// dot = 1*(-0.5) = -0.5
// projected = (0,1,0) - (0,-0.5,0.866)*(-0.5) = (0, 0.75, 0.433)
// normalized → (0, 0.866, 0.5) — slope-aligned heading with +Z tilt.
var avgVel = new Vector3(0f, 3f, 1.5f); // moving up the slope
var normal = new Vector3(0f, -0.5f, 0.866f);
var h = RetailChaseCamera.ComputeHeading(
avgVel, yaw: MathF.PI / 2f,
isOnGround: true, contactPlaneNormal: normal,
alignToSlope: true);
Assert.True(h.Z > 0.4f, $"expected slope-aligned +Z tilt, got Z={h.Z}");
Assert.Equal(1f, h.Length(), 4);
}
[Fact]
public void Heading_SlopeAlignDisabled_IgnoresVelocity()
public void Heading_AirborneJumpingStraightUp_StaysHorizontal()
{
var avgVel = new Vector3(0f, 0f, 1f); // pure upward; would dominate if slope-align were on
float yaw = 0f;
// Player standing still, then jumps straight up. avgVel.xy is
// zero, the horizontal-velocity gate fires → returns the base
// facing direction. The vertical-velocity component is ignored.
// This is THE bug the contact-plane fix prevents: in the old
// code, normalize((0,0,5)) → (0,0,1) → camera basis tilted up.
var avgVel = new Vector3(0f, 0f, 5f);
var h = RetailChaseCamera.ComputeHeading(
avgVel, yaw: 0f,
isOnGround: false, contactPlaneNormal: Vector3.Zero,
alignToSlope: true);
Assert.Equal(1f, h.X, 5);
Assert.Equal(0f, h.Y, 5);
Assert.Equal(0f, h.Z, 5);
}
var h = RetailChaseCamera.ComputeHeading(avgVel, yaw, alignToSlope: false);
[Fact]
public void Heading_AirborneRunningJump_StaysHorizontal()
{
// Running jump: horizontal velocity nonzero, vertical also
// nonzero. Airborne path projects onto world up — strips Z
// from the (already horizontal) base heading, no-op. Camera
// basis stays horizontal even though player is rising.
var avgVel = new Vector3(3f, 0f, 4f);
var h = RetailChaseCamera.ComputeHeading(
avgVel, yaw: 0f,
isOnGround: false, contactPlaneNormal: Vector3.Zero,
alignToSlope: true);
Assert.Equal(1f, h.X, 5);
Assert.Equal(0f, h.Y, 5);
Assert.Equal(0f, h.Z, 5);
}
[Fact]
public void Heading_SlopeAlignDisabled_IgnoresVelocityAndContactPlane()
{
// Pure-vertical velocity + a tilted contact normal — neither
// should affect the heading when alignToSlope is off.
var avgVel = new Vector3(0f, 0f, 1f);
var tiltedNormal = new Vector3(0f, -0.5f, 0.866f);
var h = RetailChaseCamera.ComputeHeading(
avgVel, yaw: 0f,
isOnGround: true, contactPlaneNormal: tiltedNormal,
alignToSlope: false);
Assert.Equal(1f, h.X, 5); // (cos 0, sin 0, 0) = (1, 0, 0)
Assert.Equal(0f, h.Y, 5);
@ -280,10 +340,12 @@ public class RetailChaseCameraTests
CameraDiagnostics.AlignToSlope = false; // deterministic: heading = yaw vec
cam.Update(
playerPosition: new Vector3(10f, 20f, 30f),
playerYaw: 0f, // forward = +X
playerVelocity: Vector3.Zero,
dt: 1f / 60f);
playerPosition: new Vector3(10f, 20f, 30f),
playerYaw: 0f, // forward = +X
playerVelocity: Vector3.Zero,
isOnGround: true,
contactPlaneNormal: Vector3.UnitZ, // flat
dt: 1f / 60f);
// Expected target eye:
// pivot = (10, 20, 30+1.5=31.5)
@ -317,7 +379,8 @@ public class RetailChaseCameraTests
CameraDiagnostics.RotationStiffness = 0.45f;
// First update at origin: dampedEye = (-5, 0, 1.5).
cam.Update(Vector3.Zero, playerYaw: 0f, playerVelocity: Vector3.Zero, dt: 1f / 60f);
cam.Update(Vector3.Zero, playerYaw: 0f, playerVelocity: Vector3.Zero,
isOnGround: true, contactPlaneNormal: Vector3.UnitZ, dt: 1f / 60f);
var firstEye = cam.Position;
// Teleport the player one frame later. Target eye now at (10-5, 0, 1.5) = (5, 0, 1.5).
@ -326,7 +389,8 @@ public class RetailChaseCameraTests
// = (-5,0,1.5) + 0.075 * ((5,0,1.5) - (-5,0,1.5))
// = (-5,0,1.5) + 0.075 * (10,0,0)
// = (-4.25, 0, 1.5)
cam.Update(new Vector3(10f, 0f, 0f), playerYaw: 0f, playerVelocity: Vector3.Zero, dt: 1f / 60f);
cam.Update(new Vector3(10f, 0f, 0f), playerYaw: 0f, playerVelocity: Vector3.Zero,
isOnGround: true, contactPlaneNormal: Vector3.UnitZ, dt: 1f / 60f);
Assert.Equal(-4.25f, cam.Position.X, 3);
Assert.Equal(0f, cam.Position.Y, 4);
@ -350,7 +414,8 @@ public class RetailChaseCameraTests
CameraDiagnostics.AlignToSlope = false;
// Far from pivot — translucency should be 0.
cam.Update(Vector3.Zero, playerYaw: 0f, playerVelocity: Vector3.Zero, dt: 1f / 60f);
cam.Update(Vector3.Zero, playerYaw: 0f, playerVelocity: Vector3.Zero,
isOnGround: true, contactPlaneNormal: Vector3.UnitZ, dt: 1f / 60f);
Assert.Equal(0f, cam.PlayerTranslucency, 5);
}
finally