Retail's world-camera FOV is not a constant: the applied vertical FOV is m_fGameFOV / (viewportAspect - 0.1), recomputed on every aspect or game-FOV change (CreatureMode smartbox sites 0x00452b2f/0x00453b14), gated by Render::SetFOVRad's open (0, pi) acceptance (0x0054b2d0 - rejected results keep the previous FOV). m_fGameFOV defaults to pi/2 = 90 degrees (0x00454649) and is what the Field of View option sets in degrees (0x00451e6a; registered range [10,160] default 90 - gmClient::InitUIPreferences @0x004035b0). Net effect: the horizontal view stays ~85-90 degrees across aspect ratios; wide screens trim the vertical slice instead of ballooning the sides. acdream hardcoded FovY = pi/3 = 60 degrees on all four world cameras, aspect-independent, and the Config slider wrote raw vertical-FOV degrees. New: RetailFieldOfView (the law + gate, decomp-cited), CameraController.GameFovRadians + SetGameFov + one ApplyProjection chokepoint recomputing every camera on SetAspect/SetGameFov/ EnterChaseMode/RestoreState; ApplyFieldOfView now feeds the law; DisplaySettings.Default.FieldOfView 60 -> 90 (the retail registered default; the stored number changed MEANING with this commit). The same seam closes a second latent bug the 2026-08-13 "squished" gate report exposed: SetAspect only ever updated Orbit/Fly - the CHASE cameras (the ones the player looks through) kept their creation-time aspect across every mid-session resize, drawing the world at the old shape stretched onto the new viewport. The paperdoll camera stays outside the law by design (retail portrait mode is UseSharpMode, not smartbox - DollCamera's own doc). Tests: RetailFieldOfViewTests (golden law values at 4:3/16:9/21:9, the constant-horizontal property, the rejection gate, controller propagation incl. chase attach/restore + rejected-law aspect-still-propagates); DisplaySettingsTests + RuntimeSettingsControllerTests updated to the new semantics. App suite 4,953/3 skips; UI.Abstractions 916/0. AD-89 retired in this commit; user settings.json migrated 60->90 by hand (stale pre-port default). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
162 lines
5.8 KiB
C#
162 lines
5.8 KiB
C#
// src/AcDream.App/Rendering/CameraController.cs
|
|
using AcDream.Core.Rendering;
|
|
|
|
namespace AcDream.App.Rendering;
|
|
|
|
public sealed class CameraController
|
|
{
|
|
internal readonly record struct CameraState(
|
|
int ModeCode,
|
|
ChaseCamera? Chase,
|
|
RetailChaseCamera? RetailChase);
|
|
|
|
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. Both the legacy and retail
|
|
/// chase cameras are held simultaneously so that flipping
|
|
/// <see cref="CameraDiagnostics.UseRetailChaseCamera"/> takes effect
|
|
/// on the very next access to this property — no re-entry required,
|
|
/// no notification mechanism, no stale state.
|
|
/// </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;
|
|
|
|
/// <summary>Retail <c>m_fGameFOV</c> (#389): the user-facing FOV the Config
|
|
/// slider sets in degrees, default 90°. The APPLIED per-camera vertical FOV
|
|
/// is derived from it and the current aspect via
|
|
/// <see cref="RetailFieldOfView.TryAppliedVerticalFov"/> — see that class's
|
|
/// doc for the decomp anchors.</summary>
|
|
public float GameFovRadians { get; private set; } = RetailFieldOfView.DefaultGameFovRadians;
|
|
|
|
private float _aspect = 16f / 9f;
|
|
|
|
public CameraController(OrbitCamera orbit, FlyCamera fly)
|
|
{
|
|
Orbit = orbit;
|
|
Fly = fly;
|
|
ApplyProjection();
|
|
}
|
|
|
|
public void ToggleFly()
|
|
{
|
|
_mode = IsFlyMode ? Mode.Orbit : Mode.Fly;
|
|
ModeChanged?.Invoke(IsFlyMode);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Store both cameras simultaneously; <see cref="Active"/> picks
|
|
/// between them per-read via the flag — no re-entry needed on flip.
|
|
/// </summary>
|
|
public void EnterChaseMode(ChaseCamera legacy, RetailChaseCamera retail)
|
|
{
|
|
Chase = legacy;
|
|
RetailChase = retail;
|
|
// #389: freshly attached chase cameras carry their own initialiser
|
|
// aspect/FOV — bring them onto the controller's current aspect +
|
|
// smartbox FOV immediately (before this, a resize while chase cameras
|
|
// were attached never reached them at all: SetAspect only updated
|
|
// Orbit/Fly, so the world rendered at the creation-time aspect
|
|
// stretched onto the new viewport — the 2026-08-13 "squished" gate
|
|
// report's second mechanism).
|
|
ApplyProjection();
|
|
_mode = Mode.Chase;
|
|
ModeChanged?.Invoke(IsChaseMode);
|
|
}
|
|
|
|
public void ExitChaseMode()
|
|
{
|
|
bool wasChaseMode = IsChaseMode;
|
|
Chase = null;
|
|
RetailChase = null;
|
|
if (_mode == Mode.Orbit)
|
|
return;
|
|
_mode = Mode.Fly;
|
|
if (wasChaseMode)
|
|
ModeChanged?.Invoke(IsFlyMode);
|
|
}
|
|
|
|
public void SetAspect(float aspect)
|
|
{
|
|
_aspect = aspect;
|
|
ApplyProjection();
|
|
}
|
|
|
|
/// <summary>Sets retail's <c>m_fGameFOV</c> (#389) and recomputes every
|
|
/// camera's applied FOV. Driven by the Config tab's Field of View value
|
|
/// through <c>RuntimeSettingsStartupTargets.ApplyFieldOfView</c>.</summary>
|
|
public void SetGameFov(float gameFovRadians)
|
|
{
|
|
GameFovRadians = gameFovRadians;
|
|
ApplyProjection();
|
|
}
|
|
|
|
/// <summary>Pushes the current aspect + smartbox-derived vertical FOV onto
|
|
/// every attached camera. When the law's result fails retail's
|
|
/// <c>SetFOVRad</c> gate (see <see cref="RetailFieldOfView.TryAppliedVerticalFov"/>),
|
|
/// the cameras keep their previous FOV — retail's exact behavior — but the
|
|
/// aspect still propagates (retail's viewport aspect is likewise updated
|
|
/// independently of the FOV gate).</summary>
|
|
private void ApplyProjection()
|
|
{
|
|
bool fovAccepted = RetailFieldOfView.TryAppliedVerticalFov(
|
|
GameFovRadians, _aspect, out float fovY);
|
|
|
|
Orbit.Aspect = _aspect;
|
|
Fly.Aspect = _aspect;
|
|
if (Chase is { } chase) chase.Aspect = _aspect;
|
|
if (RetailChase is { } retailChase) retailChase.Aspect = _aspect;
|
|
|
|
if (!fovAccepted)
|
|
return;
|
|
Orbit.FovY = fovY;
|
|
Fly.FovY = fovY;
|
|
if (Chase is { } chaseFov) chaseFov.FovY = fovY;
|
|
if (RetailChase is { } retailChaseFov) retailChaseFov.FovY = fovY;
|
|
}
|
|
|
|
internal CameraState CaptureState() =>
|
|
new((int)_mode, Chase, RetailChase);
|
|
|
|
/// <summary>
|
|
/// Restores a previously captured camera lifetime before notifying
|
|
/// subscribers. State remains restored even when a subscriber throws.
|
|
/// </summary>
|
|
internal void RestoreState(CameraState state)
|
|
{
|
|
if (state.ModeCode < (int)Mode.Orbit || state.ModeCode > (int)Mode.Chase)
|
|
throw new ArgumentOutOfRangeException(nameof(state));
|
|
|
|
Chase = state.Chase;
|
|
RetailChase = state.RetailChase;
|
|
// #389: the aspect/game-FOV may have changed while these cameras were
|
|
// captured (a resize during a reset) — reconverge them before anyone
|
|
// reads a projection.
|
|
ApplyProjection();
|
|
_mode = (Mode)state.ModeCode;
|
|
ModeChanged?.Invoke(IsFlyMode || IsChaseMode);
|
|
}
|
|
}
|