docs(perf): dense-town FPS attribution (CPU-bound, GPU=0.5ms) + cellobject-batch spec
Attribution report: the handoff's "~12ms GPU" was a glFinish artifact; the clean split measures GPU=0.5ms and a ~96% CPU-bound frame whose cost scales with visible buildings. [CPU-PHASE] ranks it: cellobjects 3.5 / landscape 2.6 / partition 2 / dynamics 1.2 ms; floods, punch-seal, clip-allocs, shells all <0.3ms (refuted). Spec: iteration-1 fix = batch the per-cell WbDrawDispatcher.Draw calls in DrawCellObjectLists into one cross-cell draw (the proven cells-shell pattern), two-loop structure to keep particle-after-statics depth ordering. Target: dense town solidly 144+. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
fe1f81371a
commit
6491798edf
2 changed files with 501 additions and 0 deletions
358
docs/research/2026-06-23-dense-town-fps-attribution-report.md
Normal file
358
docs/research/2026-06-23-dense-town-fps-attribution-report.md
Normal file
|
|
@ -0,0 +1,358 @@
|
|||
# Report — dense-town FPS attribution (Arwic, post-cells-fix) — 2026-06-23
|
||||
|
||||
**Mode:** report-only investigation (no edits, no diagnostic drops). Deliverable
|
||||
is this doc + the chat verdict. Continues the handoff
|
||||
`2026-06-23-dense-town-fps-deepdive-handoff.md`. The cells fix (29→75 fps) is
|
||||
KEPT and confirmed intact.
|
||||
|
||||
**Method:** 7 parallel read-only subsystem readers (orchestration, punch/seal,
|
||||
particles, portal-vis CPU, alloc/GC, pview-master, apparatus) + independent
|
||||
verification reads of the 6 core files by the lead. Every claim below is cited
|
||||
to `file:line` from source read in full. **No live measurement was taken** — the
|
||||
decisive runs are listed in §6 for the user to drive.
|
||||
|
||||
---
|
||||
|
||||
## 0a. MEASURED VERDICT — clean split, `ACDREAM_FPS_PROF=2` (2026-06-23, live Arwic)
|
||||
|
||||
The hypothesis below was **confirmed by direct measurement.** Added a diagnostic
|
||||
`ACDREAM_FPS_PROF=2` mode (whole-frame `TimeElapsed` query, per-pass `glFinish`
|
||||
DISABLED) and ran it live in Arwic:
|
||||
|
||||
| view | wall | cpuRender | **gpu** | present(wait) | vsync/msaa |
|
||||
|---|---|---|---|---|---|
|
||||
| facing AWAY from town | 6.7 ms (~149 fps) | 6.6 ms | **0.5 ms** | 0.1 ms | off / 4× |
|
||||
| facing INTO town | ~13 ms (~70 fps) | ~13 ms | **0.5 ms** | 0.1 ms | off / 4× |
|
||||
|
||||
**The frame is ~96% CPU-render-bound. The GPU renders the entire dense town in
|
||||
0.5 ms and is idle the rest of the frame.** `cpuRender ≈ wall`, `present ≈ 0.1 ms`,
|
||||
`gpu = 0.5 ms` regardless of view. The handoff's "~12 ms GPU" was **entirely a
|
||||
glFinish-serialization artifact** (§1). Turning the camera into the town doubles
|
||||
`cpuRender` (6.6 → 13 ms) while `gpu` never moves — the cost scales with the number
|
||||
of buildings in frustum, i.e. the per-building CPU work, not pixels.
|
||||
|
||||
**DEAD for good (GPU is 0.5 ms):** MSAA, fill, overdraw, fragment shaders, far draw
|
||||
distance, any GPU-side lever. An MSAA=0 test is moot. **All fixes target CPU.**
|
||||
|
||||
The spikes (cpuRender max 30–43 ms → dips to ~25 fps) are consistent with gen-0 GC
|
||||
from the ~5–8 k allocations/frame (§4); confirm with a gen-0 GC counter if needed.
|
||||
|
||||
---
|
||||
|
||||
## 0b. CPU SUB-PHASE BREAKDOWN — `[CPU-PHASE]`, live Arwic facing town (2026-06-23)
|
||||
|
||||
Added `[CPU-PHASE]` timers around each `DrawInside` phase (run under `=2`, so draw
|
||||
phases = pure CPU submission). Steady-state, facing town (`update≈0`, `cpuRender`
|
||||
~8 ms — the absolute varies 8–13 ms with view density; the RANKING is stable):
|
||||
|
||||
| phase | ms/frame | what |
|
||||
|---|---|---|
|
||||
| **cellobjects** | **3.3–4.5** | `DrawCellObjectLists` — per-cell entity/static draw + per-cell particle pass |
|
||||
| **landscape** | **2.1–2.9** | sky (per-submesh) + terrain + scenery + late dynamics/particles/weather |
|
||||
| **partition** | **0.7–3.2** | `InteriorEntityPartition.Partition` + `ViewconeCuller.Build` |
|
||||
| **dynamics** | **0.6–1.3** | `DrawDynamicsLast` — dynamics draw + dynamics particles |
|
||||
| prepare | 0.04–1.3 | `PrepareRenderBatches` |
|
||||
| shells | 0.07–0.21 | the cells fix (cheap — working) |
|
||||
| flood | 0.08–0.27 | the 48 portal floods |
|
||||
| assemble | 0.03–0.24 | `ClipFrameAssembler` |
|
||||
| portalmask | 0.06–0.09 | the 31 punch/seal fans |
|
||||
|
||||
**MEASUREMENT OVERTURNED THE STATIC RANKING (§2/§4/§5):**
|
||||
- **Portal floods are NOT the cost** (`flood = 0.1 ms`). **H1's "48 floods" emphasis and
|
||||
Tier C flood-caching are DEAD** — caching saves ~0.1 ms.
|
||||
- **Punch/seal is NOT the cost** (`portalmask = 0.08 ms`). Tier B #5 batching saves nothing.
|
||||
- **The clip-math alloc storm is NOT the steady cost** (flood+assemble ≈ 0.2 ms). It may
|
||||
still drive the *spikes* via GC (separate), but it is not the 8–13 ms steady cost.
|
||||
- **The cells fix works** (`shells = 0.1 ms`).
|
||||
|
||||
**REAL TARGET — per-cell / per-entity DRAW SUBMISSION:** `cellobjects + landscape +
|
||||
dynamics ≈ 6.5 ms` + `partition ≈ 2 ms`. Common thread: `WbDrawDispatcher.Draw` is
|
||||
called **once per visible cell** in `DrawCellObjectLists` (each orphaning 6 SSBOs via
|
||||
`glBufferData` — `WbDrawDispatcher.cs:1521-1558`), plus per-cell `DrawCellParticles`
|
||||
re-walks, plus un-batched per-submesh sky. Scales with visible cells/entities ⇒ the
|
||||
facing-the-town cost. **Lesson: allocation COUNT ≠ CPU TIME; the per-phase CPU timer
|
||||
is what found the real hotspot.**
|
||||
|
||||
**Revised fix priority (supersedes §5):**
|
||||
1. **Batch per-cell entity/static draws** across cells into few `WbDrawDispatcher.Draw`
|
||||
calls (the cell-shell fix, applied to cell OBJECTS) — targets `cellobjects` (~3.5 ms).
|
||||
Pre-cull per-entity by viewcone, then one batched draw.
|
||||
2. **`WbDrawDispatcher`: persistent SSBOs + `BufferSubData`** instead of 6 `glBufferData`
|
||||
orphans per call — compounds with #1 (helps `cellobjects`/`landscape`/`dynamics`).
|
||||
3. **Consolidate particle `Draw` calls** (per-cell `DrawCellParticles` re-walks all
|
||||
particles) — targets part of `cellobjects` + `landscape`.
|
||||
4. **Batch sky submeshes** — targets `landscape`.
|
||||
5. **Optimize `partition`** (`InteriorEntityPartition.Partition` + `ViewconeCuller.Build`,
|
||||
~2 ms CPU/frame) — possibly cache/incremental.
|
||||
|
||||
Spikes (cpuRender max 30–43 ms; correlated `gpu`-max spikes to 300+ ms) coincide with
|
||||
`landscape`/`cellobjects` jumps + GPU upload — likely streaming mesh upload hitches
|
||||
(`_wbMeshAdapter.Tick`) and/or GC. Diagnose separately from the steady cost.
|
||||
|
||||
---
|
||||
|
||||
## 0. Verdict (TL;DR) — original static prediction, now MEASURED-CONFIRMED above
|
||||
|
||||
The handoff frames the remaining ~12 ms as "diffuse GPU cost." The static
|
||||
evidence says that frame is **mis-attributed**: the 12 ms is a **glFinish-
|
||||
serialized** number, and the actual GPU *rasterization* is tiny (terrain 0.3,
|
||||
cells 0.2, entities 0.2 ms — the geometry genuinely is cheap, exactly your
|
||||
intuition). The remaining cost is overwhelmingly **CPU-submission / OpenGL
|
||||
driver-overhead / GC**, not GPU fill:
|
||||
|
||||
- **48 uncached portal floods per frame** (1 root + ~47 buildings), recomputed
|
||||
from scratch every frame even when standing still, with **no caching**.
|
||||
- **~5,000–8,000 short-lived heap allocations per frame** (the Sutherland-Hodgman
|
||||
clip math alone is ~3–5 k) → gen-0 GC → the periodic spikes to 30–40 fps.
|
||||
- **Hundreds of redundant GL state calls per frame**: 31 punch/seal fans each
|
||||
doing a full state set+restore (~450–650 GL calls), `WbDrawDispatcher`
|
||||
orphaning 6 SSBOs via `glBufferData` on every one of its 3–5 calls (~66–110
|
||||
buffer reallocs), per-submesh sky draws, ~80–128 `glEnable/Disable(ClipDistance)`
|
||||
toggles.
|
||||
- The particle system is **re-walked ~22× per frame** (once per cell), each walk
|
||||
allocating and each `Draw` doing `glBufferData` orphans — draw-count bound,
|
||||
**not fill** (consistent with the resolution-independence observation).
|
||||
|
||||
**This is the classic OpenGL CPU-bound profile**, and it explains all three of
|
||||
your observations at once: resolution-independent, scales with town density, and
|
||||
a fast GPU can't help.
|
||||
|
||||
**The single blocking gap:** we cannot prove CPU-vs-GPU today because the whole-
|
||||
frame `TimeElapsed` query and the per-pass `glFinish` are gated on the *same*
|
||||
`ACDREAM_FPS_PROF=1` flag (`FrameProfiler.cs:72-73`). The decisive next step is to
|
||||
decouple them (a ~5-line apparatus change) **or** capture a RenderDoc frame — both
|
||||
give the honest split.
|
||||
|
||||
---
|
||||
|
||||
## 1. The measurement-trust problem (read this first)
|
||||
|
||||
`cpuRender` is a wall-clock stopwatch around the *entire* `OnRender`
|
||||
(`FrameProfiler.cs:99,110`), so it **absorbs every glFinish stall**. When
|
||||
`ACDREAM_FPS_PROF=1`:
|
||||
|
||||
- Each instrumented renderer calls `gl.Finish()` **twice** (before + after):
|
||||
`EnvCellRenderer.cs:845,1050`; `ParticleRenderer.cs:128,181`;
|
||||
`PortalDepthMaskRenderer.cs:214,319`; terrain hooks `GameWindow.cs:10799,10803`.
|
||||
- Counting call frequency: ~62 finishes from punch/seal (31 fans × 2) + ~44 from
|
||||
particles (22 batches × 2) + cells + terrain ≈ **150+ full pipeline flushes per
|
||||
frame.**
|
||||
- Each `glFinish` drains the GPU and blocks the CPU. The GPU then sits **idle**
|
||||
between passes waiting for the CPU to issue the next finish-bracketed batch —
|
||||
and that idle time falls *inside* the whole-frame `TimeElapsed` window.
|
||||
|
||||
**Consequence:** the `gpu ≈ 12 ms` total is *serialized* GPU time (rasterization +
|
||||
inter-pass idle), not *pipelined* GPU time. The per-pass `[PASS-GPU]` absolutes
|
||||
(particles 3.1, punchseal 2.9) are inflated by their own finishes and are upper
|
||||
bounds, not real costs. **No honest CPU/GPU split exists in the current
|
||||
apparatus.** (Independently re-derived by two of the seven readers.)
|
||||
|
||||
`vsync` is off by default (`GameWindow.cs:998`), so vsync-quantization is unlikely
|
||||
but must be confirmed from the printed `vsync=` field.
|
||||
|
||||
---
|
||||
|
||||
## 2. Ranked hypotheses
|
||||
|
||||
### H1 (leading) — The frame is CPU-submission / driver-overhead bound, not GPU-fill bound
|
||||
- **For:** attributed GPU rasterization is tiny (terrain 0.31, cells 0.23,
|
||||
entities 0.22 ms — all measured in the handoff). Resolution-independent (resize
|
||||
didn't move FPS). Scales with building count. The CPU side has clear O(buildings)
|
||||
+ O(cells) structure issuing hundreds of small state-changing GL calls. The
|
||||
cells fix worked precisely because it collapsed 94 heavy state-setting `Render`
|
||||
submissions into 1 — a *submission* win.
|
||||
- **Against:** not yet measured cleanly (glFinish contamination, §1). The resize
|
||||
test was on a tainted `FAR_RADIUS=4` build (handoff caveat).
|
||||
- **Falsify:** RUN 1 (§6) with glFinish decoupled — if `gpu p50 ≈ wall p50` and
|
||||
`cpuRender ≪ wall`, H1 is **wrong** and it's genuinely GPU-bound. If
|
||||
`cpuRender ≈ wall` and `gpu ≪ wall`, H1 confirmed.
|
||||
|
||||
### H2 — Frame spikes (cpuRender p95 ~30 ms, dips to 30–40 fps) are gen-0 GC pauses
|
||||
- **For:** ~5,000–8,000 short-lived heap objects/frame, almost all in the
|
||||
portal-flood + clip + assemble path (§4). At 75 fps that's 375k–600k allocs/s →
|
||||
frequent gen-0 STW collections. Retail uses fixed static scratch buffers; this
|
||||
alloc storm is "purely a .NET port artifact with no retail equivalent."
|
||||
- **Against:** could partly be the streaming/upload hitch (`_wbMeshAdapter.Tick`,
|
||||
`GameWindow.cs:8427`) on cell load. Both can be true.
|
||||
- **Falsify:** watch the .NET gen-0 GC counter / allocation rate during a stutter,
|
||||
or correlate spikes with `dotnet-counters`. If allocs/frame is low, GC isn't it.
|
||||
|
||||
### H3 — Particles are draw-count/state bound (~22 fragmented batches), not fill
|
||||
- **For:** `ParticleRenderer.Draw` is called from **~11 call sites**, including
|
||||
**per-cell** `DrawCellParticles` (~31×); each call re-walks the *entire* live
|
||||
particle set via `EnumerateLive` (`ParticleRenderer.cs:196`), sorts it, and each
|
||||
batch does a `glBufferData` orphan (`:278`). Trivial fragment shader, depth-write
|
||||
off. Resolution-independent ⇒ not fill.
|
||||
- **Against:** additive emitters have real overdraw (no depth write); secondary.
|
||||
- **Falsify:** RUN 3 resolution sweep — if particle GPU is flat with resolution,
|
||||
not fill. RenderDoc quad-overdraw overlay.
|
||||
|
||||
### H4 — Punch/seal (31 fans) is CPU state-churn, NOT 2.9 ms of GPU
|
||||
- **For:** each `DrawDepthFan` is a depth-only, color-masked, ≤32-vert fan — GPU
|
||||
cost is negligible. The 2.9 ms is 62 glFinish stalls. The *real* cost is the
|
||||
full per-fan state set+restore (`PortalDepthMaskRenderer.cs:229-313`) — ~450–650
|
||||
GL calls/frame, including a `UseProgram`/`UseProgram(0)` round-trip and a
|
||||
`uViewProjection` re-upload per fan even though all fans share one matrix.
|
||||
- **Against:** none material.
|
||||
- **Falsify:** RUN 2 `[PASS-GPU]` with per-pass `TimeElapsed` (no glFinish) — fan
|
||||
GPU will read ≪ 2.9 ms. RenderDoc per-draw time.
|
||||
|
||||
### H5 — `WbDrawDispatcher` SSBO orphaning is a hidden chunk of the "unattributed 5.5 ms"
|
||||
- **For:** every `Draw()` re-uploads 6 SSBOs via `glBufferData(DynamicDraw)`
|
||||
(orphan+realloc, not `BufferSubData`) — `WbDrawDispatcher.cs:1521-1558` — ×3–5
|
||||
calls/frame = ~66–110 buffer reallocs. Plus per-transparent-cell `EnvCell.Render`
|
||||
repeats all 6 SSBO uploads for a 1-cell instance set (`EnvCellRenderer.cs:1281-1367`).
|
||||
- **Against:** the dispatcher has its own `ACDREAM_WB_DIAG` GPU timer; cross-check
|
||||
it (run separately — it nests illegally with FPS_PROF, `FrameProfiler.cs:31`).
|
||||
- **Falsify:** RenderDoc; or `ACDREAM_WB_DIAG=1` in isolation.
|
||||
|
||||
---
|
||||
|
||||
## 3. What's ruled out (do not re-chase)
|
||||
|
||||
- **Distance-degrade / LOD / triangle count** — dead (handoff §3, entity GPU 0.22 ms).
|
||||
- **MSAA / fill / overdraw of opaque geometry** — resolution-independent; re-verify
|
||||
cleanly in RUN 3 but the static evidence agrees (cheap fragment work everywhere).
|
||||
- **Update thread** — `update = 0.1 ms`.
|
||||
- **Terrain per-slice redraw** — the apparatus reader flagged `_terrain.Draw` being
|
||||
inside a per-slice loop (`GameWindow.cs:10795-10803`) as a HIGH suspect, **but
|
||||
the handoff already measured terrain at 1 slice / 0.31 ms.** At *outdoor* Arwic
|
||||
there is one full-screen outside slice, so terrain draws once. The per-slice
|
||||
multiplier only bites for **interior roots with multiple doorway slices** — a
|
||||
separate, non-Arwic concern. **NOT an Arwic FPS lever.**
|
||||
- **The cells fix** — intact and correct (`RetailPViewRenderer.cs:664-701`). Do not
|
||||
touch.
|
||||
|
||||
---
|
||||
|
||||
## 4. Per-subsystem cost ledger (static, cited)
|
||||
|
||||
**Portal visibility (CPU + GC — the biggest structural cost)**
|
||||
- 48 BFS floods/frame, **no frame-to-frame caching**, recomputed when stationary
|
||||
(`RetailPViewRenderer.cs:64-83,228-235`; rebuilt `GameWindow.cs:8752`).
|
||||
- Per flood allocates `PortalVisibilityFrame` + 2 `HashSet` + `Dictionary` +
|
||||
`uint[128]` before processing a portal (`PortalVisibilityBuilder.cs:126-227`).
|
||||
- **`PortalProjection` clip math is the #1 GC source: ~3,000–5,000 short-lived
|
||||
`List<Vector4>`/`List<Vector2>`/`ToArray()` per frame** — `ProjectToClip:95`,
|
||||
`ClipToRegion:127`, `ClipHomogeneousEdge:216`, `MergeSubPixelVertices:184`.
|
||||
Retail used a **static two-buffer swap on a fixed vertex array** — so pooling is
|
||||
*more* retail-faithful, not less.
|
||||
- `CellTodoList.Insert` is O(N) (`:1006-1016`); `GetRange` allocates (`:254,583`);
|
||||
`CanonicalKey` allocates a `StringBuilder` per polygon (`PortalView.cs:255`).
|
||||
|
||||
**ClipFrameAssembler (CPU + GC)** — ~800–1,200 heap objects/frame (4 dicts + 95
|
||||
lists + 94 `CellView` + 94 `int[]` + per-polygon `Vector4[]`), all discarded each
|
||||
frame (`ClipFrameAssembler.cs:85-219`). No retail counterpart.
|
||||
|
||||
**Punch/seal (CPU state churn)** — 31 fans × full state set+restore = ~450–650 GL
|
||||
calls/frame; per-fan `UseProgram` round-trip + `uViewProjection` re-upload
|
||||
(`PortalDepthMaskRenderer.cs:229-313`). Retail does **not** restore state per
|
||||
polygon (it leaves state installed for the next poly); our self-contained-state
|
||||
contract is the overhead. The two-pass stencil is an acdream-only #117 addition
|
||||
(retail relied on painter's order). Zero managed alloc inside `DrawDepthFan`
|
||||
(good — `_scratch` field + `stackalloc`).
|
||||
|
||||
**Particles (CPU draw-count + GC)** — ~11 `Draw` call sites, per-cell
|
||||
`DrawCellParticles` re-walks all live particles ×~31 (`ParticleRenderer.cs:196`);
|
||||
`new List<ParticleDraw>` (`:195`) + `new List<ParticleInstance>(64)` (`:152`) per
|
||||
call; per-batch `glBufferData` orphan (`:278`). Draw-count bound.
|
||||
|
||||
**EnvCellRenderer (CPU + GC + redundant uploads)** — opaque batched (the fix), but
|
||||
**transparent stays per-cell**: each transparent cell repeats all 6 SSBO uploads
|
||||
for a 1-cell set (`EnvCellRenderer.cs:1281-1367`). `_cellLightSetCache.Clear()` at
|
||||
`:1141` forces **188–282 `int[]` re-allocations/frame** (94 cells × 2–3 passes);
|
||||
the light sets are camera-independent and stable within a frame. `Render` allocates
|
||||
4 collections per call (`:904,905,927,928`). `CellHasTransparent` is an O(gfx×batch)
|
||||
walk with no cached result.
|
||||
|
||||
**Orchestration (CPU driver overhead)** — sky drawn per-submesh, no batching
|
||||
(~8–15 `DrawElements` + per-submesh `BlendFunc`/4×`SetFloat`, ×2 passes/frame,
|
||||
`SkyRenderer.cs:219-429`); ~80–128 `glEnable/Disable(ClipDistance0..7)` toggles/frame
|
||||
(`MaxPlanes=8`); `PrepareRenderBatches` runs `Parallel.ForEach` **on the render
|
||||
thread**, blocking `OnRender` (`EnvCellRenderer.cs:642`); `ParseEnvFloat` →
|
||||
`Environment.GetEnvironmentVariable` ×2/frame in the hot path
|
||||
(`GameWindow.cs:8639-8641`); `new[]{entry}` per `DrawEntityBucket` (~50–100/frame,
|
||||
`RetailPViewRenderer.cs:943`); `new[]{NoClipSlice}` per slot-less cell
|
||||
(~125/frame, `:918` — trivially a static readonly array).
|
||||
|
||||
---
|
||||
|
||||
## 5. Fix leads, ranked by (impact × retail-faithfulness)
|
||||
|
||||
Brainstorm before any render change (handoff lesson — rushed render changes were
|
||||
reverted). These are *sketches for the implementation session*, not commitments.
|
||||
|
||||
**Tier A — strictly retail-faithful (matches retail's static-scratch model), high impact, low risk:**
|
||||
1. **Pool the `PortalProjection` clip buffers** (`ArrayPool`/double-buffer swap).
|
||||
Kills the single largest GC source (~3–5 k allocs/frame). Output contract
|
||||
unchanged. → directly targets H2 spikes.
|
||||
2. **Pool `ClipFrameAssembler` + `PortalVisibilityBuilder` BFS scratch** (Reset/
|
||||
Clear instead of `new`). ~1 k allocs/frame gone.
|
||||
3. **Move `_cellLightSetCache.Clear()` out of `RenderModernMDIInternal`** to once-
|
||||
per-frame. Removes 188–282 `int[]`/frame. Light sets are frame-stable.
|
||||
4. **Static readonly `NoClipSlice[]`** + pool `DrawEntityBucket`'s `new[]{entry}`.
|
||||
|
||||
**Tier B — internal GL batching (the cells-fix pattern, no visual change), high impact:**
|
||||
5. **Batch punch/seal**: set state ONCE before the fan loop, upload `uViewProjection`
|
||||
ONCE, merge all fan vertices into one VBO + draw with offsets. Drop the per-fan
|
||||
`UseProgram(0)`. (Retail also drew per-poly but with cheap D3D state blocks; our
|
||||
GL program-rebind/uniform-upload per fan is the cost.)
|
||||
6. **Batch transparent EnvCell shells** like opaque (sort instances far→near, one
|
||||
`Render`). Removes N_transparent × 6-SSBO re-uploads.
|
||||
7. **`WbDrawDispatcher`: persistent SSBOs + `BufferSubData`** instead of
|
||||
`glBufferData(DynamicDraw)` orphan per call.
|
||||
8. **Consolidate particle `Draw` to ≤3 calls/frame** (one per `RenderPass`),
|
||||
pre-partition emitters by pass, single `BufferSubData` per `Draw`; additive in a
|
||||
separate order-independent group.
|
||||
9. **Batch sky submeshes** (shared VBO / sort by blend mode).
|
||||
|
||||
**Tier C — acceleration that diverges from retail (needs explicit brainstorm + a
|
||||
divergence-register row):**
|
||||
10. **Cache per-building portal floods** keyed on (buildingId, quantized camera
|
||||
pose). Eliminates ~47 of 48 floods/frame when stationary. **But retail
|
||||
recomputes per frame from the BSP walk** — this is a memoization acdream adds,
|
||||
not a faithful match. Pure-function caching is defensible, but it changes the
|
||||
"recompute every frame" structure and must be invalidated on camera move + cell
|
||||
load/unload. Lower priority than Tier A (which kills the GC without diverging).
|
||||
11. **Move `PrepareRenderBatches` off the render thread** (double-buffer in
|
||||
`OnUpdate`). One-frame visibility latency.
|
||||
|
||||
---
|
||||
|
||||
## 6. Decisive measurement plan (user-driven; report-only until approved)
|
||||
|
||||
**Apparatus gap to fix first (one small change, then measure):** add a second flag
|
||||
(e.g. `ACDREAM_FPS_PROF=2` or `ACDREAM_FPS_NOFINISH=1`) that keeps the whole-frame
|
||||
`TimeElapsed` query but **disables the per-pass `glFinish`** — and/or convert the
|
||||
per-pass timers to **non-nested per-pass `TimeElapsed` queries** (the
|
||||
`WbDrawDispatcher` `ACDREAM_WB_DIAG` queries at `WbDrawDispatcher.cs:1642,1670` are
|
||||
the working template — no glFinish, no inflation). This is diagnostic-only.
|
||||
|
||||
- **RUN 1 — honest split (glFinish off, frame query on), Arwic, stand still 10 s.**
|
||||
`gpu≈wall` ⇒ GPU-bound (go to RUN 4). `cpuRender≈wall, gpu≪wall` ⇒ **CPU-bound
|
||||
(H1)**. `present(wait)≈wall` ⇒ vsync/swap (check `vsync=`).
|
||||
- **RUN 2 — per-pass `[PASS-GPU]` via TimeElapsed (no glFinish).** Honest
|
||||
particles / punchseal / cells / terrain ms + calls/frame.
|
||||
- **RUN 3 — resolution sweep (720p → 1080p) on a CLEAN build.** GPU scales ~2.25× ⇒
|
||||
fill-bound; flat ⇒ vertex/CPU-bound. (Re-verifies the tainted resize test.)
|
||||
- **RUN 4 — RenderDoc Arwic frame capture.** Exact per-draw GPU, total draw count
|
||||
(>200/frame at 720p = batching problem), quad-overdraw overlay (punch fans over
|
||||
the viewport show as red), per-pass timeline, MSAA-resolve cost.
|
||||
|
||||
Confirm/refute: H1 ⇐ RUN 1; H3/H4 ⇐ RUN 2/3/4; H5 ⇐ `ACDREAM_WB_DIAG=1` solo +
|
||||
RenderDoc; H2 ⇐ gen-0 GC counter during a stutter.
|
||||
|
||||
---
|
||||
|
||||
## 7. What this is NOT
|
||||
|
||||
- **NOT a GPU fill / shading / MSAA problem** — the GPU rasterizes this geometry in
|
||||
a couple of ms; the geometry is cheap (your intuition is correct).
|
||||
- **NOT a terrain per-slice redraw at Arwic** — outdoor = 1 slice, terrain draws
|
||||
once (0.31 ms measured). That lead applies only to multi-slice interior roots.
|
||||
- **NOT a single 24 ms lever** — it's death by a thousand CPU/driver/GC cuts; the
|
||||
win is the *sum* of the Tier A+B fixes.
|
||||
- **NOT fixed by the cells batching alone** — that solved the one big GPU
|
||||
submission sink; the CPU flood + GC + state churn remained underneath.
|
||||
- **The 12 ms `gpu` number is NOT trustworthy as "GPU rasterization time"** — it's
|
||||
glFinish-serialized. Re-measure honestly before drawing conclusions.
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
# Spec — batch cell-object draws (dense-town FPS, iteration 1) — 2026-06-23
|
||||
|
||||
## Context
|
||||
|
||||
The dense-town (Arwic) FPS investigation
|
||||
([`docs/research/2026-06-23-dense-town-fps-attribution-report.md`](../../research/2026-06-23-dense-town-fps-attribution-report.md))
|
||||
proved the frame is **~96% CPU-bound** — measured GPU is **0.5 ms**; the ~8–13 ms
|
||||
frame is CPU work inside `OnRender`, scaling with how many buildings/cells are in
|
||||
view. The clean-split + `[CPU-PHASE]` measurement (no glFinish) attributed the CPU
|
||||
cost of `RetailPViewRenderer.DrawInside`:
|
||||
|
||||
| phase | ms/frame | |
|
||||
|---|---|---|
|
||||
| **cellobjects** | **3.3–4.5** | `DrawCellObjectLists` — per-cell static draw + per-cell particles |
|
||||
| landscape | 2.1–2.9 | sky + terrain + scenery + late draws |
|
||||
| partition | 0.7–3.2 | entity partition + viewcone build |
|
||||
| dynamics | 0.6–1.3 | dynamics draw + particles |
|
||||
| flood / assemble / shells / portalmask | < 0.3 each | NOT the cost (refuted the static guess) |
|
||||
|
||||
`cellobjects` is the biggest single lever. Root cause: `DrawCellObjectLists` calls
|
||||
`WbDrawDispatcher.Draw` **once per visible cell**, each call orphaning 6 SSBOs via
|
||||
`glBufferData` + full state setup (`WbDrawDispatcher.cs:1521-1558`). This is the
|
||||
same class of bug the shipped cells-shell fix solved for shell geometry (94 per-cell
|
||||
`Render` calls → 1 batched; 29→75 fps). We apply the same pattern to cell objects.
|
||||
|
||||
**Target:** dense town **solidly 144+ fps** facing the densest view (~7 ms budget).
|
||||
Iterate hotspots, re-measuring after each. This spec is **iteration 1** (cellobjects).
|
||||
|
||||
## Goal
|
||||
|
||||
Collapse the per-cell `WbDrawDispatcher.Draw` calls in `DrawCellObjectLists` into a
|
||||
**single** batched draw covering all visible cells' surviving statics, while
|
||||
preserving every rendering invariant (visibility, transparency order, particle
|
||||
occlusion). Identical pixels, far fewer draw submissions.
|
||||
|
||||
## Design
|
||||
|
||||
`RetailPViewRenderer.DrawCellObjectLists` (`RetailPViewRenderer.cs:772-825`) is
|
||||
restructured from one per-cell loop (cull → draw → particles) into **two loops**:
|
||||
|
||||
**Loop 1 — cull + accumulate (no GL draws):**
|
||||
```
|
||||
_allCellStatics.Clear(); // reused List<WorldEntity> scratch field
|
||||
_cellObjCells.Clear(); // reused HashSet<uint> scratch field
|
||||
for (i = OrderedVisibleCells.Count - 1; i >= 0; i--): // far → near
|
||||
cellId = OrderedVisibleCells[i]
|
||||
if (!drawableCells.Contains(cellId)) continue
|
||||
if (!partition.ByCell.TryGetValue(cellId, out bucket) || bucket.Count == 0) continue
|
||||
survivorsBefore = _allCellStatics.Count
|
||||
foreach (e in bucket):
|
||||
EntitySphere(e, out c, out r)
|
||||
if (viewcone.SphereVisibleInCell(cellId, c, r)) _allCellStatics.Add(e)
|
||||
if (_allCellStatics.Count > survivorsBefore) _cellObjCells.Add(cellId)
|
||||
if (ProbePhantomEnabled) EmitPhantomObjsProbe(cellId, _allCellStatics.Count - survivorsBefore)
|
||||
```
|
||||
|
||||
**One batched draw:**
|
||||
```
|
||||
if (_allCellStatics.Count > 0):
|
||||
UseIndoorMembershipOnlyRouting()
|
||||
DrawEntityBucket(ctx, _allCellStatics, _cellObjCells) // N WbDispatcher.Draw → 1
|
||||
```
|
||||
|
||||
**Loop 2 — per-cell particles, after statics are in the depth buffer:**
|
||||
```
|
||||
for (i = OrderedVisibleCells.Count - 1; i >= 0; i--): // far → near
|
||||
cellId = OrderedVisibleCells[i]
|
||||
if (!drawableCells.Contains(cellId)) continue
|
||||
if (!partition.ByCell.TryGetValue(cellId, out bucket) || bucket.Count == 0) continue
|
||||
_cellStaticScratch.Clear()
|
||||
foreach (e in bucket):
|
||||
EntitySphere(e, out c, out r)
|
||||
if (viewcone.SphereVisibleInCell(cellId, c, r)) _cellStaticScratch.Add(e)
|
||||
if (_cellStaticScratch.Count == 0) continue
|
||||
foreach (slice in GetCellSlicesOrNoClip(clipAssembly, cellId)):
|
||||
ctx.DrawCellParticles?.Invoke(new RetailPViewCellSliceContext(cellId, slice, _cellStaticScratch))
|
||||
```
|
||||
|
||||
New reused scratch fields on `RetailPViewRenderer` (cleared per call, render-thread
|
||||
only — match the existing `_cellStaticScratch`/`_dynamicsScratch` pattern):
|
||||
`private readonly List<WorldEntity> _allCellStatics = new();` and
|
||||
`private readonly HashSet<uint> _cellObjCells = new();`.
|
||||
|
||||
Re-culling in loop 2 is deliberate: it is a handful of sphere-vs-plane dot products
|
||||
per static and is negligible next to the draw-submission cost being removed; it
|
||||
avoids storing per-cell survivor ranges.
|
||||
|
||||
## Correctness invariants (must hold)
|
||||
|
||||
1. **Same entity set drawn.** Per-cell, the bucket only ever held that cell's
|
||||
entities and the filter was `{cellId}`; the batched form passes all survivors
|
||||
with the *union* `_cellObjCells`, so the dispatcher's `visibleCellIds` membership
|
||||
gate admits exactly the same set.
|
||||
2. **Transparency compositing.** `WbDrawDispatcher` sorts opaque front-to-back
|
||||
(`:1469`) and transparent back-to-front by group distance (`:1470`,
|
||||
`CompareTransparentSubmissionOrder`). Global batching yields true back-to-front
|
||||
ordering across cells — equal or better than the old per-cell-bucketed order.
|
||||
Opaque is z-buffered (order-independent).
|
||||
3. **Particle occlusion.** Particles depth-test but do not write depth; loop 2 runs
|
||||
after the batched static draw so same-cell statics already occupy the depth
|
||||
buffer. (This is the reason for two loops — a single-loop batch would draw
|
||||
particles before the statics and lose same-cell occlusion.)
|
||||
4. **Frame order unchanged elsewhere.** Shells, exit-portal masks, and
|
||||
dynamics-last all keep their positions in `DrawInside`. `lbId`,
|
||||
`neverCullLandblockId`, `animatedEntityIds` are passed exactly as before.
|
||||
5. **No double-draw.** Each surviving static is added once to `_allCellStatics`;
|
||||
the batched draw runs once.
|
||||
|
||||
## Scope
|
||||
|
||||
- **In:** `RetailPViewRenderer.DrawCellObjectLists` only.
|
||||
- **Out (explicitly):**
|
||||
- `DrawBuildingLookIns` per-cell draws — interior-root only, does not fire at
|
||||
outdoor Arwic; an analogous follow-up if a later profile shows it.
|
||||
- Particle-pass consolidation (`DrawCellParticles` re-walks the particle system
|
||||
per cell) — separate concern, separate fix.
|
||||
- `landscape` (sky-submesh batching) and `partition` optimization — iteration 2,
|
||||
pursued **only if** re-measuring after this fix shows we are not solidly 144+.
|
||||
- `WbDrawDispatcher` persistent-SSBO change (Approach 3) — back-pocket follow-on
|
||||
that compounds, if the single remaining draw's SSBO orphaning still shows up.
|
||||
|
||||
## Testing & acceptance
|
||||
|
||||
- `dotnet build` green, `dotnet test` green. Pview ordering / cell-object tests must
|
||||
stay green — especially `HouseExitWalkReplayTests` and any viewcone/cell-object
|
||||
coverage. If no test pins cross-cell cell-object batching, add one asserting the
|
||||
batched survivor set equals the union of per-cell survivor sets.
|
||||
- **Visual (user, faces dense Arwic):** all statics present (no missing furniture);
|
||||
no transparency or flame-through-furniture artifacts; FPS up.
|
||||
- **Perf (re-measure `[CPU-PHASE]` under `ACDREAM_FPS_PROF=2`, facing densest town):**
|
||||
`cellobjects` drops from ~3.5 ms toward ~0.5 ms; `wall` falls accordingly. Decide
|
||||
from this whether iteration 2 (`landscape` + `partition`) is needed for solidly-144+.
|
||||
- Retail-faithfulness: pixels identical; this is a draw-mechanism speed change only
|
||||
(per the project steer that render-perf is not faithfulness-gated as long as
|
||||
pixels and game feel are unchanged). No divergence-register row required.
|
||||
|
||||
## References
|
||||
|
||||
- Attribution report: `docs/research/2026-06-23-dense-town-fps-attribution-report.md`
|
||||
- Precedent (the blessed pattern): the cells-shell batching fix —
|
||||
`RetailPViewRenderer.DrawEnvCellShells` (`RetailPViewRenderer.cs:664-701`),
|
||||
commits `3af7d00` / `8067d3b`.
|
||||
- Dispatcher sort: `WbDrawDispatcher.cs:1469-1470`, `:1750-1756`.
|
||||
Loading…
Add table
Add a link
Reference in a new issue