acdream/docs/superpowers/specs/2026-06-07-render-unification-outdoor-as-cell-design.md
Erik bb64a674fc docs: spec — render unification (outdoor-as-a-cell, single DrawInside path)
Brainstormed design to collapse acdream's two render paths (OutdoorRoot vs
RetailPViewInside) into one, matching retail SmartBox::RenderNormalMode ->
DrawInside(viewer_cell). Roots the FLAP as the two-branch split toggling on the
viewer cell crossing the indoor/outdoor boundary (pinned 2026-06-07 via live
render-sig); the 2026-06-05 viewer-cell-stability plan (boom + dead-zone + w-clip)
is exhausted. Models the outdoor world as a flood-graph cell node whose shell is
the landscape, so one flood + one draw handle indoor and outdoor uniformly.
Clean cutover, 4-phase plan (phases 1-2 additive, phase 3 the visual-gated cutover).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 18:07:33 +02:00

15 KiB
Raw Permalink Blame History

Render Unification — Outdoor-as-a-Cell (single DrawInside path) — Design

Date: 2026-06-07 Status: Design approved (brainstorm), pending spec review → implementation plan Branch: claude/thirsty-goldberg-51bb9b Supersedes (for the flap): the 2026-06-05 3-part viewer-cell-stability plan (boom snap + dead-zone + w-clip) — exhausted; see "Why prior fixes failed".


1. Context & problem

The indoor render FLAP: textures "battle"/oscillate at every transition (outdoor↔indoor, room↔room, cellar). Walls flash transparent, the world background covers windows, the doorway appears to "teleport". This has resisted weeks of incremental fixes.

Root cause (pinned 2026-06-07 with live ACDREAM_PROBE_FLAP render-sig at the Holtburg/Arcanum cottage): the renderer chooses one of two structurally different branches per frame, keyed on whether the viewer (camera-eye) cell is indoor or outdoor (GameWindow.cs:7342-7349):

clipRoot   = playerIndoorGate && viewerRoot != null ? viewerRoot : null;
renderBranch = clipRoot == null ? "OutdoorRoot" : "RetailPViewInside";

The two branches draw the same scene differently:

Viewer cell Branch Terrain Interior cells depth-clear
outdoor (e.g. 0xA9B40031) OutdoorRoot full-screen draw 4 via 2D "look-in" no
indoor (0170/0171) RetailPViewInside skipped (door-clipped only) 6 via portal flood yes

The 3rd-person eye sits 2.63.9 m behind the player and crosses the indoor/outdoor boundary by metres as the player stands at a doorway. Each crossing toggles the branch → terrain pops, the interior cell set jumps 4↔6, depth-clear toggles → the flap. When the eye stays indoor (01700171) both solves draw the same 6 cells — no flap (verified: adjacent frames, eye ~still). So the flap is specifically the indoor/outdoor branch switch — the "two-pipe split" CLAUDE.md's 2026-05-30 banner marked abandoned, still alive as this gate.

2. Why prior fixes failed (do not retry)

  • Viewer-cell dead-zone (±0.2 mm in PointInsideCellBsp) — the eye crosses by metres; a sub-mm dead-zone is irrelevant. Tried 2026-06-07, had zero visible effect and regressed the cellar roof (it shifted the flood root via the cell pick). Reverted. The faithful Part-1 (boom snap, d2212cf) and Part-3 (w-space portal clip, ProjectToClip/ClipToRegion) are already shipped. The 3-part viewer-cell-stability plan is exhausted and the flap remains.
  • Gating the branch on the PLAYER cell — a documented dead-end (GameWindow.cs:7207-7211): forcing an indoor draw while the camera is outside "drops the outdoor pass and leaves clear color around a floating doorway slice." When the eye is genuinely outside, the outdoor view is correct — so a stable branch can't be a pure function of the player cell either.
  • A render-side debounce/grace on the branch — forbidden (no-workarounds rule; 2026-06-05 plan §5).

The lesson: the flap is architectural, not a stability tweak. Two structurally-different render paths cannot be made seamless at the boundary by adjusting when you switch between them. They must become one path.

3. The retail model (the oracle)

Retail has one render path. SmartBox::RenderNormalMode (0x00453aa0, pc:92635) calls, in the normal case, RenderDeviceD3D::DrawInside(viewer_cell) (0x0059f0d0) → PView::DrawInside(viewer_cell) (0x005a5860, pc:433793) → PView::DrawCells (0x005a4840). It does not branch on inside/outside.

PView::DrawInside(cell) (pc:433793):

  1. CEnvCell::curr_view_push(cell)
  2. PView::add_views(cell->num_stabs, cell->stab_list) — the cell's visible objects. For an outdoor cell the stab list includes the landscape.
  3. ConstructView(cell, 0xffff) (0x005a59a0) — recursive portal-clip flood. Uses the ±0.000199999995f plane side-test (POSITIVE / NEGATIVE / IN_PLANE, pc:433834) — this is where that 0.2 mm constant belongs, not in cell membership. An exit/portal leads to CEnvCell::GetVisible(other_cell_id) and recurses.
  4. DrawCells(view) — draw every visible cell.

The outdoor world is a cell (a landcell / CObjCell) with portals to buildings and a stab list that carries the landscape. There is no outdoor "mode" — outdoors is just the cell you're standing in, drawn by the same DrawInside.

4. Goal & non-goals

Goal: collapse acdream's two render paths into one, matching retail: a single flood rooted at the viewer cell (indoor or outdoor) and a single draw of every visible cell. The flap dies by construction — there is no branch to flip, and crossing a doorway is one continuous flood whose output varies continuously.

Approach chosen (brainstorm 2026-06-07): "A — make outdoor a cell" (the true retail model), with a clean cutover (no toggle; git revert is the safety net).

Non-goals (out of scope for this work):

  • Camera collision / viewer-cell resolution behaviour (kept as-is — it is correct; the flap is not a camera bug).
  • #78 outdoor terrain gating over indoor floor holes (tracked separately).
  • L-spotlight point-light artifact (separate).
  • Per-landcell outdoor granularity (retail has 64 landcells/landblock for its own landscape culling; we model the outdoor world as one flood node whose shell is the terrain — acdream's terrain renderer already does its own frustum culling).

5. Design overview

One operation per frame: flood from the viewer's cell; draw every visible cell. The only new concept is an outdoor cell node: a synthetic cell whose "shell" is the landscape and whose "doorways" are nearby building entrances. With it, the outdoor case and the indoor case are the same graph problem.

resolve viewer cell ─► Build(flood) from viewer cell ─► for each visible cell in
draw order: (outdoor node → terrain+sky+scenery clipped to its region │ interior
cell → shell+objects clipped to its region) ─► entities membership-gated

6. Components

6.1 Outdoor cell node — CellVisibility (+ a small new type)

A synthetic node representing the outdoor world near the player. One node per frame, keyed by the viewer's current outdoor landcell id (the id the camera already produces, e.g. 0xA9B40031). All building exit portals collapse to this single node (the landscape is global, so we do not model 64 landcells/landblock — the node's shell is the whole visible landscape, drawn by the terrain renderer's own frustum cull).

  • SeenOutside = true, world transform = identity.
  • Portals = the entrances of nearby buildings — the reverse of each building's exit portal (OtherCellId == 0xFFFF) polygon, with its clip plane reversed so the flood can traverse outdoor→building. Built per-frame from the same nearby-building enumeration the current exterior look-in already does (GameWindow.cs:~7538-7565).
  • Marked so the draw path knows "this cell's shell is the terrain, not EnvCell geometry."
  • CellVisibility.TryGetCell / the viewer-cell resolution at GameWindow.cs:7201-7204 returns this node when the camera's viewer-cell id is an outdoor id, so viewerRoot is non-null outdoors and ComputeVisibilityFromRoot runs the flood.

Interface: what — represents the outdoor world as a flood graph node; how to useCellVisibility builds/refreshes it per frame and returns it from the viewer-cell lookup when outdoors; depends on — the nearby-building portal enumeration and the loaded landblock set.

6.2 One flood — PortalVisibilityBuilder.Build

  • Build roots at the viewer cell — interior EnvCell or the outdoor node (today it requires a non-null interior LoadedCell, PortalVisibilityBuilder.cs:63).
  • Building exit portals (OtherCellId == 0xFFFF, PortalVisibilityBuilder.cs:234) now lead to the outdoor node and enqueue it (clipped to the door opening), instead of terminating into a 2D OutsideView.
  • The outdoor↔building cycle is bounded by the existing visited-set / per-cell reprocess guard (MaxReprocessPerCell).
  • BuildFromExterior is deleted — the exterior look-in becomes "the flood, rooted at the outdoor node."

Interface: what — given any root cell + eye, returns the set of visible cells (interior + outdoor node) with per-cell screen-space clip regions; how to use — called once per frame from the viewer cell; depends on — the cell graph (now including the outdoor node) and PortalProjection.

6.3 One draw path — RetailPViewRenderer + GameWindow

  • Delete the OutdoorRoot/RetailPViewInside branch (GameWindow.cs:7342-7349) and its two blocks.
  • Walk the flood's visible cells in draw order; for each:
    • Outdoor node → draw terrain + sky + outdoor scenery, clipped to that node's view region.
    • Interior cell → draw shell + objects, clipped to its region (today's IndoorDrawPlan.ShellPass/ObjectPass).
  • Entities membership-gated as today (InteriorEntityPartition).
  • Delete the separate OutsideView mechanism + DrawLandscapeThroughOutsideView/DrawRetailPViewLandscapeSlice (GameWindow.cs:~9239) and RetailPViewRenderer.DrawPortal — terrain-through-door is now "the outdoor node drawn clipped to its doorway region," produced by the unified flood.

6.4 Terrain-clipped-to-region — TerrainModernRenderer (reused)

No new terrain machinery. Terrain already clips to the OutsideView doorway planes in-shader via gl_ClipDistance (binding=2 clip UBO, TerrainModernRenderer.cs:206). We drive that clip from the outdoor node's view region instead: full-screen (no clip — byte-identical to today's open-world draw) when the outdoor node is the root, the doorway region when it is reached through a portal.

7. Per-frame data flow

  1. Resolve the viewer cell: interior EnvCell if the eye is indoors, else the outdoor node (CellVisibility).
  2. Build the visibility flood from the viewer cell (one call).
  3. Draw every visible cell in order: outdoor node → terrain/sky/scenery clipped to its region; interior cell → shell/objects clipped to its region.
  4. Draw entities, membership-gated to visible cells.
  5. Sky draws when the outdoor node is in the visible set, clipped to its region.

No branch anywhere in steps 25.

8. What gets deleted (clean cutover)

  • The two-branch gate + blocks (GameWindow.cs:7342-7349 and the outdoor/indoor draw blocks).
  • PortalVisibilityBuilder.BuildFromExterior.
  • RetailPViewRenderer.DrawPortal (the exterior look-in).
  • The OutsideView 2D-region path + DrawLandscapeThroughOutsideView / DrawRetailPViewLandscapeSlice.

Net result: less code, one path.

9. Phasing (implementation order)

  1. Outdoor cell node — construct it + wire building-entrance portals; resolve viewerRoot to it outdoors. Unit-tested (node has the right portals; viewer resolution returns it for outdoor ids). Additive — not yet consumed by the draw.
  2. Outdoor-root flood capabilityBuild can root at the outdoor node and flood into buildings through their entrances; cycle-safe. This is a new, additive path: the existing indoor Build and its exit-portal→OutsideView behaviour are left untouched so the live draw stays correct. Unit-tested (flood from the outdoor node reaches buildings; termination on the outdoor↔building cycle).
  3. Cutover (the one risky, visual-gated step) — switch the draw to the single unified path; repoint building exit portals from OutsideView to the outdoor node (so indoor→outdoor floods into the node through the door); delete the OutdoorRoot/RetailPViewInside branch, BuildFromExterior, RetailPViewRenderer.DrawPortal, and the OutsideView / DrawLandscapeThroughOutsideView path. Visual gate (user's eyes) at the cottage doorway/cellar/look-in-from-outside.
  4. Cleanup — remove any remaining dead code; reconcile the [render-sig] probe to the single path.

Phases 12 are purely additive — the new node + outdoor-root flood are not yet consumed by the draw, and the exit-portal→OutsideView behaviour is unchanged — so the build stays green and the game renders exactly as today. The disruptive change (repointing exit portals + switching the draw + deleting the old paths) is isolated to phase 3 and is git-revertible as a unit.

10. Testing strategy

  • Unit (TDD): outdoor-node portal wiring; viewer-cell resolution to the node; Build rooted at the outdoor node returns the expected cell set; indoor→outdoor flood through a door; cycle termination. New tests in tests/AcDream.App.Tests/Rendering/.
  • Regression guard: the pure-outdoor case (no building in view) must stay byte-for-byte today's behaviour — full-screen terrain, no clip — so open-world rendering cannot regress. Assert the outdoor node's root region is full-screen and the terrain clip is the no-clip UBO in that case.
  • Visual gate (acceptance): user walks in/out of the cottage, pans the camera at the threshold, drops to the cellar and back, looks at the cottage from outside — no flap, no missing textures, terrain/sky correct, no see-through walls.

11. Risks & mitigations

  • Occlusion/ordering when terrain + interiors share one flood — keep the draw order retail-faithful (far→near, exit-portal masks as today); mitigate by keeping the pure-outdoor path identical (regression guard above).
  • Cycle blow-up (outdoor→building→outdoor) — reuse the existing visited-set + MaxReprocessPerCell cap; unit-test termination at the cottage.
  • Performance — the outdoor node is a single flood node (terrain is its shell, drawn once with its own frustum cull), not millions of cells, so the flood does not become combinatorial. Watch outdoor FPS at the visual gate.
  • Clean cutover (no toggle) — phases 12 are additive and green; phase 3 is the only risky step and is git-revertible as a unit.

12. References / decomp anchors

  • Retail: SmartBox::RenderNormalMode 0x00453aa0 (pc:92635); RenderDeviceD3D::DrawInside 0x0059f0d0; PView::DrawInside 0x005a5860 (pc:433793); PView::ConstructView 0x005a59a0 (side-test pc:433834); PView::DrawCells 0x005a4840; PView::GetClip 0x005a4320.
  • acdream: GameWindow.cs 7183-7204 (viewer/player root), 7342-7349 (branch), ~7538-7601 (look-in), ~9239 (landscape-through-OutsideView); PortalVisibilityBuilder.cs 63 (Build), 234 (exit portal), 339 (BuildFromExterior), 664 (side-test); PortalProjection.cs (w-space clip); TerrainModernRenderer.cs 206 (Draw/clip); CellVisibility.cs 276 (TryGetCell), 338 (ComputeVisibilityFromRoot).
  • Memory: project_indoor_flap_rootcause, reference_render_pipeline_state, feedback_render_one_gate, feedback_render_downstream_of_membership, project_camera_visibility_coupling.