acdream/docs/superpowers/specs/2026-06-06-verbatim-retail-indoor-render-port-design.md
Erik eb7b1fa67c docs: spec — verbatim retail indoor render port (DrawInside/DrawCells)
Design for replacing the indoor render approximation layer with a verbatim
port of retail PView::DrawCells (0x5a4840). Locates the grey/bleed in the
ClipFrameAssembler slot-pool + drawableCells filter (RetailPViewRenderer.cs:52/237):
visible cells without a clip-slot are dropped (grey) and the per-cell trim was
globally disabled (bleed). Plan: draw EVERY OrderedVisibleCells cell, trim shells
per-slice via ClipPlaneSet gl_ClipDistance, draw objects membership+depth gated
(no hard clip → no half-character). Scope A+B (DrawInside + look-in DrawPortal);
keeps the faithful PortalVisibilityBuilder + ProjectToClip/ClipToRegion ported
this session. Local commit only (not pushed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 21:28:27 +02:00

264 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Verbatim Retail Indoor Render Port (`DrawInside` / `DrawCells`) — Design — 2026-06-06
> **Why this exists.** Two weeks of patching the indoor renderer have not produced
> retail's seamless inside↔outside↔inside behavior. The interior **walls/floor render
> grey** (the clear color shows through) and geometry **bleeds** between cells. Every
> attempt kept an *approximation layer* over retail's membership logic and patched its
> symptoms. This spec stops that: it ports retail's `DrawCells` **verbatim at the
> algorithm level** and **deletes the approximation layer** that keeps reintroducing the
> bug. Scope agreed with the user: **A + B** (indoor seal + look-out, plus look-in from
> outside). The outdoor `LScape` branch is out of scope — it already works.
> **Worktree:** `thirsty-goldberg-51bb9b`, branch `claude/thirsty-goldberg-51bb9b`,
> HEAD `8116d10`. PowerShell on Windows; launch logs are UTF-16. Do NOT branch/worktree,
> push, or `git stash`/`gc`.
---
## 1. The problem, located in the code
The indoor draw lives in `RetailPViewRenderer.DrawInside` (`src/AcDream.App/Rendering/RetailPViewRenderer.cs`).
Its loop *structure* already mirrors retail (landscape → exit masks → shells → objects,
reverse `OrderedVisibleCells`, per-cell, per-slice). **Two things defeat it:**
1. **Dropped shells → grey.** `RetailPViewRenderer.cs:52`
`drawableCells = clipAssembly.CellIdToSlot.Keys`, and every loop does
`if (!drawableCells.Contains(cellId)) continue;`. A visible cell is drawn **only if
`ClipFrameAssembler` assigned it a clip-slot.** Any cell whose view did not yield a
slot is silently skipped → its sealed shell is never drawn → the clear color shows →
**grey**. Retail draws **every** cell in `cell_draw_list`.
2. **No trim → bleed (and the half-character).** `RetailPViewRenderer.cs:237` `UseIndoorMembershipOnlyRouting()`
sets `_envCells.SetClipRouting(null)` — the per-cell trim was **globally disabled** as
an emergency fix for "characters/shells sliced at stair/door boundaries." So cells that
*do* draw are not trimmed to the opening they're seen through → geometry bleeds; and the
reason it had to be disabled is the clip was being applied to **objects/characters**
(which retail never hard-clips), slicing them.
Both failure modes come from the `ClipFrameAssembler` slot-pool + `drawableCells` filter
sitting on top of the (faithful) membership. **That layer is the "bad code."**
## 2. What retail does (the oracle)
From the named decomp (Sept 2013 EoR). `SmartBox::RenderNormalMode` (0x453aa0) branches on
`is_player_outside`: outside → `LScape::draw`; inside → `DrawInside(viewer_cell)`.
`PView::DrawInside` (0x5a5860) seeds the root cell's view to the full screen, then runs two
phases:
- **`ConstructView` (0x5a57b0)** — build membership. Distance-priority flood from the root;
each popped cell is **appended to `cell_draw_list`** (once); `ClipPortals` clips its
portals against its view; `AddViewToPortals` propagates the clipped openings to neighbours
and enqueues new ones. `cell_draw_list` is the **single** membership source.
- **`DrawCells` (0x5a4840)** — draw, three loops over **reverse** `cell_draw_list` (far→near):
- **Landscape:** if `outside_view.view_count > 0``LScape::draw` clipped to `outside_view`,
then `Clear(DEPTH)`.
- **Loop 1 — exit-portal masks:** per cell, per `portal_view` slice: `setup_view(cell, slice)`;
for each exit portal `DrawPortalPolyInternal` (depth mask for the opening).
- **Loop 2 — shells:** per cell, per slice: `setup_view(cell, slice)`; `DrawEnvCell(cell)`
— the **closed cell mesh** (walls/floor/ceiling), hard-clipped to the slice.
- **Loop 3 — objects:** per cell: `Render::PortalList = cell.portal_view[last]`;
`DrawObjCellForDummies(cell)` — the cell's objects, **visibility-gated by the portal view,
not hard-clipped** (so whole creatures are never sliced).
The trim mechanism is `setup_view` + `polyClipFinish` (0x6b6d00): clip geometry to the
slice's **convex screen region** (CPU, every frame). The two facts that make retail seamless:
**(i)** every `cell_draw_list` cell gets its closed shell drawn (it seals); **(ii)** shells are
hard-clipped per-slice, objects are only visibility-gated.
## 3. Goal & success criteria
- Standing **anywhere inside** (room, cellar, on stairs), the interior is **sealed**: no grey,
no see-through walls, cellar floor + stairs present, character whole.
- **Seamless transitions:** room↔room, room↔cellar, outside→inside (walk in), and **look-in**
from outside through an open door (B).
- **Look-out:** windows/doors show the outdoor world through the opening.
- The draw is a **literal translation** of `DrawCells` (every visible cell's shell, per-slice
trim on shells only, objects visibility-gated), with the slot-pool/filter layer **deleted**.
- Acceptance is **visual** (the user's eyes) — pure logic is unit-tested, the draw is GPU.
Non-goals: rewriting the outdoor `LScape` branch; fixing any residual texture-pipeline issue
that survives a correct seal (would be a separate, evidence-led follow-up).
## 4. Architecture
The pipeline is one binary branch (retail `RenderNormalMode`), already in place at
`GameWindow.cs:7343`: `ShouldRenderIndoor` → indoor `DrawInside` vs outdoor `LScape` +
look-in `DrawPortal`. This spec rewrites the **indoor draw body** and the **look-in body**;
it does not change the branch or the outdoor body.
### 4.1 Components
| Unit | Role | Change |
|---|---|---|
| `PortalVisibilityBuilder``PortalVisibilityFrame.OrderedVisibleCells` + per-cell `CellView` | retail `ConstructView` / `cell_draw_list` + `portal_view` | **KEEP** (faithful, unit-tested) |
| `PortalProjection.ProjectToClip` / `ClipToRegion` | retail `GetClip` / `polyClipFinish` (homogeneous) | **KEEP** (ported this session) |
| `ClipPlaneSet.From(CellView)` | NDC convex region → ≤8 `gl_ClipDistance` planes / scissor AABB / nothing-visible | **KEEP**, call **per slice** |
| `EnvCellRenderer.Render(pass, {cellId})` | draw one cell's closed shell | **KEEP**; drive per-cell, per-slice |
| `WbDrawDispatcher.Draw(...)` | draw entity meshes | **KEEP**; drive per-cell, **no clip** |
| `ClipFrame` | upload clip region to the shader (SSBO) + terrain clip UBO | **SIMPLIFY** to one region (the current slice) |
| `RetailPViewRenderer.DrawInside` / `DrawPortal` | the indoor / look-in orchestration | **REWRITE** to the verbatim `DrawCells` loop |
| `ClipFrameAssembler` (+ `ClipFrameAssemblerTests`) | the slot-pool that produces `CellIdToSlot` / `ClipViewSlice[]` | **DELETE** |
| `drawableCells` filter | "draw only cells with a slot" | **DELETE** (draw all `OrderedVisibleCells`) |
| `UseIndoorMembershipOnlyRouting` / clip-off compromise | globally disables the trim | **DELETE** |
| `InteriorEntityPartition` | bucket entities by cell | **KEEP** as the cell→objects map (not as an eligibility filter); call with **all** visible cells |
| `InteriorRenderer` | outdoor entity-bucket wrapper | **KEEP** (outdoor path) — re-evaluate if it becomes dead |
### 4.2 The new `DrawCells` loop (verbatim translation)
`RetailPViewRenderer.DrawInside(viewerCell)` becomes, in pseudocode:
```
frame = PortalVisibilityBuilder.Build(viewerCell, eye, lookup, viewProj) // cell_draw_list + per-cell CellView
cells = frame.OrderedVisibleCells // NO drawableCells filter
objectsByCell = InteriorEntityPartition.Partition(cells, landblockEntries).ByCell
// --- Landscape through outside_view (look-out) ---
if frame.OutsideView not empty:
for slice in frame.OutsideView.Polygons:
setSliceClip(slice) // ClipPlaneSet.From(slice-as-CellView)
drawLandscapeSlice(slice) // GameWindow callback (terrain/sky/scenery clipped to slice)
clearDepth(outsideView bounds)
// --- Loop 1: exit-portal depth masks (only needed with look-out) ---
for cell in reverse(cells) where cell.drawing_bsp:
for slice in cell.CellView.Polygons:
setSliceClip(slice); drawExitPortalMasks(cell) // depth-only, punches the openings
// --- Loop 2: SHELLS (the seal) ---
for cell in reverse(cells) where cell.drawing_bsp:
for slice in cell.CellView.Polygons:
planes = ClipPlaneSet.From(singlePolygonRegion(slice)) // <=8 planes, or scissor, or nothing
if planes.IsNothingVisible: continue
applyShellClip(planes) // gl_ClipDistance (or scissor)
EnvCellRenderer.Render(Opaque, {cell}); Render(Transparent, {cell})
clearShellClip()
// --- Loop 3: OBJECTS (no hard clip) ---
for cell in reverse(cells):
if objectsByCell[cell] empty: continue
WbDrawDispatcher.Draw(objectsByCell[cell], frustum, animatedIds) // depth + frustum + membership; NO clip
drawCellParticles(cell)
```
Two differences from retail are intentional GL adaptations, both faithful in result:
- **Trim is `gl_ClipDistance` (set per slice), not CPU `polyClipFinish`.** Same convex-region
clip; `ClipPlaneSet.From` already produces the planes. A slice that exceeds 8 edges degrades
to its scissor AABB (over-includes a sliver, never drops geometry).
- **Objects are membership-gated, not hard-clipped.** Retail visibility-tests objects against
`PortalList`; we draw the cell's objects (depth + frustum) without clip planes — this is what
prevents the half-character. (A per-object portal-view visibility test is a possible future
refinement if objects visibly poke past a doorway; the cell shells + depth occlude most cases.)
### 4.3 Look-in (B) — `DrawPortal`
Identical loop, seeded by `PortalVisibilityBuilder.BuildFromExterior` (exterior-facing portals)
instead of the root cell. It reuses Loops 13 unchanged; there is no second draw engine. Runs in
the outdoor branch after `LScape`, before scene particles, exactly where it is wired today
(`GameWindow.cs:7552`).
### 4.4 Clip application detail
`setSliceClip` / `applyShellClip` turn one `ClipPlaneSet` into GPU state:
- `Count > 0` → upload the ≤8 planes (one region, slot 0) and enable that many `gl_ClipDistance`
outputs; the existing mesh/terrain shaders already read a clip region and write
`gl_ClipDistance`, so the shader side is unchanged — only the *feed* shrinks from a slot pool
to one region.
- `UseScissorFallback``glScissor` to `ScissorNdcAabb` (mapped to pixels), no clip planes.
- `IsNothingVisible` → draw nothing for that slice.
`clearShellClip` disables all `gl_ClipDistance` + scissor so Loop 3 (objects) and downstream
passes are unclipped.
## 5. Data flow (per indoor frame)
```
ShouldRenderIndoor(player) == true
→ RetailPViewRenderer.DrawInside(viewerCell, eye, viewProj, callbacks)
Build → cells + per-cell CellView + OutsideView
InteriorEntityPartition → objectsByCell
look-out: per OutsideView slice → setSliceClip → DrawLandscapeSlice (GameWindow GL callback) → clearDepth
Loop1 exit masks (reverse cells, per slice)
Loop2 shells (reverse cells, per slice, clip ON) → EnvCellRenderer.Render({cell})
Loop3 objects (reverse cells, clip OFF) → WbDrawDispatcher.Draw + DrawCellParticles
```
GameWindow keeps providing the GL-bound callbacks it already passes today
(`DrawLandscapeSlice`, `ClearDepthSlice`, `DrawCellParticles`, `EmitDiagnostics`); only their
*orchestration* inside `RetailPViewRenderer` changes.
## 6. Error handling / edge cases
- **Empty `CellView` for a visible cell** (`ClipPlaneSet.IsNothingVisible`): skip that slice's
draw, but the cell may still draw via its other slices. (A cell with *no* non-empty slice is
effectively not visible — consistent with retail, where it would not be in `cell_draw_list`.)
- **Slice > 8 edges:** scissor-AABB fallback (over-include, never drop). Expected to be rare
(a single doorway opening is ~46 edges).
- **Eye standing in a portal / behind-eye portal:** handled upstream by the faithful
`ProjectToClip` (eye-plane clip) + the existing `EyeInsidePortalOpening` flood gate in the
builder — unchanged by this spec.
- **No exit portal:** `OutsideView` empty → no landscape/look-out, no depth-clear; interior still
fully sealed by Loop 2.
## 7. Testing
- **Unit (already green, must stay):** `PortalVisibilityBuilderTests` (membership/cell list),
`PortalProjectionTests` (clip math), `ClipPlaneSet` behavior. App suite baseline 205/205.
- **New unit:** the `DrawCells` orchestration is GL-bound, so extract the pure decision —
*"which (cell, slice) pairs are drawn, in what order"* — into a testable function over a
`PortalVisibilityFrame`, and assert: (a) **every** `OrderedVisibleCells` cell with a non-empty
view appears in the shell pass (regression test for the grey: no cell is dropped); (b) reverse
(far→near) order; (c) objects pass has no clip state. This pins the two bugs from §1 as tests.
- **Integration:** visual, with light `[shell]`/`[vis]` probes confirming `draw=[…]` equals the
visible-cell set (no cell dropped) at the cottage + cellar. **Acceptance is the user's eyes**
on a sealed cottage + cellar with seamless transitions.
## 8. Risks & mitigations
- **Per-slice shell clip re-slices shells at boundaries** (the symptom that caused the emergency
clip-off). Mitigation: the slices now come from the *faithful* `ClipToRegion` (not the old
degenerate projection), and **only shells are clipped** (objects never are). If a shell still
gaps, that is a too-small slice — a visible, localized clip-math case to fix, **not** a return
to dropping cells.
- **Texture-pipeline grey could survive a correct seal.** HEAD's commit notes "interior walls
grey." If, after every visible cell's shell draws, walls are still untextured (vs. clear-color
grey), that is a *separate* surface/texture bug (out of scope here) — but the seal must be
correct first to even tell the two apart.
- **Per-cell draw-call count.** Indoor frames have a handful of visible cells × a few slices →
tens of draws, not thousands. Acceptable; matches retail's per-cell-per-slice cadence.
## 9. File-level change list
- **Rewrite:** `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (the `DrawCells` loop;
delete `drawableCells`, `UseIndoorMembershipOnlyRouting`, slot routing).
- **Delete:** `src/AcDream.App/Rendering/ClipFrameAssembler.cs` +
`tests/AcDream.App.Tests/Rendering/ClipFrameAssemblerTests.cs`; the `ClipViewSlice`/slot types
if unused after the rewrite.
- **Simplify:** `src/AcDream.App/Rendering/ClipFrame.cs` (one region per slice, no slot pool);
`src/AcDream.App/Rendering/ClipPlaneSet.cs` stays as-is (already does the per-slice math);
`EnvCellRenderer` / `WbDrawDispatcher` clip-routing API trimmed to "set one region / clear".
- **Keep, re-purpose:** `InteriorEntityPartition` (cell→objects map for all visible cells).
- **Light touch:** `GameWindow.cs` indoor/look-in call sites (callbacks unchanged; remove
references to deleted types).
- **Untouched:** `PortalVisibilityBuilder`, `PortalProjection`, the outdoor `LScape` branch,
`SkyRenderer`, `TerrainModernRenderer`, `ParticleRenderer`.
## 10. Decomp references
- `SmartBox::RenderNormalMode` 0x453aa0 — the `is_player_outside` branch.
- `PView::DrawInside` 0x5a5860 — seed full-screen view, `ConstructView`, `DrawCells`.
- `PView::ConstructView` 0x5a57b0 — `cell_draw_list` build (= `OrderedVisibleCells`).
- `PView::DrawCells` 0x5a4840 — the three loops (this spec's §4.2).
- `CEnvCell::setup_view` / `ACRender::polyClipFinish` 0x6b6d00 — per-slice convex clip (= `ClipPlaneSet` + `gl_ClipDistance`).
- `RenderDeviceD3D::DrawObjCellForDummies` 0x5a0760 — objects gated by `PortalList`, not hard-clipped.
- `PView::GetClip` 0x5a4320 / `PrimD3DRender::xformStart` 0x59b990 — homogeneous projection (= `ProjectToClip`).
## 11. Prior art / context
- `docs/research/2026-06-05-shell-sealing-cellar-floor-handoff.md` — the grey = shell-sealing /
flood-root, **not** the projection; "draw every visible cell's shell."
- `docs/research/2026-06-06-retail-pview-renderer-replacement-attempts-handoff.md` — the attempt
history (the slot-pool/filter layer this spec deletes).
- `docs/research/2026-06-05-retail-pview-indoor-render-pseudocode.md` — the `DrawCells` model.
- This session: `PortalProjection.ProjectToClip`/`ClipToRegion` (homogeneous `GetClip` port) +
`PortalVisibilityBuilder` made faithful — the membership + clip-math this draw builds on.