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>
This commit is contained in:
Erik 2026-05-18 19:56:24 +02:00
parent 0c1403f2e6
commit e5a5916679
5 changed files with 187 additions and 58 deletions

View file

@ -1,47 +1,81 @@
// src/AcDream.App/Rendering/CameraController.cs // src/AcDream.App/Rendering/CameraController.cs
using AcDream.Core.Rendering;
namespace AcDream.App.Rendering; namespace AcDream.App.Rendering;
public sealed class CameraController public sealed class CameraController
{ {
public OrbitCamera Orbit { get; } public OrbitCamera Orbit { get; }
public FlyCamera Fly { get; } public FlyCamera Fly { get; }
public ChaseCamera? Chase { get; private set; } public ChaseCamera? Chase { get; private set; }
public ICamera Active { get; private set; } public RetailChaseCamera? RetailChase { get; private set; }
public bool IsFlyMode => Active == Fly;
public bool IsChaseMode => Chase is not null && Active == Chase; /// <summary>
/// The renderer-facing active camera. In chase mode, returns
/// <see cref="RetailChase"/> when
/// <see cref="CameraDiagnostics.UseRetailChaseCamera"/> is true,
/// otherwise <see cref="Chase"/>. In fly mode returns
/// <see cref="Fly"/>; default is <see cref="Orbit"/>.
/// </summary>
public ICamera Active
{
get
{
if (_mode == Mode.Fly) return Fly;
if (_mode == Mode.Chase)
{
if (CameraDiagnostics.UseRetailChaseCamera && RetailChase is not null)
return RetailChase;
if (Chase is not null) return Chase;
}
return Orbit;
}
}
public bool IsFlyMode => _mode == Mode.Fly;
public bool IsChaseMode => _mode == Mode.Chase;
public event Action<bool>? ModeChanged; public event Action<bool>? ModeChanged;
private enum Mode { Orbit, Fly, Chase }
private Mode _mode = Mode.Orbit;
public CameraController(OrbitCamera orbit, FlyCamera fly) public CameraController(OrbitCamera orbit, FlyCamera fly)
{ {
Orbit = orbit; Orbit = orbit;
Fly = fly; Fly = fly;
Active = orbit;
} }
public void ToggleFly() public void ToggleFly()
{ {
Active = IsFlyMode ? (ICamera)Orbit : Fly; _mode = IsFlyMode ? Mode.Orbit : Mode.Fly;
ModeChanged?.Invoke(IsFlyMode); ModeChanged?.Invoke(IsFlyMode);
} }
public void EnterChaseMode(ChaseCamera chase) /// <summary>
/// Enter chase mode with both candidate cameras. Both are held;
/// <see cref="Active"/> picks based on
/// <see cref="CameraDiagnostics.UseRetailChaseCamera"/>.
/// </summary>
public void EnterChaseMode(ChaseCamera legacy, RetailChaseCamera retail)
{ {
Chase = chase; Chase = legacy;
Active = chase; RetailChase = retail;
_mode = Mode.Chase;
ModeChanged?.Invoke(IsChaseMode); ModeChanged?.Invoke(IsChaseMode);
} }
public void ExitChaseMode() public void ExitChaseMode()
{ {
Active = Fly; Chase = null;
Chase = null; RetailChase = null;
_mode = Mode.Fly;
ModeChanged?.Invoke(IsFlyMode); ModeChanged?.Invoke(IsFlyMode);
} }
public void SetAspect(float aspect) public void SetAspect(float aspect)
{ {
Orbit.Aspect = aspect; Orbit.Aspect = aspect;
Fly.Aspect = aspect; Fly.Aspect = aspect;
} }
} }

View file

@ -9856,7 +9856,7 @@ public sealed class GameWindow : IDisposable
&& _playerMode && _playerMode
&& _chaseCamera is not null) && _chaseCamera is not null)
{ {
_cameraController.EnterChaseMode(_chaseCamera); _cameraController.EnterChaseMode(_chaseCamera, new RetailChaseCamera { Aspect = _chaseCamera.Aspect });
return; return;
} }
_cameraController.ToggleFly(); _cameraController.ToggleFly();
@ -9962,7 +9962,7 @@ public sealed class GameWindow : IDisposable
// accumulated value from a previous session doesn't leak // accumulated value from a previous session doesn't leak
// into a future code path that re-enables mouse-yaw. // into a future code path that re-enables mouse-yaw.
_playerMouseDeltaX = 0f; _playerMouseDeltaX = 0f;
_cameraController?.EnterChaseMode(_chaseCamera); _cameraController?.EnterChaseMode(_chaseCamera, new RetailChaseCamera { Aspect = _chaseCamera.Aspect });
// K-fix1 (2026-04-26): latch the "we have entered chase at least // K-fix1 (2026-04-26): latch the "we have entered chase at least
// once" flag so the live-mode pre-login render gate stops // once" flag so the live-mode pre-login render gate stops
// suppressing the scene. From here on, the orbit camera (if the // suppressing the scene. From here on, the orbit camera (if the

View file

@ -93,7 +93,7 @@ public sealed class RetailChaseCamera : ICamera
Vector3 heading = ComputeHeading(avgVel, playerYaw + YawOffset, CameraDiagnostics.AlignToSlope); Vector3 heading = ComputeHeading(avgVel, playerYaw + YawOffset, CameraDiagnostics.AlignToSlope);
// 3. Orthonormal heading-frame basis. // 3. Orthonormal heading-frame basis.
var (forward, right, up) = BuildBasis(heading); var (forward, _, up) = BuildBasis(heading);
// 4. Target pose. // 4. Target pose.
Vector3 pivotWorld = playerPosition + new Vector3(0f, 0f, PivotHeight); Vector3 pivotWorld = playerPosition + new Vector3(0f, 0f, PivotHeight);

View file

@ -0,0 +1,67 @@
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.Core.Rendering;
using Xunit;
namespace AcDream.App.Tests.Rendering;
public class CameraControllerTests
{
private static (CameraController ctl, ChaseCamera legacy, RetailChaseCamera retail) MakeChaseFixture()
{
var orbit = new OrbitCamera();
var fly = new FlyCamera();
var ctl = new CameraController(orbit, fly);
var legacy = new ChaseCamera();
var retail = new RetailChaseCamera();
ctl.EnterChaseMode(legacy, retail);
return (ctl, legacy, retail);
}
[Fact]
public void ChaseMode_WhenFlagOff_ActiveIsLegacy()
{
CameraDiagnostics.UseRetailChaseCamera = false;
var (ctl, legacy, _) = MakeChaseFixture();
Assert.Same(legacy, ctl.Active);
Assert.True(ctl.IsChaseMode);
}
[Fact]
public void ChaseMode_WhenFlagOn_ActiveIsRetail()
{
CameraDiagnostics.UseRetailChaseCamera = true;
var (ctl, _, retail) = MakeChaseFixture();
Assert.Same(retail, ctl.Active);
Assert.True(ctl.IsChaseMode);
// Reset.
CameraDiagnostics.UseRetailChaseCamera = false;
}
[Fact]
public void ChaseMode_FlagFlipped_ActiveSwaps()
{
CameraDiagnostics.UseRetailChaseCamera = false;
var (ctl, legacy, retail) = MakeChaseFixture();
Assert.Same(legacy, ctl.Active);
CameraDiagnostics.UseRetailChaseCamera = true;
Assert.Same(retail, ctl.Active);
CameraDiagnostics.UseRetailChaseCamera = false;
Assert.Same(legacy, ctl.Active);
}
[Fact]
public void ExitChaseMode_ClearsBothCameras()
{
CameraDiagnostics.UseRetailChaseCamera = false;
var (ctl, _, _) = MakeChaseFixture();
ctl.ExitChaseMode();
Assert.Null(ctl.Chase);
Assert.Null(ctl.RetailChase);
Assert.False(ctl.IsChaseMode);
}
}

View file

@ -273,62 +273,90 @@ public class RetailChaseCameraTests
[Fact] [Fact]
public void FirstUpdate_SnapsToTarget() public void FirstUpdate_SnapsToTarget()
{ {
var cam = new RetailChaseCamera { Distance = 5f, Pitch = 0f }; bool savedAlign = CameraDiagnostics.AlignToSlope;
CameraDiagnostics.AlignToSlope = false; // deterministic: heading = yaw vec try
{
var cam = new RetailChaseCamera { Distance = 5f, Pitch = 0f };
CameraDiagnostics.AlignToSlope = false; // deterministic: heading = yaw vec
cam.Update( cam.Update(
playerPosition: new Vector3(10f, 20f, 30f), playerPosition: new Vector3(10f, 20f, 30f),
playerYaw: 0f, // forward = +X playerYaw: 0f, // forward = +X
playerVelocity: Vector3.Zero, playerVelocity: Vector3.Zero,
dt: 1f / 60f); dt: 1f / 60f);
// Expected target eye: // Expected target eye:
// pivot = (10, 20, 30+1.5=31.5) // pivot = (10, 20, 30+1.5=31.5)
// forward (yaw=0)= (1, 0, 0) // forward (yaw=0)= (1, 0, 0)
// right = (0, -1, 0) since (1,0,0) × (0,0,1) = (0, -1, 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) // 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) // viewer_offset = (0, -5, 0) (Distance=5, Pitch=0 → -Distance*cos = -5, sin = 0)
// eye = pivot + right*0 + forward*-5 + up*0 // eye = pivot + right*0 + forward*-5 + up*0
// = (10 - 5, 20, 31.5) = (5, 20, 31.5) // = (10 - 5, 20, 31.5) = (5, 20, 31.5)
Assert.Equal(5f, cam.Position.X, 4); Assert.Equal(5f, cam.Position.X, 4);
Assert.Equal(20f, cam.Position.Y, 4); Assert.Equal(20f, cam.Position.Y, 4);
Assert.Equal(31.5f, cam.Position.Z, 4); Assert.Equal(31.5f, cam.Position.Z, 4);
}
finally
{
CameraDiagnostics.AlignToSlope = savedAlign;
}
} }
[Fact] [Fact]
public void SecondUpdate_LerpsTowardTarget() public void SecondUpdate_LerpsTowardTarget()
{ {
var cam = new RetailChaseCamera { Distance = 5f, Pitch = 0f }; bool savedAlign = CameraDiagnostics.AlignToSlope;
CameraDiagnostics.AlignToSlope = false; float savedTranslation = CameraDiagnostics.TranslationStiffness;
CameraDiagnostics.TranslationStiffness = 0.45f; float savedRotation = CameraDiagnostics.RotationStiffness;
CameraDiagnostics.RotationStiffness = 0.45f; try
{
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). // First update at origin: dampedEye = (-5, 0, 1.5).
cam.Update(Vector3.Zero, playerYaw: 0f, playerVelocity: Vector3.Zero, dt: 1f / 60f); cam.Update(Vector3.Zero, playerYaw: 0f, playerVelocity: Vector3.Zero, dt: 1f / 60f);
var firstEye = cam.Position; var firstEye = cam.Position;
// Teleport the player one frame later. Target eye now at (10-5, 0, 1.5) = (5, 0, 1.5). // 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. // alpha = 0.45 * (1/60) * 10 = 0.075.
// New eye = firstEye + 0.075 * (target - firstEye) // 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 * ((5,0,1.5) - (-5,0,1.5))
// = (-5,0,1.5) + 0.075 * (10,0,0) // = (-5,0,1.5) + 0.075 * (10,0,0)
// = (-4.25, 0, 1.5) // = (-4.25, 0, 1.5)
cam.Update(new Vector3(10f, 0f, 0f), playerYaw: 0f, playerVelocity: Vector3.Zero, dt: 1f / 60f); 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(-4.25f, cam.Position.X, 3);
Assert.Equal(0f, cam.Position.Y, 4); Assert.Equal(0f, cam.Position.Y, 4);
Assert.Equal(1.5f, cam.Position.Z, 4); Assert.Equal(1.5f, cam.Position.Z, 4);
}
finally
{
CameraDiagnostics.AlignToSlope = savedAlign;
CameraDiagnostics.TranslationStiffness = savedTranslation;
CameraDiagnostics.RotationStiffness = savedRotation;
}
} }
[Fact] [Fact]
public void Translucency_PropertyReflectsCurrentDampedDistance() public void Translucency_PropertyReflectsCurrentDampedDistance()
{ {
var cam = new RetailChaseCamera { Distance = 5f, Pitch = 0f, PivotHeight = 1.5f }; bool savedAlign = CameraDiagnostics.AlignToSlope;
CameraDiagnostics.AlignToSlope = false; try
{
var cam = new RetailChaseCamera { Distance = 5f, Pitch = 0f, PivotHeight = 1.5f };
CameraDiagnostics.AlignToSlope = false;
// Far from pivot — translucency should be 0. // Far from pivot — translucency should be 0.
cam.Update(Vector3.Zero, playerYaw: 0f, playerVelocity: Vector3.Zero, dt: 1f / 60f); cam.Update(Vector3.Zero, playerYaw: 0f, playerVelocity: Vector3.Zero, dt: 1f / 60f);
Assert.Equal(0f, cam.PlayerTranslucency, 5); Assert.Equal(0f, cam.PlayerTranslucency, 5);
}
finally
{
CameraDiagnostics.AlignToSlope = savedAlign;
}
} }
[Fact] [Fact]