acdream/docs/superpowers/specs/2026-06-23-cellobject-draw-batching-design.md
Erik 6491798edf docs(perf): dense-town FPS attribution (CPU-bound, GPU=0.5ms) + cellobject-batch spec
Attribution report: the handoff's "~12ms GPU" was a glFinish artifact; the clean
split measures GPU=0.5ms and a ~96% CPU-bound frame whose cost scales with visible
buildings. [CPU-PHASE] ranks it: cellobjects 3.5 / landscape 2.6 / partition 2 /
dynamics 1.2 ms; floods, punch-seal, clip-allocs, shells all <0.3ms (refuted).

Spec: iteration-1 fix = batch the per-cell WbDrawDispatcher.Draw calls in
DrawCellObjectLists into one cross-cell draw (the proven cells-shell pattern),
two-loop structure to keep particle-after-statics depth ordering. Target: dense
town solidly 144+.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 20:41:45 +02:00

143 lines
7.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Spec — batch cell-object draws (dense-town FPS, iteration 1) — 2026-06-23
## Context
The dense-town (Arwic) FPS investigation
([`docs/research/2026-06-23-dense-town-fps-attribution-report.md`](../../research/2026-06-23-dense-town-fps-attribution-report.md))
proved the frame is **~96% CPU-bound** — measured GPU is **0.5 ms**; the ~813 ms
frame is CPU work inside `OnRender`, scaling with how many buildings/cells are in
view. The clean-split + `[CPU-PHASE]` measurement (no glFinish) attributed the CPU
cost of `RetailPViewRenderer.DrawInside`:
| phase | ms/frame | |
|---|---|---|
| **cellobjects** | **3.34.5** | `DrawCellObjectLists` — per-cell static draw + per-cell particles |
| landscape | 2.12.9 | sky + terrain + scenery + late draws |
| partition | 0.73.2 | entity partition + viewcone build |
| dynamics | 0.61.3 | dynamics draw + particles |
| flood / assemble / shells / portalmask | < 0.3 each | NOT the cost (refuted the static guess) |
`cellobjects` is the biggest single lever. Root cause: `DrawCellObjectLists` calls
`WbDrawDispatcher.Draw` **once per visible cell**, each call orphaning 6 SSBOs via
`glBufferData` + full state setup (`WbDrawDispatcher.cs:1521-1558`). This is the
same class of bug the shipped cells-shell fix solved for shell geometry (94 per-cell
`Render` calls 1 batched; 2975 fps). We apply the same pattern to cell objects.
**Target:** dense town **solidly 144+ fps** facing the densest view (~7 ms budget).
Iterate hotspots, re-measuring after each. This spec is **iteration 1** (cellobjects).
## Goal
Collapse the per-cell `WbDrawDispatcher.Draw` calls in `DrawCellObjectLists` into a
**single** batched draw covering all visible cells' surviving statics, while
preserving every rendering invariant (visibility, transparency order, particle
occlusion). Identical pixels, far fewer draw submissions.
## Design
`RetailPViewRenderer.DrawCellObjectLists` (`RetailPViewRenderer.cs:772-825`) is
restructured from one per-cell loop (cull draw particles) into **two loops**:
**Loop 1 — cull + accumulate (no GL draws):**
```
_allCellStatics.Clear(); // reused List<WorldEntity> scratch field
_cellObjCells.Clear(); // reused HashSet<uint> scratch field
for (i = OrderedVisibleCells.Count - 1; i >= 0; i--): // far → near
cellId = OrderedVisibleCells[i]
if (!drawableCells.Contains(cellId)) continue
if (!partition.ByCell.TryGetValue(cellId, out bucket) || bucket.Count == 0) continue
survivorsBefore = _allCellStatics.Count
foreach (e in bucket):
EntitySphere(e, out c, out r)
if (viewcone.SphereVisibleInCell(cellId, c, r)) _allCellStatics.Add(e)
if (_allCellStatics.Count > survivorsBefore) _cellObjCells.Add(cellId)
if (ProbePhantomEnabled) EmitPhantomObjsProbe(cellId, _allCellStatics.Count - survivorsBefore)
```
**One batched draw:**
```
if (_allCellStatics.Count > 0):
UseIndoorMembershipOnlyRouting()
DrawEntityBucket(ctx, _allCellStatics, _cellObjCells) // N WbDispatcher.Draw → 1
```
**Loop 2 — per-cell particles, after statics are in the depth buffer:**
```
for (i = OrderedVisibleCells.Count - 1; i >= 0; i--): // far → near
cellId = OrderedVisibleCells[i]
if (!drawableCells.Contains(cellId)) continue
if (!partition.ByCell.TryGetValue(cellId, out bucket) || bucket.Count == 0) continue
_cellStaticScratch.Clear()
foreach (e in bucket):
EntitySphere(e, out c, out r)
if (viewcone.SphereVisibleInCell(cellId, c, r)) _cellStaticScratch.Add(e)
if (_cellStaticScratch.Count == 0) continue
foreach (slice in GetCellSlicesOrNoClip(clipAssembly, cellId)):
ctx.DrawCellParticles?.Invoke(new RetailPViewCellSliceContext(cellId, slice, _cellStaticScratch))
```
New reused scratch fields on `RetailPViewRenderer` (cleared per call, render-thread
only match the existing `_cellStaticScratch`/`_dynamicsScratch` pattern):
`private readonly List<WorldEntity> _allCellStatics = new();` and
`private readonly HashSet<uint> _cellObjCells = new();`.
Re-culling in loop 2 is deliberate: it is a handful of sphere-vs-plane dot products
per static and is negligible next to the draw-submission cost being removed; it
avoids storing per-cell survivor ranges.
## Correctness invariants (must hold)
1. **Same entity set drawn.** Per-cell, the bucket only ever held that cell's
entities and the filter was `{cellId}`; the batched form passes all survivors
with the *union* `_cellObjCells`, so the dispatcher's `visibleCellIds` membership
gate admits exactly the same set.
2. **Transparency compositing.** `WbDrawDispatcher` sorts opaque front-to-back
(`:1469`) and transparent back-to-front by group distance (`:1470`,
`CompareTransparentSubmissionOrder`). Global batching yields true back-to-front
ordering across cells equal or better than the old per-cell-bucketed order.
Opaque is z-buffered (order-independent).
3. **Particle occlusion.** Particles depth-test but do not write depth; loop 2 runs
after the batched static draw so same-cell statics already occupy the depth
buffer. (This is the reason for two loops a single-loop batch would draw
particles before the statics and lose same-cell occlusion.)
4. **Frame order unchanged elsewhere.** Shells, exit-portal masks, and
dynamics-last all keep their positions in `DrawInside`. `lbId`,
`neverCullLandblockId`, `animatedEntityIds` are passed exactly as before.
5. **No double-draw.** Each surviving static is added once to `_allCellStatics`;
the batched draw runs once.
## Scope
- **In:** `RetailPViewRenderer.DrawCellObjectLists` only.
- **Out (explicitly):**
- `DrawBuildingLookIns` per-cell draws interior-root only, does not fire at
outdoor Arwic; an analogous follow-up if a later profile shows it.
- Particle-pass consolidation (`DrawCellParticles` re-walks the particle system
per cell) separate concern, separate fix.
- `landscape` (sky-submesh batching) and `partition` optimization iteration 2,
pursued **only if** re-measuring after this fix shows we are not solidly 144+.
- `WbDrawDispatcher` persistent-SSBO change (Approach 3) back-pocket follow-on
that compounds, if the single remaining draw's SSBO orphaning still shows up.
## Testing & acceptance
- `dotnet build` green, `dotnet test` green. Pview ordering / cell-object tests must
stay green especially `HouseExitWalkReplayTests` and any viewcone/cell-object
coverage. If no test pins cross-cell cell-object batching, add one asserting the
batched survivor set equals the union of per-cell survivor sets.
- **Visual (user, faces dense Arwic):** all statics present (no missing furniture);
no transparency or flame-through-furniture artifacts; FPS up.
- **Perf (re-measure `[CPU-PHASE]` under `ACDREAM_FPS_PROF=2`, facing densest town):**
`cellobjects` drops from ~3.5 ms toward ~0.5 ms; `wall` falls accordingly. Decide
from this whether iteration 2 (`landscape` + `partition`) is needed for solidly-144+.
- Retail-faithfulness: pixels identical; this is a draw-mechanism speed change only
(per the project steer that render-perf is not faithfulness-gated as long as
pixels and game feel are unchanged). No divergence-register row required.
## References
- Attribution report: `docs/research/2026-06-23-dense-town-fps-attribution-report.md`
- Precedent (the blessed pattern): the cells-shell batching fix
`RetailPViewRenderer.DrawEnvCellShells` (`RetailPViewRenderer.cs:664-701`),
commits `3af7d00` / `8067d3b`.
- Dispatcher sort: `WbDrawDispatcher.cs:1469-1470`, `:1750-1756`.