# 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 scratch field _cellObjCells.Clear(); // reused HashSet 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 _allCellStatics = new();` and `private readonly HashSet _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`.