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>
This commit is contained in:
parent
8601137330
commit
0f7b395be1
1 changed files with 424 additions and 0 deletions
|
|
@ -0,0 +1,424 @@
|
|||
# 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`](../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_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 9–10).
|
||||
- `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, ~11007–11319).
|
||||
- `RenderOutsideInAcdream` method (~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 + 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 (~7342–7348, 7356–7523, 7613–7715, 7825–7831). **Collapse it to the DEFAULT branch only** (the `else` at ~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).
|
||||
- `_indoorStencilPipeline` field (~172), its construction (~1941–1944), 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`):**
|
||||
```csharp
|
||||
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`:
|
||||
```csharp
|
||||
[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.
|
||||
```csharp
|
||||
[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:**
|
||||
```csharp
|
||||
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.**
|
||||
```csharp
|
||||
[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:**
|
||||
```csharp
|
||||
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):**
|
||||
```glsl
|
||||
// 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:
|
||||
```glsl
|
||||
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`.
|
||||
```glsl
|
||||
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):**
|
||||
```csharp
|
||||
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 != 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 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 ~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` / `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. ✓
|
||||
Loading…
Add table
Add a link
Reference in a new issue