diff --git a/src/AcDream.App/AcDream.App.csproj b/src/AcDream.App/AcDream.App.csproj index 84eb67a..c5378e2 100644 --- a/src/AcDream.App/AcDream.App.csproj +++ b/src/AcDream.App/AcDream.App.csproj @@ -11,6 +11,7 @@ + diff --git a/src/AcDream.App/Rendering/RetailChaseCamera.cs b/src/AcDream.App/Rendering/RetailChaseCamera.cs new file mode 100644 index 0000000..e1dc141 --- /dev/null +++ b/src/AcDream.App/Rendering/RetailChaseCamera.cs @@ -0,0 +1,170 @@ +using System; +using System.Numerics; +using AcDream.Core.Rendering; + +namespace AcDream.App.Rendering; + +/// +/// Retail-faithful chase camera. Ports the chase-cam behavior from the +/// 2013 acclient (CameraManager + CameraSet, decomp at +/// docs/research/named-retail/acclient_2013_pseudo_c.txt:95505): +/// exponential damping toward a target pose, 5-frame velocity-averaged +/// slope-aligned heading frame, mouse-input low-pass filter. +/// +/// +/// Sits behind +/// next to the legacy ; 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 _trackedZ hack. +/// +/// +/// +/// Spec: docs/superpowers/specs/2026-05-18-retail-chase-camera-design.md. +/// +/// +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. + + /// + /// 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 target_status & + /// ALIGN_WITH_PLANE path with the contact-plane branch + /// collapsed into the flat fallback. + /// + 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); + } + + /// + /// Build an orthonormal basis with forward = heading. World + /// up is (0, 0, 1); if heading is near-parallel to it + /// the right axis falls back to world +X so the cross + /// product doesn't collapse. + /// + 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); + } + + /// + /// 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). grows from 0 toward 5 + /// and stays at 5 once the ring is full. + /// + 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; + } + + /// + /// Average the most-recent entries of the + /// ring (entries [5-count .. 5)). Returns + /// when count is zero. + /// + 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; + } + + /// + /// Exponential-damping rate per frame. + /// alpha = clamp(stiffness * dt * 10, 0, 1). At + /// stiffness=0.45, dt=1/60~0.075 + /// (~150 ms half-life). Matches retail's + /// x_1 = stiffness * dt * 10 formulation. + /// + 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; + } + + /// + /// Low-pass filter for a single mouse axis. Mirrors retail's + /// CameraSet::FilterMouseInput: if last sample was within + /// , blend output with the average of + /// (previous, raw); otherwise pass-through. Final output = + /// raw * (1 - weight) + blended * weight. Updates + /// and + /// to the new state. + /// + 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; + } + + /// + /// Player-mesh translucency as a function of camera-to-pivot + /// distance. 0 = fully opaque, 1 = fully transparent. + /// Opaque at and beyond 0.45 m; fully transparent at and within + /// 0.20 m; linear ramp between. Matches retail's CameraSet:: + /// UpdateCamera distance check (decomp :97703–97725). + /// + 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); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs b/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs new file mode 100644 index 0000000..ddcaae3 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs @@ -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); + } +}