acdream/docs/research/2026-06-23-dense-town-fps-attribution-report.md
Erik 02578ddb74 docs(perf): dense-town FPS — final outcome (75->165) + ISSUES record
Report: OUTCOME section (the two shipped fixes, the glFinish-artifact correction,
the deliberately-unpursued scenery-CPU/terrain-GPU headroom). ISSUES: recently-
closed entry with SHAs + pointers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 00:13:32 +02:00

22 KiB
Raw Blame History

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.


OUTCOME (FINAL — shipped 2026-06-24)

Dense Arwic: 75 → ~165 fps (steady; ~130 at the absolute densest standing-still view — over the 144 target except that one extreme). Two isolated, verified, pixels-identical fixes (render-perf is not faithfulness-gated):

  1. Cell-object draw batching (290e731) — DrawCellObjectLists collapsed N per-cell WbDrawDispatcher.Draw calls into one cross-cell batched draw (cellobjects 3.5 → ~0.4 ms).
  2. Cell-particle consolidation (9f51a4d) — the per-cell DrawCellParticles re-walk (O(cells×particles)) collapsed into one union pass; also fixed a latent additive double-draw in multi-aperture cells.

Throwaway profiling apparatus stripped (a9d06a6). Build + full suite green.

The handoff's framing was wrong, and is corrected here: the dense town was NEVER GPU-bound. The "~12 ms GPU" was a glFinish self-measurement artifact (the profiler's own pipeline flushes inflated its number); the true GPU is ~0.5 ms standing still. The frame is ~96 % CPU-bound, and the cost scales with how many buildings are in view.

Remaining headroom — deliberately NOT pursued (user-approved finalize): the last ~2.1 ms CPU is WbDrawDispatcher's per-frame static-scenery rebuild (ls.scenery); ~2.1 ms GPU is terrain rasterization (ls.terrain). Both live in FROZEN, load-bearing subsystems. The only way to fully cut scenery-CPU is a cross-frame cache of the entity dispatcher — judged HIGH risk (regresses ALL entity rendering if mis-keyed; the #53/#119/#128 bug class; thrashes on the eye's rest-jitter) for ~15 fps at one extreme view already over target. The safe micro-opts net only ~0.3 ms. Full analysis: §0b + the dispatcher-investigation workflow.


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 3043 ms → dips to ~25 fps) are consistent with gen-0 GC from the ~58 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 813 ms with view density; the RANKING is stable):

phase ms/frame what
cellobjects 3.34.5 DrawCellObjectLists — per-cell entity/static draw + per-cell particle pass
landscape 2.12.9 sky (per-submesh) + terrain + scenery + late dynamics/particles/weather
partition 0.73.2 InteriorEntityPartition.Partition + ViewconeCuller.Build
dynamics 0.61.3 DrawDynamicsLast — dynamics draw + dynamics particles
prepare 0.041.3 PrepareRenderBatches
shells 0.070.21 the cells fix (cheap — working)
flood 0.080.27 the 48 portal floods
assemble 0.030.24 ClipFrameAssembler
portalmask 0.060.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 813 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 glBufferDataWbDrawDispatcher.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 3043 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,0008,000 short-lived heap allocations per frame (the Sutherland-Hodgman clip math alone is ~35 k) → gen-0 GC → the periodic spikes to 3040 fps.
  • Hundreds of redundant GL state calls per frame: 31 punch/seal fans each doing a full state set+restore (~450650 GL calls), WbDrawDispatcher orphaning 6 SSBOs via glBufferData on every one of its 35 calls (~66110 buffer reallocs), per-submesh sky draws, ~80128 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 3040 fps) are gen-0 GC pauses

  • For: ~5,0008,000 short-lived heap objects/frame, almost all in the portal-flood + clip + assemble path (§4). At 75 fps that's 375k600k 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) — ~450650 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×35 calls/frame = ~66110 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 threadupdate = 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,0005,000 short-lived List<Vector4>/List<Vector2>/ToArray() per frameProjectToClip: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) — ~8001,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 = ~450650 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 188282 int[] re-allocations/frame (94 cells × 23 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 (~815 DrawElements + per-submesh BlendFunc/4×SetFloat, ×2 passes/frame, SkyRenderer.cs:219-429); ~80128 glEnable/Disable(ClipDistance0..7) toggles/frame (MaxPlanes=8); PrepareRenderBatches runs Parallel.ForEach on the render thread, blocking OnRender (EnvCellRenderer.cs:642); ParseEnvFloatEnvironment.GetEnvironmentVariable ×2/frame in the hot path (GameWindow.cs:8639-8641); new[]{entry} per DrawEntityBucket (~50100/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 (~35 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 188282 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≪wallCPU-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.