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>
123 lines
7 KiB
Markdown
123 lines
7 KiB
Markdown
# 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 → ~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.
|