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>
7.5 KiB
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)
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)
- 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'svisibleCellIdsmembership gate admits exactly the same set. - Transparency compositing.
WbDrawDispatchersorts 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). - 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.)
- Frame order unchanged elsewhere. Shells, exit-portal masks, and
dynamics-last all keep their positions in
DrawInside.lbId,neverCullLandblockId,animatedEntityIdsare passed exactly as before. - No double-draw. Each surviving static is added once to
_allCellStatics; the batched draw runs once.
Scope
- In:
RetailPViewRenderer.DrawCellObjectListsonly. - Out (explicitly):
DrawBuildingLookInsper-cell draws — interior-root only, does not fire at outdoor Arwic; an analogous follow-up if a later profile shows it.- Particle-pass consolidation (
DrawCellParticlesre-walks the particle system per cell) — separate concern, separate fix. landscape(sky-submesh batching) andpartitionoptimization — iteration 2, pursued only if re-measuring after this fix shows we are not solidly 144+.WbDrawDispatcherpersistent-SSBO change (Approach 3) — back-pocket follow-on that compounds, if the single remaining draw's SSBO orphaning still shows up.
Testing & acceptance
dotnet buildgreen,dotnet testgreen. Pview ordering / cell-object tests must stay green — especiallyHouseExitWalkReplayTestsand 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]underACDREAM_FPS_PROF=2, facing densest town):cellobjectsdrops from ~3.5 ms toward ~0.5 ms;wallfalls 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), commits3af7d00/8067d3b. - Dispatcher sort:
WbDrawDispatcher.cs:1469-1470,:1750-1756.