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>
This commit is contained in:
parent
d9c8b5762b
commit
fc819a4814
1 changed files with 478 additions and 0 deletions
478
docs/superpowers/specs/2026-05-18-retail-chase-camera-design.md
Normal file
478
docs/superpowers/specs/2026-05-18-retail-chase-camera-design.md
Normal file
|
|
@ -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`.
|
||||||
Loading…
Add table
Add a link
Reference in a new issue