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>
20 KiB
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:
- 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.
- 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.
- Low-passes mouse-look deltas within a 0.25 s window so high-DPI mice don't make the camera jitter.
- Integrates held-key offset adjustments (Closer/Farther/Raise/ Lower) at a settable rate per frame so zoom/elevation transitions are smooth.
- Fades the player mesh linearly from opaque at 0.45 m to fully transparent at 0.20 m when the camera approaches the pivot.
- Orbits independently of player yaw — Rotate inputs spin
viewer_offsetaround 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 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'sold_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 ofviewer_offset.Pitch(default 0.291 rad ≈ 16.7°, clamp [-0.7, 1.4]) — angle ofviewer_offsetabove 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(fromICamera).
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
- Process start →
CameraDiagnosticsstatic initialisers read env vars; defaults applied if env unset. - PlayerMode entry (existing
EnterPlayerModeIfPossibleatGameWindow.cs:9776) — construct both cameras, hand toCameraController.EnterChaseMode(legacy, retail). - Per-frame (existing tick at
GameWindow.cs:6390) — pass the sameplayerPosition,playerYaw,playerVelocity,isOnGround,dtto both cameras'Update(). Inactive camera stays warm so toggle swaps are instant. - View matrix — renderer pulls
cameraController.Active.View. - Translucency —
RetailChaseCamera.PlayerTranslucencyis read afterUpdate()and applied to the player entity (mechanism TBD during impl — see "Open implementation questions" below). - DebugPanel — new "Chase camera" CollapsingHeader writes to
CameraDiagnosticsviaDebugVMmirror 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):
CameraZoomIn—Distance -= adjSpeed * dtCameraZoomOut—Distance += adjSpeed * dtCameraRaise—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; applyPlayerTranslucency. ~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 = targetEyeexactly (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
dotnet buildgreen.dotnet testgreen, with new tests covering the math above.- With
ACDREAM_RETAIL_CHASE=1set (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. - With the toggle OFF, behavior is identical to before this change
(legacy
ChaseCamerauntouched). - 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.
- Jumping with retail mode on produces the "see yourself rise above
the camera" feedback without the
_trackedZhack — the existing hack stays inChaseCamera(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
SetInHeadcollapsesviewer_offsetto(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_trackedZhack.
References
- Retail decomp:
docs/research/named-retail/acclient_2013_pseudo_c.txtlines 95505 (CameraManager::UpdateCamera), 97643 (CameraSet::UpdateCamera), 95957 (CameraManagerconstructor), 97916 (CameraSet::SetDefaultOffsets), 96250 (CameraSet::FilterMouseInput), 97103 (CameraSet::Rotate), 97350 (CameraSet::Closer). - Retail symbols:
docs/research/named-retail/symbols.json— search forCameraManager/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.