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>
300 lines
12 KiB
C#
300 lines
12 KiB
C#
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 &
|
||
/// 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 :97703–97725).
|
||
/// </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);
|
||
}
|
||
}
|