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:
Erik 2026-06-23 20:41:45 +02:00
parent fe1f81371a
commit 6491798edf
2 changed files with 501 additions and 0 deletions

View file

@ -0,0 +1,358 @@
# Report — dense-town FPS attribution (Arwic, post-cells-fix) — 2026-06-23
**Mode:** report-only investigation (no edits, no diagnostic drops). Deliverable
is this doc + the chat verdict. Continues the handoff
`2026-06-23-dense-town-fps-deepdive-handoff.md`. The cells fix (29→75 fps) is
KEPT and confirmed intact.
**Method:** 7 parallel read-only subsystem readers (orchestration, punch/seal,
particles, portal-vis CPU, alloc/GC, pview-master, apparatus) + independent
verification reads of the 6 core files by the lead. Every claim below is cited
to `file:line` from source read in full. **No live measurement was taken** — the
decisive runs are listed in §6 for the user to drive.
---
## 0a. MEASURED VERDICT — clean split, `ACDREAM_FPS_PROF=2` (2026-06-23, live Arwic)
The hypothesis below was **confirmed by direct measurement.** Added a diagnostic
`ACDREAM_FPS_PROF=2` mode (whole-frame `TimeElapsed` query, per-pass `glFinish`
DISABLED) and ran it live in Arwic:
| view | wall | cpuRender | **gpu** | present(wait) | vsync/msaa |
|---|---|---|---|---|---|
| facing AWAY from town | 6.7 ms (~149 fps) | 6.6 ms | **0.5 ms** | 0.1 ms | off / 4× |
| facing INTO town | ~13 ms (~70 fps) | ~13 ms | **0.5 ms** | 0.1 ms | off / 4× |
**The frame is ~96% CPU-render-bound. The GPU renders the entire dense town in
0.5 ms and is idle the rest of the frame.** `cpuRender ≈ wall`, `present ≈ 0.1 ms`,
`gpu = 0.5 ms` regardless of view. The handoff's "~12 ms GPU" was **entirely a
glFinish-serialization artifact** (§1). Turning the camera into the town doubles
`cpuRender` (6.6 → 13 ms) while `gpu` never moves — the cost scales with the number
of buildings in frustum, i.e. the per-building CPU work, not pixels.
**DEAD for good (GPU is 0.5 ms):** MSAA, fill, overdraw, fragment shaders, far draw
distance, any GPU-side lever. An MSAA=0 test is moot. **All fixes target CPU.**
The spikes (cpuRender max 3043 ms → dips to ~25 fps) are consistent with gen-0 GC
from the ~58 k allocations/frame (§4); confirm with a gen-0 GC counter if needed.
---
## 0b. CPU SUB-PHASE BREAKDOWN — `[CPU-PHASE]`, live Arwic facing town (2026-06-23)
Added `[CPU-PHASE]` timers around each `DrawInside` phase (run under `=2`, so draw
phases = pure CPU submission). Steady-state, facing town (`update≈0`, `cpuRender`
~8 ms — the absolute varies 813 ms with view density; the RANKING is stable):
| phase | ms/frame | what |
|---|---|---|
| **cellobjects** | **3.34.5** | `DrawCellObjectLists` — per-cell entity/static draw + per-cell particle pass |
| **landscape** | **2.12.9** | sky (per-submesh) + terrain + scenery + late dynamics/particles/weather |
| **partition** | **0.73.2** | `InteriorEntityPartition.Partition` + `ViewconeCuller.Build` |
| **dynamics** | **0.61.3** | `DrawDynamicsLast` — dynamics draw + dynamics particles |
| prepare | 0.041.3 | `PrepareRenderBatches` |
| shells | 0.070.21 | the cells fix (cheap — working) |
| flood | 0.080.27 | the 48 portal floods |
| assemble | 0.030.24 | `ClipFrameAssembler` |
| portalmask | 0.060.09 | the 31 punch/seal fans |
**MEASUREMENT OVERTURNED THE STATIC RANKING (§2/§4/§5):**
- **Portal floods are NOT the cost** (`flood = 0.1 ms`). **H1's "48 floods" emphasis and
Tier C flood-caching are DEAD** — caching saves ~0.1 ms.
- **Punch/seal is NOT the cost** (`portalmask = 0.08 ms`). Tier B #5 batching saves nothing.
- **The clip-math alloc storm is NOT the steady cost** (flood+assemble ≈ 0.2 ms). It may
still drive the *spikes* via GC (separate), but it is not the 813 ms steady cost.
- **The cells fix works** (`shells = 0.1 ms`).
**REAL TARGET — per-cell / per-entity DRAW SUBMISSION:** `cellobjects + landscape +
dynamics ≈ 6.5 ms` + `partition ≈ 2 ms`. Common thread: `WbDrawDispatcher.Draw` is
called **once per visible cell** in `DrawCellObjectLists` (each orphaning 6 SSBOs via
`glBufferData``WbDrawDispatcher.cs:1521-1558`), plus per-cell `DrawCellParticles`
re-walks, plus un-batched per-submesh sky. Scales with visible cells/entities ⇒ the
facing-the-town cost. **Lesson: allocation COUNT ≠ CPU TIME; the per-phase CPU timer
is what found the real hotspot.**
**Revised fix priority (supersedes §5):**
1. **Batch per-cell entity/static draws** across cells into few `WbDrawDispatcher.Draw`
calls (the cell-shell fix, applied to cell OBJECTS) — targets `cellobjects` (~3.5 ms).
Pre-cull per-entity by viewcone, then one batched draw.
2. **`WbDrawDispatcher`: persistent SSBOs + `BufferSubData`** instead of 6 `glBufferData`
orphans per call — compounds with #1 (helps `cellobjects`/`landscape`/`dynamics`).
3. **Consolidate particle `Draw` calls** (per-cell `DrawCellParticles` re-walks all
particles) — targets part of `cellobjects` + `landscape`.
4. **Batch sky submeshes** — targets `landscape`.
5. **Optimize `partition`** (`InteriorEntityPartition.Partition` + `ViewconeCuller.Build`,
~2 ms CPU/frame) — possibly cache/incremental.
Spikes (cpuRender max 3043 ms; correlated `gpu`-max spikes to 300+ ms) coincide with
`landscape`/`cellobjects` jumps + GPU upload — likely streaming mesh upload hitches
(`_wbMeshAdapter.Tick`) and/or GC. Diagnose separately from the steady cost.
---
## 0. Verdict (TL;DR) — original static prediction, now MEASURED-CONFIRMED above
The handoff frames the remaining ~12 ms as "diffuse GPU cost." The static
evidence says that frame is **mis-attributed**: the 12 ms is a **glFinish-
serialized** number, and the actual GPU *rasterization* is tiny (terrain 0.3,
cells 0.2, entities 0.2 ms — the geometry genuinely is cheap, exactly your
intuition). The remaining cost is overwhelmingly **CPU-submission / OpenGL
driver-overhead / GC**, not GPU fill:
- **48 uncached portal floods per frame** (1 root + ~47 buildings), recomputed
from scratch every frame even when standing still, with **no caching**.
- **~5,0008,000 short-lived heap allocations per frame** (the Sutherland-Hodgman
clip math alone is ~35 k) → gen-0 GC → the periodic spikes to 3040 fps.
- **Hundreds of redundant GL state calls per frame**: 31 punch/seal fans each
doing a full state set+restore (~450650 GL calls), `WbDrawDispatcher`
orphaning 6 SSBOs via `glBufferData` on every one of its 35 calls (~66110
buffer reallocs), per-submesh sky draws, ~80128 `glEnable/Disable(ClipDistance)`
toggles.
- The particle system is **re-walked ~22× per frame** (once per cell), each walk
allocating and each `Draw` doing `glBufferData` orphans — draw-count bound,
**not fill** (consistent with the resolution-independence observation).
**This is the classic OpenGL CPU-bound profile**, and it explains all three of
your observations at once: resolution-independent, scales with town density, and
a fast GPU can't help.
**The single blocking gap:** we cannot prove CPU-vs-GPU today because the whole-
frame `TimeElapsed` query and the per-pass `glFinish` are gated on the *same*
`ACDREAM_FPS_PROF=1` flag (`FrameProfiler.cs:72-73`). The decisive next step is to
decouple them (a ~5-line apparatus change) **or** capture a RenderDoc frame — both
give the honest split.
---
## 1. The measurement-trust problem (read this first)
`cpuRender` is a wall-clock stopwatch around the *entire* `OnRender`
(`FrameProfiler.cs:99,110`), so it **absorbs every glFinish stall**. When
`ACDREAM_FPS_PROF=1`:
- Each instrumented renderer calls `gl.Finish()` **twice** (before + after):
`EnvCellRenderer.cs:845,1050`; `ParticleRenderer.cs:128,181`;
`PortalDepthMaskRenderer.cs:214,319`; terrain hooks `GameWindow.cs:10799,10803`.
- Counting call frequency: ~62 finishes from punch/seal (31 fans × 2) + ~44 from
particles (22 batches × 2) + cells + terrain ≈ **150+ full pipeline flushes per
frame.**
- Each `glFinish` drains the GPU and blocks the CPU. The GPU then sits **idle**
between passes waiting for the CPU to issue the next finish-bracketed batch —
and that idle time falls *inside* the whole-frame `TimeElapsed` window.
**Consequence:** the `gpu ≈ 12 ms` total is *serialized* GPU time (rasterization +
inter-pass idle), not *pipelined* GPU time. The per-pass `[PASS-GPU]` absolutes
(particles 3.1, punchseal 2.9) are inflated by their own finishes and are upper
bounds, not real costs. **No honest CPU/GPU split exists in the current
apparatus.** (Independently re-derived by two of the seven readers.)
`vsync` is off by default (`GameWindow.cs:998`), so vsync-quantization is unlikely
but must be confirmed from the printed `vsync=` field.
---
## 2. Ranked hypotheses
### H1 (leading) — The frame is CPU-submission / driver-overhead bound, not GPU-fill bound
- **For:** attributed GPU rasterization is tiny (terrain 0.31, cells 0.23,
entities 0.22 ms — all measured in the handoff). Resolution-independent (resize
didn't move FPS). Scales with building count. The CPU side has clear O(buildings)
+ O(cells) structure issuing hundreds of small state-changing GL calls. The
cells fix worked precisely because it collapsed 94 heavy state-setting `Render`
submissions into 1 — a *submission* win.
- **Against:** not yet measured cleanly (glFinish contamination, §1). The resize
test was on a tainted `FAR_RADIUS=4` build (handoff caveat).
- **Falsify:** RUN 1 (§6) with glFinish decoupled — if `gpu p50 ≈ wall p50` and
`cpuRender ≪ wall`, H1 is **wrong** and it's genuinely GPU-bound. If
`cpuRender ≈ wall` and `gpu ≪ wall`, H1 confirmed.
### H2 — Frame spikes (cpuRender p95 ~30 ms, dips to 3040 fps) are gen-0 GC pauses
- **For:** ~5,0008,000 short-lived heap objects/frame, almost all in the
portal-flood + clip + assemble path (§4). At 75 fps that's 375k600k allocs/s →
frequent gen-0 STW collections. Retail uses fixed static scratch buffers; this
alloc storm is "purely a .NET port artifact with no retail equivalent."
- **Against:** could partly be the streaming/upload hitch (`_wbMeshAdapter.Tick`,
`GameWindow.cs:8427`) on cell load. Both can be true.
- **Falsify:** watch the .NET gen-0 GC counter / allocation rate during a stutter,
or correlate spikes with `dotnet-counters`. If allocs/frame is low, GC isn't it.
### H3 — Particles are draw-count/state bound (~22 fragmented batches), not fill
- **For:** `ParticleRenderer.Draw` is called from **~11 call sites**, including
**per-cell** `DrawCellParticles` (~31×); each call re-walks the *entire* live
particle set via `EnumerateLive` (`ParticleRenderer.cs:196`), sorts it, and each
batch does a `glBufferData` orphan (`:278`). Trivial fragment shader, depth-write
off. Resolution-independent ⇒ not fill.
- **Against:** additive emitters have real overdraw (no depth write); secondary.
- **Falsify:** RUN 3 resolution sweep — if particle GPU is flat with resolution,
not fill. RenderDoc quad-overdraw overlay.
### H4 — Punch/seal (31 fans) is CPU state-churn, NOT 2.9 ms of GPU
- **For:** each `DrawDepthFan` is a depth-only, color-masked, ≤32-vert fan — GPU
cost is negligible. The 2.9 ms is 62 glFinish stalls. The *real* cost is the
full per-fan state set+restore (`PortalDepthMaskRenderer.cs:229-313`) — ~450650
GL calls/frame, including a `UseProgram`/`UseProgram(0)` round-trip and a
`uViewProjection` re-upload per fan even though all fans share one matrix.
- **Against:** none material.
- **Falsify:** RUN 2 `[PASS-GPU]` with per-pass `TimeElapsed` (no glFinish) — fan
GPU will read ≪ 2.9 ms. RenderDoc per-draw time.
### H5 — `WbDrawDispatcher` SSBO orphaning is a hidden chunk of the "unattributed 5.5 ms"
- **For:** every `Draw()` re-uploads 6 SSBOs via `glBufferData(DynamicDraw)`
(orphan+realloc, not `BufferSubData`) — `WbDrawDispatcher.cs:1521-1558`×35
calls/frame = ~66110 buffer reallocs. Plus per-transparent-cell `EnvCell.Render`
repeats all 6 SSBO uploads for a 1-cell instance set (`EnvCellRenderer.cs:1281-1367`).
- **Against:** the dispatcher has its own `ACDREAM_WB_DIAG` GPU timer; cross-check
it (run separately — it nests illegally with FPS_PROF, `FrameProfiler.cs:31`).
- **Falsify:** RenderDoc; or `ACDREAM_WB_DIAG=1` in isolation.
---
## 3. What's ruled out (do not re-chase)
- **Distance-degrade / LOD / triangle count** — dead (handoff §3, entity GPU 0.22 ms).
- **MSAA / fill / overdraw of opaque geometry** — resolution-independent; re-verify
cleanly in RUN 3 but the static evidence agrees (cheap fragment work everywhere).
- **Update thread**`update = 0.1 ms`.
- **Terrain per-slice redraw** — the apparatus reader flagged `_terrain.Draw` being
inside a per-slice loop (`GameWindow.cs:10795-10803`) as a HIGH suspect, **but
the handoff already measured terrain at 1 slice / 0.31 ms.** At *outdoor* Arwic
there is one full-screen outside slice, so terrain draws once. The per-slice
multiplier only bites for **interior roots with multiple doorway slices** — a
separate, non-Arwic concern. **NOT an Arwic FPS lever.**
- **The cells fix** — intact and correct (`RetailPViewRenderer.cs:664-701`). Do not
touch.
---
## 4. Per-subsystem cost ledger (static, cited)
**Portal visibility (CPU + GC — the biggest structural cost)**
- 48 BFS floods/frame, **no frame-to-frame caching**, recomputed when stationary
(`RetailPViewRenderer.cs:64-83,228-235`; rebuilt `GameWindow.cs:8752`).
- Per flood allocates `PortalVisibilityFrame` + 2 `HashSet` + `Dictionary` +
`uint[128]` before processing a portal (`PortalVisibilityBuilder.cs:126-227`).
- **`PortalProjection` clip math is the #1 GC source: ~3,0005,000 short-lived
`List<Vector4>`/`List<Vector2>`/`ToArray()` per frame** — `ProjectToClip:95`,
`ClipToRegion:127`, `ClipHomogeneousEdge:216`, `MergeSubPixelVertices:184`.
Retail used a **static two-buffer swap on a fixed vertex array** — so pooling is
*more* retail-faithful, not less.
- `CellTodoList.Insert` is O(N) (`:1006-1016`); `GetRange` allocates (`:254,583`);
`CanonicalKey` allocates a `StringBuilder` per polygon (`PortalView.cs:255`).
**ClipFrameAssembler (CPU + GC)** — ~8001,200 heap objects/frame (4 dicts + 95
lists + 94 `CellView` + 94 `int[]` + per-polygon `Vector4[]`), all discarded each
frame (`ClipFrameAssembler.cs:85-219`). No retail counterpart.
**Punch/seal (CPU state churn)** — 31 fans × full state set+restore = ~450650 GL
calls/frame; per-fan `UseProgram` round-trip + `uViewProjection` re-upload
(`PortalDepthMaskRenderer.cs:229-313`). Retail does **not** restore state per
polygon (it leaves state installed for the next poly); our self-contained-state
contract is the overhead. The two-pass stencil is an acdream-only #117 addition
(retail relied on painter's order). Zero managed alloc inside `DrawDepthFan`
(good — `_scratch` field + `stackalloc`).
**Particles (CPU draw-count + GC)** — ~11 `Draw` call sites, per-cell
`DrawCellParticles` re-walks all live particles ×~31 (`ParticleRenderer.cs:196`);
`new List<ParticleDraw>` (`:195`) + `new List<ParticleInstance>(64)` (`:152`) per
call; per-batch `glBufferData` orphan (`:278`). Draw-count bound.
**EnvCellRenderer (CPU + GC + redundant uploads)** — opaque batched (the fix), but
**transparent stays per-cell**: each transparent cell repeats all 6 SSBO uploads
for a 1-cell set (`EnvCellRenderer.cs:1281-1367`). `_cellLightSetCache.Clear()` at
`:1141` forces **188282 `int[]` re-allocations/frame** (94 cells × 23 passes);
the light sets are camera-independent and stable within a frame. `Render` allocates
4 collections per call (`:904,905,927,928`). `CellHasTransparent` is an O(gfx×batch)
walk with no cached result.
**Orchestration (CPU driver overhead)** — sky drawn per-submesh, no batching
(~815 `DrawElements` + per-submesh `BlendFunc`/4×`SetFloat`, ×2 passes/frame,
`SkyRenderer.cs:219-429`); ~80128 `glEnable/Disable(ClipDistance0..7)` toggles/frame
(`MaxPlanes=8`); `PrepareRenderBatches` runs `Parallel.ForEach` **on the render
thread**, blocking `OnRender` (`EnvCellRenderer.cs:642`); `ParseEnvFloat`
`Environment.GetEnvironmentVariable` ×2/frame in the hot path
(`GameWindow.cs:8639-8641`); `new[]{entry}` per `DrawEntityBucket` (~50100/frame,
`RetailPViewRenderer.cs:943`); `new[]{NoClipSlice}` per slot-less cell
(~125/frame, `:918` — trivially a static readonly array).
---
## 5. Fix leads, ranked by (impact × retail-faithfulness)
Brainstorm before any render change (handoff lesson — rushed render changes were
reverted). These are *sketches for the implementation session*, not commitments.
**Tier A — strictly retail-faithful (matches retail's static-scratch model), high impact, low risk:**
1. **Pool the `PortalProjection` clip buffers** (`ArrayPool`/double-buffer swap).
Kills the single largest GC source (~35 k allocs/frame). Output contract
unchanged. → directly targets H2 spikes.
2. **Pool `ClipFrameAssembler` + `PortalVisibilityBuilder` BFS scratch** (Reset/
Clear instead of `new`). ~1 k allocs/frame gone.
3. **Move `_cellLightSetCache.Clear()` out of `RenderModernMDIInternal`** to once-
per-frame. Removes 188282 `int[]`/frame. Light sets are frame-stable.
4. **Static readonly `NoClipSlice[]`** + pool `DrawEntityBucket`'s `new[]{entry}`.
**Tier B — internal GL batching (the cells-fix pattern, no visual change), high impact:**
5. **Batch punch/seal**: set state ONCE before the fan loop, upload `uViewProjection`
ONCE, merge all fan vertices into one VBO + draw with offsets. Drop the per-fan
`UseProgram(0)`. (Retail also drew per-poly but with cheap D3D state blocks; our
GL program-rebind/uniform-upload per fan is the cost.)
6. **Batch transparent EnvCell shells** like opaque (sort instances far→near, one
`Render`). Removes N_transparent × 6-SSBO re-uploads.
7. **`WbDrawDispatcher`: persistent SSBOs + `BufferSubData`** instead of
`glBufferData(DynamicDraw)` orphan per call.
8. **Consolidate particle `Draw` to ≤3 calls/frame** (one per `RenderPass`),
pre-partition emitters by pass, single `BufferSubData` per `Draw`; additive in a
separate order-independent group.
9. **Batch sky submeshes** (shared VBO / sort by blend mode).
**Tier C — acceleration that diverges from retail (needs explicit brainstorm + a
divergence-register row):**
10. **Cache per-building portal floods** keyed on (buildingId, quantized camera
pose). Eliminates ~47 of 48 floods/frame when stationary. **But retail
recomputes per frame from the BSP walk** — this is a memoization acdream adds,
not a faithful match. Pure-function caching is defensible, but it changes the
"recompute every frame" structure and must be invalidated on camera move + cell
load/unload. Lower priority than Tier A (which kills the GC without diverging).
11. **Move `PrepareRenderBatches` off the render thread** (double-buffer in
`OnUpdate`). One-frame visibility latency.
---
## 6. Decisive measurement plan (user-driven; report-only until approved)
**Apparatus gap to fix first (one small change, then measure):** add a second flag
(e.g. `ACDREAM_FPS_PROF=2` or `ACDREAM_FPS_NOFINISH=1`) that keeps the whole-frame
`TimeElapsed` query but **disables the per-pass `glFinish`** — and/or convert the
per-pass timers to **non-nested per-pass `TimeElapsed` queries** (the
`WbDrawDispatcher` `ACDREAM_WB_DIAG` queries at `WbDrawDispatcher.cs:1642,1670` are
the working template — no glFinish, no inflation). This is diagnostic-only.
- **RUN 1 — honest split (glFinish off, frame query on), Arwic, stand still 10 s.**
`gpu≈wall` ⇒ GPU-bound (go to RUN 4). `cpuRender≈wall, gpu≪wall` ⇒ **CPU-bound
(H1)**. `present(wait)≈wall` ⇒ vsync/swap (check `vsync=`).
- **RUN 2 — per-pass `[PASS-GPU]` via TimeElapsed (no glFinish).** Honest
particles / punchseal / cells / terrain ms + calls/frame.
- **RUN 3 — resolution sweep (720p → 1080p) on a CLEAN build.** GPU scales ~2.25×
fill-bound; flat ⇒ vertex/CPU-bound. (Re-verifies the tainted resize test.)
- **RUN 4 — RenderDoc Arwic frame capture.** Exact per-draw GPU, total draw count
(>200/frame at 720p = batching problem), quad-overdraw overlay (punch fans over
the viewport show as red), per-pass timeline, MSAA-resolve cost.
Confirm/refute: H1 ⇐ RUN 1; H3/H4 ⇐ RUN 2/3/4; H5 ⇐ `ACDREAM_WB_DIAG=1` solo +
RenderDoc; H2 ⇐ gen-0 GC counter during a stutter.
---
## 7. What this is NOT
- **NOT a GPU fill / shading / MSAA problem** — the GPU rasterizes this geometry in
a couple of ms; the geometry is cheap (your intuition is correct).
- **NOT a terrain per-slice redraw at Arwic** — outdoor = 1 slice, terrain draws
once (0.31 ms measured). That lead applies only to multi-slice interior roots.
- **NOT a single 24 ms lever** — it's death by a thousand CPU/driver/GC cuts; the
win is the *sum* of the Tier A+B fixes.
- **NOT fixed by the cells batching alone** — that solved the one big GPU
submission sink; the CPU flood + GC + state churn remained underneath.
- **The 12 ms `gpu` number is NOT trustworthy as "GPU rasterization time"** — it's
glFinish-serialized. Re-measure honestly before drawing conclusions.

View file

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