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>
31 KiB
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 1–10 cover U.1–U.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.cs—ACDREAM_PROBE_VISowner (visibility probe), DebugPanel-toggleable.tests/AcDream.App.Tests/Rendering/ClipPlaneSetTests.cs— extractor unit tests.
Modified files:
src/AcDream.App/Rendering/PortalVisibilityBuilder.cs— rework: addOrderedVisibleCells, distance-priority queue, timestamp/update-count fixpoint, reciprocalOtherPortalClip. (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_ClipDistancewrites 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 inInstanceData/SSBO; bind clip SSBO;glEnable(GL_CLIP_DISTANCE…).src/AcDream.App/Rendering/TerrainModernRenderer.cs— uploadOutsideViewclip uniforms + enable clip distances.src/AcDream.App/Rendering/GameWindow.cs— DELETE two-pipe machinery (Task 1); wire the unified gated pass (Tasks 9–10).tests/AcDream.App.Tests/Rendering/PortalVisibilityBuilderTests.cs— add ordering / fixpoint /OtherPortalClipcases.
Deleted files (Task 1):
src/AcDream.App/Rendering/IndoorCellStencilPipeline.cssrc/AcDream.App/Rendering/Shaders/portal_stencil.vert,…/portal_stencil.fragtests/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 touchEnvCellRendererpool/GL-state,BuildingLoader, orCellVisibility— 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):RenderInsideOutAcdreammethod (theRenderInsideOut-port, ~11007–11319).RenderOutsideInAcdreammethod (~213–325).- The entire A8-perf instrumentation:
_a8Perf*fields (~7134–7177),MaybeFlushA8Perf,A8PerfStart/Stop/BeginGpuQuery/EndGpuQuery,EmitDrawOrderProbe/EmitEnvCellProbe/EmitStencilProbe/EmitBuildingsProbe, and all their call sites (~11321–11609 + thea8PerfStart = …/A8Perf…(…)lines scattered in the render method). - The
cameraInsideBuilding/a8IndoorBranchEnabled/ACDREAM_A8_INDOOR_BRANCHdeclarations + the wholeif (cameraInsideBuilding) { … } else { if (a8IndoorBranchEnabled) { … } else { <DEFAULT> } }block (~7342–7348, 7356–7523, 7613–7715, 7825–7831). Collapse it to the DEFAULT branch only (theelseat ~7704–7715): 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 (~7613–7614).
_indoorStencilPipelinefield (~172), its construction (~1941–1944), and itsDispose(~11690).- The orphaned
PortalVisibilityBuilder.Buildcall insideRenderInsideOutAcdream(deleted with the method).
-
Step 4: Remove now-dead
EntitySetpartition values. InWbDrawDispatcher.cs, theIndoorPass,OutdoorScenery,BuildingShells,LiveDynamicenum members are referenced only from the deleted two-pipe code. Remove them and anyswitch/branch arms inWbDrawDispatcherthat handle them, leavingEntitySet.Allas the sole member (or remove theset:parameter entirely ifAllis now the only path — prefer the smaller diff: keep the enum with justAll). 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 (OrderedVisibleCellsmissing / wrong order). -
Step 3: Implement. In
PortalVisibilityBuilder.Build: replace theQueue<LoadedCell>with a distance-priority structure (insert cells keyed by distance fromcameraPosto the cell'sWorldPosition; dequeue nearest). Track per-cell aviewVersion(count of polygons accumulated, or a content hash); re-enqueue a neighbour only when itsCellViewgenuinely grows (compare against the version recorded when last enqueued). DropMaxReprocessPerCell. Append each dequeued cell id toOrderedVisibleCells. Keep theOutsideViewandCrossBuildingViewsaccumulation 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
CellViewarea 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 viaportal.PolygonId/ the neighbour'sPortalsback-link) to NDC and intersect it intoclippedRegionbefore unioning into the neighbour'sCellView. - 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 (
ClipPlaneSetnot defined). - Step 3: Implement
ClipPlaneSet.From: pick the region's principal convex polygon; merge edges whose direction differs by < ~0.5° (retailcopy_view~1px dedup); if > 8 edges remain, setUseScissorFallback=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
ProbeVisibilitydefaults to the env value andEmitVisis a no-op when false (captureConsole.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
clipSlotfield reference (coordinate the exactInstanceDatalayout with Task 8 — this task may land the shader withslot=0hardcoded ifInstanceDataisn'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 thePortalVisibilityFrame):slot 0 = no-clip(count 0);slot 1 = OutsideView;slot 2..N = each visible cell's ClipPlaneSet; aDictionary<uint,int> cellIdToSlot. -
Upload the
CellClip[]array to SSBObinding=2once per frame. -
mesh_modern.vertinstance slot: extendInstanceDatawithuint ClipSlot(or pack into an existing unused field).WbDrawDispatchersets each instance's slot fromcellIdToSlot[ParentCellId](cell statics),1(outdoor scenery / building shells when indoors → OutsideView;0when outdoors), or0(live dynamic =ServerGuid != 0, unclipped, retail-faithful).EnvCellRenderersets each cell's instances tocellIdToSlot[cellId]. -
Terrain: upload
OutsideViewplanes (orcount=0when outdoors) to the UBO;TerrainModernRenderer.Drawbinds it. -
glEnable(GL_CLIP_DISTANCE0 + i)fori < 8once 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
InstanceDatawithClipSlot; 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 paralleluint[] instanceClipSlotSSBO atbinding=3indexed bygl_InstanceID/draw-id to avoid disturbing the proven mat4 instance buffer — decide and document. -
Step 2: Implement the
ClipFrameassembly + upload helper +glEnableclip 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.Renderalready supports afilter(the visible-cell set). PassOrderedVisibleCells. The per-cell clip-slot binding from Task 8 supplies the planes;EnvCellRendererbinds the clip SSBO before its MDI call. -
When
rootis null (outdoor camera), terrain + entities draw ungated (clipFrame has only slot 0 + an empty OutsideView ⇒count=0), andEnvCellRenderer.Renderis 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
animatedIdsplumbing, and GL state (depth/cull) expectations. -
Step 1: Implement
ClipFrame(assembly fromPortalVisibilityFrame, holding theCellClip[],cellIdToSlot, terrain plane set) +Upload. -
Step 2: Wire the sequence above into the render method. Ensure
EnvCellRenderer/WbDrawDispatcher/TerrainModernRendererbind 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, setclipSlot:ServerGuid != 0(live dynamic) →0(no-clip, retail draws live-dynamic unclipped).ParentCellId != null→cellIdToSlot.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 tocellIdToSlot[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; readlaunch.logfor[vis]lines. Acceptance before the visual gate:OutsideViewis non-empty AND narrows (planes/AABB smaller) as you descend from the ground floor into the cellar;cellscount is small + stable. IfOutsideViewis empty most frames (the #103 Finding 2 symptom), STOP and debug the builder (verify exit-portal0xFFFFpolygons are populated inPortalPolygonsatBuildLoadedCell; 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; keepACDREAM_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 ~4–15, 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 2–4 (unit), Task 10 (probe + gate). ✓
- Spec §9 staging (U.1–U.4 first gate; U.5/U.6 post): Task ordering + post-gate stubs. ✓
- Type consistency:
PortalVisibilityFrame.OrderedVisibleCells/CellViews/OutsideViewused identically in Tasks 2, 3, 9, 10;ClipPlaneSet.From/Count/Planes/UseScissorFallbackconsistent in Tasks 4, 8;ClipFrame.Build/Upload+cellIdToSlotconsistent in Tasks 8, 9, 10. ✓ - Live-dynamic unclipped (retail-faithful): Task 8 + Task 10 Step 1. ✓