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

16 KiB
Raw Permalink Blame History

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 > 0LScape::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
PortalVisibilityBuilderPortalVisibilityFrame.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.
  • UseScissorFallbackglScissor 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.