12 KiB
EnvCell Shell Batching Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Cut dense-town frame time by batching the per-cell EnvCellRenderer.Render calls (~94/frame, 24.75 ms) into one call per pass.
Architecture: RetailPViewRenderer.DrawEnvCellShells and DrawBuildingLookIns currently loop visible/look-in cells and call the heavy per-frame EnvCellRenderer.Render(pass, oneCell) once per cell × 2 passes. Replace with: opaque → one Render(Opaque, allCells) (z-buffer handles order; lighting is per-instance/CellId-keyed so cross-cell batching is safe); transparent → skip cells with no transparent geometry, keep far→near per-cell for the rest. Verified safe in the spec.
Tech Stack: C# / .NET 10, Silk.NET OpenGL, bindless multi-draw-indirect. Verification: FrameProfiler apparatus (ACDREAM_FPS_PROF=1) + live client + dotnet build/dotnet test.
Spec: docs/superpowers/specs/2026-06-23-envcell-shell-batching-design.md
Verification model: No unit tests — the draw path is heavy GL with no mockable seam, and render-perf is verified empirically (meter + visual), matching the project's render-change norm. Each task: edit → build → measure on the meter / visual → commit.
Task 1: Add CellHasTransparent predicate to EnvCellRenderer
Phase 2 needs to skip the transparent Render for cells whose prepared snapshot has no transparent batch. Add a cheap read-only predicate mirroring the existing [shell] probe logic (EnvCellRenderer.cs:1027-1034).
Files:
-
Modify:
src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs(add a public method nearRender) -
Step 1: Add the predicate
Add this method to EnvCellRenderer (e.g. just after the Render method, before GetCellLightSet):
/// <summary>
/// True if the cell's prepared snapshot has any transparent render batch.
/// Used by the pview shell pass to skip the (heavy) transparent Render call
/// for opaque-only cells — most cell geometry is opaque walls/floors/ceilings.
/// Read-only; mirrors the [shell] probe's per-cell batch scan.
/// </summary>
public bool CellHasTransparent(uint cellId)
{
var snapshot = _activeSnapshot;
if (snapshot is null || !snapshot.BatchedByCell.TryGetValue(cellId, out var gfxDict))
return false;
foreach (var (gfxObjId, transforms) in gfxDict)
{
if (transforms.Count == 0) continue;
var rd = _meshManager.TryGetRenderData(gfxObjId);
if (rd is null) continue;
foreach (var b in rd.Batches)
if (b.IsTransparent) return true;
}
return false;
}
- Step 2: Build
Run: dotnet build src\AcDream.App\AcDream.App.csproj -c Release
Expected: Build succeeded. 0 Error(s). (If _activeSnapshot / BatchedByCell / _meshManager.TryGetRenderData / rd.Batches / b.IsTransparent names differ, match them to the existing usages at EnvCellRenderer.cs:910-934 and :1031-1034 before building.)
- Step 3: Commit
git add src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs
git commit -m "feat(envcell): CellHasTransparent predicate (shell-batching prep)"
Task 2: Batch the shell pass in DrawEnvCellShells (the Arwic win)
Files:
-
Modify:
src/AcDream.App/Rendering/RetailPViewRenderer.cs— the_oneCellfield area +DrawEnvCellShells(currently:659-665) -
Step 1: Add a reusable batch set field
Find the _oneCell scratch field declaration (grep _oneCell in RetailPViewRenderer.cs) and add next to it:
// Shell-batch scratch: all of a pass's cells collected for ONE Render call
// (opaque). Reused across frames + across look-in buildings. See
// docs/superpowers/specs/2026-06-23-envcell-shell-batching-design.md.
private readonly HashSet<uint> _shellBatch = new();
- Step 2: Replace the per-cell loop body in
DrawEnvCellShells
Replace this (currently RetailPViewRenderer.cs:658-665):
UseIndoorMembershipOnlyRouting();
foreach (var entry in IndoorDrawPlan.ShellPass(pvFrame))
{
_oneCell.Clear();
_oneCell.Add(entry.CellId);
_envCells.Render(WbRenderPass.Opaque, _oneCell);
_envCells.Render(WbRenderPass.Transparent, _oneCell);
}
with:
UseIndoorMembershipOnlyRouting();
// Opaque: ONE batched Render for all shell cells. Opaque needs no draw
// order (z-buffer), and lighting is per-instance (CellId-keyed SSBO,
// EnvCellRenderer RenderModernMDIInternal), so cross-cell batching is
// visually identical to the old per-cell loop — at ~1 call instead of N.
_shellBatch.Clear();
foreach (var entry in IndoorDrawPlan.ShellPass(pvFrame))
_shellBatch.Add(entry.CellId);
if (_shellBatch.Count > 0)
_envCells.Render(WbRenderPass.Opaque, _shellBatch);
// Transparent: far→near order matters for compositing, so keep these
// per-cell in ShellPass order — but skip cells with no transparent
// geometry (most cells are opaque-only), which removes the bulk of the
// per-cell Render calls.
foreach (var entry in IndoorDrawPlan.ShellPass(pvFrame))
{
if (!_envCells.CellHasTransparent(entry.CellId)) continue;
_oneCell.Clear();
_oneCell.Add(entry.CellId);
_envCells.Render(WbRenderPass.Transparent, _oneCell);
}
- Step 3: Build
Run: dotnet build src\AcDream.App\AcDream.App.csproj -c Release
Expected: Build succeeded. 0 Error(s).
- Step 4: Measure on the meter (user-driven live run)
Launch with ACDREAM_FPS_PROF=1 (see Apparatus in the spec), portal to Arwic, hold the worst facing ~15 s. Read [PASS-GPU] cells=… (calls/frame=…).
Expected: calls/frame for cells drops sharply (opaque 1 + transparent = only the few transparent cells); cells ms drops; frame ms drops. Visual: no missing walls, no half-culled geometry, windows/transparency still correct.
- Step 5: Commit
git add src/AcDream.App/Rendering/RetailPViewRenderer.cs
git commit -m "perf(pview): batch EnvCell shell opaque pass + skip empty transparent (dense-town FPS)"
Task 3: Apply the same batching to DrawBuildingLookIns (interior roots)
The look-in path (interior root — standing inside a building looking out at others) has the identical per-cell pattern, plus a per-building aperture punch pass that must stay ordered before that building's shells. Batch each building's shells; keep punches per building.
Files:
-
Modify:
src/AcDream.App/Rendering/RetailPViewRenderer.cs—DrawBuildingLookInspass-2 loop (currently:348-355) -
Step 1: Replace the pass-2 per-cell shell loop
Replace this (currently RetailPViewRenderer.cs:346-355, the "Pass 2: shells + statics, far→near." block's cell loop):
// Pass 2: shells + statics, far→near.
UseIndoorMembershipOnlyRouting();
for (int i = frame.OrderedVisibleCells.Count - 1; i >= 0; i--)
{
uint cellId = frame.OrderedVisibleCells[i];
_oneCell.Clear();
_oneCell.Add(cellId);
_envCells.Render(WbRenderPass.Opaque, _oneCell);
_envCells.Render(WbRenderPass.Transparent, _oneCell);
with:
// Pass 2: shells + statics. Opaque batched per building (this
// building's punches already ran above; z-buffer handles order).
UseIndoorMembershipOnlyRouting();
_shellBatch.Clear();
foreach (uint cellId in frame.OrderedVisibleCells)
_shellBatch.Add(cellId);
if (_shellBatch.Count > 0)
_envCells.Render(WbRenderPass.Opaque, _shellBatch);
// Transparent: far→near, skip opaque-only cells.
for (int i = frame.OrderedVisibleCells.Count - 1; i >= 0; i--)
{
uint cellId = frame.OrderedVisibleCells[i];
if (!_envCells.CellHasTransparent(cellId)) continue;
_oneCell.Clear();
_oneCell.Add(cellId);
_envCells.Render(WbRenderPass.Transparent, _oneCell);
NOTE: preserve whatever follows the old
_envCells.Render(WbRenderPass.Transparent, _oneCell);line inside the original loop body (e.g. per-cell static/entity draws) — readRetailPViewRenderer.cs:355-...and keep the remainder of the loop body intact, only the shell-Render calls move. If the loop body does more than the two Render calls, the transparent loop must retain that per-cell work; in that case keep the per-cellforloop structure and only lift the opaque Render out into the batched call before the loop, leaving opaque's per-cell call removed.
- Step 2: Build
Run: dotnet build src\AcDream.App\AcDream.App.csproj -c Release
Expected: Build succeeded. 0 Error(s).
- Step 3: Measure (interior-root scene)
Launch with ACDREAM_FPS_PROF=1, stand INSIDE a building looking out toward other buildings (interior root). Read [PASS-GPU] cells calls/frame — should drop. Visual: look-in building interiors through your doorway still render correctly (no missing far-building walls, the #124 behavior intact).
- Step 4: Commit
git add src/AcDream.App/Rendering/RetailPViewRenderer.cs
git commit -m "perf(pview): batch EnvCell look-in shell draws (interior-root parity)"
Task 4: Full verification
Files: none (verification only)
- Step 1: Build + test green
Run: dotnet build (solution) then dotnet test tests\AcDream.App.Tests\AcDream.App.Tests.csproj -c Debug and dotnet test tests\AcDream.Core.Tests\AcDream.Core.Tests.csproj -c Debug
Expected: build succeeds; test suites green (App / Core baselines).
- Step 2: Before/after meter record
Confirm at the same Arwic facing: cells calls/frame ~94 → low single digits, cells ms 24.75 → low single digits, frame ms 34 → target (~12 ms / ~80 fps). Record the numbers in the commit message / handoff.
- Step 3: User visual gate
User confirms at Arwic / Holtburg / Fort Tethana: dense-town fps up, no missing walls, transparency/windows correct, no flicker, interiors correct.
Task 5: Strip the profiling apparatus
Once the fix is verified, remove all throwaway diagnostics (mirrors 92e95be).
Files:
-
Delete:
src/AcDream.App/Rendering/FrameProfiler.cs -
Delete:
tests/AcDream.Core.Tests/Conformance/DegradeCoverageProbeTests.cs -
Modify:
src/AcDream.App/Rendering/GameWindow.cs— remove_fpsProf+_frameProfiler+_msaaSamplesfields, the_msaaSamples = startupQuality.MsaaSamples;line, theOnRenderBeginFrame/EndFrame hooks, theOnUpdateMarkUpdateStart()hook, and the terrain glFinish bracket inDrawRetailPViewLandscapeSlice. -
Modify:
src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs— remove theRenderglFinish try/finally bracket (keepCellHasTransparent— it's part of the fix, not apparatus). -
Step 1: Remove the apparatus (edits above)
-
Step 2: Build + test green
Run: dotnet build + the two dotnet test suites.
Expected: green, no references to FrameProfiler / ACDREAM_FPS_PROF remain (grep to confirm: grep -rn "FrameProfiler\|ACDREAM_FPS_PROF\|MarkUpdateStart\|AddRendererGpu" src tests → only CellHasTransparent-unrelated zero hits).
- Step 3: Clean up throwaway logs
Remove the untracked fps-prof*.log, wbdiag.log, degrade-probe.log from the worktree root.
- Step 4: Commit
git add -A
git commit -m "chore: strip dense-town FPS profiling apparatus (fix verified)"
Notes for the implementer
_envCellsis the concreteEnvCellRenderer(field onRetailPViewRenderer).Render(WbRenderPass, HashSet<uint>?)already supports a multi-cell filter (filtered path,EnvCellRenderer.cs:923-968) — Task 2/3 just call it with the full set.- Do NOT batch transparent across cells (loses far→near order → compositing artifacts). Skip-empty is the safe transparent win.
- Do NOT globally batch look-in shells across buildings — punches are per-building.
- If
DrawBuildingLookIns's loop body does per-cell work beyond the two Render calls, keep the per-cellforand only lift the opaque Render out (see Task 3 Step 1 note). - Roadmap/issues: this is a tactical perf fix; update
docs/ISSUES.md(note the dense-town FPS root cause + fix) when it lands.