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>
7 KiB
7 KiB
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.Renderis 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) andDrawBuildingLookIns(:348-354) loop over every visible / look-in cell and callRender(pass, _oneCell)once per cell × two passes (opaque + transparent). With ~47 cells that is 94 calls. Renderis 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)
- Lighting is per-instance, not per-call.
RenderModernMDIInternal(EnvCellRenderer.cs:1324-1325) looks upGetCellLightSet(allInstances[i].CellId)per instance and uploads a per-instance light-set SSBO (binding 5). ⇒ batching multiple cells into oneRendercall keeps each cell's torch/lantern lighting correct. - The filtered path already collapses cells into one MDI.
Render'sfilter != nullbranch (EnvCellRenderer.cs:923-968) groups instances bygfxObjIdacross all filtered cells, then oneRenderModernMDIInternal. - Membership routing is per-pass, not per-cell.
UseIndoorMembershipOnlyRouting()is called once before each loop, not inside it. - Opaque order is the z-buffer; only transparent needs far→near.
- Arwic (outdoor root) hits
DrawEnvCellShells(the merged nearby-building cells viaMergeNearbyBuildingFloods).DrawBuildingLookInsis interior-roots only (RetailPViewRenderer.cs:95gate). So the outdoor-town win is inDrawEnvCellShells;DrawBuildingLookInsis fixed in parallel for interior scenes. - A cell's transparent content is detectable from its prepared batches
(
renderData.Batches[].IsTransparent, used by the[shell]probe atEnvCellRenderer.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 everyIndoorDrawPlan.ShellPass(pvFrame)cell id into one reusableHashSet<uint>; callRender(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 oneRender(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). SkipRender(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 smallbool CellHasTransparent(uint)helper or a per-pass empty-check is the only EnvCellRenderer addition, for Phase 2.) - Replace the
_oneCellsingle-cell scratch with a reusableHashSet<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) —
Renderresets the cull-state cache per call and runs per-batchSetCullMode; 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 —
DrawEnvCellShellsonly draws shells; seals run elsewhere. Batching the shell Render does not move the seal pass.
Verification
FrameProfiler[PASS-GPU] cells=… (calls/frame)before/after at the same Arwic facing — expect ~94 calls → ~2–5,cells24.75 ms → low single digits, frame 34 ms → ~12 ms (~80 fps).dotnet build+dotnet testgreen.- User visual: no missing walls, windows/transparency correct, no flicker, dense town fps up — at Arwic / Holtburg / Fort Tethana.
- Strip all apparatus when it lands (mirrors
92e95be):FrameProfiler.cs, theOnRender/OnUpdatehooks +_fpsProf/_msaaSamplesfields, the terrain glFinish bracket (GameWindow.cs), theEnvCellRenderer.RenderglFinish try/finally, andDegradeCoverageProbeTests.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.