From eb7b1fa67c6273c2742e864857d0327fb05754cd Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 6 Jun 2026 21:28:27 +0200 Subject: [PATCH] =?UTF-8?q?docs:=20spec=20=E2=80=94=20verbatim=20retail=20?= =?UTF-8?q?indoor=20render=20port=20(DrawInside/DrawCells)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- ...rbatim-retail-indoor-render-port-design.md | 264 ++++++++++++++++++ 1 file changed, 264 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-06-verbatim-retail-indoor-render-port-design.md diff --git a/docs/superpowers/specs/2026-06-06-verbatim-retail-indoor-render-port-design.md b/docs/superpowers/specs/2026-06-06-verbatim-retail-indoor-render-port-design.md new file mode 100644 index 00000000..743ba6da --- /dev/null +++ b/docs/superpowers/specs/2026-06-06-verbatim-retail-indoor-render-port-design.md @@ -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 1–3 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 ~4–6 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.