acdream/docs/research/2026-05-29-a8f-camera-collision-handoff.md
Erik ce909ad0a8 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>
2026-05-29 16:40:41 +02:00

20 KiB
Raw Blame History

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/016Fpolys=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 builderPortalVisibilityBuilder.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.RootBSPQuery.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:

$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.csUseRetailChaseCamera, 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.csCameraOnInteriorSide :196-203, cull :124, CAMPORTAL census in Build.
  • AcDream.App/Rendering/CellVisibility.csComputeVisibility :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).