docs(render): Phase A8.F — swept-sphere camera collision design spec

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 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-29 18:07:56 +02:00
parent 9757818e95
commit 9bdd50287b

View file

@ -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`).