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);
}
}