acdream/docs/superpowers/specs/2026-06-23-envcell-shell-batching-design.md
Erik fcf60d868a 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>
2026-06-23 15:46:19 +02:00

123 lines
7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.