acdream/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs
Erik aae5300fea 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>
2026-05-30 09:40:08 +02:00

575 lines
22 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);
}
// ── Camera collision (A8.F) ───────────────────────────────────────
private sealed class FakeProbe : ICameraCollisionProbe
{
public int Calls;
public Vector3 ReturnEye;
public Vector3 SweepEye(Vector3 pivot, Vector3 desiredEye, uint cellId, uint selfEntityId)
{
Calls++;
return ReturnEye;
}
}
[Fact]
public void Update_WithProbeAndFlagOn_PublishesCollidedEye()
{
CameraDiagnostics.CollideCamera = true;
var collided = new Vector3(1f, 2f, 3f);
var probe = new FakeProbe { ReturnEye = collided };
var cam = new RetailChaseCamera { CollisionProbe = probe };
cam.Update(
playerPosition: Vector3.Zero, playerYaw: 0f, playerVelocity: Vector3.Zero,
isOnGround: true, contactPlaneNormal: Vector3.UnitZ, dt: 1f / 60f,
cellId: 0x100, selfEntityId: 0x5);
Assert.Equal(1, probe.Calls);
Assert.Equal(collided, cam.Position);
}
[Fact]
public void Update_FlagOff_DoesNotConsultProbe()
{
CameraDiagnostics.CollideCamera = false;
try
{
var probe = new FakeProbe { ReturnEye = new Vector3(99f, 99f, 99f) };
var cam = new RetailChaseCamera { CollisionProbe = probe };
cam.Update(
playerPosition: Vector3.Zero, playerYaw: 0f, playerVelocity: Vector3.Zero,
isOnGround: true, contactPlaneNormal: Vector3.UnitZ, dt: 1f / 60f,
cellId: 0x100, selfEntityId: 0x5);
Assert.Equal(0, probe.Calls);
Assert.NotEqual(new Vector3(99f, 99f, 99f), cam.Position);
}
finally
{
CameraDiagnostics.CollideCamera = true; // reset even if an assert throws
}
}
[Fact]
public void Update_NullProbe_DoesNotThrow()
{
CameraDiagnostics.CollideCamera = true;
var cam = new RetailChaseCamera { CollisionProbe = null };
cam.Update(
playerPosition: Vector3.Zero, playerYaw: 0f, playerVelocity: Vector3.Zero,
isOnGround: true, contactPlaneNormal: Vector3.UnitZ, dt: 1f / 60f,
cellId: 0x100, selfEntityId: 0x5);
Assert.NotEqual(default, cam.View);
}
[Fact]
public void Update_ProbePullsEyeInClose_FullyFadesPlayer()
{
// Spec §9 / retail stage 3: when the collided eye ends up very close to
// the head-pivot, the player mesh fades. Pivot = (0,0,1.5); a collided
// eye 0.1 m above it (≤ the 0.20 m full-fade threshold) → translucency 1.
CameraDiagnostics.CollideCamera = true;
var pulledIn = new Vector3(0f, 0f, 1.6f);
var cam = new RetailChaseCamera { CollisionProbe = new FakeProbe { ReturnEye = pulledIn } };
cam.Update(
playerPosition: Vector3.Zero, playerYaw: 0f, playerVelocity: Vector3.Zero,
isOnGround: true, contactPlaneNormal: Vector3.UnitZ, dt: 1f / 60f,
cellId: 0x100, selfEntityId: 0x5);
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}");
}
}