acdream/docs/superpowers/specs/2026-05-18-retail-chase-camera-design.md
Erik fc819a4814 docs(camera): design — retail-faithful chase camera with dev-tools toggle
Spec for porting retail's CameraManager + CameraSet behavior to a new
RetailChaseCamera class, controlled by a CameraDiagnostics toggle so the
user can A/B against legacy ChaseCamera. Covers six retail behaviors:
exponential damping (stiffness*dt*10), 5-frame velocity-averaged slope
alignment, mouse low-pass (0.25s window), held-key offset integration,
auto-fade <0.45m, and independent translation/rotation stiffness rates.

Brainstormed end-to-end before any code change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 19:05:35 +02:00

478 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Retail-faithful chase camera with dev-tools toggle
**Date:** 2026-05-18
**Author:** Claude (with @erikn)
**Phase:** ad-hoc rendering polish (not on the M2 critical path)
**Status:** brainstormed → ready for plan
## Motivation
`src/AcDream.App/Rendering/ChaseCamera.cs` is a rigid follow-cam: each
frame `Position` is recomputed as a pure function of
`playerPosition + yaw + Pitch + Distance + EyeHeight`. The character
is welded to the camera, which makes movement feel mechanical (no lag,
no overshoot, no slope-awareness). A `_trackedZ` hack pins camera Z
during jumps as a workaround for the visual feel that retail's camera
gets naturally from low-stiffness damping.
The retail 2013 client uses a two-class chase camera
(`CameraManager` + `CameraSet`, decomp at
`docs/research/named-retail/acclient_2013_pseudo_c.txt:95505` and
`:97643`) that:
1. **Exponentially damps both translation and rotation toward a target
pose each frame.** Default stiffness 0.45 → ~7.5 % of the gap closed
per 60 Hz frame → ~150 ms half-life. This is the dominant "alive"
feel.
2. **Aligns the camera basis to the player's recent velocity vector**
(5-frame moving average) instead of pure world-up. The camera tilts
with the terrain when running up/down hills.
3. **Low-passes mouse-look deltas** within a 0.25 s window so
high-DPI mice don't make the camera jitter.
4. **Integrates held-key offset adjustments** (Closer/Farther/Raise/
Lower) at a settable rate per frame so zoom/elevation transitions
are smooth.
5. **Fades the player mesh** linearly from opaque at 0.45 m to fully
transparent at 0.20 m when the camera approaches the pivot.
6. **Orbits independently of player yaw** — Rotate inputs spin
`viewer_offset` around the pivot's Z-axis, the character's heading
isn't touched.
This spec ports all six behaviors as a new `RetailChaseCamera` class,
controlled by a dev-tools toggle so the user can A/B against the
existing legacy camera before we make retail the default.
## Architecture
```
┌─ ChaseCamera ────────┐
│ (existing, legacy) │
└──────────────────────┘
│ ICamera
CameraDiagnostics ─── flag ─► CameraController ──► renderer (View matrix)
(static) │
│ ICamera
┌─ RetailChaseCamera ──┐
│ (new) │
└──────────────────────┘
│ per-frame inputs
GameWindow (updates both, picks active)
```
### Components
**`AcDream.Core.Rendering.CameraDiagnostics`** (new static class)
Owns the runtime-tunable knobs. Mirrors the `PhysicsDiagnostics` pattern
(diagnostic owner classes per CLAUDE.md §"Code Structure Rules" rule 5).
Initial values from env vars at process start; runtime-settable via
property setters that the DebugPanel writes to.
```csharp
public static class CameraDiagnostics
{
public static bool UseRetailChaseCamera { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_RETAIL_CHASE") == "1";
public static bool AlignToSlope { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_CAMERA_ALIGN_SLOPE") != "0";
public static float TranslationStiffness { get; set; } = 0.45f;
public static float RotationStiffness { get; set; } = 0.45f;
public static float MouseLowPassWindowSec { get; set; } = 0.25f;
public static float CameraAdjustmentSpeed { get; set; } = 40.0f;
}
```
Lives in `AcDream.Core` (math-relevant tunables don't need the GL/
window dependency). Defaults match retail's `CameraManager` constructor
(decomp lines 9595795988).
**`AcDream.App.Rendering.RetailChaseCamera : ICamera`** (new)
The retail-faithful camera. Owns per-frame state:
- `_velocityRing[5]` — 5-frame velocity history for the slope-align
moving average. Matches retail's `old_velocities[5]`.
- `_velocityCount` — ring fill level (0..5). Until full, average uses
the actual count.
- `_dampedEye` (Vector3) — current damped camera world position.
- `_dampedForward` (Vector3, unit-length) — current damped look
direction.
- `_initialised` — first-frame snap flag.
- `_lastMouseDeltaX`, `_lastMouseDeltaY`, `_lastFilterTimeSec` — mouse
low-pass state.
User-tunable properties (independent of `CameraDiagnostics` — those are
global, these are per-camera-instance settings the controller can
clamp):
- `Distance` (default 2.61, clamp [2, 40]) — length of `viewer_offset`.
- `Pitch` (default 0.291 rad ≈ 16.7°, clamp [-0.7, 1.4]) — angle of
`viewer_offset` above the heading-frame XY plane.
- `YawOffset` (default 0) — orbit offset added on top of player yaw
when slope-align is off OR when running stationary.
- `PivotHeight` (default 1.5 m) — height of look-at anchor above the
player's feet.
- `Aspect`, `FovY` (from `ICamera`).
**`AcDream.App.Rendering.CameraController`** (existing, extended)
Carries both `Chase` and `RetailChase`. `Active` reads the flag and
returns whichever is selected. `EnterChaseMode(legacy, retail)` takes
both at once and tracks them in parallel.
### Data flow
1. **Process start**`CameraDiagnostics` static initialisers read env
vars; defaults applied if env unset.
2. **PlayerMode entry** (existing `EnterPlayerModeIfPossible` at
`GameWindow.cs:9776`) — construct both cameras, hand to
`CameraController.EnterChaseMode(legacy, retail)`.
3. **Per-frame** (existing tick at `GameWindow.cs:6390`) — pass the
same `playerPosition`, `playerYaw`, `playerVelocity`, `isOnGround`,
`dt` to *both* cameras' `Update()`. Inactive camera stays warm so
toggle swaps are instant.
4. **View matrix** — renderer pulls `cameraController.Active.View`.
5. **Translucency**`RetailChaseCamera.PlayerTranslucency` is read
after `Update()` and applied to the player entity (mechanism TBD
during impl — see "Open implementation questions" below).
6. **DebugPanel** — new "Chase camera" CollapsingHeader writes to
`CameraDiagnostics` via `DebugVM` mirror properties; changes take
effect on the next frame.
## Math (the retail-faithful update loop)
Per-frame inputs:
- `playerPos` (Vector3, world coords; Z up)
- `playerYaw` (radians; 0 = +X, π/2 = +Y)
- `playerVelocity` (Vector3, world space; Z component nonzero on
slopes)
- `isOnGround` (bool; currently unused but accepted for parity with
the legacy camera signature — may be used for an extra-damping branch
in a future iteration)
- `dt` (seconds since last frame)
**Step 1 — velocity history.** FIFO-push `playerVelocity` into
`_velocityRing`. Bump `_velocityCount` to min(count+1, 5).
**Step 2 — averaged velocity.**
```
avgVel = sum(_velocityRing[0.._velocityCount]) / _velocityCount
```
**Step 3 — heading vector.**
```
if CameraDiagnostics.AlignToSlope and ‖avgVel‖² > 1e-4:
heading = normalize(avgVel) # tilts with terrain
else:
yaw = playerYaw + YawOffset
heading = (cos(yaw), sin(yaw), 0) # flat fallback
```
Matches retail's `target_status & ALIGN_WITH_PLANE` branch (decomp
:95644-95795) with the contact-plane fallback collapsed into the
flat-fallback path (we don't have `contact_plane.N` exposed yet; the
flat fallback is visually indistinguishable in the stationary case).
**Step 4 — orthonormal basis** (heading-frame, slope-tilted):
```
forward = heading
if |forward.Z| > 0.99: # heading is near-vertical (rare; airborne edge case)
right = normalize(cross(forward, (1,0,0))) # use world +X as a tilt reference
else:
right = normalize(cross(forward, (0,0,1))) # standard
up = cross(right, forward) # already unit (forward + right orthonormal)
```
**Step 5 — target pose.**
```
pivotWorld = playerPos + (0, 0, PivotHeight)
viewer_offset = (0, -Distance*cos(Pitch), Distance*sin(Pitch)) # (right, forward, up) local
targetEye = pivotWorld
+ right * viewer_offset.X
+ forward * viewer_offset.Y
+ up * viewer_offset.Z
targetForward = normalize(pivotWorld - targetEye) # camera looks at pivot
```
The `viewer_offset` parameterization matches retail's default `(0,
-2.5·scale, 0.75·scale)` when `Distance ≈ 2.61` and `Pitch ≈ 16.7°`
(0.291 rad).
**Step 6 — exponential damping** (two independent decay rates):
```
tAlpha = clamp(CameraDiagnostics.TranslationStiffness * dt * 10, 0, 1)
rAlpha = clamp(CameraDiagnostics.RotationStiffness * dt * 10, 0, 1)
if not _initialised:
_dampedEye = targetEye
_dampedForward = targetForward
_initialised = true
else:
_dampedEye = lerp(_dampedEye, targetEye, tAlpha)
_dampedForward = normalize(lerp(_dampedForward, targetForward, rAlpha))
```
The normalized lerp of the forward unit vector is the standard
small-step equivalent of quaternion slerp; at `rAlpha ≤ ~0.1` per frame
(the working range) the difference from `Quaternion.Slerp` is below
floating-point noise. This preserves retail's *independent*
translation/rotation rates without quaternion handedness pitfalls.
**Step 7 — view matrix.**
```
Position = _dampedEye
View = Matrix4x4.CreateLookAt(_dampedEye, _dampedEye + _dampedForward, (0,0,1))
```
The `up` reference passed to `CreateLookAt` is the world up, not the
heading-frame up. This produces a camera that always has the horizon
horizontal on screen — matches retail (the camera basis tilts pitch,
but the screen's "up" stays world-up).
**Step 8 — mouse low-pass filter** (separate entry point;
`FilterMouseDelta(rawX, rawY, weight) → (outX, outY)`):
```
nowSec = stopwatch elapsed seconds
if nowSec - _lastFilterTimeSec < CameraDiagnostics.MouseLowPassWindowSec:
avgX = (_lastMouseDeltaX + rawX) * 0.5
avgY = (_lastMouseDeltaY + rawY) * 0.5
else:
avgX = rawX
avgY = rawY
outX = rawX * (1 - weight) + avgX * weight # weight typically 0.5
outY = rawY * (1 - weight) + avgY * weight
_lastMouseDeltaX = outX
_lastMouseDeltaY = outY
_lastFilterTimeSec = nowSec
```
Matches retail's `CameraSet::FilterMouseInput` (decomp :96250-96279).
GameWindow's mouse-move handler calls this before feeding `dy` to
`AdjustPitch` / `dx` to `YawOffset`.
**Step 9 — auto-fade translucency.**
```
d = distance(_dampedEye, pivotWorld)
if d >= 0.45: PlayerTranslucency = 0.0
elif d > 0.20: PlayerTranslucency = 1 - (0.20 - d) / (0.20 - 0.45)
else: PlayerTranslucency = 1.0
```
Matches retail's `CameraSet::UpdateCamera` distance check
(decomp :97703-97725). Reading this property at 0 produces a fully
opaque player; at 1 fully invisible. GameWindow applies it to the
player entity's render path (see implementation question Q1).
## Continuous-key offset integration
Retail integrates `viewer_offset += FlagsToVector(held_keys) * dt`
each frame for Closer/Farther/Raise/Lower as held keys. Since
acdream's existing input scheme is mouse-wheel = zoom (`AdjustDistance`)
and RMB-orbit-mouse-Y = pitch (`AdjustPitch`), we don't have keyboard
bindings for these.
This spec adds four `InputAction`s, all default-unbound (no retail
keymap entries for them yet — users wire them in Settings if wanted):
- `CameraZoomIn``Distance -= adjSpeed * dt`
- `CameraZoomOut``Distance += adjSpeed * dt`
- `CameraRaise``Pitch += adjSpeed * dt * 0.02` (smaller multiplier; pitch in radians)
- `CameraLower``Pitch -= adjSpeed * dt * 0.02`
The wheel-driven `AdjustDistance(step)` and the RMB-mouse-Y-driven
`AdjustPitch(delta)` keep working unchanged. The damping in step 6
makes the discrete step from a wheel scroll feel smooth automatically.
## DebugPanel UI
New CollapsingHeader **"Chase camera"** in `DebugPanel.cs`, sitting
between "Player Info" and "Performance", defaults open. Renders six
controls bound to `DebugVM` mirror properties:
```
[ ] Use retail chase camera [env: ACDREAM_RETAIL_CHASE]
[x] Align to slope [env: ACDREAM_CAMERA_ALIGN_SLOPE]
Translation stiffness [====|====] 0.45 (slider 0.05 .. 1.0, step 0.01)
Rotation stiffness [====|====] 0.45 (slider 0.05 .. 1.0, step 0.01)
Mouse low-pass window [==|======] 0.25 s (slider 0.0 .. 0.5, step 0.01)
Adjustment speed [=====|===] 40.0 (slider 10 .. 80, step 1)
```
`DebugVM` gets six new properties, each forwarding to a
`CameraDiagnostics` static, matching the `ProbeResolveEnabled` mirror
pattern.
`IPanelRenderer` may need a `SliderFloat(label, ref value, min, max)`
method if not already present. The ImGui backend wraps
`ImGui.SliderFloat`; tests use a stub renderer.
## Files touched
**New:**
- `src/AcDream.Core/Rendering/CameraDiagnostics.cs` — static tunable owner.
- `src/AcDream.App/Rendering/RetailChaseCamera.cs` — the camera class.
- `tests/AcDream.Core.Tests/Rendering/CameraDiagnosticsTests.cs` — env-var parse + setter passthrough.
- `tests/AcDream.Core.Tests/Rendering/RetailChaseCameraTests.cs` — math + damping + low-pass + fade tests.
**Modified:**
- `src/AcDream.App/Rendering/CameraController.cs` — carry both cameras, swap on flag.
- `src/AcDream.App/Rendering/GameWindow.cs` — construct both at chase-entry; update both per frame; route mouse low-pass; apply `PlayerTranslucency`. ~30 LOC of new code spread across existing chase-camera call sites, no new feature body.
- `src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs` — six new mirror properties.
- `src/AcDream.UI.Abstractions/Panels/Debug/DebugPanel.cs` — new CollapsingHeader.
- `src/AcDream.UI.Abstractions/Input/InputAction.cs` (or wherever the enum lives) — four new actions.
(Possibly: `src/AcDream.UI.Abstractions/Panels/IPanelRenderer.cs` if
`SliderFloat` doesn't exist there yet.)
If a new namespace `AcDream.Core.Rendering` doesn't exist, it's created
fresh. `AcDream.Core` does not currently reference any GL/window/Silk
types, so adding `CameraDiagnostics` (pure floats + bools) does not
violate the layering rule (CLAUDE.md §"Code Structure Rules" rule 2).
## Tests
Five test groups in `RetailChaseCameraTests`:
**1. Heading-source fallbacks** — verify the slope-align toggle and
the small-velocity fallback:
- `StationaryAlignToSlope_HeadingMatchesPlayerYaw` — zero velocity →
heading vector ≈ `(cos yaw, sin yaw, 0)`.
- `MovingHorizontal_HeadingMatchesVelocity` — sustained `(1, 0, 0)`
velocity over 5 frames → heading ≈ `(1, 0, 0)`.
- `MovingUphill_HeadingHasPositiveZ` — velocity `(1, 0, 0.5)` over 5
frames → `heading.Z > 0`.
- `SlopeAlignDisabled_IgnoresVelocity` — heading always falls back to
flat yaw vector regardless of velocity.
**2. 5-frame averaging** — verify the ring buffer:
- `VelocityRing_AveragesLastN` — feed `[(1,0,0), (1,0,0), (2,0,0),
(2,0,0), (3,0,0)]`, expect avg `(1.8, 0, 0)`.
- `VelocityRing_FifoEvictsOldest` — feed 6 entries, ring still holds 5
with the first evicted.
- `VelocityRing_PartialFillUsesActualCount` — feed 2 entries, avg
divides by 2 not 5.
**3. Damping** — verify alpha formula + first-frame snap + lerp:
- `DampingAlpha_AtRetailDefault_ProducesSevenAndAHalfPercent` —
stiffness=0.45, dt=1/60 → alpha ≈ 0.075.
- `DampingAlpha_LargeDtClampsToOne` — stiffness=0.45, dt=1 → alpha = 1.
- `FirstUpdate_SnapsToTarget` — initial Update with no prior state
sets `_dampedEye = targetEye` exactly (no damping from a stale (0,0,0)).
- `SecondUpdate_LerpsTowardTarget` — initial pose A, target pose B,
alpha=0.5 → position = A + 0.5·(B-A).
**4. Mouse low-pass** — verify averaging window:
- `MouseDelta_WithinWindow_BlendedWithPrevious` — feed delta=10 at
t=0, delta=20 at t=0.1 (window=0.25) → second output averages with
the first.
- `MouseDelta_BeyondWindow_PassesThrough` — feed delta=10 at t=0,
delta=20 at t=0.5 → second output is unmodified.
- `MouseDelta_WeightZero_OutputsRaw` — weight=0 → output = raw
regardless of history.
- `MouseDelta_WeightOne_OutputsAveraged` — weight=1 within window →
output = average.
**5. Auto-fade** — verify the linear ramp constants:
- `Translucency_DistanceFar_IsZero` — d ≥ 0.45 → t = 0.
- `Translucency_DistanceMid_RampsLinearly` — d=0.325 (midpoint) →
t = 0.5.
- `Translucency_DistanceNear_IsOne` — d ≤ 0.20 → t = 1.
- `Translucency_AtThreshold_IsExact` — d = 0.45 → t = 0; d = 0.20 → t = 1.
Plus a small `CameraDiagnosticsTests` covering env-var parsing
(`UseRetailChaseCamera=1` reads as true, default false; `AlignToSlope=0`
reads as false, default true).
No visual / integration test; visual feel is the manual acceptance
test (see below).
## Acceptance criteria
1. `dotnet build` green.
2. `dotnet test` green, with new tests covering the math above.
3. With `ACDREAM_RETAIL_CHASE=1` set (or the dev-tools toggle flipped
on), running the client + walking in Holtburg produces noticeably
smoother camera motion than the legacy camera: visible lag when
turning, visible coast-and-settle when stopping, visible
tilt-with-terrain on hill crests.
4. With the toggle OFF, behavior is identical to before this change
(legacy `ChaseCamera` untouched).
5. Toggling the switch at runtime swaps cameras without snapping or
crashing; the newly-active camera takes a few frames to ease into
place from the warm state of the inactive camera.
6. Jumping with retail mode on produces the "see yourself rise above
the camera" feedback *without* the `_trackedZ` hack — the existing
hack stays in `ChaseCamera` (legacy) untouched.
## Open implementation questions
The plan resolves these before coding:
**Q1: Where does `PlayerTranslucency` apply?**
The retail call is `CPhysicsObj::SetTranslucencyHierarchical(player, t)`.
Need to find the acdream equivalent — likely a property on `WorldEntity`
or its mesh-batch metadata. If absent, the plan adds a minimal
`Translucency` property and threads it through the render path. If
the change to `WorldEntity` is more than ~20 LOC, the auto-fade feature
ships in a follow-up commit rather than blocking the main toggle.
**Q2: Where does the player's world velocity come from?**
`PlayerMovementController` has `BodyVelocity` (Vector3) at line 155.
The chase-camera update call site at `GameWindow.cs:6390` already
holds a reference to `_playerController`; threading `playerController.
BodyVelocity` into the `RetailChaseCamera.Update` call is one extra
argument. Confirm during impl.
**Q3: `IPanelRenderer.SliderFloat`?**
Quick check during impl — if not present, add it to
`IPanelRenderer` + `ImGuiPanelRenderer` (one-line each).
## Out of scope / future work
- **First-person ("InHead") mode toggle.** Retail's `SetInHead`
collapses `viewer_offset` to `(0, 0.18, 0)`. We don't have a first-
person mode yet; out of scope.
- **Look-down mode (`ToggleLookDown`)** — retail's "look down at floor"
mode for inventory drag/drop. Out of scope.
- **Map mode (`ToggleMapMode`)** — retail's "top-down map view." Out
of scope.
- **Camera-vs-world collision.** Retail's per-frame update doesn't
raycast world geometry (see investigation report 2026-05-18 in chat).
The auto-fade handles "camera passes through player"; we don't
attempt "camera collides with wall" — same as retail.
- **Making retail the default.** Default stays off in this spec; flip
in a follow-up commit after visual verification.
- **Deleting legacy `ChaseCamera`.** Stays around for A/B comparison
until retail mode is proven and made default; then a single
cleanup commit deletes it + the `_trackedZ` hack.
## References
- **Retail decomp:** `docs/research/named-retail/acclient_2013_pseudo_c.txt`
lines 95505 (`CameraManager::UpdateCamera`), 97643
(`CameraSet::UpdateCamera`), 95957 (`CameraManager` constructor),
97916 (`CameraSet::SetDefaultOffsets`), 96250
(`CameraSet::FilterMouseInput`), 97103 (`CameraSet::Rotate`), 97350
(`CameraSet::Closer`).
- **Retail symbols:** `docs/research/named-retail/symbols.json` —
search for `CameraManager` / `CameraSet`.
- **Investigation report:** chat transcript 2026-05-18 (the brainstorm
preceding this spec).
- **Existing legacy:** `src/AcDream.App/Rendering/ChaseCamera.cs`.
- **Diagnostic owner pattern:**
`src/AcDream.Core/Physics/PhysicsDiagnostics.cs`.