acdream/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs
Erik b7e954e50b 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>
2026-05-19 09:32:50 +02:00

448 lines
17 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.Core.Rendering;
using Xunit;
namespace AcDream.App.Tests.Rendering;
public class RetailChaseCameraTests
{
// ── Heading source ────────────────────────────────────────────────
[Fact]
public void Heading_StationaryWithSlopeAlign_FallsBackToYawVector()
{
var avgVel = Vector3.Zero;
float yaw = MathF.PI / 4f; // 45°
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);
Assert.Equal(0f, h.Z, 5);
}
[Fact]
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,
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_OnUphillSlope_TiltsWithSlope()
{
// 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_AirborneJumpingStraightUp_StaysHorizontal()
{
// 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);
}
[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);
Assert.Equal(0f, h.Z, 5);
}
// ── Basis from heading ────────────────────────────────────────────
[Fact]
public void Basis_HorizontalHeading_IsOrthonormalAndRightHanded()
{
var (forward, right, up) = RetailChaseCamera.BuildBasis(new Vector3(1f, 0f, 0f));
Assert.Equal(1f, forward.Length(), 5);
Assert.Equal(1f, right.Length(), 5);
Assert.Equal(1f, up.Length(), 5);
// Orthogonal
Assert.Equal(0f, Vector3.Dot(forward, right), 5);
Assert.Equal(0f, Vector3.Dot(forward, up), 5);
Assert.Equal(0f, Vector3.Dot(right, up), 5);
// forward = (1,0,0), world up = (0,0,1) → right = (0,-1,0), camera-up = (0,0,1)
Assert.Equal(0f, up.X, 5);
Assert.Equal(0f, up.Y, 5);
Assert.True(up.Z > 0f);
}
[Fact]
public void Basis_NearVerticalHeading_UsesXFallbackForRight()
{
// forward nearly straight up (rare; airborne edge case). Must not produce
// a zero-length right vector from cross(forward, worldUp).
var (_, right, up) = RetailChaseCamera.BuildBasis(new Vector3(0f, 0f, 1f));
Assert.Equal(1f, right.Length(), 5);
Assert.Equal(1f, up.Length(), 5);
Assert.Equal(0f, Vector3.Dot(right, up), 5);
}
// ── Velocity ring & averaging ────────────────────────────────────
[Fact]
public void VelocityRing_AveragesLastN()
{
var ring = new Vector3[5];
int count = 0;
ring = RetailChaseCamera.PushVelocity(ring, ref count, new Vector3(1, 0, 0));
ring = RetailChaseCamera.PushVelocity(ring, ref count, new Vector3(1, 0, 0));
ring = RetailChaseCamera.PushVelocity(ring, ref count, new Vector3(2, 0, 0));
ring = RetailChaseCamera.PushVelocity(ring, ref count, new Vector3(2, 0, 0));
ring = RetailChaseCamera.PushVelocity(ring, ref count, new Vector3(3, 0, 0));
Assert.Equal(5, count);
var avg = RetailChaseCamera.AverageVelocity(ring, count);
Assert.Equal(1.8f, avg.X, 5);
Assert.Equal(0f, avg.Y, 5);
Assert.Equal(0f, avg.Z, 5);
}
[Fact]
public void VelocityRing_FifoEvictsOldest()
{
var ring = new Vector3[5];
int count = 0;
// Push 6 entries; oldest (the first 1,0,0) should be evicted.
for (int i = 0; i < 5; i++)
ring = RetailChaseCamera.PushVelocity(ring, ref count, new Vector3(1, 0, 0));
ring = RetailChaseCamera.PushVelocity(ring, ref count, new Vector3(10, 0, 0));
Assert.Equal(5, count); // still capped at 5
// Sum of newest 5 entries: 4*(1,0,0) + (10,0,0) = (14,0,0), avg = 2.8
var avg = RetailChaseCamera.AverageVelocity(ring, count);
Assert.Equal(2.8f, avg.X, 5);
}
[Fact]
public void VelocityRing_PartialFillUsesActualCount()
{
var ring = new Vector3[5];
int count = 0;
ring = RetailChaseCamera.PushVelocity(ring, ref count, new Vector3(2, 0, 0));
ring = RetailChaseCamera.PushVelocity(ring, ref count, new Vector3(4, 0, 0));
Assert.Equal(2, count);
var avg = RetailChaseCamera.AverageVelocity(ring, count);
Assert.Equal(3f, avg.X, 5); // (2+4)/2, not (2+4)/5
}
// ── Damping alpha ────────────────────────────────────────────────
[Fact]
public void DampingAlpha_RetailDefault_ProducesSevenAndAHalfPercent()
{
// stiffness=0.45, dt=1/60 → 0.45 * (1/60) * 10 ≈ 0.075
float alpha = RetailChaseCamera.ComputeDampingAlpha(stiffness: 0.45f, dt: 1f / 60f);
Assert.Equal(0.075f, alpha, 4);
}
[Fact]
public void DampingAlpha_LargeDtClampsToOne()
{
float alpha = RetailChaseCamera.ComputeDampingAlpha(stiffness: 0.45f, dt: 1f);
Assert.Equal(1f, alpha);
}
[Fact]
public void DampingAlpha_NegativeOrZero_ClampsToZero()
{
Assert.Equal(0f, RetailChaseCamera.ComputeDampingAlpha(stiffness: 0.45f, dt: 0f));
Assert.Equal(0f, RetailChaseCamera.ComputeDampingAlpha(stiffness: 0.0f, dt: 1f));
}
// ── Mouse low-pass ───────────────────────────────────────────────
[Fact]
public void MouseFilter_BeyondWindow_OutputsRaw()
{
float lastDelta = 5f;
float lastTime = 0f;
float windowSec = 0.25f;
float result = RetailChaseCamera.FilterMouseAxis(
raw: 10f, weight: 0.5f, nowSec: 1.0f,
ref lastDelta, ref lastTime, windowSec);
// Beyond window, blended == raw, so out = raw * 0.5 + raw * 0.5 = raw.
Assert.Equal(10f, result, 5);
}
[Fact]
public void MouseFilter_WithinWindow_AveragesWithPrevious()
{
float lastDelta = 10f;
float lastTime = 0f;
float windowSec = 0.25f;
float result = RetailChaseCamera.FilterMouseAxis(
raw: 20f, weight: 0.5f, nowSec: 0.1f,
ref lastDelta, ref lastTime, windowSec);
// Within window: avg = (10 + 20)/2 = 15.
// Output: 20 * 0.5 + 15 * 0.5 = 17.5
Assert.Equal(17.5f, result, 5);
}
[Fact]
public void MouseFilter_WeightZero_OutputsRaw()
{
float lastDelta = 10f;
float lastTime = 0f;
float windowSec = 0.25f;
float result = RetailChaseCamera.FilterMouseAxis(
raw: 20f, weight: 0f, nowSec: 0.1f,
ref lastDelta, ref lastTime, windowSec);
Assert.Equal(20f, result, 5);
}
[Fact]
public void MouseFilter_WeightOne_OutputsAveraged()
{
float lastDelta = 10f;
float lastTime = 0f;
float windowSec = 0.25f;
float result = RetailChaseCamera.FilterMouseAxis(
raw: 20f, weight: 1f, nowSec: 0.1f,
ref lastDelta, ref lastTime, windowSec);
// weight=1 → out = avg = 15
Assert.Equal(15f, result, 5);
}
[Fact]
public void MouseFilter_UpdatesLastDeltaAndTime()
{
float lastDelta = 10f;
float lastTime = 0f;
float windowSec = 0.25f;
float result = RetailChaseCamera.FilterMouseAxis(
raw: 20f, weight: 0.5f, nowSec: 0.1f,
ref lastDelta, ref lastTime, windowSec);
Assert.Equal(result, lastDelta); // last is updated to output
Assert.Equal(0.1f, lastTime, 5); // last time advances
}
// ── Auto-fade translucency ───────────────────────────────────────
[Fact]
public void Translucency_AtFarThreshold_IsZero()
{
Assert.Equal(0f, RetailChaseCamera.ComputeTranslucency(distance: 0.45f), 5);
Assert.Equal(0f, RetailChaseCamera.ComputeTranslucency(distance: 1.00f), 5);
}
[Fact]
public void Translucency_MidwayBetweenThresholds_IsHalf()
{
// Midpoint between 0.20 and 0.45 = 0.325
// t = 1 - (0.20 - 0.325) / (0.20 - 0.45)
// = 1 - (-0.125) / (-0.25)
// = 1 - 0.5 = 0.5
Assert.Equal(0.5f, RetailChaseCamera.ComputeTranslucency(distance: 0.325f), 4);
}
[Fact]
public void Translucency_AtNearThreshold_IsOne()
{
Assert.Equal(1f, RetailChaseCamera.ComputeTranslucency(distance: 0.20f), 5);
Assert.Equal(1f, RetailChaseCamera.ComputeTranslucency(distance: 0.10f), 5);
Assert.Equal(1f, RetailChaseCamera.ComputeTranslucency(distance: 0.0f), 5);
}
// ── Update() integration ─────────────────────────────────────────
[Fact]
public void FirstUpdate_SnapsToTarget()
{
bool savedAlign = CameraDiagnostics.AlignToSlope;
try
{
var cam = new RetailChaseCamera { Distance = 5f, Pitch = 0f };
CameraDiagnostics.AlignToSlope = false; // deterministic: heading = yaw vec
cam.Update(
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)
// forward (yaw=0)= (1, 0, 0)
// right = (0, -1, 0) since (1,0,0) × (0,0,1) = (0, -1, 0)
// up = right × forward = (0,-1,0) × (1,0,0) = (0,0,1)
// viewer_offset = (0, -5, 0) (Distance=5, Pitch=0 → -Distance*cos = -5, sin = 0)
// eye = pivot + right*0 + forward*-5 + up*0
// = (10 - 5, 20, 31.5) = (5, 20, 31.5)
Assert.Equal(5f, cam.Position.X, 4);
Assert.Equal(20f, cam.Position.Y, 4);
Assert.Equal(31.5f, cam.Position.Z, 4);
}
finally
{
CameraDiagnostics.AlignToSlope = savedAlign;
}
}
[Fact]
public void SecondUpdate_LerpsTowardTarget()
{
bool savedAlign = CameraDiagnostics.AlignToSlope;
float savedTranslation = CameraDiagnostics.TranslationStiffness;
float savedRotation = CameraDiagnostics.RotationStiffness;
try
{
var cam = new RetailChaseCamera { Distance = 5f, Pitch = 0f };
CameraDiagnostics.AlignToSlope = false;
CameraDiagnostics.TranslationStiffness = 0.45f;
CameraDiagnostics.RotationStiffness = 0.45f;
// First update at origin: dampedEye = (-5, 0, 1.5).
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).
// alpha = 0.45 * (1/60) * 10 = 0.075.
// New eye = firstEye + 0.075 * (target - firstEye)
// = (-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,
isOnGround: true, contactPlaneNormal: Vector3.UnitZ, dt: 1f / 60f);
Assert.Equal(-4.25f, cam.Position.X, 3);
Assert.Equal(0f, cam.Position.Y, 4);
Assert.Equal(1.5f, cam.Position.Z, 4);
}
finally
{
CameraDiagnostics.AlignToSlope = savedAlign;
CameraDiagnostics.TranslationStiffness = savedTranslation;
CameraDiagnostics.RotationStiffness = savedRotation;
}
}
[Fact]
public void Translucency_PropertyReflectsCurrentDampedDistance()
{
bool savedAlign = CameraDiagnostics.AlignToSlope;
try
{
var cam = new RetailChaseCamera { Distance = 5f, Pitch = 0f, PivotHeight = 1.5f };
CameraDiagnostics.AlignToSlope = false;
// Far from pivot — translucency should be 0.
cam.Update(Vector3.Zero, playerYaw: 0f, playerVelocity: Vector3.Zero,
isOnGround: true, contactPlaneNormal: Vector3.UnitZ, dt: 1f / 60f);
Assert.Equal(0f, cam.PlayerTranslucency, 5);
}
finally
{
CameraDiagnostics.AlignToSlope = savedAlign;
}
}
[Fact]
public void AdjustDistance_ClampsToRange()
{
var cam = new RetailChaseCamera { Distance = 5f };
cam.AdjustDistance(-100f);
Assert.Equal(RetailChaseCamera.DistanceMin, cam.Distance);
cam.AdjustDistance(+200f);
Assert.Equal(RetailChaseCamera.DistanceMax, cam.Distance);
}
[Fact]
public void AdjustPitch_ClampsToRange()
{
var cam = new RetailChaseCamera { Pitch = 0f };
cam.AdjustPitch(-10f);
Assert.Equal(RetailChaseCamera.PitchMin, cam.Pitch);
cam.AdjustPitch(+10f);
Assert.Equal(RetailChaseCamera.PitchMax, cam.Pitch);
}
}