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:
parent
8116d101bc
commit
eb7b1fa67c
1 changed files with 264 additions and 0 deletions
|
|
@ -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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue