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>
This commit is contained in:
Erik 2026-06-06 21:28:27 +02:00
parent 8116d101bc
commit eb7b1fa67c

View file

@ -0,0 +1,264 @@
# 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.