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, bool isOnGround, Vector3 contactPlaneNormal, float dt) { // 1. Push velocity into 5-frame ring, get average. PushVelocity(_velocityRing, ref _velocityCount, playerVelocity); Vector3 avgVel = AverageVelocity(_velocityRing, _velocityCount); // 2. Heading vector — player's facing projected onto the contact // plane (grounded) or world XY (airborne). See ComputeHeading // doc + retail decomp :95644-95795 for why this is facing-based // rather than velocity-based. Vector3 heading = ComputeHeading( avgVel, playerYaw + YawOffset, isOnGround, contactPlaneNormal, 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. Mirrors /// retail's CameraManager::UpdateCamera ALIGN_WITH_PLANE /// path (decomp acclient_2013_pseudo_c.txt:95644-95795): /// /// Base heading is the player's facing /// direction in world space — (cos yaw, sin yaw, 0) /// — not the velocity vector. Velocity only gates whether /// slope-alignment fires. /// If is off /// OR the player's horizontal velocity is below epsilon (i.e. /// stationary OR jumping straight up), return that base /// heading unchanged. This is the bit that keeps the camera /// from swinging vertically during a jump. /// Otherwise project the base heading onto /// the plane perpendicular to a surface normal: /// 's Normal when /// grounded (slope-aligned), world (0, 0, 1) when /// airborne (which is a no-op since the base is already /// horizontal). /// Normalize. If the projection collapsed /// (heading parallel to normal), fall back to the unprojected /// base. /// /// /// 5-frame averaged player velocity in world space. /// Player facing yaw + any orbit offset, radians. /// Player's transient_state & 1 — does describe a valid contact plane? /// Player's current contact plane normal in world space; ignored when is false. /// User-tunable; when false skips the projection and returns the flat facing direction. internal static Vector3 ComputeHeading( Vector3 avgVelocity, float yaw, bool isOnGround, Vector3 contactPlaneNormal, bool alignToSlope) { // Base heading: player's facing direction in world XY plane. Vector3 baseHeading = new(MathF.Cos(yaw), MathF.Sin(yaw), 0f); if (!alignToSlope) return baseHeading; // Slope-align gate: player must be moving in XY. Retail tests // |vx| > 0.0002 AND |vy| > 0.0002 (decomp :95704, :95713). The // horizontal-magnitude-squared form is a cleaner equivalent. // Without this, the airborne path would still project against // world up (no-op) which is fine — but the standing-jump case // wants the historical `direction` fallback that retail uses. float hMagSq = avgVelocity.X * avgVelocity.X + avgVelocity.Y * avgVelocity.Y; if (hMagSq < 1e-4f) return baseHeading; // Pick the projection plane normal: // grounded → contact_plane.N (slope-aligned camera basis) // airborne → world up (projection becomes a no-op because // baseHeading is already in the XY plane — but // keeping the code path uniform makes the airborne // case impossible to swing vertically). Vector3 normal; if (isOnGround && contactPlaneNormal.LengthSquared() > 0.01f) normal = Vector3.Normalize(contactPlaneNormal); else normal = new Vector3(0f, 0f, 1f); // Project baseHeading onto plane perpendicular to normal: // projected = forward - normal * dot(forward, normal) // On flat ground this is a no-op (dot ≈ 0). On a slope the // projected vector gains a Z component matching the slope angle, // which tilts the camera basis with the terrain. float dot = Vector3.Dot(baseHeading, normal); Vector3 projected = baseHeading - normal * dot; // Degenerate: facing nearly parallel to normal (rare — would // require player rotated to face into the ground). Fall back to // the unprojected base heading. if (projected.LengthSquared() < 1e-4f) return baseHeading; return Vector3.Normalize(projected); } /// /// 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); } }