acdream/docs/superpowers/plans/2026-05-30-phase-u-unified-render-pipeline.md
Erik 0f7b395be1 docs(render): Phase U — implementation plan (U.1-U.4 detailed, U.5/U.6 stubbed)
Ten bite-sized tasks to the first visual gate: U.1 delete two-pipe; U.2 GL-free
core (builder ordering+fixpoint, OtherPortalClip, ClipPlaneSet, ACDREAM_PROBE_VIS);
U.3 GPU gate (gl_ClipDistance in mesh_modern/terrain_modern + clip SSBO/UBO upload);
U.4 unified gated draw (EnvCellRenderer cell shells + WbDrawDispatcher All +
gated terrain; live-dynamic unclipped per retail) + per-instance slot assignment +
probe validation. U.5 outdoor-peering / U.6 dungeon-scale detailed after the gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 15:48:17 +02:00

31 KiB
Raw Blame History

Phase U — Unified Render Pipeline Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking. Each task is a fresh subagent dispatch on Opus 4.8. Build + test green at every task; commit per task.

Goal: Replace the WorldBuilder-inherited two-pipe (inside/outside) render split with one retail-faithful PView portal-visibility pass — visible cells + per-cell screen-space clip region + an OutsideView for the outdoors — gated on the GPU by hardware clip planes (gl_ClipDistance). Seamless indoor/outdoor transitions by construction.

Architecture: Per frame: CellVisibility.FindCameraCell picks the root (cell → indoor, null → outdoor); PortalVisibilityBuilder runs a closest-first portal BFS to produce per-cell convex NDC clip regions + OutsideView; ClipPlaneSet turns each region's edges into clip-space planes; one draw pass gates three paths (EnvCellRenderer.Render for cell shells, WbDrawDispatcher.Draw(All) for statics/scenery/entities, TerrainModernRenderer.Draw for ground) by those planes. No cameraInsideBuilding branch, no RenderInsideOut stencil pass.

Tech Stack: C# .NET 10, Silk.NET OpenGL 4.3+ bindless/MDI, gl_ClipDistance, GLSL #version 430/460. xUnit tests. Retail oracle: docs/research/named-retail/acclient_2013_pseudo_c.txt.

Spec: docs/superpowers/specs/2026-05-30-phase-u-unified-render-pipeline-design.md. Read it before starting; it has the retail anchors and the full component contracts.

Build/test commands (PowerShell, from repo root):

  • Build: dotnet build AcDream.slnx -c Debug
  • Core tests: dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj -c Debug
  • App tests: dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj -c Debug

Staging: Tasks 110 cover U.1U.4 (first visual gate after Task 10). U.5 (outdoor-peering root) and U.6 (dungeon-scale validation) are detailed after the U.4 visual gate — they depend on what the gate reveals and on the §6.3 building-portal data dependency. They are stubbed at the end of this plan.

Diagnostic / probe conventions: new runtime toggles go through a diagnostic-owner static class (Code Structure Rule 5), read once from env at startup, runtime-toggleable via DebugPanel. Strip temporary [pv-dump]-style probes before marking a task done; keep the durable ACDREAM_PROBE_VIS.


File structure

New files:

  • src/AcDream.App/Rendering/ClipPlaneSet.cs — pure NDC-edge → clip-space-plane extractor (+ 8-plane cap / scissor fallback).
  • src/AcDream.App/Rendering/RenderDiagnostics.csACDREAM_PROBE_VIS owner (visibility probe), DebugPanel-toggleable.
  • tests/AcDream.App.Tests/Rendering/ClipPlaneSetTests.cs — extractor unit tests.

Modified files:

  • src/AcDream.App/Rendering/PortalVisibilityBuilder.cs — rework: add OrderedVisibleCells, distance-priority queue, timestamp/update-count fixpoint, reciprocal OtherPortalClip. (Keep the file + class name to preserve the ~36 tests' references; evolve internals + output type.)
  • src/AcDream.App/Rendering/Shaders/mesh_modern.vert — clip-plane SSBO (binding=2) + gl_ClipDistance writes keyed by per-instance cell slot.
  • src/AcDream.App/Rendering/Shaders/terrain_modern.vert — uniform clip-plane block + gl_ClipDistance.
  • src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs — accept a per-cell clip-slot binding + bind the clip SSBO at draw.
  • src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs — per-instance clip-slot in InstanceData/SSBO; bind clip SSBO; glEnable(GL_CLIP_DISTANCE…).
  • src/AcDream.App/Rendering/TerrainModernRenderer.cs — upload OutsideView clip uniforms + enable clip distances.
  • src/AcDream.App/Rendering/GameWindow.cs — DELETE two-pipe machinery (Task 1); wire the unified gated pass (Tasks 910).
  • tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs — add ordering / fixpoint / OtherPortalClip cases.

Deleted files (Task 1):

  • src/AcDream.App/Rendering/IndoorCellStencilPipeline.cs
  • src/AcDream.App/Rendering/Shaders/portal_stencil.vert, …/portal_stencil.frag
  • tests/AcDream.App.Tests/Rendering/IndoorCellStencilPipelineTests.cs

Keep untouched (audited real fixes — do NOT regress): EnvCellRenderer.cs pool/GL-state logic (9559726, 9ee42d4, d5deeb3, 0940d79, 5dc4140), BuildingLoader/BuildingRegistry/Building (0fc6003), CellVisibility, the clip-math trio (PortalView.cs, ScreenPolygonClip.cs, PortalProjection.cs), and the camera-collision work (PhysicsCameraCollisionProbe, RetailChaseCamera).


Task 1 (U.1): Delete the two-pipe machinery

Goal: Remove all inside-out / two-pipe code so the unified pass is built clean. After this task the default game is byte-for-byte unchanged (any current indoor-wall degradation persists until Task 9 — that is expected, NOT a regression introduced here).

Files:

  • Delete: src/AcDream.App/Rendering/IndoorCellStencilPipeline.cs, Shaders/portal_stencil.vert, Shaders/portal_stencil.frag, tests/AcDream.App.Tests/Rendering/IndoorCellStencilPipelineTests.cs

  • Modify: src/AcDream.App/Rendering/GameWindow.cs

  • Step 1: Verify the keep-list fixes before touching anything. Read each commit's diff so you know what must survive: git show 9559726 9ee42d4 d5deeb3 0940d79 0fc6003 -- src/AcDream.App/Rendering/. Confirm these touch EnvCellRenderer pool/GL-state, BuildingLoader, or CellVisibility — NOT the inside-out visibility/stencil path. Do not modify any of them.

  • Step 2: Delete the dead files.

git rm src/AcDream.App/Rendering/IndoorCellStencilPipeline.cs
git rm src/AcDream.App/Rendering/Shaders/portal_stencil.vert src/AcDream.App/Rendering/Shaders/portal_stencil.frag
git rm tests/AcDream.App.Tests/Rendering/IndoorCellStencilPipelineTests.cs

(If any of the shader paths differ, locate them first with Glob **/portal_stencil.*.)

  • Step 3: Excise the GameWindow two-pipe code. Remove, in GameWindow.cs (line numbers are approximate — locate by symbol):

    • RenderInsideOutAcdream method (the RenderInsideOut-port, ~1100711319).
    • RenderOutsideInAcdream method (~213325).
    • The entire A8-perf instrumentation: _a8Perf* fields (~71347177), MaybeFlushA8Perf, A8PerfStart/Stop/BeginGpuQuery/EndGpuQuery, EmitDrawOrderProbe/EmitEnvCellProbe/EmitStencilProbe/EmitBuildingsProbe, and all their call sites (~1132111609 + the a8PerfStart = … / A8Perf…(…) lines scattered in the render method).
    • The cameraInsideBuilding / a8IndoorBranchEnabled / ACDREAM_A8_INDOOR_BRANCH declarations + the whole if (cameraInsideBuilding) { … } else { if (a8IndoorBranchEnabled) { … } else { <DEFAULT> } } block (~73427348, 73567523, 76137715, 78257831). Collapse it to the DEFAULT branch only (the else at ~77047715): a single _wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntries, frustum, neverCullLandblockId: playerLb, visibleCellIds: visibility?.VisibleCellIds, animatedEntityIds: animatedIds);. (Task 9 replaces this collapsed call with the gated unified sequence.)
    • The depth-clear-if-inside workaround (~76137614).
    • _indoorStencilPipeline field (~172), its construction (~19411944), and its Dispose (~11690).
    • The orphaned PortalVisibilityBuilder.Build call inside RenderInsideOutAcdream (deleted with the method).
  • Step 4: Remove now-dead EntitySet partition values. In WbDrawDispatcher.cs, the IndoorPass, OutdoorScenery, BuildingShells, LiveDynamic enum members are referenced only from the deleted two-pipe code. Remove them and any switch/branch arms in WbDrawDispatcher that handle them, leaving EntitySet.All as the sole member (or remove the set: parameter entirely if All is now the only path — prefer the smaller diff: keep the enum with just All). Verify with Grep that no references to the removed members remain.

  • Step 5: Build. Run: dotnet build AcDream.slnx -c Debug. Expected: PASS, zero errors. Fix any dangling references (unused usings, leftover field refs) until green.

  • Step 6: Test. Run the App + Core test suites. Expected: PASS (minus the deleted IndoorCellStencilPipelineTests). The ~36 clip-math tests (PortalView/ScreenPolygonClip/PortalProjection/PortalVisibilityBuilder) MUST still pass — Task 1 does not touch their subjects.

  • Step 7: Commit.

git add -A
git commit -m "refactor(render): Phase U.1 — delete two-pipe inside-out machinery

Remove IndoorCellStencilPipeline + portal_stencil shaders, RenderInsideOutAcdream,
RenderOutsideInAcdream, the A8-perf instrumentation, the cameraInsideBuilding /
ACDREAM_A8_INDOOR_BRANCH branch, and the dead EntitySet partition values. Collapse
the render branch to the default Draw(All) path (Task 9 replaces it with the gated
unified pass). Keep all audited EnvCellRenderer / BuildingLoader / CellVisibility /
camera-collision fixes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"

Task 2 (U.2a): PortalVisibilityFrame + distance-priority BFS + fixpoint termination

Goal: Rework the builder so it (a) returns an ordered visible-cell list, (b) traverses closest-first via a distance-priority queue (retail InsCellTodoList 433183), (c) terminates via a real grow-watermark fixpoint instead of the MaxReprocessPerCell hard cap (retail master_timestamp + update_count, AddViewToPortals 433446). Reuses PortalView/ScreenPolygonClip/PortalProjection unchanged.

Files:

  • Modify: src/AcDream.App/Rendering/PortalVisibilityBuilder.cs
  • Test: tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs

Interface (evolve PortalVisibilityFrame):

public sealed class PortalVisibilityFrame
{
    public CellView OutsideView { get; }                              // 0xFFFF exits, clipped
    public Dictionary<uint, CellView> CellViews { get; }             // per-cell clip region (== CellClipRegions)
    public List<uint> OrderedVisibleCells { get; }                   // NEW: closest-first draw order
    public Dictionary<uint, CellView> CrossBuildingViews { get; }    // keep (U.5 outdoor-peering)
}
  • Step 1: Write failing tests for ordering + fixpoint. Add to PortalVisibilityBuilderTests.cs:
[Fact] // closest-first ordering
public void Build_OrdersVisibleCells_ClosestFirst()
{
    // Three cells in a straight chain A->B->C, camera in A. Expect order [A,B,C].
    var (cells, lookup) = SyntheticChain(/* see helpers below */);
    var f = PortalVisibilityBuilder.Build(cells[0], cameraPos: A_center, viewProj, lookup);
    Assert.Equal(new[]{ A_id, B_id, C_id }, f.OrderedVisibleCells);
}

[Fact] // cyclic graph terminates and bounds the visible set
public void Build_CyclicHub_TerminatesAndBounds()
{
    // Hub cell with 4 rooms each portal-linked back to the hub (a cycle).
    var (cells, lookup) = SyntheticCyclicHub();
    var f = PortalVisibilityBuilder.Build(hub, hubCenter, viewProj, lookup);
    Assert.True(f.OrderedVisibleCells.Count <= 5);          // hub + 4 rooms, no blow-up
    Assert.Equal(f.OrderedVisibleCells.Count, f.OrderedVisibleCells.Distinct().Count()); // no dup cells
}

Build the SyntheticChain / SyntheticCyclicHub helpers using LoadedCell with hand-set WorldTransform = Identity, Portals, ClipPlanes, PortalPolygons (reuse patterns already in the test file).

  • Step 2: Run tests — verify they fail. dotnet test …AcDream.App.Tests… --filter PortalVisibilityBuilder. Expected: FAIL (OrderedVisibleCells missing / wrong order).

  • Step 3: Implement. In PortalVisibilityBuilder.Build: replace the Queue<LoadedCell> with a distance-priority structure (insert cells keyed by distance from cameraPos to the cell's WorldPosition; dequeue nearest). Track per-cell a viewVersion (count of polygons accumulated, or a content hash); re-enqueue a neighbour only when its CellView genuinely grows (compare against the version recorded when last enqueued). Drop MaxReprocessPerCell. Append each dequeued cell id to OrderedVisibleCells. Keep the OutsideView and CrossBuildingViews accumulation logic.

  • Step 4: Run tests — verify pass. Expected: PASS, including the pre-existing ~12 builder tests (no regression).

  • Step 5: Commit.

git commit -am "feat(render): Phase U.2a — portal BFS ordering + fixpoint termination

PortalVisibilityFrame gains OrderedVisibleCells (closest-first). Replace the FIFO +
MaxReprocessPerCell cap with a distance-priority queue and a grow-watermark fixpoint
(retail InsCellTodoList 433183 / AddViewToPortals 433446) so cyclic dungeon graphs
converge without duplicate-cell blow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"

Task 3 (U.2b): Reciprocal OtherPortalClip

Goal: When a portal leads to a loaded neighbour, also clip the portal opening against the neighbour's matching (reciprocal) portal polygon (retail PView::OtherPortalClip 433524) — prevents over-inclusion through skewed openings.

Files: Modify PortalVisibilityBuilder.cs; test in PortalVisibilityBuilderTests.cs.

  • Step 1: Failing test. Construct two cells whose shared portal, viewed at an oblique angle, has a smaller reciprocal opening than the near-side projection. Assert the neighbour's resulting CellView area is bounded by the reciprocal (smaller) opening, not the near-side one.
[Fact]
public void Build_AppliesReciprocalOtherPortalClip()
{
    var (camCell, neighbour, lookup) = SyntheticReciprocalPair(obliqueAngleDeg: 60);
    var f = PortalVisibilityBuilder.Build(camCell, camPos, viewProj, lookup);
    var area = PolygonArea(f.CellViews[neighbourId]);   // helper: signed-area sum of polys
    Assert.True(area <= ReciprocalOpeningAreaNdc + Eps); // clipped to the narrower opening
}
  • Step 2: Run — fail. Expected: FAIL (neighbour region too large).
  • Step 3: Implement the reciprocal clip: at the TODO site already marked in PortalVisibilityBuilder.cs (the // TODO(A8.F): neighbour-side OtherPortalClip (decomp:433524) comment), project the neighbour's matching portal polygon (resolve via portal.PolygonId / the neighbour's Portals back-link) to NDC and intersect it into clippedRegion before unioning into the neighbour's CellView.
  • Step 4: Run — pass. Expected: PASS + no regression.
  • Step 5: Commit feat(render): Phase U.2b — reciprocal OtherPortalClip (retail 433524).

Task 4 (U.2c): ClipPlaneSet extractor

Goal: Turn a CellView (set of convex NDC polygons) into ≤8 clip-space planes for gl_ClipDistance, with collinear-edge merge + AABB-scissor fallback when over budget.

Files: Create src/AcDream.App/Rendering/ClipPlaneSet.cs; create tests/AcDream.App.Tests/Rendering/ClipPlaneSetTests.cs.

Interface:

public readonly struct ClipPlaneSet
{
    public int Count { get; }                  // 0..8
    public IReadOnlyList<Vector4> Planes { get; }   // clip-space (nx, ny, 0, d)
    public bool UseScissorFallback { get; }
    public Vector4 ScissorNdcAabb { get; }     // (minX, minY, maxX, maxY)
    public static ClipPlaneSet From(CellView region);
    public static ClipPlaneSet Empty { get; }  // Count==0, nothing passes
}

Edge → plane: for the region's (single, after intersection) convex polygon wound CCW, each edge (p→q) has inward normal n = normalize(perp(q-p)) with perp((x,y)) = (-y, x) for CCW; plane = (n.x, n.y, 0, -dot(n, p)), so gl_ClipDistance = n.x*clip.x + n.y*clip.y + (-dot(n,p))*clip.w ≥ 0 inside. (A CellView with multiple disjoint polygons after intersection is rare for a single chain; take the first/largest polygon for the plane set and set UseScissorFallback with the union AABB if there are several — documented conservative behavior.)

  • Step 1: Failing tests.
[Fact]
public void From_AxisAlignedSquare_FourPlanes_PointInsideHasPositiveDistances()
{
    var sq = new CellView(); sq.Add(new ViewPolygon(new[]{
        new Vector2(-0.5f,-0.5f), new Vector2(0.5f,-0.5f), new Vector2(0.5f,0.5f), new Vector2(-0.5f,0.5f)});
    var cps = ClipPlaneSet.From(sq);
    Assert.Equal(4, cps.Count);
    // clip-space point at NDC (0,0) with w=1 → inside → all distances >= 0
    var clip = new Vector4(0,0,0,1);
    foreach (var p in cps.Planes) Assert.True(Vector4.Dot(p, clip) >= 0);
    // point at NDC (0.9,0) → outside the square → at least one distance < 0
    var outClip = new Vector4(0.9f,0,0,1);
    Assert.Contains(cps.Planes, p => Vector4.Dot(p, outClip) < 0);
}

[Fact]
public void From_NineEdgePolygon_FallsBackToScissor()
{
    var poly = RegularNgonCellView(n: 9, radius: 0.6f);
    var cps = ClipPlaneSet.From(poly);
    Assert.True(cps.UseScissorFallback || cps.Count <= 8); // merge may reduce; if not, scissor
    if (cps.UseScissorFallback) { Assert.Equal(0, cps.Count); /* AABB carries the gate */ }
}

[Fact]
public void From_EmptyRegion_IsEmpty()
{
    Assert.Equal(0, ClipPlaneSet.From(new CellView()).Count);
}
  • Step 2: Run — fail (ClipPlaneSet not defined).
  • Step 3: Implement ClipPlaneSet.From: pick the region's principal convex polygon; merge edges whose direction differs by < ~0.5° (retail copy_view ~1px dedup); if > 8 edges remain, set UseScissorFallback=true, Count=0, and compute the NDC AABB; else emit the per-edge planes. Sign per the CCW formula above.
  • Step 4: Run — pass.
  • Step 5: Commit feat(render): Phase U.2c — ClipPlaneSet (NDC edges → gl_ClipDistance planes).

Task 5 (U.2d): ACDREAM_PROBE_VIS runtime probe

Goal: A durable per-frame visibility probe (the apparatus #103 lacked) so the builder is validated on live frames before any GL work.

Files: Create src/AcDream.App/Rendering/RenderDiagnostics.cs; wire one emit site (the place that will call PortalVisibilityBuilder.Build in Task 9 — for now, add a temporary call behind the probe so it can run pre-U.4, or defer the emit wiring to Task 9 and land only the owner here). Prefer: land the owner + a static void EmitVis(...) formatter here; call it from Task 9.

Interface:

public static class RenderDiagnostics
{
    public static bool ProbeVisibility { get; set; }   // env ACDREAM_PROBE_VIS=1, DebugPanel-toggleable
    public static void EmitVis(uint rootCellId, IReadOnlyList<uint> visibleCells,
                               CellView outsideView, int outsidePlaneCount,
                               IReadOnlyDictionary<uint,int> perCellPlaneCounts,
                               int scissorFallbacks);
}

EmitVis prints one [vis] line: root=0x… cells=N ids=[…] outside(polys=…,planes=…) fallbacks=…. Fire only on cell change (track last root id inside the owner).

  • Step 1: Write a small unit test that ProbeVisibility defaults to the env value and EmitVis is a no-op when false (capture Console.Out).
  • Step 2: Run — fail.
  • Step 3: Implement the owner.
  • Step 4: Run — pass. Build green.
  • Step 5: Commit feat(render): Phase U.2d — ACDREAM_PROBE_VIS visibility probe owner.

Task 6 (U.3a): gl_ClipDistance in mesh_modern.vert

Goal: Indoor cell shells, cell statics, scenery, building shells (everything through mesh_modern.vert) honor a per-instance clip-plane set.

Files: Modify src/AcDream.App/Rendering/Shaders/mesh_modern.vert.

Shader additions (std430):

// binding=2: per-clip-slot plane set. Slot index supplied per-instance (see Task 8).
struct CellClip { uint count; uint _p0; uint _p1; uint _p2; vec4 planes[8]; };
layout(std430, binding = 2) readonly buffer ClipBuf { CellClip clips[]; };
// per-instance clip slot: add to InstanceData (Task 8 fills it). Until then, default 0 = no-clip.

In main(), after gl_Position is computed:

uint slot = inst.clipSlot;                 // 0 reserved = no-clip sentinel (count 0)
CellClip c = clips[slot];
for (uint i = 0u; i < c.count; ++i)
    gl_ClipDistance[i] = dot(c.planes[i], gl_Position);
for (uint i = c.count; i < 8u; ++i)
    gl_ClipDistance[i] = 1.0;              // unused planes pass everything
  • Step 1: Add the SSBO + the clipSlot field reference (coordinate the exact InstanceData layout with Task 8 — this task may land the shader with slot=0 hardcoded if InstanceData isn't extended yet, then Task 8 wires the real slot). Verify the shader compiles: build + launch is not required, but ensure the shader-load path doesn't throw (run the app once headless if practical, or rely on Task 9's launch).
  • Step 2: Build. Expected: PASS.
  • Step 3: Commit feat(render): Phase U.3a — gl_ClipDistance clip-plane SSBO in mesh_modern.vert.

(No unit test — shader behavior is validated at the U.4 visual gate. Keep the change minimal + reviewable.)


Task 7 (U.3b): gl_ClipDistance in terrain_modern.vert

Goal: Terrain honors the single OutsideView plane set (gated when indoors, ungated when count==0).

Files: Modify src/AcDream.App/Rendering/Shaders/terrain_modern.vert.

layout(std140, binding = N) uniform TerrainClip { int uClipCount; vec4 uClipPlanes[8]; };
// in main(), after gl_Position:
for (int i = 0; i < uClipCount; ++i) gl_ClipDistance[i] = dot(uClipPlanes[i], gl_Position);
for (int i = uClipCount; i < 8;   ++i) gl_ClipDistance[i] = 1.0;
  • Step 1: Add the uniform block + writes. Pick a free UBO binding; document it next to the existing terrain UBOs.
  • Step 2: Build. Expected: PASS.
  • Step 3: Commit feat(render): Phase U.3b — gl_ClipDistance OutsideView gate in terrain_modern.vert.

Task 8 (U.3c): CPU clip-buffer upload + per-instance slot + enable clip distances

Goal: Build the per-frame CellId → slot map, upload the clip SSBO (binding=2) and terrain clip UBO, set per-instance clipSlot, and glEnable(GL_CLIP_DISTANCE0…7).

Files: Modify WbDrawDispatcher.cs, EnvCellRenderer.cs, TerrainModernRenderer.cs. New small helper (in GameWindow or a ClipBufferUploader) to assemble + upload the SSBO.

Design:

  • A per-frame ClipFrame (built in Task 9 from the PortalVisibilityFrame): slot 0 = no-clip (count 0); slot 1 = OutsideView; slot 2..N = each visible cell's ClipPlaneSet; a Dictionary<uint,int> cellIdToSlot.

  • Upload the CellClip[] array to SSBO binding=2 once per frame.

  • mesh_modern.vert instance slot: extend InstanceData with uint ClipSlot (or pack into an existing unused field). WbDrawDispatcher sets each instance's slot from cellIdToSlot[ParentCellId] (cell statics), 1 (outdoor scenery / building shells when indoors → OutsideView; 0 when outdoors), or 0 (live dynamic = ServerGuid != 0, unclipped, retail-faithful). EnvCellRenderer sets each cell's instances to cellIdToSlot[cellId].

  • Terrain: upload OutsideView planes (or count=0 when outdoors) to the UBO; TerrainModernRenderer.Draw binds it.

  • glEnable(GL_CLIP_DISTANCE0 + i) for i < 8 once at init (planes default to pass-all when unused, so always-enabled is fine and avoids per-draw state thrash); document the choice.

  • Step 1: Extend InstanceData with ClipSlot; bump the SSBO stride if needed and update the std430 layout comment (mind the existing 64-byte mat4 stride note — add the slot in a way that preserves alignment, e.g. a parallel slot SSBO if extending the mat4 stride is risky). Prefer a separate parallel uint[] instanceClipSlot SSBO at binding=3 indexed by gl_InstanceID/draw-id to avoid disturbing the proven mat4 instance buffer — decide and document.

  • Step 2: Implement the ClipFrame assembly + upload helper + glEnable clip distances.

  • Step 3: Build. Expected: PASS. (Behavioral validation at Task 9/U.4 gate.)

  • Step 4: Commit feat(render): Phase U.3c — clip SSBO/UBO upload + per-instance clip slot.


Task 9 (U.4a): Wire the unified gated draw pass

Goal: Replace the collapsed default draw (from Task 1) with the unified sequence: build visibility → assemble clip frame → upload → draw the three gated paths. This restores indoor cell shells (via EnvCellRenderer.Render) and stops the terrain bleed. First visual gate.

Files: Modify GameWindow.cs (the render method where Task 1 left the collapsed Draw(All)); EnvCellRenderer.cs / TerrainModernRenderer.cs draw signatures as needed to accept the clip frame.

Sequence (in the render method, replacing the collapsed call):

var root = visibility?.CameraCell;                         // null ⇒ outdoor root
var pvFrame = root is not null
    ? PortalVisibilityBuilder.Build(root, camPos, envCellViewProj, id => _cellVisibility.TryGetCell(id, out var c) ? c : null)
    : null;                                                 // outdoor root: no indoor BFS (U.5 adds peering)
var clipFrame = ClipFrame.Build(pvFrame);                  // slot 0 no-clip, 1 OutsideView, 2..N per cell
clipFrame.Upload(_gl);                                     // SSBO binding=2 + terrain UBO
if (RenderDiagnostics.ProbeVisibility && pvFrame is not null)
    RenderDiagnostics.EmitVis(root.CellId, pvFrame.OrderedVisibleCells, pvFrame.OutsideView, );

// sky (unchanged, before this)
_terrain.Draw(camera, frustum, neverCullLandblockId: playerLb);            // gated via terrain UBO (OutsideView or count0)
if (pvFrame is not null)
    _envCellRenderer.Render(WbRenderPass.Opaque, pvFrame.OrderedVisibleCells /* as filter */);   // cell shells, gated by clip SSBO
_wbDrawDispatcher.Draw(camera, _worldState.LandblockEntries, frustum,
    neverCullLandblockId: playerLb, visibleCellIds: visibility?.VisibleCellIds, animatedEntityIds: animatedIds);  // EntitySet.All, gated per-instance slot
if (pvFrame is not null)
    _envCellRenderer.Render(WbRenderPass.Transparent, pvFrame.OrderedVisibleCells);
// particles / weather (unchanged, after this)

Notes:

  • EnvCellRenderer.Render already supports a filter (the visible-cell set). Pass OrderedVisibleCells. The per-cell clip-slot binding from Task 8 supplies the planes; EnvCellRenderer binds the clip SSBO before its MDI call.

  • When root is null (outdoor camera), terrain + entities draw ungated (clipFrame has only slot 0 + an empty OutsideView ⇒ count=0), and EnvCellRenderer.Render is skipped (cell shells are only drawn when the camera's portal BFS includes them; U.5 adds outdoor→building peering so you can see interiors from outside).

  • This is the integration the CLAUDE.md warns about ("don't integrate via subagent without full context"). The subagent executing this task MUST read the surrounding render method first and preserve sky/particle/weather ordering, the animatedIds plumbing, and GL state (depth/cull) expectations.

  • Step 1: Implement ClipFrame (assembly from PortalVisibilityFrame, holding the CellClip[], cellIdToSlot, terrain plane set) + Upload.

  • Step 2: Wire the sequence above into the render method. Ensure EnvCellRenderer/WbDrawDispatcher/TerrainModernRenderer bind the clip SSBO/UBO + that clip distances are enabled.

  • Step 3: Build + full test suite. Expected: PASS.

  • Step 4: Commit feat(render): Phase U.4a — unified gated draw pass (indoor root).


Task 10 (U.4b): Per-instance slot assignment correctness + probe validation

Goal: Make the slot assignment exactly right for every entity class, then validate on a live capture before declaring the visual gate ready.

Files: WbDrawDispatcher.cs (slot assignment in the entity walk), EnvCellRenderer.cs (cell-id slot), GameWindow.cs.

  • Step 1: In WbDrawDispatcher's per-instance walk, set clipSlot:

    • ServerGuid != 0 (live dynamic) → 0 (no-clip, retail draws live-dynamic unclipped).
    • ParentCellId != nullcellIdToSlot.GetValueOrDefault(ParentCellId.Value, /*not visible*/ -1). A -1/culled slot ⇒ skip the instance (cell not in the visible set).
    • ParentCellId == null (outdoor scenery / building shells) → root is not null ? 1 /*OutsideView*/ : 0 /*ungated outdoors*/.
  • Step 2: In EnvCellRenderer, set each cell's instance slot to cellIdToSlot[cellId].

  • Step 3: Self-validate with the probe (no user needed yet): launch with ACDREAM_PROBE_VIS=1 (see CLAUDE.md launch block) into a Holtburg cottage cellar; read launch.log for [vis] lines. Acceptance before the visual gate: OutsideView is non-empty AND narrows (planes/AABB smaller) as you descend from the ground floor into the cellar; cells count is small + stable. If OutsideView is empty most frames (the #103 Finding 2 symptom), STOP and debug the builder (verify exit-portal 0xFFFF polygons are populated in PortalPolygons at BuildLoadedCell; verify the portal-side test isn't over-culling) — do NOT proceed to the user gate with a known-empty OutsideView.

  • Step 4: Build + test green. Strip any temporary [pv-dump] probes; keep ACDREAM_PROBE_VIS.

  • Step 5: Commit feat(render): Phase U.4b — per-instance clip-slot assignment + probe validation.

  • Step 6: VISUAL GATE #1 (user). Hand off to the user to walk Holtburg cottage → cellar → out the door: expect no flap, solid walls, no terrain bleed, seamless threshold; Holtburg Inn no through-floor bleed (#78); no regression to outdoor rendering. Do not start U.5 until the user confirms.


Post-gate (detailed after Visual Gate #1)

U.5 — Outdoor-peering root

Root the builder at a building's camera-facing exterior portal so interiors are visible through doors/windows from outside (retail outdoor_pview / DrawBuilding / DrawPortal / ConstructView(CBldPortal) — anchors in spec §3.4). Open data dependency (spec §6.3): surface render-side building-exterior-portal geometry (BuildingExteriorPortal { polygon, destCellId, side }) — we carry BldPortalInfo on the physics side (BuildingPhysics / CheckBuildingTransit); U.5 either reuses it or reads the same dat structure. Detail this task after the gate, once the indoor root is confirmed and the data path is scoped.

U.6 — Dungeon-scale validation

Walk a dungeon via the Town Network portal; confirm OrderedVisibleCells stays ~415, no foreign-dungeon geometry, perf sane. Close/relate #95 + #102. Detail after the gate.


Self-review (against spec)

  • Spec §2 (clip gate = clip planes + scissor): Tasks 4, 6, 7, 8. ✓
  • Spec §2 (terrain separate, gated to OutsideView): Tasks 7, 8, 9. ✓
  • Spec §5.1 (priority queue + fixpoint + OtherPortalClip): Tasks 2, 3. ✓
  • Spec §5.2 (ClipPlaneSet, 8-cap, merge, scissor fallback): Task 4. ✓
  • Spec §5.3 (mesh + terrain shaders, no MDI break via per-vertex clip): Tasks 6, 7, 8. ✓
  • Spec §5.4 (unified orchestrator, EnvCellRenderer revived, keep audited fixes): Tasks 1, 9, 10. ✓
  • Spec §5.5 (ACDREAM_PROBE_VIS built first): Task 5 (owner), Task 10 (validation). ✓
  • Spec §7 (surgical delete + keep list): Task 1. ✓
  • Spec §8 (unit tests + visual gate): Tasks 24 (unit), Task 10 (probe + gate). ✓
  • Spec §9 staging (U.1U.4 first gate; U.5/U.6 post): Task ordering + post-gate stubs. ✓
  • Type consistency: PortalVisibilityFrame.OrderedVisibleCells / CellViews / OutsideView used identically in Tasks 2, 3, 9, 10; ClipPlaneSet.From/Count/Planes/UseScissorFallback consistent in Tasks 4, 8; ClipFrame.Build/Upload + cellIdToSlot consistent in Tasks 8, 9, 10. ✓
  • Live-dynamic unclipped (retail-faithful): Task 8 + Task 10 Step 1. ✓