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

7 KiB
Raw Blame History

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.
  • SealsDrawEnvCellShells 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.