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
using AcDream.Core.Rendering;
namespace AcDream.App.Rendering;
public sealed class CameraController
{
public OrbitCamera Orbit { get; }
public FlyCamera Fly { get; }
public ChaseCamera? Chase { get; private set; }
public ICamera Active { get; private set; }
public bool IsFlyMode => Active == Fly;
public bool IsChaseMode => Chase is not null && Active == Chase;
public OrbitCamera Orbit { get; }
public FlyCamera Fly { get; }
public ChaseCamera? Chase { get; private set; }
public RetailChaseCamera? RetailChase { get; private set; }
/// <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;
private enum Mode { Orbit, Fly, Chase }
private Mode _mode = Mode.Orbit;
public CameraController(OrbitCamera orbit, FlyCamera fly)
{
Orbit = orbit;
Fly = fly;
Active = orbit;
Fly = fly;
}
public void ToggleFly()
{
Active = IsFlyMode ? (ICamera)Orbit : Fly;
_mode = IsFlyMode ? Mode.Orbit : Mode.Fly;
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;
Active = chase;
Chase = legacy;
RetailChase = retail;
_mode = Mode.Chase;
ModeChanged?.Invoke(IsChaseMode);
}
public void ExitChaseMode()
{
Active = Fly;
Chase = null;
Chase = null;
RetailChase = null;
_mode = Mode.Fly;
ModeChanged?.Invoke(IsFlyMode);
}
public void SetAspect(float aspect)
{
Orbit.Aspect = aspect;
Fly.Aspect = aspect;
Fly.Aspect = aspect;
}
}

View file

@ -9856,7 +9856,7 @@ public sealed class GameWindow : IDisposable
&& _playerMode
&& _chaseCamera is not null)
{
_cameraController.EnterChaseMode(_chaseCamera);
_cameraController.EnterChaseMode(_chaseCamera, new RetailChaseCamera { Aspect = _chaseCamera.Aspect });
return;
}
_cameraController.ToggleFly();
@ -9962,7 +9962,7 @@ public sealed class GameWindow : IDisposable
// accumulated value from a previous session doesn't leak
// into a future code path that re-enables mouse-yaw.
_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
// once" flag so the live-mode pre-login render gate stops
// 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);
// 3. Orthonormal heading-frame basis.
var (forward, right, up) = BuildBasis(heading);
var (forward, _, up) = BuildBasis(heading);
// 4. Target pose.
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]
public void FirstUpdate_SnapsToTarget()
{
var cam = new RetailChaseCamera { Distance = 5f, Pitch = 0f };
CameraDiagnostics.AlignToSlope = false; // deterministic: heading = yaw vec
bool savedAlign = CameraDiagnostics.AlignToSlope;
try
{
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);
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);
// 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);
}
finally
{
CameraDiagnostics.AlignToSlope = savedAlign;
}
}
[Fact]
public void SecondUpdate_LerpsTowardTarget()
{
var cam = new RetailChaseCamera { Distance = 5f, Pitch = 0f };
CameraDiagnostics.AlignToSlope = false;
CameraDiagnostics.TranslationStiffness = 0.45f;
CameraDiagnostics.RotationStiffness = 0.45f;
bool savedAlign = CameraDiagnostics.AlignToSlope;
float savedTranslation = CameraDiagnostics.TranslationStiffness;
float savedRotation = CameraDiagnostics.RotationStiffness;
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).
cam.Update(Vector3.Zero, playerYaw: 0f, playerVelocity: Vector3.Zero, dt: 1f / 60f);
var firstEye = cam.Position;
// 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);
// 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);
Assert.Equal(-4.25f, cam.Position.X, 3);
Assert.Equal(0f, cam.Position.Y, 4);
Assert.Equal(1.5f, cam.Position.Z, 4);
}
finally
{
CameraDiagnostics.AlignToSlope = savedAlign;
CameraDiagnostics.TranslationStiffness = savedTranslation;
CameraDiagnostics.RotationStiffness = savedRotation;
}
}
[Fact]
public void Translucency_PropertyReflectsCurrentDampedDistance()
{
var cam = new RetailChaseCamera { Distance = 5f, Pitch = 0f, PivotHeight = 1.5f };
CameraDiagnostics.AlignToSlope = false;
bool savedAlign = CameraDiagnostics.AlignToSlope;
try
{
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);
// 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);
}
finally
{
CameraDiagnostics.AlignToSlope = savedAlign;
}
}
[Fact]