feat(camera): wire RetailChaseCamera Update() + tunables + state
Adds the per-frame Update(playerPos, yaw, velocity, dt) entrypoint that composes the math primitives into a renderable View matrix + PlayerTranslucency. State: 5-frame velocity ring, damped eye + forward unit vector, first-frame snap flag, mouse-filter shared state. Public surface: Distance/Pitch/YawOffset/PivotHeight tunables, AdjustDistance/Pitch (with clamps), FilterMouseDelta entry, View + Position + PlayerTranslucency outputs. 5 new integration tests, all pass; total RetailChaseCamera test count 25. Also folds in two minor cleanups from the Task 2 code review: - AverageVelocity uses ring.Length instead of hardcoded 5 - Basis_NearVerticalHeading test asserts orthogonality of right & up Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8ebd33dc8f
commit
0c1403f2e6
2 changed files with 220 additions and 3 deletions
|
|
@ -25,7 +25,7 @@ namespace AcDream.App.Rendering;
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class RetailChaseCamera : ICamera
|
public sealed class RetailChaseCamera : ICamera
|
||||||
{
|
{
|
||||||
// ICamera surface — filled in by Task 3.
|
// ICamera surface.
|
||||||
public Vector3 Position { get; private set; }
|
public Vector3 Position { get; private set; }
|
||||||
public float Aspect { get; set; } = 16f / 9f;
|
public float Aspect { get; set; } = 16f / 9f;
|
||||||
public float FovY { get; set; } = MathF.PI / 3f;
|
public float FovY { get; set; } = MathF.PI / 3f;
|
||||||
|
|
@ -33,6 +33,135 @@ public sealed class RetailChaseCamera : ICamera
|
||||||
public Matrix4x4 Projection =>
|
public Matrix4x4 Projection =>
|
||||||
Matrix4x4.CreatePerspectiveFieldOfView(FovY, Aspect, 1f, 5000f);
|
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, right, 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.
|
// Math primitives — pure, internal-static for unit-testability.
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -95,14 +224,15 @@ public sealed class RetailChaseCamera : ICamera
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Average the <paramref name="count"/> most-recent entries of the
|
/// Average the <paramref name="count"/> most-recent entries of the
|
||||||
/// ring (entries <c>[5-count .. 5)</c>). Returns
|
/// ring (entries <c>[ring.Length-count .. ring.Length)</c>). Returns
|
||||||
/// <see cref="Vector3.Zero"/> when count is zero.
|
/// <see cref="Vector3.Zero"/> when count is zero.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal static Vector3 AverageVelocity(Vector3[] ring, int count)
|
internal static Vector3 AverageVelocity(Vector3[] ring, int count)
|
||||||
{
|
{
|
||||||
if (count == 0) return Vector3.Zero;
|
if (count == 0) return Vector3.Zero;
|
||||||
Vector3 sum = Vector3.Zero;
|
Vector3 sum = Vector3.Zero;
|
||||||
for (int i = 5 - count; i < 5; i++) sum += ring[i];
|
int start = ring.Length - count;
|
||||||
|
for (int i = start; i < ring.Length; i++) sum += ring[i];
|
||||||
return sum / count;
|
return sum / count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Numerics;
|
using System.Numerics;
|
||||||
using AcDream.App.Rendering;
|
using AcDream.App.Rendering;
|
||||||
|
using AcDream.Core.Rendering;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace AcDream.App.Tests.Rendering;
|
namespace AcDream.App.Tests.Rendering;
|
||||||
|
|
@ -84,6 +85,7 @@ public class RetailChaseCameraTests
|
||||||
|
|
||||||
Assert.Equal(1f, right.Length(), 5);
|
Assert.Equal(1f, right.Length(), 5);
|
||||||
Assert.Equal(1f, up.Length(), 5);
|
Assert.Equal(1f, up.Length(), 5);
|
||||||
|
Assert.Equal(0f, Vector3.Dot(right, up), 5);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Velocity ring & averaging ────────────────────────────────────
|
// ── Velocity ring & averaging ────────────────────────────────────
|
||||||
|
|
@ -265,4 +267,89 @@ public class RetailChaseCameraTests
|
||||||
Assert.Equal(1f, RetailChaseCamera.ComputeTranslucency(distance: 0.10f), 5);
|
Assert.Equal(1f, RetailChaseCamera.ComputeTranslucency(distance: 0.10f), 5);
|
||||||
Assert.Equal(1f, RetailChaseCamera.ComputeTranslucency(distance: 0.0f), 5);
|
Assert.Equal(1f, RetailChaseCamera.ComputeTranslucency(distance: 0.0f), 5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Update() integration ─────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FirstUpdate_SnapsToTarget()
|
||||||
|
{
|
||||||
|
var cam = new RetailChaseCamera { Distance = 5f, Pitch = 0f };
|
||||||
|
CameraDiagnostics.AlignToSlope = false; // deterministic: heading = yaw vec
|
||||||
|
|
||||||
|
cam.Update(
|
||||||
|
playerPosition: new Vector3(10f, 20f, 30f),
|
||||||
|
playerYaw: 0f, // forward = +X
|
||||||
|
playerVelocity: Vector3.Zero,
|
||||||
|
dt: 1f / 60f);
|
||||||
|
|
||||||
|
// Expected target eye:
|
||||||
|
// pivot = (10, 20, 30+1.5=31.5)
|
||||||
|
// forward (yaw=0)= (1, 0, 0)
|
||||||
|
// right = (0, -1, 0) since (1,0,0) × (0,0,1) = (0, -1, 0)
|
||||||
|
// up = right × forward = (0,-1,0) × (1,0,0) = (0,0,1)
|
||||||
|
// viewer_offset = (0, -5, 0) (Distance=5, Pitch=0 → -Distance*cos = -5, sin = 0)
|
||||||
|
// eye = pivot + right*0 + forward*-5 + up*0
|
||||||
|
// = (10 - 5, 20, 31.5) = (5, 20, 31.5)
|
||||||
|
Assert.Equal(5f, cam.Position.X, 4);
|
||||||
|
Assert.Equal(20f, cam.Position.Y, 4);
|
||||||
|
Assert.Equal(31.5f, cam.Position.Z, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SecondUpdate_LerpsTowardTarget()
|
||||||
|
{
|
||||||
|
var cam = new RetailChaseCamera { Distance = 5f, Pitch = 0f };
|
||||||
|
CameraDiagnostics.AlignToSlope = false;
|
||||||
|
CameraDiagnostics.TranslationStiffness = 0.45f;
|
||||||
|
CameraDiagnostics.RotationStiffness = 0.45f;
|
||||||
|
|
||||||
|
// First update at origin: dampedEye = (-5, 0, 1.5).
|
||||||
|
cam.Update(Vector3.Zero, playerYaw: 0f, playerVelocity: Vector3.Zero, dt: 1f / 60f);
|
||||||
|
var firstEye = cam.Position;
|
||||||
|
|
||||||
|
// Teleport the player one frame later. Target eye now at (10-5, 0, 1.5) = (5, 0, 1.5).
|
||||||
|
// alpha = 0.45 * (1/60) * 10 = 0.075.
|
||||||
|
// New eye = firstEye + 0.075 * (target - firstEye)
|
||||||
|
// = (-5,0,1.5) + 0.075 * ((5,0,1.5) - (-5,0,1.5))
|
||||||
|
// = (-5,0,1.5) + 0.075 * (10,0,0)
|
||||||
|
// = (-4.25, 0, 1.5)
|
||||||
|
cam.Update(new Vector3(10f, 0f, 0f), playerYaw: 0f, playerVelocity: Vector3.Zero, dt: 1f / 60f);
|
||||||
|
|
||||||
|
Assert.Equal(-4.25f, cam.Position.X, 3);
|
||||||
|
Assert.Equal(0f, cam.Position.Y, 4);
|
||||||
|
Assert.Equal(1.5f, cam.Position.Z, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Translucency_PropertyReflectsCurrentDampedDistance()
|
||||||
|
{
|
||||||
|
var cam = new RetailChaseCamera { Distance = 5f, Pitch = 0f, PivotHeight = 1.5f };
|
||||||
|
CameraDiagnostics.AlignToSlope = false;
|
||||||
|
|
||||||
|
// Far from pivot — translucency should be 0.
|
||||||
|
cam.Update(Vector3.Zero, playerYaw: 0f, playerVelocity: Vector3.Zero, dt: 1f / 60f);
|
||||||
|
Assert.Equal(0f, cam.PlayerTranslucency, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AdjustDistance_ClampsToRange()
|
||||||
|
{
|
||||||
|
var cam = new RetailChaseCamera { Distance = 5f };
|
||||||
|
cam.AdjustDistance(-100f);
|
||||||
|
Assert.Equal(RetailChaseCamera.DistanceMin, cam.Distance);
|
||||||
|
|
||||||
|
cam.AdjustDistance(+200f);
|
||||||
|
Assert.Equal(RetailChaseCamera.DistanceMax, cam.Distance);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AdjustPitch_ClampsToRange()
|
||||||
|
{
|
||||||
|
var cam = new RetailChaseCamera { Pitch = 0f };
|
||||||
|
cam.AdjustPitch(-10f);
|
||||||
|
Assert.Equal(RetailChaseCamera.PitchMin, cam.Pitch);
|
||||||
|
|
||||||
|
cam.AdjustPitch(+10f);
|
||||||
|
Assert.Equal(RetailChaseCamera.PitchMax, cam.Pitch);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue