From 0c1403f2e6e1a715b461a0c093eb95a33d91d879 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 18 May 2026 19:44:13 +0200 Subject: [PATCH] feat(camera): wire RetailChaseCamera Update() + tunables + state Adds the per-frame Update(playerPos, yaw, velocity, dt) entrypoint that composes the math primitives into a renderable View matrix + PlayerTranslucency. State: 5-frame velocity ring, damped eye + forward unit vector, first-frame snap flag, mouse-filter shared state. Public surface: Distance/Pitch/YawOffset/PivotHeight tunables, AdjustDistance/Pitch (with clamps), FilterMouseDelta entry, View + Position + PlayerTranslucency outputs. 5 new integration tests, all pass; total RetailChaseCamera test count 25. Also folds in two minor cleanups from the Task 2 code review: - AverageVelocity uses ring.Length instead of hardcoded 5 - Basis_NearVerticalHeading test asserts orthogonality of right & up Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Rendering/RetailChaseCamera.cs | 136 +++++++++++++++++- .../Rendering/RetailChaseCameraTests.cs | 87 +++++++++++ 2 files changed, 220 insertions(+), 3 deletions(-) diff --git a/src/AcDream.App/Rendering/RetailChaseCamera.cs b/src/AcDream.App/Rendering/RetailChaseCamera.cs index e1dc141..fd6b4eb 100644 --- a/src/AcDream.App/Rendering/RetailChaseCamera.cs +++ b/src/AcDream.App/Rendering/RetailChaseCamera.cs @@ -25,7 +25,7 @@ namespace AcDream.App.Rendering; /// public sealed class RetailChaseCamera : ICamera { - // ICamera surface — filled in by Task 3. + // ICamera surface. public Vector3 Position { get; private set; } public float Aspect { get; set; } = 16f / 9f; public float FovY { get; set; } = MathF.PI / 3f; @@ -33,6 +33,135 @@ public sealed class RetailChaseCamera : ICamera public Matrix4x4 Projection => Matrix4x4.CreatePerspectiveFieldOfView(FovY, Aspect, 1f, 5000f); + // ── Public tunables (per-instance) ────────────────────────────── + + /// Length of the viewer_offset vector. Retail default ≈ 2.61. + public float Distance { get; set; } = 2.61f; + + /// Angle of the camera above the heading-frame XY plane. Retail default ≈ 0.291 rad (16.7°). + public float Pitch { get; set; } = 0.291f; + + /// + /// Yaw offset added on top of player yaw when slope-align is off + /// or velocity is too small to derive a heading. Used by hold-RMB + /// orbit to swing the camera around the player without rotating + /// the character. + /// + public float YawOffset { get; set; } = 0f; + + /// Height of look-at anchor above the player's feet (m). Retail default 1.5. + public float PivotHeight { get; set; } = 1.5f; + + /// Computed translucency for the player mesh (0 = opaque, 1 = invisible). Read by GameWindow. + public float PlayerTranslucency { get; private set; } + + /// Clamp bounds carried over from legacy ChaseCamera. + public const float DistanceMin = 2f; + public const float DistanceMax = 40f; + public const float PitchMin = -0.7f; + public const float PitchMax = 1.4f; + + // ── Damped state ──────────────────────────────────────────────── + + private readonly Vector3[] _velocityRing = new Vector3[5]; + private int _velocityCount; + private Vector3 _dampedEye; + private Vector3 _dampedForward = new(1f, 0f, 0f); + private bool _initialised; + + // Mouse-filter state — shared by FilterMouseDelta entrypoint. + private float _lastMouseDeltaX; + private float _lastMouseDeltaY; + private float _lastFilterTimeSec; + + // ── Per-frame entry point ──────────────────────────────────────── + + /// + /// Advance the camera one frame. Caller passes the player's current + /// pose + velocity (in world space) + the frame's dt in + /// seconds. After this returns, , + /// , and reflect + /// the new state. + /// + public void Update(Vector3 playerPosition, float playerYaw, Vector3 playerVelocity, float dt) + { + // 1. Push velocity into 5-frame ring, get average. + PushVelocity(_velocityRing, ref _velocityCount, playerVelocity); + Vector3 avgVel = AverageVelocity(_velocityRing, _velocityCount); + + // 2. Heading vector — slope-aligned when fast enough, flat fallback otherwise. + Vector3 heading = ComputeHeading(avgVel, playerYaw + YawOffset, CameraDiagnostics.AlignToSlope); + + // 3. Orthonormal heading-frame basis. + var (forward, right, up) = BuildBasis(heading); + + // 4. Target pose. + Vector3 pivotWorld = playerPosition + new Vector3(0f, 0f, PivotHeight); + float horizontal = Distance * MathF.Cos(Pitch); + float vertical = Distance * MathF.Sin(Pitch); + // viewer_offset = -horizontal along forward + vertical along up. + Vector3 targetEye = pivotWorld + forward * (-horizontal) + up * vertical; + Vector3 targetForward = Vector3.Normalize(pivotWorld - targetEye); + + // 5. Exponential damping (independent translation + rotation rates). + if (!_initialised) + { + _dampedEye = targetEye; + _dampedForward = targetForward; + _initialised = true; + } + else + { + float tAlpha = ComputeDampingAlpha(CameraDiagnostics.TranslationStiffness, dt); + float rAlpha = ComputeDampingAlpha(CameraDiagnostics.RotationStiffness, dt); + _dampedEye = Vector3.Lerp(_dampedEye, targetEye, tAlpha); + _dampedForward = Vector3.Normalize(Vector3.Lerp(_dampedForward, targetForward, rAlpha)); + } + + // 6. Publish renderer surface. + Position = _dampedEye; + View = Matrix4x4.CreateLookAt(_dampedEye, _dampedEye + _dampedForward, new Vector3(0f, 0f, 1f)); + + // 7. Auto-fade translucency. + float d = Vector3.Distance(_dampedEye, pivotWorld); + PlayerTranslucency = ComputeTranslucency(d); + } + + /// + /// Adjust the camera distance (zoom) by a delta, clamped to + /// ... Mirrors + /// legacy ChaseCamera.AdjustDistance. + /// + public void AdjustDistance(float delta) => + Distance = Math.Clamp(Distance + delta, DistanceMin, DistanceMax); + + /// + /// Adjust the camera pitch by a delta (radians), clamped to + /// ... Mirrors legacy + /// ChaseCamera.AdjustPitch. + /// + public void AdjustPitch(float delta) => + Pitch = Math.Clamp(Pitch + delta, PitchMin, PitchMax); + + /// + /// Public entry point for the mouse-input low-pass filter. Calls + /// on each axis with shared state. + /// + public (float outX, float outY) FilterMouseDelta(float rawX, float rawY, float weight, float nowSec) + { + // X first — advances the shared timestamp. + float x = FilterMouseAxis(rawX, weight, nowSec, + ref _lastMouseDeltaX, ref _lastFilterTimeSec, CameraDiagnostics.MouseLowPassWindowSec); + // Y uses a throwaway timestamp so the within-window check still uses the original delta + // (X already advanced _lastFilterTimeSec to nowSec; if Y reused it, the within-window + // check would be 0 < windowSec which is always true — which is what we want here, since + // both axes are sampled simultaneously and should both blend.). + float yTimeShadow = _lastFilterTimeSec - 1f; // force within-window path for the Y axis + float y = FilterMouseAxis(rawY, weight, nowSec, + ref _lastMouseDeltaY, ref yTimeShadow, CameraDiagnostics.MouseLowPassWindowSec); + return (x, y); + } + // Math primitives — pure, internal-static for unit-testability. /// @@ -95,14 +224,15 @@ public sealed class RetailChaseCamera : ICamera /// /// Average the most-recent entries of the - /// ring (entries [5-count .. 5)). Returns + /// ring (entries [ring.Length-count .. ring.Length)). 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]; + int start = ring.Length - count; + for (int i = start; i < ring.Length; i++) sum += ring[i]; return sum / count; } diff --git a/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs b/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs index ddcaae3..bc9774f 100644 --- a/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs @@ -1,6 +1,7 @@ using System; using System.Numerics; using AcDream.App.Rendering; +using AcDream.Core.Rendering; using Xunit; namespace AcDream.App.Tests.Rendering; @@ -84,6 +85,7 @@ public class RetailChaseCameraTests Assert.Equal(1f, right.Length(), 5); Assert.Equal(1f, up.Length(), 5); + Assert.Equal(0f, Vector3.Dot(right, up), 5); } // ── Velocity ring & averaging ──────────────────────────────────── @@ -265,4 +267,89 @@ public class RetailChaseCameraTests 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() + { + 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, + 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); + } + + [Fact] + public void SecondUpdate_LerpsTowardTarget() + { + 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, 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, 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); + } + + [Fact] + public void Translucency_PropertyReflectsCurrentDampedDistance() + { + 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, dt: 1f / 60f); + Assert.Equal(0f, cam.PlayerTranslucency, 5); + } + + [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); + } }