feat(camera): add RetailChaseCamera math primitives
Seven pure-math helpers in the new RetailChaseCamera class: ComputeHeading (slope-align with flat fallback), BuildBasis (heading → orthonormal frame, near-vertical fallback), PushVelocity + AverageVelocity (5-entry FIFO ring), ComputeDampingAlpha (retail's stiffness*dt*10), FilterMouseAxis (0.25s low-pass), ComputeTranslucency (linear ramp 0.20..0.45 m). 20 tests, all pass. State machine + Update() land in the next commit. Per spec docs/superpowers/specs/2026-05-18-retail-chase-camera-design.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5945f1d915
commit
8ebd33dc8f
3 changed files with 439 additions and 0 deletions
|
|
@ -11,6 +11,7 @@
|
|||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="AcDream.Core.Tests" />
|
||||
<InternalsVisibleTo Include="AcDream.App.Tests" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Silk.NET.OpenGL" Version="2.23.0" />
|
||||
|
|
|
|||
170
src/AcDream.App/Rendering/RetailChaseCamera.cs
Normal file
170
src/AcDream.App/Rendering/RetailChaseCamera.cs
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
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 — filled in by Task 3.
|
||||
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);
|
||||
|
||||
// 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>[5-count .. 5)</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;
|
||||
for (int i = 5 - count; i < 5; 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue