docs(render): Phase A8.F — camera-collision root cause + handoff (session 2)

Root cause of the A8.F flap / missing-walls reframed (with the user's help):
the 3rd-person camera EYE passes through walls, and the A8.F renderer keys its
"am I inside?" (PointInCell) and portal side-tests (CameraOnInteriorSide) off
that eye position (camPos = invView translation, GameWindow.cs:7271). Eye clips
a wall -> those decisions flip frame-to-frame -> the flap.

Key finding from camera research (Opus agent + verified against the decomp):
retail's camera does NOT collide with walls either — it fades the player to
translucent (CameraSet::UpdateCamera @ 0x00458ae0 -> SetTranslucencyHierarchical),
which acdream already ports as RetailChaseCamera.ComputeTranslucency. So a
"spring arm that pulls the eye in on a wall hit" is a deliberate divergence from
retail, not a faithful port — needs user sign-off before coding.

Handoff documents: the eye->visibility coupling + flap mechanism, acdream's
current camera (the ported turn/jump input-lag = damping + velocity ring +
mouse filter; no collision), retail's camera (symbols+addresses), the reusable
swept-sphere collision machinery (BSPQuery.FindCollisions vs CellPhysics.BSP),
3 fix options (lead: modern spring arm), open design questions, apparatus, and a
pickup prompt.

Bug A (cellar terrain flood) already fixed + committed in 9417d3c; the
recursive-clip builder works (the prior "Bug B" framing was wrong).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-29 16:40:41 +02:00
parent 9417d3c4ce
commit ce909ad0a8

View file

@ -0,0 +1,371 @@
# Phase A8.F — camera-collision root cause + handoff (2026-05-29, session 2)
## TL;DR
The A8.F "flap" (building walls / ground blinking in and out) and the
missing/transparent-wall symptoms are **not** a builder or enforcement bug.
Their root cause is the **3rd-person camera EYE passing through walls**: the
A8.F renderer computes "am I inside a building?" and "which side of each
doorway am I on?" from the camera eye position, so when the eye clips a wall
those decisions flip frame-to-frame.
This session: (1) **fixed + committed** the worst symptom — the cellar terrain
flood (commit `9417d3c`); (2) established the recursive-clip **builder actually
works** for most cells (the prior "Bug B" framing was wrong); (3) **reframed the
root cause** to the camera, with the user's help; (4) researched the camera
system (Opus agent + my verification).
**The decision that blocks the next session** (brainstorm with the user FIRST):
retail's camera does **NOT** collide with walls and pull the eye in — it lets
the eye clip and **fades the player to translucent** instead (a mechanism
acdream already ports). So "make the camera move closer on a wall hit" is a
**deliberate divergence from retail**, not a faithful port. The next session
must choose an approach (spring arm vs. decouple visibility from the eye) and
get user sign-off before coding, per the CLAUDE.md no-redesign rule.
---
## What this session shipped / established
- **Bug A FIXED + committed (`9417d3c`):** an empty `OutsideView` while inside a
building now draws **no** outdoor terrain/scenery (empty mask = "no outdoors
visible," not "all outdoors"). Previously the `else` branch disabled the
stencil and flooded ungated terrain over the cell interior. **Visual-confirmed
by the user: cottage cellar walls are solid now, no terrain bleed-through.**
App tests 108/108. All behind `ACDREAM_A8_INDOOR_BRANCH=1`; default play
unaffected.
- **"Bug B" (builder under-produces) is substantially NOT real.** The pv-dump
census showed `PortalVisibilityBuilder` produces correctly *narrowed*
`OutsideView` regions for most cells (`0172/0173/0162/015E/0165/016F`
`polys=1`). The empty cases are mostly legitimate (a windowless cellar can't
see daylight) or driven by the camera being in an invalid position (see root
cause). The handoff predecessor's Finding 2 ("never narrows") does not hold.
- **Root cause reframed → the camera.** Confirmed below.
- **Camera research done** (Opus agent, findings verified against the decomp +
the code by this session).
### Diagnostics added (committed in `9417d3c`, all opt-in)
- `PortalVisibilityBuilder.Build` now emits a **`CAMPORTAL[i]` census** under
`ACDREAM_A8_DUMP_PV=1`: per camera-cell portal, before the BFS guards, it logs
`other=`, `polyLen=`, `hasPlane=`, `interiorSide=`, `planeN=`. This is what let
us see that the cottage front-door exit portal was *culled by the side test*
(`interiorSide=False`) rather than missing.
- `[opaque]` probe (`ACDREAM_PROBE_ENVCELL=1`) — opaque cell-render stats
(`cells`/`tris`) **before** the transparent loop overwrites them (the existing
`[envcells]` line reads post-loop and is misleading — ignore its `cells=1
tris=0`).
- `tools/A8CellAudit` `portals <cellId>` now **replicates `BuildLoadedCell`'s
polygon-vertex resolution** and prints `BUILDER_SEES=OK/EMPTY` per portal, so
exit-portal validity is checkable offline without a launch.
---
## The visual symptoms (user-observed, 2026-05-29, with `ACDREAM_A8_INDOOR_BRANCH=1`)
1. Cellar walls **solid** (Bug A fix confirmed). ✓
2. **Flap**: buildings/ground disappear when passing from inside to outside.
3. Cellar entrance no longer covered by terrain (Bug A fix). ✓
4. Looking into certain windows from **outside**: back walls missing.
5. Inside **other** buildings: external + internal walls transparent (showing
sky / NPCs / particles).
**The user's key correlation:** items 2/4/5 happen **when the camera passes
through a door, or when standing inside and panning the camera through a wall**.
They're intermittent — not always reproducible — which fits a camera-position
trigger, not a static rendering bug.
---
## Root cause: the camera eye drives A8.F visibility, and it clips through walls
`camPos` is the **camera eye**, extracted from the inverse of the active
camera's View matrix:
```
GameWindow.cs:7270-7271
Matrix4x4.Invert(camera.View, out var invView);
var camPos = new Vector3(invView.M41, invView.M42, invView.M43); // = the eye
```
(`camera.View` is `CreateLookAt(_dampedEye, …)`, so the translation is the
chase-camera eye — **not** the player avatar position, which is tracked
separately for interior lighting at `GameWindow.cs:7415-7417`.)
That eye then drives all three A8.F visibility decisions:
1. **Camera-cell + portal BFS**`_cellVisibility.ComputeVisibility(camPos)`
(`GameWindow.cs:7323`) → `FindCameraCell` via `PointInCell(camPos, …)`. The
BFS portal side-test (`CellVisibility.cs:466-481`) culls portals the eye is on
the wrong side of.
2. **Strict inside-building gate** (`GameWindow.cs:7343-7346`):
`cameraInsideBuilding = a8IndoorBranchEnabled && PointInCell(camPos,
CameraCell) && CameraCell.BuildingId != null`. Picks the inside-out vs
outside-in render branch.
3. **Per-portal interior-side cull in the recursive-clip builder**
`PortalVisibilityBuilder.CameraOnInteriorSide(cell, i, cameraPos)`
(`PortalVisibilityBuilder.cs:196-203`; cull site ~`:124`):
transforms `cameraPos` to cell-local and dot-tests against the portal plane.
**The flap mechanism:** when the eye damps to a position outside the room (or in
the next room), `PointInCell(eye)` flips and `CameraOnInteriorSide` inverts.
The camera-cell ping-pongs, the inside/outside branch switches, and the exit
portal through a doorway is culled-then-uncovered frame-to-frame → walls/ground
blink. The 3-frame `CellSwitchGraceFrameCount` hysteresis (`CellVisibility.cs:167`)
only masks single-frame blips; a sustained multi-frame clip defeats it.
**Hard evidence (this session's capture):** at cottage cell `0xA9B40170` the
front-door exit portal was valid (`polyLen=4`) but the census showed
`interiorSide=False` — i.e. the eye was on the *outdoor* side of the door plane
`(0,-0.995,0.105)`. The plane math puts the cull threshold at eye Y < 8.50 with
the door at Y≈8.5: **the eye had poked out through the front wall while the
player stood inside.** The same portal projected fine when the BFS reached
`0170` from a deeper room (`0173`) where the eye was well inside.
---
## KEY FINDING: retail does NOT collide the camera — it fades the player
This **reframes the intended fix** and needs user attention.
- **Retail's camera does no eye-vs-wall collision.** `CameraManager::UpdateCamera`
(0x00456660, decomp `:95505-95953`) computes the eye as
`pivot + viewer_offset`, damps it (`Frame::interpolate_origin`, `:95922`), and
writes it straight to the render viewer via
`SmartBox::PlayerPhysicsUpdatedCallback` (0x00452d60, decomp `:91842`) — no
raycast, no sphere sweep, no BSP query in between. The `CameraManager` struct
(`acclient.h:35238-35263`) has **no** collision-radius / clipped-distance /
obstruction field.
- **Retail's anti-clip mechanism is to fade the *player***.
`CameraSet::UpdateCamera` (0x00458ae0) calls
`CPhysicsObj::SetTranslucencyHierarchical(player, …)` (decomp `:97679`, `:97698`,
`:97725`, `:97737`) — opaque at ≥0.45 m camera-to-pivot, fully transparent at
≤0.20 m. **acdream already ports this** as `RetailChaseCamera.ComputeTranslucency`
(`RetailChaseCamera.cs:367-376`, far=0.45 / near=0.20).
**Verification (mine, this session):** I confirmed in the decomp that
`SetTranslucencyHierarchical` is called from `CameraSet::UpdateCamera` (the four
lines above), that `CameraManager::UpdateCamera`'s sole caller writes the eye
with no intervening clip, that `RetailChaseCamera.cs` has zero
collision/physics references (only `PlayerTranslucency`), and that `camPos` is
the eye. (I relied on the Opus agent's full read of `CameraManager::UpdateCamera`
for "zero collision calls in the whole function body"; I spot-checked the entry,
the damping tail, the caller, and the struct — all consistent.)
**Implication:** "make the camera move closer when it hits a wall" is **not what
retail does**. A spring arm would be a *modern divergence* from the retail
behavior acdream faithfully ported. Per CLAUDE.md ("do not replace working
retail-faithful logic with a modern redesign without explicit user approval"),
the next session must get the user's sign-off on the approach before coding —
*but note* that here "retail-faithful" literally means "no collision," which is
the behavior that produces the A8.F flap. So some divergence or decoupling is
unavoidable to fix the flap.
---
## acdream's current camera (file:line)
All under `src/AcDream.App/Rendering/` unless noted.
- **Two chase cameras, toggled per-frame:** legacy `ChaseCamera.cs` (rigid) and
default `RetailChaseCamera.cs` (damped). Selected by
`CameraController.Active` (`CameraController.cs:20-33`) via
`CameraDiagnostics.UseRetailChaseCamera` (default ON,
`src/AcDream.Core/Rendering/CameraDiagnostics.cs`).
- **Eye computation (`RetailChaseCamera.Update`, `:86-142`):**
`pivotWorld = playerPos + (0,0,1.5)`; `targetEye = pivotWorld +
forward*(-Distance·cosPitch) + up*(Distance·sinPitch)` (`:113-117`); then
exponential damping `_dampedEye = Lerp(_dampedEye, targetEye, alpha)` (`:121-133`);
published as `Position` + `View = CreateLookAt(_dampedEye, …)` (`:136-137`).
**No geometry test anywhere between target and publish.**
- **The "input-lag for turning/jumping" port** = the RetailChaseCamera's three
smoothing mechanisms, all verified faithful to the decomp:
- Exponential damping (follow-lag): `:121-133` + `ComputeDampingAlpha:323-329`
↔ retail `:95866-95923` (`stiffness*dt*10` clamped).
- 5-frame velocity-averaged, slope-aligned heading (the jump/slope feel):
`:94-107`, `:290-314`, `ComputeHeading:211-257` ↔ retail `old_velocities[5]`
`:95644-95677`.
- Mouse low-pass filter (input lag on flicks): `FilterMouseDelta:164-177` +
`FilterMouseAxis:340-358` ↔ retail `CameraSet::FilterMouseInput` (0x00457530,
`:96250-96279`); wired at `GameWindow.cs:1171-1199`.
- **Constants** (from retail `CameraSet::SetDefaultOffsets` 0x00458F80,
`:97916-97967`): `PivotHeight=1.5`, `Distance=2.61` (=|(0,2.5,0.75)|),
`Pitch=0.291 rad`, distance clamp `[2,40]`, pitch clamp `[0.7,1.4]`. **No
collision-radius constant exists** (no collision is done).
- **No 1st-person mode** (retail `SetInHead` 0x00458CE0 unported).
- Spec: `docs/superpowers/specs/2026-05-18-retail-chase-camera-design.md`
explicitly scopes collision OUT (`:454-457`: "we don't attempt 'camera
collides with wall' — same as retail").
---
## Integration: acdream already has the collision machinery
A camera "spring-arm" sweep can reuse the player's existing swept-sphere engine
(`src/AcDream.Core/Physics/`):
- **`PhysicsEngine.ResolveWithTransition(curPos, targetPos, cellId, radius,
height, stepUp, stepDown, isOnGround, body, moverFlags, entityId)`**
(`PhysicsEngine.cs:589`) → `Transition.FindTransitionalPosition`
(`TransitionTypes.cs:653`) → per sub-step `FindEnvCollisions`
(`:1933`, indoor branch fetches `GetCellStruct(cellId)``cellPhysics.BSP.Root`
`BSPQuery.FindCollisions`).
- **`BSPQuery`** primitives (`BSPQuery.cs`): `PointInsideCellBsp` (`:1034`),
`SphereIntersectsCellBsp` (`:1077`), `FindCollisions` (`:1637`),
`SphereIntersectsPoly` (`:2085`). A purpose-built pivot→eye cast off these is
likely a **better fit** than the full `Transition` (which carries unwanted
step-up / walkable / gravity semantics).
- **`CellPhysics`** (`PhysicsDataCache.cs:511-564`): per-cell `BSP` (collision),
`CellBSP` (point-in-cell), polys/planes, transforms. Fetched via
`GetCellStruct(cellId)`.
- Player sweep params for reference: `radius 0.48`, `height 1.2`
(`PlayerMovementController.cs:1107-1108`). A camera probe wants a **small**
radius (e.g. `PhysicsGlobals.DummySphereRadius = 0.1`), `height 0` (single
sphere), `isOnGround:false`, `body:null` (no contact-plane persistence).
- **Slot-in point:** clamp `targetEye` toward `pivotWorld` **between
`RetailChaseCamera.cs:117` (target) and `:131` (damp)** so the damping eases
toward the clamped point. This requires injecting a physics/collision probe
into `RetailChaseCamera` (currently GL-free, no engine ref); App→Core is the
allowed dependency direction, so inject `PhysicsEngine` or a narrow
`ICameraCollisionProbe` interface.
---
## The design decision (MUST brainstorm with the user before coding)
Fixing the flap requires the eye (or the point feeding visibility) to stop
leaving the room. Three shapes, none purely retail-faithful:
- **Option A — modern spring arm (leading candidate; matches user's instinct).**
Sweep pivot→targetEye, stop the eye at the first wall hit. Fixes *both* the
render (no see-through) *and* the A8.F visibility (eye stays inside → stable
camera-cell + side-tests). **Diverges from retail** (retail lets the eye clip +
fades the player). The already-ported translucency fade still applies and will
fade *less* (eye stays farther from the player when not clipping). Needs user
sign-off for the divergence.
- **Option B — decouple visibility from the rendered eye.** Leave the rendered
eye retail-faithful (clips + fades), but feed `ComputeVisibility` /
`PointInCell` / `CameraOnInteriorSide` a *clamped* point. Fixes the flap, but
the render still shows through walls when the eye clips (you'd see exterior
through the wall). Less satisfying visually.
- **Option C — make A8.F robust to an outside eye.** When the eye is outside but
the player is inside, use the *player's* cell for the camera-cell + side tests.
Similar outcome to B (flap fixed, render still clips).
**Recommendation to put to the user:** Option A. It's the only one that fixes
both the render and the visibility, it matches the user's stated expectation,
and acdream already owns the collision machinery to do it. The cost is an
explicit, documented divergence from retail's no-collision design. Confirm with
the user, then (per roadmap discipline) file it as a phase before coding.
---
## Open design questions for the implementation session
1. **Approval for the retail divergence** (Option A spring arm) — the gating item.
2. **Sweep radius:** small (≈0.10.2 m). Too small → eye hugs walls / near-plane
clips; too large → eye yanks in aggressively in tight rooms.
3. **Which primitive:** purpose-built `BSPQuery.FindCollisions` ray/sphere cast
(recommended) vs. full `ResolveWithTransition` (wrong semantics for a camera).
4. **Indoor vs outdoor geometry:** indoor walls are in `CellPhysics.BSP` (per
cell). **Cottage exterior shells live in landblock-baked GfxObjs**, not cells
(cf. issue #98/#101 — cottage floors/walls in GfxObj `0x01000A2B` etc.). A
cell-BSP-only sweep fixes the indoor case but misses outdoor shells (the
`FindObjCollisions` / ShadowEntry path would be needed for those). Decide scope.
5. **1st-person fallback:** none today; a spring arm must no-op at distance 0 if
1st-person is added.
6. **Interaction with the ported translucency fade** (`ComputeTranslucency`) —
verify the fade still behaves once the eye stops clipping.
7. **Pivot reference:** sweep from `pivotWorld` (head, `:113`), not the feet.
---
## Apparatus / diagnostics (committed `9417d3c`; opt-in)
Launch (PowerShell), then walk `+Acdream` into a Holtburg cottage:
```powershell
$env:ACDREAM_DAT_DIR="$env:USERPROFILE\Documents\Asheron's Call"; $env:ACDREAM_LIVE="1"
$env:ACDREAM_TEST_HOST="127.0.0.1"; $env:ACDREAM_TEST_PORT="9000"
$env:ACDREAM_TEST_USER="testaccount"; $env:ACDREAM_TEST_PASS="testpassword"
$env:ACDREAM_A8_INDOOR_BRANCH="1"; $env:ACDREAM_A8_DUMP_PV="1"; $env:ACDREAM_PROBE_ENVCELL="1"
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath "a8f.log"
```
- `ACDREAM_A8_DUMP_PV=1``[pv-dump]` per camera cell incl. the **`CAMPORTAL[i]`
census** (polyLen + interiorSide per portal, before the guards) + the
EXIT-CULLED/PROJ/CLIP trace + `OUTSIDEVIEW polys=N`.
- `ACDREAM_PROBE_ENVCELL=1``[opaque]` cell-render stats (reliable; pre-loop).
- `ACDREAM_PROBE_VIS=1``[buildings]`/`[draworder]`/`[stencil]`/`[envcells]`
(heavy: ~17 GL queries × several calls per frame; keep captures short).
- `tools/A8CellAudit portals <cellId>` — offline portal dump with
`BUILDER_SEES=OK/EMPTY` per portal.
**A future "camera-cell ≠ player-cell" probe** would directly confirm the
flap-by-frame: log when `PointInCell(eye)` disagrees with the player's cell.
---
## Current state / safety
- **Default game safe** — everything gated behind `ACDREAM_A8_INDOOR_BRANCH=1`.
- Bug A fix + diagnostics committed (`9417d3c`); App tests 108/108.
- Builder works (not the bug); camera-collision work **not started** (awaiting the
design decision).
- Tree clean as of `9417d3c` plus untracked launch logs (`a8f-*.log`).
---
## Pickup prompt
> Read `docs/research/2026-05-29-a8f-camera-collision-handoff.md`. The A8.F flap /
> missing-walls are caused by the 3rd-person camera EYE clipping through walls and
> destabilizing the camera-cell + portal side-tests (the eye drives
> `PointInCell` / `CameraOnInteriorSide` via `camPos` at GameWindow.cs:7271).
> Bug A (cellar terrain flood) is already fixed + committed (`9417d3c`); the
> recursive-clip builder works. **Before coding, brainstorm the fix with the user**
> (`superpowers:brainstorming`): retail does NOT collide the camera — it fades the
> player (`SetTranslucencyHierarchical`, already ported as
> `RetailChaseCamera.ComputeTranslucency`), so a "spring arm that pulls the eye in"
> is a deliberate divergence needing sign-off. Leading option: a modern spring-arm
> camera collision (Option A) sweeping pivot→targetEye via a small-radius
> `BSPQuery.FindCollisions` against `CellPhysics.BSP`, clamping `targetEye` between
> `RetailChaseCamera.cs:117` and `:131`. Watch the open questions (sweep radius,
> outdoor shells live in GfxObjs not cells, 1st-person, the existing fade). File a
> roadmap phase once the approach is agreed.
---
## Reference index
**acdream code** (`src/…`):
- `AcDream.App/Rendering/RetailChaseCamera.cs` — eye `:113-137`, damping
`:121-133`/`:323-329`, heading `:211-257`, mouse filter `:340-358`,
translucency `:367-376`.
- `AcDream.App/Rendering/ChaseCamera.cs` (legacy), `CameraController.cs:20-33`,
`ICamera.cs`.
- `AcDream.Core/Rendering/CameraDiagnostics.cs``UseRetailChaseCamera`, tunables.
- `AcDream.App/Rendering/GameWindow.cs` — eye `:7270-7271`, visibility `:7323`,
`cameraInsideBuilding` `:7343-7346`, camera updates `:6851`/`:6862`, mouse-filter
`:1171-1199`.
- `AcDream.App/Rendering/PortalVisibilityBuilder.cs``CameraOnInteriorSide`
`:196-203`, cull `:124`, CAMPORTAL census in `Build`.
- `AcDream.App/Rendering/CellVisibility.cs``ComputeVisibility` `:272`,
`PointInCell` `:367`, BFS side-test `:466-481`, grace `:167`.
- `AcDream.Core/Physics/``PhysicsEngine.cs:589`, `TransitionTypes.cs:653/1933`,
`BSPQuery.cs:1034/1077/1637`, `PhysicsDataCache.cs:511-564`,
`PlayerMovementController.cs:1107-1108`.
- Spec: `docs/superpowers/specs/2026-05-18-retail-chase-camera-design.md` (collision
out-of-scope `:454-457`).
**Retail decomp** (`docs/research/named-retail/acclient_2013_pseudo_c.txt`):
- `CameraManager::UpdateCamera` @ 0x00456660 (`:95505-95953`) — NO collision;
damping `:95866-95923`; velocity ring `:95644-95677`.
- `CameraSet::UpdateCamera` @ 0x00458AE0 (`:97643-97742`) — player-fade
`SetTranslucencyHierarchical` `:97679/97698/97725/97737`.
- `CameraSet::FilterMouseInput` @ 0x00457530 (`:96250-96279`).
- `CameraSet::SetDefaultOffsets` @ 0x00458F80 (`:97916-97967`) — pivot (0,0,1.5),
viewer (0,2.5,0.75).
- `SmartBox::PlayerPhysicsUpdatedCallback` @ 0x00452d60 (`:91842`) — sole
UpdateCamera caller; eye → render with no clip.
- `CameraManager` struct `acclient.h:35238-35263` (no collision fields).