docs: spec — batch EnvCell shell draws (dense-town FPS root cause)

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) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-23 15:46:19 +02:00
parent d0dd3b17ad
commit fcf60d868a

View file

@ -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<uint>`; 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<uint>`
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 → ~25, `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.