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>
21 KiB
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):
- Batch per-cell entity/static draws across cells into few
WbDrawDispatcher.Drawcalls (the cell-shell fix, applied to cell OBJECTS) — targetscellobjects(~3.5 ms). Pre-cull per-entity by viewcone, then one batched draw. WbDrawDispatcher: persistent SSBOs +BufferSubDatainstead of 6glBufferDataorphans per call — compounds with #1 (helpscellobjects/landscape/dynamics).- Consolidate particle
Drawcalls (per-cellDrawCellParticlesre-walks all particles) — targets part ofcellobjects+landscape. - Batch sky submeshes — targets
landscape. - 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),
WbDrawDispatcherorphaning 6 SSBOs viaglBufferDataon every one of its 3–5 calls (~66–110 buffer reallocs), per-submesh sky draws, ~80–128glEnable/Disable(ClipDistance)toggles. - The particle system is re-walked ~22× per frame (once per cell), each walk
allocating and each
DrawdoingglBufferDataorphans — 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 hooksGameWindow.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
glFinishdrains 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-frameTimeElapsedwindow.
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
Rendersubmissions into 1 — a submission win.
- O(cells) structure issuing hundreds of small state-changing GL calls. The
cells fix worked precisely because it collapsed 94 heavy state-setting
- Against: not yet measured cleanly (glFinish contamination, §1). The resize
test was on a tainted
FAR_RADIUS=4build (handoff caveat). - Falsify: RUN 1 (§6) with glFinish decoupled — if
gpu p50 ≈ wall p50andcpuRender ≪ wall, H1 is wrong and it's genuinely GPU-bound. IfcpuRender ≈ wallandgpu ≪ 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.Drawis called from ~11 call sites, including per-cellDrawCellParticles(~31×); each call re-walks the entire live particle set viaEnumerateLive(ParticleRenderer.cs:196), sorts it, and each batch does aglBufferDataorphan (: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
DrawDepthFanis 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 aUseProgram/UseProgram(0)round-trip and auViewProjectionre-upload per fan even though all fans share one matrix. - Against: none material.
- Falsify: RUN 2
[PASS-GPU]with per-passTimeElapsed(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 viaglBufferData(DynamicDraw)(orphan+realloc, notBufferSubData) —WbDrawDispatcher.cs:1521-1558— ×3–5 calls/frame = ~66–110 buffer reallocs. Plus per-transparent-cellEnvCell.Renderrepeats all 6 SSBO uploads for a 1-cell instance set (EnvCellRenderer.cs:1281-1367). - Against: the dispatcher has its own
ACDREAM_WB_DIAGGPU timer; cross-check it (run separately — it nests illegally with FPS_PROF,FrameProfiler.cs:31). - Falsify: RenderDoc; or
ACDREAM_WB_DIAG=1in 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.Drawbeing 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; rebuiltGameWindow.cs:8752). - Per flood allocates
PortalVisibilityFrame+ 2HashSet+Dictionary+uint[128]before processing a portal (PortalVisibilityBuilder.cs:126-227). PortalProjectionclip math is the #1 GC source: ~3,000–5,000 short-livedList<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.Insertis O(N) (:1006-1016);GetRangeallocates (:254,583);CanonicalKeyallocates aStringBuilderper 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:
- Pool the
PortalProjectionclip buffers (ArrayPool/double-buffer swap). Kills the single largest GC source (~3–5 k allocs/frame). Output contract unchanged. → directly targets H2 spikes. - Pool
ClipFrameAssembler+PortalVisibilityBuilderBFS scratch (Reset/ Clear instead ofnew). ~1 k allocs/frame gone. - Move
_cellLightSetCache.Clear()out ofRenderModernMDIInternalto once- per-frame. Removes 188–282int[]/frame. Light sets are frame-stable. - Static readonly
NoClipSlice[]+ poolDrawEntityBucket'snew[]{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 (checkvsync=). - 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
gpunumber is NOT trustworthy as "GPU rasterization time" — it's glFinish-serialized. Re-measure honestly before drawing conclusions.