# 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 // InitPath offsets sphere0's center up by radius (foot-capsule convention), // but retail's viewer_sphere center is (0,0,0). Shift the path down by radius // so the SPHERE CENTER travels pivot→eye, then add it back to the result. var zoff = new Vector3(0f, 0f, 0.3f); var r = _physics.ResolveWithTransition( currentPos: pivot - zoff, targetPos: desiredEye - zoff, 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 // Retail init_object(player, 0x5c) = IsViewer|PathClipped|FreeRotate| // PerfectClip (pseudo-C :92864). PathClipped = hard-stop at first contact // (the spring arm, not edge-slide); IsViewer = eye passes through creatures, // colliding only with world geometry. Not IsPlayer → stays out of the #98 // capture filter. moverFlags: ObjectInfoState.IsViewer | ObjectInfoState.PathClipped | ObjectInfoState.FreeRotate | ObjectInfoState.PerfectClip, movingEntityId: selfEntityId); // skip the player's own ShadowEntry return r.Position + zoff; // r.Position = sp.CheckPos (path pt); + zoff = eye } ``` `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` deterministic units (no heavy fixture setup): - `ToSpherePath`/`FromSpherePath` z-offset round-trip; - `cellId == 0` guard → returns `desiredEye` unchanged. Collision correctness itself (eye stops at a wall/shell; self-skip) is already covered by the exhaustive `ResolveWithTransition` / `BSPQuery` suites and is confirmed end-to-end by the visual acceptance below — re-proving it at the probe layer would duplicate that coverage with brittle fixture wiring. **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 — RESOLVED (hard-stop).** Retail's `update_viewer` calls `init_object(player, 0x5c)`, and `0x5c` includes `PathClipped`, which makes the transition HARD-STOP at first contact (`TransitionTypes.cs:811`) rather than edge-slide. The probe therefore passes `IsViewer | PathClipped | FreeRotate | PerfectClip` (see §5.1). Hard-stop also avoids the eye sliding around a corner into the next room, which would re-trigger the A8.F camera-cell instability this fix targets. 2. **`sphereHeight: 0f` — RESOLVED.** `SpherePath.InitPath` with height 0 yields a single sphere (`NumSphere = 1`, `TransitionTypes.cs:534-537`). It also offsets sphere0's center to `pathPos + (0,0,radius)` (foot-capsule convention) whereas retail's `viewer_sphere` center is (0,0,0); the probe compensates with the `ToSpherePath`/`FromSpherePath` z-shift (§5.1) so the sphere center travels pivot→eye. 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`).