acdream/src/AcDream.App/Rendering/RetailChaseCamera.cs
Erik e5a5916679 feat(camera): CameraController carries both legacy + retail chase cams
EnterChaseMode now takes (ChaseCamera, RetailChaseCamera); Active
consults CameraDiagnostics.UseRetailChaseCamera to pick which to
expose. Flag flip at runtime swaps cameras instantly (both are kept
warm). GameWindow's two EnterChaseMode call sites get a temporary
stub RetailChaseCamera; Task 7 wires proper construction +
per-frame updates.

Also folds in two minor cleanups from the Task 3 code review:
- Update() discards the unused `right` axis from BuildBasis (no
  caller in the chase-cam math; viewer_offset.X is always 0)
- The three CameraDiagnostics-mutating integration tests now
  save and restore the static state in try/finally to avoid
  ordering-dependent contamination

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 19:56:24 +02:00

300 lines
12 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Numerics;
using AcDream.Core.Rendering;
namespace AcDream.App.Rendering;
/// <summary>
/// Retail-faithful chase camera. Ports the chase-cam behavior from the
/// 2013 acclient (<c>CameraManager</c> + <c>CameraSet</c>, decomp at
/// <c>docs/research/named-retail/acclient_2013_pseudo_c.txt:95505</c>):
/// exponential damping toward a target pose, 5-frame velocity-averaged
/// slope-aligned heading frame, mouse-input low-pass filter.
///
/// <para>
/// Sits behind <see cref="CameraDiagnostics.UseRetailChaseCamera"/>
/// next to the legacy <see cref="ChaseCamera"/>; 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 <c>_trackedZ</c> hack.
/// </para>
///
/// <para>
/// Spec: <c>docs/superpowers/specs/2026-05-18-retail-chase-camera-design.md</c>.
/// </para>
/// </summary>
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) ──────────────────────────────
/// <summary>Length of the viewer_offset vector. Retail default ≈ 2.61.</summary>
public float Distance { get; set; } = 2.61f;
/// <summary>Angle of the camera above the heading-frame XY plane. Retail default ≈ 0.291 rad (16.7°).</summary>
public float Pitch { get; set; } = 0.291f;
/// <summary>
/// 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.
/// </summary>
public float YawOffset { get; set; } = 0f;
/// <summary>Height of look-at anchor above the player's feet (m). Retail default 1.5.</summary>
public float PivotHeight { get; set; } = 1.5f;
/// <summary>Computed translucency for the player mesh (0 = opaque, 1 = invisible). Read by GameWindow.</summary>
public float PlayerTranslucency { get; private set; }
/// <summary>Clamp bounds carried over from legacy ChaseCamera.</summary>
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 ────────────────────────────────────────
/// <summary>
/// Advance the camera one frame. Caller passes the player's current
/// pose + velocity (in world space) + the frame's <c>dt</c> in
/// seconds. After this returns, <see cref="Position"/>,
/// <see cref="View"/>, and <see cref="PlayerTranslucency"/> reflect
/// the new state.
/// </summary>
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);
}
/// <summary>
/// Adjust the camera distance (zoom) by a delta, clamped to
/// <see cref="DistanceMin"/>..<see cref="DistanceMax"/>. Mirrors
/// legacy <c>ChaseCamera.AdjustDistance</c>.
/// </summary>
public void AdjustDistance(float delta) =>
Distance = Math.Clamp(Distance + delta, DistanceMin, DistanceMax);
/// <summary>
/// Adjust the camera pitch by a delta (radians), clamped to
/// <see cref="PitchMin"/>..<see cref="PitchMax"/>. Mirrors legacy
/// <c>ChaseCamera.AdjustPitch</c>.
/// </summary>
public void AdjustPitch(float delta) =>
Pitch = Math.Clamp(Pitch + delta, PitchMin, PitchMax);
/// <summary>
/// Public entry point for the mouse-input low-pass filter. Calls
/// <see cref="FilterMouseAxis"/> on each axis with shared state.
/// </summary>
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.
/// <summary>
/// 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 <c>target_status &amp;
/// ALIGN_WITH_PLANE</c> path with the contact-plane branch
/// collapsed into the flat fallback.
/// </summary>
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);
}
/// <summary>
/// Build an orthonormal basis with <c>forward = heading</c>. World
/// up is <c>(0, 0, 1)</c>; if <c>heading</c> is near-parallel to it
/// the right axis falls back to world <c>+X</c> so the cross
/// product doesn't collapse.
/// </summary>
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);
}
/// <summary>
/// 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). <paramref name="count"/> grows from 0 toward 5
/// and stays at 5 once the ring is full.
/// </summary>
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;
}
/// <summary>
/// Average the <paramref name="count"/> most-recent entries of the
/// ring (entries <c>[ring.Length-count .. ring.Length)</c>). Returns
/// <see cref="Vector3.Zero"/> when count is zero.
/// </summary>
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;
}
/// <summary>
/// Exponential-damping rate per frame.
/// <c>alpha = clamp(stiffness * dt * 10, 0, 1)</c>. At
/// <c>stiffness=0.45</c>, <c>dt=1/60</c> → <c>~0.075</c>
/// (~150 ms half-life). Matches retail's
/// <c>x_1 = stiffness * dt * 10</c> formulation.
/// </summary>
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;
}
/// <summary>
/// Low-pass filter for a single mouse axis. Mirrors retail's
/// <c>CameraSet::FilterMouseInput</c>: if last sample was within
/// <paramref name="windowSec"/>, blend output with the average of
/// (previous, raw); otherwise pass-through. Final output =
/// <c>raw * (1 - weight) + blended * weight</c>. Updates
/// <paramref name="lastDelta"/> and <paramref name="lastTimeSec"/>
/// to the new state.
/// </summary>
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;
}
/// <summary>
/// Player-mesh translucency as a function of camera-to-pivot
/// distance. <c>0</c> = fully opaque, <c>1</c> = fully transparent.
/// Opaque at and beyond 0.45 m; fully transparent at and within
/// 0.20 m; linear ramp between. Matches retail's <c>CameraSet::
/// UpdateCamera</c> distance check (decomp :9770397725).
/// </summary>
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);
}
}