# Spec — Batch EnvCell shell draws (dense-town FPS fix) — 2026-06-23 ## Status Design approved 2026-06-23. Supersedes the handoff's distance-degrade theory (`docs/research/2026-06-23-fps-distance-degrade-handoff.md`), which a live profiling investigation **refuted** as the cause (see Problem below). ## Problem (measured, not assumed) Dense town (Arwic), **29 fps / 34 ms frame**. Live-profiled with a throwaway frame profiler (`ACDREAM_FPS_PROF=1`, see Apparatus). Root cause: - **`EnvCellRenderer.Render` is called ~94× per frame = 24.75 ms = 75 % of the 32.8 ms GPU frame.** Everything else is negligible: terrain 0.45 ms (1 draw), entity dispatch 0.22 ms, update thread 0.1 ms. - Caller: `RetailPViewRenderer.DrawEnvCellShells` (`RetailPViewRenderer.cs:659-665`) and `DrawBuildingLookIns` (`:348-354`) loop over every visible / look-in cell and call `Render(pass, _oneCell)` **once per cell × two passes (opaque + transparent)**. With ~47 cells that is 94 calls. - `Render` is a **heavy per-frame method**: it resets the GL-state caches, re-uploads uniforms + per-instance SSBOs, rebuilds the cross-cell instance groups, and issues an MDI — *every call*. It is designed to be called once per frame with a multi-cell filter; calling it per-cell pays that setup 94 times. - The per-cell loop exists **only** for far→near transparency ordering (`RetailPViewRenderer.cs:656-657, 648-649`). ### Eliminated with evidence (do not re-investigate) | Suspect | Evidence it's NOT the cause | |---|---| | Distance-degrade / triangle count (handoff theory) | entity-draw GPU = 0.22 ms | | MSAA / fill / overdraw / shading | **resolution-independent** (window-resize test: FPS unchanged by size) + MSAA-off didn't help | | Update thread (physics/anim/net/apply) | `update = 0.1 ms` | | Far terrain draw distance | `ACDREAM_FAR_RADIUS=4` didn't help (had streaming side-effects) | | Terrain draw | 1 draw / 0.45 ms | | Entity leak | modest (`esg=288`), and entity GPU is 0.22 ms regardless | ## Invariants verified before designing (load-bearing) 1. **Lighting is per-instance, not per-call.** `RenderModernMDIInternal` (`EnvCellRenderer.cs:1324-1325`) looks up `GetCellLightSet(allInstances[i].CellId)` per instance and uploads a per-instance light-set SSBO (binding 5). ⇒ batching multiple cells into one `Render` call keeps each cell's torch/lantern lighting correct. 2. **The filtered path already collapses cells into one MDI.** `Render`'s `filter != null` branch (`EnvCellRenderer.cs:923-968`) groups instances by `gfxObjId` across *all* filtered cells, then one `RenderModernMDIInternal`. 3. **Membership routing is per-pass, not per-cell.** `UseIndoorMembershipOnlyRouting()` is called once before each loop, not inside it. 4. **Opaque order is the z-buffer; only transparent needs far→near.** 5. **Arwic (outdoor root) hits `DrawEnvCellShells`** (the merged nearby-building cells via `MergeNearbyBuildingFloods`). `DrawBuildingLookIns` is interior-roots only (`RetailPViewRenderer.cs:95` gate). So the outdoor-town win is in `DrawEnvCellShells`; `DrawBuildingLookIns` is fixed in parallel for interior scenes. 6. A cell's transparent content is detectable from its prepared batches (`renderData.Batches[].IsTransparent`, used by the `[shell]` probe at `EnvCellRenderer.cs:1034`). ## Design **Core idea:** call `EnvCellRenderer.Render` **once per pass with all the pass's cells**, instead of once per cell. Identical visual output (same cells, same far→near transparency); a modern-implementation optimization of the bindless-MDI path. Retail draws cells per-cell but cheaply (prebuilt mesh, one submit); we get the same result with fewer, fatter MDI calls. ### Phase 1 — opaque batch (the Arwic win) - `DrawEnvCellShells`: gather every `IndoorDrawPlan.ShellPass(pvFrame)` cell id into one reusable `HashSet`; call `Render(Opaque, allCells)` **once**. Opaque needs no order (z-buffer). ~47 opaque calls → 1. - `DrawBuildingLookIns` (interior roots): keep the per-building aperture **punch** pass (pass 1) as-is, then replace the per-cell shell loop (pass 2) with **one `Render(Opaque, thisBuilding'sCells)` per building** — punch-before-shell order preserved per building. ### Phase 2 — transparent - Add a cheap predicate "does this cell have any transparent batch?" (probe the prepared snapshot like the `[shell]` diag does). **Skip `Render(Transparent, cell)` for opaque-only cells** — most cell geometry is opaque walls/floors/ceilings, so this removes most of the remaining ~47 transparent calls outright. - For cells that *do* have transparent surfaces, preserve far→near. First cut: keep those per-cell (expected small count). If the meter still shows the transparent pass hot, batch them with a per-instance depth sort. Decide on the meter — do not pre-optimize. - Apply the same skip-empty to `DrawBuildingLookIns`. ## Implementation notes - No change to `EnvCellRenderer.Render`'s multi-cell filter path is required for Phase 1 — just call it with the full set. (A small `bool CellHasTransparent(uint)` helper or a per-pass empty-check is the only EnvCellRenderer addition, for Phase 2.) - Replace the `_oneCell` single-cell scratch with a reusable `HashSet` populated once per pass. - `IndoorDrawPlan.ShellPass(pvFrame)` order: opaque ignores it; the transparent remainder respects it. ## Risks / correctness checks - **Transparency reorder artifacts** — mitigated by skip-empty + keeping order for the few transparent cells. Verify windows/water/decals composite correctly. - **Missing walls (the #52 / cull-state class)** — `Render` resets the cull-state cache per call and runs per-batch `SetCullMode`; batching = fewer resets but the per-batch cull still runs. Verify no missing/half-culled walls after batching. - **Look-in punch ordering** — batch shells *per building* (after that building's punches), never globally, or punches won't mark the right apertures. - **Seals** — `DrawEnvCellShells` only draws shells; seals run elsewhere. Batching the shell Render does not move the seal pass. ## Verification 1. `FrameProfiler` `[PASS-GPU] cells=… (calls/frame)` before/after at the **same Arwic facing** — expect ~94 calls → ~2–5, `cells` 24.75 ms → low single digits, frame 34 ms → ~12 ms (~80 fps). 2. `dotnet build` + `dotnet test` green. 3. User visual: no missing walls, windows/transparency correct, no flicker, dense town fps up — at Arwic / Holtburg / Fort Tethana. 4. **Strip all apparatus** when it lands (mirrors `92e95be`): `FrameProfiler.cs`, the `OnRender`/`OnUpdate` hooks + `_fpsProf`/`_msaaSamples` fields, the terrain glFinish bracket (`GameWindow.cs`), the `EnvCellRenderer.Render` glFinish try/finally, and `DegradeCoverageProbeTests.cs`. ## Out of scope - Reducing the *number* of nearby buildings flooded (look-in / flood culling) — a secondary lever; batching addresses the measured per-call overhead. Revisit only if batching alone doesn't reach target. - Distance-degrade / LOD — refuted as the cause; not pursued here.