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:
parent
fe1f81371a
commit
6491798edf
2 changed files with 501 additions and 0 deletions
|
|
@ -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 ~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`.
|
||||
Loading…
Add table
Add a link
Reference in a new issue