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

7.5 KiB
Raw Blame History

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 ~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.