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

20 KiB
Raw Blame History

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.

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 startCameraDiagnostics 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. TranslucencyRetailChaseCamera.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 InputActions, all default-unbound (no retail keymap entries for them yet — users wire them in Settings if wanted):

  • CameraZoomInDistance -= adjSpeed * dt
  • CameraZoomOutDistance += adjSpeed * dt
  • CameraRaisePitch += adjSpeed * dt * 0.02 (smaller multiplier; pitch in radians)
  • CameraLowerPitch -= 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.