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>
16 KiB
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
DrawCellsverbatim 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 outdoorLScapebranch is out of scope — it already works.
Worktree:
thirsty-goldberg-51bb9b, branchclaude/thirsty-goldberg-51bb9b, HEAD8116d10. PowerShell on Windows; launch logs are UTF-16. Do NOT branch/worktree, push, orgit 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:
-
Dropped shells → grey.
RetailPViewRenderer.cs:52drawableCells = clipAssembly.CellIdToSlot.Keys, and every loop doesif (!drawableCells.Contains(cellId)) continue;. A visible cell is drawn only ifClipFrameAssemblerassigned 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 incell_draw_list. -
No trim → bleed (and the half-character).
RetailPViewRenderer.cs:237UseIndoorMembershipOnlyRouting()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 tocell_draw_list(once);ClipPortalsclips its portals against its view;AddViewToPortalspropagates the clipped openings to neighbours and enqueues new ones.cell_draw_listis the single membership source.DrawCells(0x5a4840) — draw, three loops over reversecell_draw_list(far→near):- Landscape: if
outside_view.view_count > 0→LScape::drawclipped tooutside_view, thenClear(DEPTH). - Loop 1 — exit-portal masks: per cell, per
portal_viewslice:setup_view(cell, slice); for each exit portalDrawPortalPolyInternal(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).
- Landscape: if
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 CPUpolyClipFinish. Same convex-region clip;ClipPlaneSet.Fromalready 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 manygl_ClipDistanceoutputs; the existing mesh/terrain shaders already read a clip region and writegl_ClipDistance, so the shader side is unchanged — only the feed shrinks from a slot pool to one region.UseScissorFallback→glScissortoScissorNdcAabb(mapped to pixels), no clip planes.IsNothingVisible→ draw nothing for that slice.clearShellClipdisables allgl_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
CellViewfor 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 incell_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 existingEyeInsidePortalOpeningflood gate in the builder — unchanged by this spec. - No exit portal:
OutsideViewempty → 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),ClipPlaneSetbehavior. App suite baseline 205/205. - New unit: the
DrawCellsorchestration is GL-bound, so extract the pure decision — "which (cell, slice) pairs are drawn, in what order" — into a testable function over aPortalVisibilityFrame, and assert: (a) everyOrderedVisibleCellscell 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 confirmingdraw=[…]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(theDrawCellsloop; deletedrawableCells,UseIndoorMembershipOnlyRouting, slot routing). - Delete:
src/AcDream.App/Rendering/ClipFrameAssembler.cs+tests/AcDream.App.Tests/Rendering/ClipFrameAssemblerTests.cs; theClipViewSlice/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.csstays as-is (already does the per-slice math);EnvCellRenderer/WbDrawDispatcherclip-routing API trimmed to "set one region / clear". - Keep, re-purpose:
InteriorEntityPartition(cell→objects map for all visible cells). - Light touch:
GameWindow.csindoor/look-in call sites (callbacks unchanged; remove references to deleted types). - Untouched:
PortalVisibilityBuilder,PortalProjection, the outdoorLScapebranch,SkyRenderer,TerrainModernRenderer,ParticleRenderer.
10. Decomp references
SmartBox::RenderNormalMode0x453aa0 — theis_player_outsidebranch.PView::DrawInside0x5a5860 — seed full-screen view,ConstructView,DrawCells.PView::ConstructView0x5a57b0 —cell_draw_listbuild (=OrderedVisibleCells).PView::DrawCells0x5a4840 — the three loops (this spec's §4.2).CEnvCell::setup_view/ACRender::polyClipFinish0x6b6d00 — per-slice convex clip (=ClipPlaneSet+gl_ClipDistance).RenderDeviceD3D::DrawObjCellForDummies0x5a0760 — objects gated byPortalList, not hard-clipped.PView::GetClip0x5a4320 /PrimD3DRender::xformStart0x59b990 — 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— theDrawCellsmodel.- This session:
PortalProjection.ProjectToClip/ClipToRegion(homogeneousGetClipport) +PortalVisibilityBuildermade faithful — the membership + clip-math this draw builds on.