docs(render): ARCHITECTURE RESET — indoor render is a 3-gate patchwork; handoff + unified-PView target

A week on the indoor render (Phase U.4 → U.4c → 2026-05-31) fixed the flap but
produced NO shippable progress: walls/ceiling don't seal, outdoor terrain is
visible from inside (#78), the enclosure reads grey/transparent. Root cause is
ARCHITECTURAL, not a bug.

Evidence this session (direct, via the new [shell] probe + screenshots) RULED OUT
every subsystem except the gating architecture: the interior cell shells render
fine (geometry/texture/opaque/depth all correct, zh=0 tr=0); the visibility
traversal computes correct sets + non-empty portal clips; cull mode is fine; the
camera/eye thread was a detour. The residual is that OUTDOOR geometry is not gated
to portal openings when indoors, and acdream enforces visibility THREE inconsistent
ways (TerrainClipMode / per-cell shell clip / entity ParentCellId filter with an
outdoor-stab bypass) instead of retail's ONE PView gate.

This commit is the reset handoff + documentation, not a code fix:
- docs/research/2026-05-31-render-architecture-reset-handoff.md — canonical: honest
  state, evidence ledger (ruled-out / do-not-repeat), the mapped 3-gate patchwork,
  the retail PView target (one traversal → one gate for ALL geometry), the reset
  mission, and a copy-paste pickup prompt.
- docs/architecture/acdream-architecture.md — new "Render Pipeline" SSOT section
  (current divergence + unified-PView target + the one rule: compute visibility
  once, enforce it once). (Doc has pre-existing corruption below this section —
  flagged for separate cleanup.)
- Apparatus: ACDREAM_PROBE_SHELL → [shell] (EnvCellRenderer per-cell prepared/drawn
  geometry + flags) added to RenderingDiagnostics + EnvCellRenderer. Throwaway.
- docs/superpowers/specs/2026-05-31-camera-collision-indoor-engagement-design.md —
  spec for e099b4c (camera collision; now parked as orthogonal to the seam).

Next session: STOP point-fixing; do the architecture reset to a single PView gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-31 21:35:55 +02:00
parent e099b4c4a3
commit 0013819fa1
4 changed files with 417 additions and 0 deletions

View file

@ -0,0 +1,124 @@
# Camera-collision indoor engagement — the shared-root residual fix (2026-05-31)
**Phase:** M1.5 "indoor world feels right" — post-flap-fix residuals.
**Status:** design (pre-approved by user 2026-05-31; "most thorough + retail-faithful").
**Predecessors:** flap fix `0ee328a`; diagnosis `docs/research/2026-05-31-camera-collision-indoor-diagnosis.md`; camera-collision research `docs/research/2026-05-29-a8f-camera-collision-handoff.md`.
## Problem (one shared root, three faces)
After the U.4c flap fix (indoor visibility rooted at the **player's** cell; the 3rd-person
camera **eye** still drives the per-frame projection), three residuals remain — transparent
outer walls (#2), terrain-through-floor (#78), "stairs everything grey" (#3). Evidence
(`u4c-fix.log`, current code state): the eye is **outside the player's cell ~90% of frames**,
at full chase distance (~3.4 m) — i.e. **the shipped camera collision is not engaging in
interior cells.** When the eye is displaced, it projects the player-traversed portal openings
to garbage/off-screen NDC (74% of frames), collapsing `OutsideView` to empty → terrain `Skip`
(grey, #3) or tripping the giant-scissor fallback → over-include (bleed, #78/#2). The projection
**from the eye is correct** (the OutsideView is a screen-space clip and the screen is the eye's
view) — the fault is the eye being in the wrong place. So the root fix is **keeping the eye in
valid space**, which resolves all three faces at once.
## Why the shipped collision doesn't engage (code-verified)
`PhysicsCameraCollisionProbe.SweepEye` → `PhysicsEngine.ResolveWithTransition(... cellId =
playerCell, moverFlags = IsViewer|PathClipped|FreeRotate|PerfectClip ...)`. The camera eye
sweeps UP+BACK from the head-pivot (~2.6 m back, ~2.25 m up). In an acdream cottage the
enclosing geometry (walls/floor/roof) lives in a **landblock-baked exterior-shell GfxObj**
registered `cellScope=0` (outdoor/landblock-wide shadow list) — established by issue #98
(the cottage floor that capped the player's head sphere is GfxObj `0x01000A2B`, not cell BSP).
`ShadowObjectRegistry.GetNearbyObjects` has the **issue-#98 indoor gate** at
`ShadowObjectRegistry.cs:480`:
```csharp
if ((primaryCellId & 0xFFFFu) >= 0x0100u) // primary cell is indoor
return; // skip the outdoor radial sweep entirely
```
So while the viewer sphere's primary cell is the indoor cottage cell, the sweep is **gated
away from the exterior-shell GfxObj** — the only geometry that encloses the cottage in our
data model. The eye therefore finds nothing to stop it and flies to full distance.
## Retail behavior (decomp-verified — the faithfulness anchor)
`SmartBox::update_viewer` @ `acclient_2013_pseudo_c.txt:92761-92892`:
- roots the viewer `CTransition` at the **player/pivot cell** (`init_path(t, cell_1, pivot,
sought)`; `cell_1` = `AdjustPosition`'s pivot cell, fallback `player->cell`);
- `init_object(player, 0x5c)` = `IsViewer|PathClipped|FreeRotate|PerfectClip` (acdream matches);
- sweeps `viewer_sphere` (0.3 m) via `find_valid_position` and publishes the **stopped**
`sphere_path.curr_pos` + `curr_cell`; fallbacks `AdjustPosition` → snap-to-player.
So retail bounds the viewer by **whatever the swept transition hits inside the player's cell.**
Retail's interior EnvCells are **self-enclosing** (walls + ceiling in the cell's own geometry),
so the viewer is stopped by interior geometry, and retail's "structural separation" (`CObjCell::
find_cell_list` `:308751-308769` adds outdoor GfxObjs only to outdoor cells' shadow lists)
holds *because* the interior is self-contained.
**acdream's divergence:** our cottages are NOT self-enclosing — the enclosure is the landblock
shell GfxObj (per #98). So for our data model, the shell GfxObj **is** the enclosure the viewer
must collide. Letting the viewer reach it is the faithful analog of retail's
viewer-bounded-by-enclosure.
## The fix (Option A — viewer-exempt the #98 gate)
Thread the mover flags (or a derived `isViewer` bool) from the `FindObjCollisions` call site
(`ObjectInfo.State` is already on the `Transition`) down through to
`ShadowObjectRegistry.GetNearbyObjects`, and change the gate to:
```csharp
// Issue #98 gate is correct ONLY for the player foot/head capsule (it stops the cottage-floor
// GfxObj from capping the head sphere from the cellar below). The camera viewer (IsViewer, a
// single 0.3 m sphere, no capsule, no contact-plane) must reach the landblock-baked building
// shell that forms the cottage enclosure — retail's update_viewer bounds the viewer by the
// cell enclosure (acclient_2013_pseudo_c.txt:92761); in acdream's data model that enclosure is
// the shell GfxObj. find_obj_collisions has no indoor gate (acclient_2013_pseudo_c.txt:308918).
if ((primaryCellId & 0xFFFFu) >= 0x0100u && !isViewer)
return;
```
`GetNearbyObjects` gains an `isViewer` parameter (default `false` → existing callers keep the
gate). Only `PhysicsCameraCollisionProbe.SweepEye` passes `IsViewer`, so only the camera sweep
changes behavior.
### Why not the alternatives
- **Re-scope the cottage shell registration to the indoor cell** (`cellScope` = cottage cell):
requires per-GfxObj→cell adjacency we don't have and isn't how retail models it; would also
re-expose the player to the #98 head-cap. Rejected.
- **Make interior cells self-enclosing (faithful hydration of ceiling/walls into cell BSP):**
the genuinely-retail-faithful end state, but a large, #98-adjacent, high-risk refactor of
cottage hydration. **Documented out-of-scope follow-on** (file as a residual after the visual
gate). Option A is the low-risk bridge that matches retail's *observable* behavior for our
data model.
- **Retail fallback chain (`AdjustPosition` → snap-to-player):** faithful completeness, but not
load-bearing for this residual (our sweep succeeds and returns a position; it doesn't *fail*).
Out-of-scope follow-on.
## Tests (TDD)
1. **GREEN the RED test** `CameraCollisionIndoorTests.SweepEye_IndoorCellExteriorGfxObjWall_
NotReachedFromIndoorContext_CurrentlyFails`: after the fix, the viewer sweep stops at the
shell wall → `pulledIn ≥ 0.5` (rename to drop `_CurrentlyFails`).
2. **#98 regression guard (new):** an `IsViewer` sweep in the cellar toward the cottage-floor
GfxObj behaves correctly (the viewer single-sphere is not a head-capsule, so the #98 cap does
not apply); assert the player's gate behavior is untouched (an `IsPlayer` sweep at the same
site still hits the gate / keeps the #98 fix). Use the cottage GfxObj fixture
(`RegisterCottageGfxObj`, `0x01000A2B`).
3. Full `dotnet build` + `dotnet test` green; with-fix failure set ⊆ baseline (the pre-existing
static-leak flakiness is independent).
## Acceptance
- Build + tests green; RED→GREEN; #98 guard passes.
- **Visual gate (the one user step):** walk `+Acdream` into a Holtburg cottage / cellar / stairs
and a window room with `ACDREAM_PROBE_FLAP=1`. Expect: the eye stops at walls/ceiling (no
transparent outer walls, no grey, no terrain-through-floor); `[flap-sweep]` shows `pulledIn > 0`
+ `eyeInRoot=Y` for indoor cells; `[flap-cam]` shows `terrain=Planes` through windows (not
`Skip`/`Scissor`-fallback). The visual gate is the **arbiter on real geometry** — if the eye
still flies free in the main-floor residual cells (0174/0175), the residual is a different
cause (interior-BSP completeness) and we iterate with the live `[flap-sweep]` data.
## Out of scope (documented residuals)
- **U.5** outside-camera → building-interior peering, and the legitimate eye-through-an-open-
doorway exit (both deferred in the Phase U spec).
- **Interior-cell self-enclosure hydration** (the deeper retail divergence; faithful end state).
- **Viewer fallback chain** (`AdjustPosition` → snap-to-player) for the find-valid-position-fails
path.
- The throwaway `[flap]`/`[flap-cam]`/`[flap-sweep]` apparatus is kept for the visual gate and
stripped once the indoor residuals close.