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>
143 lines
7.5 KiB
Markdown
143 lines
7.5 KiB
Markdown
# 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 ~8–13 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.3–4.5** | `DrawCellObjectLists` — per-cell static draw + per-cell particles |
|
||
| landscape | 2.1–2.9 | sky + terrain + scenery + late draws |
|
||
| partition | 0.7–3.2 | entity partition + viewcone build |
|
||
| dynamics | 0.6–1.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`.
|