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>
448 lines
17 KiB
C#
448 lines
17 KiB
C#
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);
|
||
}
|
||
}
|