feat(camera): add RetailChaseCamera math primitives

Seven pure-math helpers in the new RetailChaseCamera class:
ComputeHeading (slope-align with flat fallback), BuildBasis (heading
→ orthonormal frame, near-vertical fallback), PushVelocity +
AverageVelocity (5-entry FIFO ring), ComputeDampingAlpha (retail's
stiffness*dt*10), FilterMouseAxis (0.25s low-pass), ComputeTranslucency
(linear ramp 0.20..0.45 m). 20 tests, all pass. State machine + Update()
land in the next commit.

Per spec docs/superpowers/specs/2026-05-18-retail-chase-camera-design.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-18 19:36:24 +02:00
parent 5945f1d915
commit 8ebd33dc8f
3 changed files with 439 additions and 0 deletions

View file

@ -11,6 +11,7 @@
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="AcDream.Core.Tests" />
<InternalsVisibleTo Include="AcDream.App.Tests" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Silk.NET.OpenGL" Version="2.23.0" />

View file

@ -0,0 +1,170 @@
using System;
using System.Numerics;
using AcDream.Core.Rendering;
namespace AcDream.App.Rendering;
/// <summary>
/// Retail-faithful chase camera. Ports the chase-cam behavior from the
/// 2013 acclient (<c>CameraManager</c> + <c>CameraSet</c>, decomp at
/// <c>docs/research/named-retail/acclient_2013_pseudo_c.txt:95505</c>):
/// exponential damping toward a target pose, 5-frame velocity-averaged
/// slope-aligned heading frame, mouse-input low-pass filter.
///
/// <para>
/// Sits behind <see cref="CameraDiagnostics.UseRetailChaseCamera"/>
/// next to the legacy <see cref="ChaseCamera"/>; both update every
/// frame so toggling the flag swaps cameras instantly. Visible behavior
/// vs legacy: lag-then-catch-up on turn/stop, tilt-with-terrain on
/// hills, jump-feedback without the legacy <c>_trackedZ</c> hack.
/// </para>
///
/// <para>
/// Spec: <c>docs/superpowers/specs/2026-05-18-retail-chase-camera-design.md</c>.
/// </para>
/// </summary>
public sealed class RetailChaseCamera : ICamera
{
// ICamera surface — filled in by Task 3.
public Vector3 Position { get; private set; }
public float Aspect { get; set; } = 16f / 9f;
public float FovY { get; set; } = MathF.PI / 3f;
public Matrix4x4 View { get; private set; } = Matrix4x4.Identity;
public Matrix4x4 Projection =>
Matrix4x4.CreatePerspectiveFieldOfView(FovY, Aspect, 1f, 5000f);
// Math primitives — pure, internal-static for unit-testability.
/// <summary>
/// Pick the heading vector that drives the camera basis. Slope-
/// aligned when velocity is non-trivial and the toggle is on; flat
/// fallback otherwise. Matches retail's <c>target_status &amp;
/// ALIGN_WITH_PLANE</c> path with the contact-plane branch
/// collapsed into the flat fallback.
/// </summary>
internal static Vector3 ComputeHeading(Vector3 avgVelocity, float yaw, bool alignToSlope)
{
if (alignToSlope && avgVelocity.LengthSquared() > 1e-4f)
return Vector3.Normalize(avgVelocity);
return new Vector3(MathF.Cos(yaw), MathF.Sin(yaw), 0f);
}
/// <summary>
/// Build an orthonormal basis with <c>forward = heading</c>. World
/// up is <c>(0, 0, 1)</c>; if <c>heading</c> is near-parallel to it
/// the right axis falls back to world <c>+X</c> so the cross
/// product doesn't collapse.
/// </summary>
internal static (Vector3 forward, Vector3 right, Vector3 up) BuildBasis(Vector3 heading)
{
Vector3 forward = Vector3.Normalize(heading);
Vector3 worldUp = new(0f, 0f, 1f);
Vector3 right;
if (MathF.Abs(forward.Z) > 0.99f)
{
// Near-vertical forward — use world +X as the secondary axis.
right = Vector3.Normalize(Vector3.Cross(forward, new Vector3(1f, 0f, 0f)));
}
else
{
right = Vector3.Normalize(Vector3.Cross(forward, worldUp));
}
Vector3 up = Vector3.Cross(right, forward); // already unit (forward + right orthonormal)
return (forward, right, up);
}
/// <summary>
/// FIFO-push a velocity sample into the 5-entry ring. Returns the
/// updated ring (mutates the input array; the return is for fluent
/// usage in tests). <paramref name="count"/> grows from 0 toward 5
/// and stays at 5 once the ring is full.
/// </summary>
internal static Vector3[] PushVelocity(Vector3[] ring, ref int count, Vector3 sample)
{
if (ring.Length != 5)
throw new ArgumentException("velocity ring must have 5 entries", nameof(ring));
// Shift left by 1 (oldest is overwritten), append new sample at the tail.
for (int i = 0; i < 4; i++) ring[i] = ring[i + 1];
ring[4] = sample;
if (count < 5) count++;
return ring;
}
/// <summary>
/// Average the <paramref name="count"/> most-recent entries of the
/// ring (entries <c>[5-count .. 5)</c>). Returns
/// <see cref="Vector3.Zero"/> when count is zero.
/// </summary>
internal static Vector3 AverageVelocity(Vector3[] ring, int count)
{
if (count == 0) return Vector3.Zero;
Vector3 sum = Vector3.Zero;
for (int i = 5 - count; i < 5; i++) sum += ring[i];
return sum / count;
}
/// <summary>
/// Exponential-damping rate per frame.
/// <c>alpha = clamp(stiffness * dt * 10, 0, 1)</c>. At
/// <c>stiffness=0.45</c>, <c>dt=1/60</c> → <c>~0.075</c>
/// (~150 ms half-life). Matches retail's
/// <c>x_1 = stiffness * dt * 10</c> formulation.
/// </summary>
internal static float ComputeDampingAlpha(float stiffness, float dt)
{
float a = stiffness * dt * 10f;
if (a <= 0f) return 0f;
if (a >= 1f) return 1f;
return a;
}
/// <summary>
/// Low-pass filter for a single mouse axis. Mirrors retail's
/// <c>CameraSet::FilterMouseInput</c>: if last sample was within
/// <paramref name="windowSec"/>, blend output with the average of
/// (previous, raw); otherwise pass-through. Final output =
/// <c>raw * (1 - weight) + blended * weight</c>. Updates
/// <paramref name="lastDelta"/> and <paramref name="lastTimeSec"/>
/// to the new state.
/// </summary>
internal static float FilterMouseAxis(
float raw,
float weight,
float nowSec,
ref float lastDelta,
ref float lastTimeSec,
float windowSec)
{
float avg;
if (nowSec - lastTimeSec < windowSec)
avg = (lastDelta + raw) * 0.5f;
else
avg = raw;
float output = raw * (1f - weight) + avg * weight;
lastDelta = output;
lastTimeSec = nowSec;
return output;
}
/// <summary>
/// Player-mesh translucency as a function of camera-to-pivot
/// distance. <c>0</c> = fully opaque, <c>1</c> = fully transparent.
/// Opaque at and beyond 0.45 m; fully transparent at and within
/// 0.20 m; linear ramp between. Matches retail's <c>CameraSet::
/// UpdateCamera</c> distance check (decomp :9770397725).
/// </summary>
internal static float ComputeTranslucency(float distance)
{
const float Far = 0.45f;
const float Near = 0.20f;
if (distance >= Far) return 0f;
if (distance <= Near) return 1f;
// Linear: t = 1 - (Near - distance) / (Near - Far)
return 1f - (Near - distance) / (Near - Far);
}
}

View file

@ -0,0 +1,268 @@
using System;
using System.Numerics;
using AcDream.App.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, 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_MovingHorizontal_MatchesNormalizedVelocity()
{
var avgVel = new Vector3(3f, 0f, 0f);
var h = RetailChaseCamera.ComputeHeading(avgVel, yaw: 0f, 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()
{
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}");
}
[Fact]
public void Heading_SlopeAlignDisabled_IgnoresVelocity()
{
var avgVel = new Vector3(0f, 0f, 1f); // pure upward; would dominate if slope-align were on
float yaw = 0f;
var h = RetailChaseCamera.ComputeHeading(avgVel, yaw, 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);
}
// ── 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);
}
}