diff --git a/docs/superpowers/specs/2026-05-18-retail-chase-camera-design.md b/docs/superpowers/specs/2026-05-18-retail-chase-camera-design.md new file mode 100644 index 0000000..14946d5 --- /dev/null +++ b/docs/superpowers/specs/2026-05-18-retail-chase-camera-design.md @@ -0,0 +1,478 @@ +# 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 95957–95988). + +**`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`.