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>
This commit is contained in:
Erik 2026-06-23 20:41:45 +02:00
parent fe1f81371a
commit 6491798edf
2 changed files with 501 additions and 0 deletions

View file

@ -0,0 +1,143 @@
# 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; 29→75 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`.