From fcf60d868a45cc551ce9bbe94f289d9f1c1d07d5 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 23 Jun 2026 15:46:19 +0200 Subject: [PATCH] =?UTF-8?q?docs:=20spec=20=E2=80=94=20batch=20EnvCell=20sh?= =?UTF-8?q?ell=20draws=20(dense-town=20FPS=20root=20cause)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live profiling found the dense-town (Arwic 29fps) bottleneck: EnvCellRenderer .Render called ~94x/frame (per-cell x opaque+transparent) = 24.75ms = 75% of the GPU frame. Render is a heavy per-frame method (state reset + SSBO upload + MDI) invoked per-cell for far->near transparency order. Eliminated, with evidence, every other suspect incl. the handoff's distance-degrade theory (entities 0.22ms; resolution-independent => not fill; update 0.1ms). Spec: batch the shell draws into one Render per pass. Opaque needs no order (z-buffer) + lighting is per-instance (CellId-keyed SSBO) => safe to batch. Transparent: skip opaque-only cells, preserve order for the rest. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...026-06-23-envcell-shell-batching-design.md | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-23-envcell-shell-batching-design.md diff --git a/docs/superpowers/specs/2026-06-23-envcell-shell-batching-design.md b/docs/superpowers/specs/2026-06-23-envcell-shell-batching-design.md new file mode 100644 index 00000000..200978e4 --- /dev/null +++ b/docs/superpowers/specs/2026-06-23-envcell-shell-batching-design.md @@ -0,0 +1,123 @@ +# 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.