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:
parent
0c1403f2e6
commit
e5a5916679
5 changed files with 187 additions and 58 deletions
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
67
tests/AcDream.App.Tests/Rendering/CameraControllerTests.cs
Normal file
67
tests/AcDream.App.Tests/Rendering/CameraControllerTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue