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. 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); // ── 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, _, 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. /// /// 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 [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; int start = ring.Length - count; for (int i = start; i < ring.Length; 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); } }