From 9bdd50287b6d797ce47c3a2defc9cbc3ce4e519d Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 29 May 2026 18:07:56 +0200 Subject: [PATCH] =?UTF-8?q?docs(render):=20Phase=20A8.F=20=E2=80=94=20swep?= =?UTF-8?q?t-sphere=20camera=20collision=20design=20spec?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design for porting retail's stage-2 camera collision (SmartBox::update_viewer): sweep a 0.3 m sphere from the head-pivot to the damped eye via the existing ResolveWithTransition engine (collides both indoor cell walls and GfxObj building shells, e.g. the cottage cellar per #98/#101), publish the stopped position as the eye. Fixes the A8.F flap by keeping the eye out of walls so the camera-cell + portal side-tests stay stable. Self-skip via LocalEntityId; gated by CameraDiagnostics.CollideCamera (default ON). Corrects the prior retail-chase-camera spec's "no camera collision" note. Co-Authored-By: Claude Opus 4.7 --- .../2026-05-29-a8f-camera-collision-design.md | 302 ++++++++++++++++++ 1 file changed, 302 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-29-a8f-camera-collision-design.md diff --git a/docs/superpowers/specs/2026-05-29-a8f-camera-collision-design.md b/docs/superpowers/specs/2026-05-29-a8f-camera-collision-design.md new file mode 100644 index 0000000..2c77fc8 --- /dev/null +++ b/docs/superpowers/specs/2026-05-29-a8f-camera-collision-design.md @@ -0,0 +1,302 @@ +# Phase A8.F — Swept-sphere camera collision (design) + +**Date:** 2026-05-29 +**Phase:** A8.F (indoor rendering) — camera-collision sub-step +**Milestone:** M1.5 — Indoor world feels right +**Status:** Design — approved, pending spec review → implementation plan +**Predecessor handoff:** [`docs/research/2026-05-29-a8f-camera-collision-handoff.md`](../../research/2026-05-29-a8f-camera-collision-handoff.md) + +--- + +## 1. Problem + +With `ACDREAM_A8_INDOOR_BRANCH=1`, walking `+Acdream` through a Holtburg +cottage produces a **flap** (walls/ground blink in and out) and intermittent +**missing/transparent walls**. The handoff established the root cause: the +**3rd-person camera eye clips through walls**, and the A8.F renderer derives +*all three* of its indoor-visibility decisions from that eye position +(`camPos`, extracted from the inverse View matrix at +[`GameWindow.cs:7270-7271`](../../../src/AcDream.App/Rendering/GameWindow.cs)): + +1. Camera-cell + portal BFS — `PointInCell(camPos)` picks the camera cell. +2. Strict inside-building gate — `cameraInsideBuilding` (`GameWindow.cs:7343-7346`). +3. Per-portal interior-side cull — `CameraOnInteriorSide(cell, i, cameraPos)` + (`PortalVisibilityBuilder.cs:196-203`). + +When the eye damps to a position outside the room (through a wall), `PointInCell` +flips and `CameraOnInteriorSide` inverts frame-to-frame → the camera-cell +ping-pongs, the inside/outside render branch switches, and the exit portal is +culled-then-uncovered → walls/ground blink. The existing 3-frame grace +(`CellVisibility.cs:167`) only masks single-frame blips. + +**Fix:** stop the eye from clipping walls. This stabilizes the camera-cell and +side-tests (the eye stays in valid space) *and* fixes the render (the eye is +never behind a wall). It is a **retail-faithful** change, not a divergence. + +## 2. Retail behavior (the thing we are porting) + +Retail's chase camera is a **spring arm**: think of the eye as a ball on a stick +behind the player's head. Retail does not let the ball pass through walls — it +**rolls a small collision ball outward from the head toward the desired eye and +stops it where it first hits geometry** (a *swept sphere*). Three stages, decomp +in [`docs/research/named-retail/acclient_2013_pseudo_c.txt`](../../research/named-retail/acclient_2013_pseudo_c.txt): + +- **Stage 1 — desired eye (no collision).** `CameraManager::UpdateCamera` + (`0x00456660`, `:95505-95953`) computes `eye = pivot + viewer_offset`, damps + it, stores it as `SmartBox::viewer_sought_position`. +- **Stage 2 — collide the desired eye (the pull-in).** `SmartBox::update_viewer` + (`0x00453ce0`, `:92761-92892`) sweeps a swept-sphere `CTransition` from the + head-pivot to `viewer_sought_position`: `makeTransition` → `init_object(player, + 0x5c)` → `init_sphere(1, &viewer_sphere, 1f)` → `init_path(cell, pivot, sought)` + → **`find_valid_position`** → on success `set_viewer(sphere_path.curr_pos)` (the + **stopped** position). Fallbacks: `CPhysicsObj::AdjustPosition`, then snap to + the player's position. `viewer_sphere` is a global `CSphere`, **radius 0.3 m**, + center (0,0,0) (`:93308-93314`, `:1144645`). +- **Stage 3 — fade the player when the collided eye is very close.** + `CameraSet::UpdateCamera` (`0x00458ae0`) calls `SetTranslucencyHierarchical` + (`:97679/97698/97725/97737`) — opaque ≥0.45 m, transparent ≤0.20 m. **Already + ported** as `RetailChaseCamera.ComputeTranslucency` + ([`:367-376`](../../../src/AcDream.App/Rendering/RetailChaseCamera.cs)). + +We are porting **stage 2**; stages 1 and 3 already exist in acdream. + +> **Correction to the prior spec.** The retail-chase-camera spec +> [`2026-05-18-retail-chase-camera-design.md:454-457`](2026-05-18-retail-chase-camera-design.md) +> scoped collision out with "retail's per-frame update doesn't raycast world +> geometry … we don't attempt 'camera collides with wall' — same as retail." +> That is **falsified**: the earlier investigation traced only the *producer* +> (`CameraManager::UpdateCamera`) and missed the *consumer* (`update_viewer`), +> where the collision lives. This spec supersedes that note. + +## 3. Scope (decided) + +**Full faithful — reuse the engine sweep.** The camera sphere is swept through +acdream's existing `Transition` swept-sphere engine, which already collides +against **both** geometry types in one path: + +- indoor cell walls — `FindEnvCollisions` ([`TransitionTypes.cs:870`](../../../src/AcDream.Core/Physics/TransitionTypes.cs)), +- outdoor / landblock-baked **GfxObj building shells** — `FindObjCollisions` + ([`TransitionTypes.cs:894`](../../../src/AcDream.Core/Physics/TransitionTypes.cs)) + via the ShadowObjectRegistry. + +This is **required**, not just nice-to-have: per issue #98/#101 the cottage +**cellar** floors/walls live in GfxObj `0x01000A2B`, *not* in a cell BSP. A +cell-BSP-only camera sweep would miss exactly the cellar walls the A8.F flap is +about. Reusing the engine also matches retail's "full `CTransition`" call and +reuses tested code rather than adding a parallel cast. + +## 4. Architecture + +A narrow collision-probe interface is injected into `RetailChaseCamera`; the +sweep runs after damping and before publish, exactly the handoff's slot-in. + +``` +GameWindow (has player state) + └─ injects PhysicsCameraCollisionProbe(physicsEngine) into RetailChaseCamera + └─ per frame: _retailChaseCamera.Update(..., cellId, selfEntityId) [:6862] + +RetailChaseCamera.Update (App, GL-free, unit-testable) + ... compute pivotWorld [:113], damp _dampedEye [:131] + if CameraDiagnostics.CollideCamera && _probe != null: + _dampedEye = _probe.SweepEye(pivotWorld, _dampedEye, cellId, selfEntityId) + publish Position + View [:136-137]; fade from collided eye [:140-141] + +ICameraCollisionProbe.SweepEye(pivot, desiredEye, cellId, selfEntityId) -> Vector3 (App) + └─ PhysicsCameraCollisionProbe wraps PhysicsEngine.ResolveWithTransition (Core) +``` + +Dependency direction is App → Core throughout (allowed; CLAUDE.md rule #2). No +Core change is required beyond the new `CameraDiagnostics` flag. Camera logic +stays out of `GameWindow` (rule #1). + +## 5. Components + +### 5.1 `ICameraCollisionProbe` + `PhysicsCameraCollisionProbe` (new, App) + +`src/AcDream.App/Rendering/ICameraCollisionProbe.cs`: + +```csharp +public interface ICameraCollisionProbe +{ + /// Roll a small sphere from pivot to desiredEye; return the stopped + /// (non-penetrating) eye. Returns desiredEye unchanged when nothing is hit. + Vector3 SweepEye(Vector3 pivot, Vector3 desiredEye, uint cellId, uint selfEntityId); +} +``` + +`PhysicsCameraCollisionProbe` (wraps `PhysicsEngine`): + +```csharp +public Vector3 SweepEye(Vector3 pivot, Vector3 desiredEye, uint cellId, uint selfEntityId) +{ + if (cellId == 0) return desiredEye; // no starting cell → can't sweep + var r = _physics.ResolveWithTransition( + currentPos: pivot, + targetPos: desiredEye, + cellId: cellId, + sphereRadius: 0.3f, // retail viewer_sphere radius + sphereHeight: 0f, // single sphere (no head sphere) + stepUpHeight: 0f, // no step-up for a camera + stepDownHeight: 0f, // no step-down / ground snap + isOnGround: false, // no contact-plane / walkable semantics + body: null, // no cross-frame contact-plane persistence + moverFlags: ObjectInfoState.None, // all targets collide; also keeps + // camera sweeps out of the #98 + // IsPlayer capture filter + movingEntityId: selfEntityId); // skip the player's own ShadowEntry + return r.Position; // = sp.CheckPos, the swept stop position +} +``` + +`ResolveWithTransition` returns `sp.CheckPos` as `.Position` in both the success +and partial branches ([`PhysicsEngine.cs:846`/`:865`](../../../src/AcDream.Core/Physics/PhysicsEngine.cs)), +so the returned position **is** the swept stop point — exactly retail's +`set_viewer(sphere_path.curr_pos)`. + +### 5.2 `RetailChaseCamera` integration (edit) + +- Add a nullable `ICameraCollisionProbe? _probe` field, set via constructor/init + (nullable so existing unit tests and the flag-off path keep today's behavior — + a `null` probe = no collision). +- Extend `Update(...)` with `uint cellId, uint selfEntityId` (Update is + `RetailChaseCamera`-specific, not on `ICamera`; only the call site at + `GameWindow.cs:6862` and tests change). +- Between damp ([`:131`](../../../src/AcDream.App/Rendering/RetailChaseCamera.cs)) + and publish ([`:136`](../../../src/AcDream.App/Rendering/RetailChaseCamera.cs)): + +```csharp +if (CameraDiagnostics.CollideCamera && _probe is not null) + _dampedEye = _probe.SweepEye(pivotWorld, _dampedEye, cellId, selfEntityId); +``` + +The fade ([`:140-141`](../../../src/AcDream.App/Rendering/RetailChaseCamera.cs)) +then reads eye→pivot distance from the *collided* eye automatically. + +### 5.3 `GameWindow` wiring (edit) + +- Construct the probe once with the live `PhysicsEngine` and pass it to the two + `RetailChaseCamera` constructions (`GameWindow.cs:10693`, `:10826`). +- At the `Update` call ([`:6862`](../../../src/AcDream.App/Rendering/GameWindow.cs)), + pass `cellId: _playerController.CellId` + ([`PlayerMovementController.cs:133`](../../../src/AcDream.App/Input/PlayerMovementController.cs)) + and `selfEntityId: _playerController.LocalEntityId` + ([`:144`](../../../src/AcDream.App/Input/PlayerMovementController.cs)). + +### 5.4 `CameraDiagnostics.CollideCamera` (new flag, Core) + +In [`CameraDiagnostics.cs`](../../../src/AcDream.Core/Rendering/CameraDiagnostics.cs), +matching the `UseRetailChaseCamera` pattern (default-on; `"0"` disables): + +```csharp +public static bool CollideCamera { get; set; } = + Environment.GetEnvironmentVariable("ACDREAM_CAMERA_COLLIDE") != "0"; +``` + +Plus a DebugPanel checkbox (Camera section) for live A/B. **Default ON** — the +behavior is retail-faithful and fixes a real bug; the flag exists for instant +revert and A/B during visual verification. + +## 6. Self-skip correctness (the one subtle hazard) + +The sweep starts at `pivotWorld` (the player's head), which is **inside** the +player's own 0.48 m collision sphere / registered ShadowEntry. Without skipping +self, `FindObjCollisions` would report an immediate collision and snap the eye +onto the head every frame. Passing `movingEntityId: LocalEntityId` is the same +self-skip the player's own sweep uses +([`PlayerMovementController.cs:1129`](../../../src/AcDream.App/Input/PlayerMovementController.cs), +retail `CObjCell::find_obj_collisions` at `:308931`, our #42 fix). If +`LocalEntityId` is 0 (player not yet spawned), the sweep degrades to "no +self-skip" — acceptable, since chase mode is not active pre-spawn. + +## 7. Fallbacks (mirror retail) + +`ResolveWithTransition` already returns the partial (stopped) position when the +sweep can't fully resolve ([`:864-869`](../../../src/AcDream.Core/Physics/PhysicsEngine.cs)), +so the eye lands in front of whatever blocked it. Retail's deeper fallbacks are +`AdjustPosition` then snap-to-player; the engine's partial-position return covers +the common case, and a fully-degenerate result (eye stays at `pivot`) is the +snap-to-player worst case. We do **not** add a separate `AdjustPosition` path +unless visual verification shows the eye hugging/penetrating in a tight room. + +## 8. Player-fade interaction + +No code change. After collision the eye is *closer* to the pivot when pulled in, +so `ComputeTranslucency` fades the player more in tight spots — retail's stage 3. +Verify during visual check that the player fades (rather than the camera sitting +inside the player mesh) when backed into a corner. + +## 9. Testing + +**Unit (`tests/AcDream.App.Tests/`):** +- `RetailChaseCamera` with a fake `ICameraCollisionProbe`: + - probe returns `desiredEye` unchanged → published `Position`/`View` identical + to today (guards the no-regression path; keeps existing camera-math tests valid). + - probe returns a pulled-in eye → published `Position`/`View` use the collided + eye; fade increases. + - `CollideCamera = false` → probe never consulted. +- `PhysicsCameraCollisionProbe` against fixtures (reuse the issue-#98 cell + + GfxObj fixtures already in the test tree): + - clear path → eye unchanged; + - wall/shell between pivot and desiredEye → eye stops short (does not penetrate); + - `selfEntityId` set → sweep does not collide with the player's own ShadowEntry. + +**Visual (acceptance):** with `ACDREAM_A8_INDOOR_BRANCH=1`, walk into a Holtburg +cottage and its cellar: +- the flap is gone — walls/ground stay solid while panning the camera through a + wall and while crossing the doorway inside↔outside; +- back walls no longer go missing when looking through a window from outside; +- the player fades (not the wall) when backed into a corner. +Compare `ACDREAM_CAMERA_COLLIDE=0` vs default to confirm the flag isolates the fix. + +## 10. Out of scope + +- First-person / look-down / map modes (none exist; a spring arm must no-op at + distance 0 if 1st-person is added later). +- A separate `AdjustPosition` fallback (see §7) — added only if visual + verification demands it. +- Deleting the legacy `ChaseCamera`. + +## 11. Open implementation questions (decide during the plan, not now) + +1. **Slide vs hard-stop.** Reusing `ResolveWithTransition` gives the player + path's edge-slide (the eye glides along a wall rather than jittering). Read + retail's `find_valid_position` during implementation and confirm whether it + slides or hard-stops; match it. Both keep the eye out of walls, so this does + not change the architecture. +2. **`sphereHeight: 0f`.** Confirm `SpherePath.InitPath` with height 0 yields a + single sphere (no degenerate coincident head sphere). If not, pass a tiny + height or a single-sphere init. +3. **Probe construction order.** The `PhysicsEngine` must exist before the + `RetailChaseCamera` constructions at `GameWindow.cs:10693/:10826`; confirm + lifetime ordering or make the probe field settable post-construction. + +## 12. Acceptance criteria + +- [ ] Camera sphere (0.3 m) swept pivot→damped-eye via `ResolveWithTransition`; + published eye is the stopped position. +- [ ] Collides against indoor cell walls **and** GfxObj shells (verified at the + cottage cellar). +- [ ] Self-skip via `LocalEntityId` (no self-collision snap). +- [ ] Gated by `CameraDiagnostics.CollideCamera` (default ON; `ACDREAM_CAMERA_COLLIDE=0` + disables; DebugPanel toggle). +- [ ] `dotnet build` green; `dotnet test` green; new unit tests pass. +- [ ] Visual verification at Holtburg cottage + cellar: flap gone, walls solid. +- [ ] Roadmap + `2026-05-18-retail-chase-camera-design.md` collision note updated. + +## 13. References + +**acdream code:** +- [`RetailChaseCamera.cs`](../../../src/AcDream.App/Rendering/RetailChaseCamera.cs) — eye `:113-117`, damp `:131`, publish `:136-137`, fade `:367-376`. +- [`CameraController.cs`](../../../src/AcDream.App/Rendering/CameraController.cs), [`CameraDiagnostics.cs`](../../../src/AcDream.Core/Rendering/CameraDiagnostics.cs). +- [`GameWindow.cs`](../../../src/AcDream.App/Rendering/GameWindow.cs) — camera Update `:6862`, eye extract `:7270-7271`, visibility `:7323`, `cameraInsideBuilding` `:7343-7346`, RetailChaseCamera ctor `:10693`/`:10826`. +- [`PlayerMovementController.cs`](../../../src/AcDream.App/Input/PlayerMovementController.cs) — `CellId` `:133`, `LocalEntityId` `:144`, self-skip sweep `:1105-1129`. +- [`PhysicsEngine.cs`](../../../src/AcDream.Core/Physics/PhysicsEngine.cs) — `ResolveWithTransition` `:589`, returns `sp.CheckPos` `:846`/`:865`. +- [`TransitionTypes.cs`](../../../src/AcDream.Core/Physics/TransitionTypes.cs) — `FindTransitionalPosition` `:653`, `FindEnvCollisions` `:870`, `FindObjCollisions` `:894`. +- [`BSPQuery.cs`](../../../src/AcDream.Core/Physics/BSPQuery.cs) — `FindCollisions` `:1637`. + +**Retail decomp** (`docs/research/named-retail/acclient_2013_pseudo_c.txt`): +- `SmartBox::update_viewer` `0x00453ce0` (`:92761-92892`) — the camera collision. +- `CameraManager::UpdateCamera` `0x00456660` (`:95505-95953`) — desired eye. +- `CameraSet::UpdateCamera` `0x00458ae0` (`:97643-97742`) — player fade. +- `viewer_sphere` radius 0.3 m (`:93308-93314`, `:1144645`). +- `CObjCell::find_obj_collisions` self-skip (`:308931`).