acdream/docs/research/2026-06-23-dense-town-fps-deepdive-handoff.md
Erik 1473e4dbf9 docs: handoff — dense-town FPS deep-dive (push past 75fps)
Cells batching shipped (29->75fps, 2.6x); remaining ~12ms is diffuse (particles
~3, punch/seal ~3, ~5.5 unattributed) + frame spikes. Next session: deep-dive
with RenderDoc to attribute + push higher. Distance-degrade theory is dead.

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

193 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Handoff — dense-town FPS deep-dive (push past 75 fps) — 2026-06-23
**Branch:** `claude/thirsty-goldberg-51bb9b` (continue in THIS worktree:
`C:\Users\erikn\source\repos\acdream\.claude\worktrees\thirsty-goldberg-51bb9b`).
**References / named-retail / WorldBuilder** live in the MAIN repo
`C:\Users\erikn\source\repos\acdream\` (read via absolute path; NOT in the worktree).
> **Read with fresh eyes.** This session **shipped a real, verified fix** (EnvCell
> shell-draw batching: dense-town Arwic **29 → 75 fps, 2.6×**) — KEEP IT. But the
> user finds 75 fps in a dense town on modern hardware **unacceptable** ("a modern
> GPU + OpenGL should chew through this") and wants a **deep-dive to find the next
> root cause and push FPS much higher.** This is not "ship it"; this is "keep
> hunting." The remaining cost is **diffuse** (no single big lever left) — so the
> deep-dive needs a *better* instrument than this session's glFinish hack. **Strong
> recommendation: capture a frame with RenderDoc** (the right tool for "where does
> the GPU time actually go, per draw").
---
## 0. TL;DR
1. **SHIPPED + committed (do NOT revert):** EnvCell shell batching. Root cause was
`EnvCellRenderer.Render` called **~94×/frame** (once per visible/look-in cell ×
opaque+transparent) = **24.75 ms = 75 % of the GPU frame** at Arwic. Fixed by
batching the opaque pass into ONE `Render(Opaque, allCells)` + skipping the
transparent `Render` for opaque-only cells. cells: **94 calls/24.75 ms → 1 call/
0.37 ms**. Frame **34 ms/29 fps → ~13 ms/75 fps**. Visually verified (no missing
walls, transparency + look-ins correct).
2. **STILL OPEN (the user's ask):** 75 fps is unacceptable. The remaining ~1213 ms
is **diffuse** — particles ~3 ms, aperture punch/seal ~3 ms, ~5.5 ms scattered
(sky/entities/clear/weather), plus periodic frame **spikes** to 3040 ms. No
second 24 ms sink. **Deep-dive to find why a modern GPU needs ~12 ms for a dense
AC town at 720p, and push FPS much higher.**
3. **The handoff's original theory (distance-degrade / LOD) is DEAD** — refuted by
measurement (entity-draw GPU is 0.22 ms; it was never triangle/detail-bound).
Do not re-pursue distance-degrade.
---
## 1. What SHIPPED this session (commits on the branch, newest→oldest)
| SHA | What |
|---|---|
| `376f4e6` | chore(diag): particle + punch/seal glFinish timers [throwaway] |
| `8067d3b` | **perf(pview): batch EnvCell look-in shell opaque pass** (interior roots) |
| `3af7d00` | **perf(pview): batch EnvCell shell opaque + skip empty transparent** (the Arwic win) |
| `f72f7ce` | feat(envcell): `CellHasTransparent` predicate (part of the fix, NOT apparatus) |
| `e27923b` | chore(diag): FPS profiling apparatus [throwaway] |
| `fd9354f` | docs: implementation plan |
| `fcf60d8` | docs: design spec |
(Prior session, KEEP: `536f1c0` `_datLock` fix.)
**The fix (KEEP):** `RetailPViewRenderer.DrawEnvCellShells` + `DrawBuildingLookIns`
no longer call `EnvCellRenderer.Render` per-cell. Opaque shells batch into one
`Render(Opaque, allCells)` (z-buffer order; lighting is per-instance, `CellId`-keyed
SSBO at `EnvCellRenderer.cs` `RenderModernMDIInternal` ~`:1324` → safe to batch).
Transparent stays per-cell far→near but skips opaque-only cells via
`EnvCellRenderer.CellHasTransparent`. Spec/plan: `docs/superpowers/specs|plans/
2026-06-23-envcell-shell-batching-*.md`. **Plan Task 4 (final verify) + Task 5
(strip apparatus) are NOT done** — apparatus kept for the deep-dive.
---
## 2. The apparatus (`ACDREAM_FPS_PROF=1`) — committed, throwaway, STRIP at the end
- **`src/AcDream.App/Rendering/FrameProfiler.cs`** — per-frame split, printed `[FPS-PROF]`
every ~1 s: `wall` (real frame period, p50/p95/min/max), `update` (OnUpdate CPU),
`cpuRender` (OnRender CPU), `present(wait)` (= wall update cpuRender), `gpu`
(whole-frame `TimeElapsed` query — **the reliable total**). Plus `[PASS-GPU]`
per-renderer **glFinish-bracketed** GPU via `AddRendererGpu(name, ms)`: `cells`,
`terrain`, `particles`, `punchseal`, each with `ms/frame` + `calls/frame`.
- **Hooks:** `GameWindow.OnRender` BeginFrame/EndFrame + `OnUpdate` `MarkUpdateStart`
+ terrain glFinish (in `DrawRetailPViewLandscapeSlice`); `EnvCellRenderer.Render`
glFinish try/finally; `ParticleRenderer.Draw` glFinish; `PortalDepthMaskRenderer.
DrawDepthFan` glFinish. All gated on `FrameProfiler.PassGpuEnabled`.
- **`tests/AcDream.Core.Tests/Conformance/DegradeCoverageProbeTests.cs`** — throwaway
dat probe (degrade-coverage; was for the dead distance-degrade theory).
- **⚠ glFinish CAVEAT:** the per-renderer glFinish **serializes CPU↔GPU and inflates**
the per-pass absolutes (and folds GPU wait into `cpuRender`). Use `[PASS-GPU]` for
**relative** attribution only; trust the whole-frame `gpu` for the total. For
accurate per-draw GPU, use **RenderDoc** (see §5).
- **STRIP everything** (plan Task 5) when the FPS work fully lands: `FrameProfiler.cs`,
the GameWindow hooks/fields (`_fpsProf`/`_frameProfiler`/`_msaaSamples`), the terrain
glFinish, the EnvCell/Particle/PortalDepthMask glFinish brackets (KEEP
`CellHasTransparent` — it's the fix), `DegradeCoverageProbeTests.cs`, and the
worktree-root `fps-*.log` / `wbdiag.log` / `degrade-probe.log`.
---
## 3. The investigation — what's RULED OUT (do not re-investigate)
Measured live in Arwic (the dense-town test bed). Frame was 29 fps / 34 ms pre-fix.
| Suspect | Verdict + evidence |
|---|---|
| **Distance-degrade / LOD / triangle count** (the prior handoff's theory) | ❌ DEAD — entity-draw GPU is **0.22 ms**; never detail-bound. (Degrade-coverage data: 9296 % of scenery HAS degrade tables, but trees hide at 200 m+ and degrade only saves triangles, which aren't the cost.) |
| **MSAA / fill / overdraw / shading** | ❌ **Resolution-independent** — resizing the window tiny did NOT change FPS; MSAA-off (`ACDREAM_MSAA_SAMPLES=0`) didn't help. ⚠ the resize test was run on a `FAR_RADIUS=4` build that had side-effects — **RE-VERIFY resolution-dependence on a CLEAN build in the deep-dive** before fully trusting "not fill". |
| **Update thread** (physics/anim/net/apply) | ❌ `update = 0.1 ms`. |
| **Far terrain draw distance** | ❌ `ACDREAM_FAR_RADIUS=4` didn't help (and slowed things — streaming side-effects; that build's numbers are suspect). |
| **Terrain draw** | ❌ 1 slice / 0.3 ms. |
| **Entity leak** | ❌ modest (`esg≈288`), entity GPU 0.22 ms regardless. |
| **EnvCell per-cell Render (94×)** | ✅ WAS the cause — **FIXED this session.** |
---
## 4. The diffuse remainder — the deep-dive target
Arwic, **after** the cells fix, whole-frame `gpu ≈ 12 ms` (profiler on; ~13 ms /
75 fps profiler-off). glFinish-inflated per-pass (upper bounds):
| Pass | GPU (inflated) | calls/frame | Notes |
|---|---|---|---|
| cells | 0.23 ms | 1 | ✅ fixed |
| terrain | 0.31 ms | 1 | cheap |
| **particles** | ~3.1 ms | **22 batches** | additive blend; overdraw? |
| **punch/seal** (`DrawDepthFan`) | ~2.9 ms | **31 fans** | per-fan full GL-state setup (smells like the cells issue) |
| **UNATTRIBUTED** | **~5.5 ms** | — | sky, entities (0.22), depth clear, weather, MDI-dispatch overhead, + glFinish inflation |
Plus **frame SPIKES** (separate from the steady cost): `cpuRender` p95 ~30 ms,
`present(wait)` p95 ~18 ms → periodic dips to "30 low" fps (stutter). Likely GC or a
periodic streaming/upload hitch. May matter more for *perceived* smoothness.
**Open questions for the deep-dive (ranked):**
1. **Attribute the ~5.5 ms unattributed GPU.** Instrument sky / weather / the depth
clear / the entity MDI, OR (better) RenderDoc. Is one of them secretly big?
2. **Separate CPU from GPU cleanly.** The glFinish folds GPU into `cpuRender`. Run
with the per-renderer glFinish **disabled** (whole-frame `gpu` query only) to get
the honest `cpuRender` vs `gpu` vs `present` split. If `cpuRender` is high, suspect
the **CPU side**: `PortalVisibilityBuilder` flooding ~47 nearby buildings in
`MergeNearbyBuildingFloods` (the per-frame flood/walk for a dense town could be
real CPU). That was never isolated.
3. **particles (~3 ms, 22 batches):** additive overdraw vs draw-count. Reconcile with
"resolution-independent" (re-verify the resize test cleanly). If overdraw, reducing
particle fill / count helps; if draw-count, batch the 22.
4. **punch/seal (31 fans):** are we punching/sealing too many apertures for an outdoor
root? Each `DrawDepthFan` does a full GL-state set/restore (like the per-cell
Render that we just fixed) — likely **batchable** (set state once, draw N fans).
Check what retail does (decomp: `DrawPortalPolyInternal`, the seal/punch order).
5. **Frame spikes:** GC (allocations per frame — the pview path allocates
`new CellView()`/`new Vector4[]` per aperture in `DrawBuildingLookIns`, `new[]`
tuples per slice) or streaming. A GC/alloc audit of the per-frame render path is
worthwhile.
6. **The meta-question:** even fully attributed, why ~12 ms GPU for this geometry on
modern HW? Look for state thrashing / many small draws / the 2-pass translucency /
redundant per-frame uploads. The user's "something is structurally off" thesis
still stands for the remaining 12 ms.
---
## 5. RECOMMENDED next-session method
- **RenderDoc frame capture in Arwic** is the right tool (CLAUDE.md: rendering perf →
RenderDoc). It gives exact per-draw GPU time, draw count, overdraw, and state
changes WITHOUT the glFinish distortion. This will attribute the 12 ms far better
than the apparatus. The user is on Windows with a modern GPU. Start here.
- Keep the `[FPS-PROF]`/`[PASS-GPU]` apparatus as a cheap secondary check, but trust
RenderDoc for absolutes. Consider switching the per-pass timers from glFinish to
**non-nested per-pass `TimeElapsed` queries** (no glFinish → no inflation; sequential
passes don't nest) if you want accurate in-engine per-pass numbers without RenderDoc.
- Measurement discipline (this session's lessons): profiler-OFF for the true FPS;
constant-view (stand still) for stable per-pass reads; re-verify the resize/
resolution test on a CLEAN build; the user drives the client lifecycle (graceful
`CloseMainWindow` before any rebuild — the DLL is locked while it runs).
---
## 6. Repro / launch
User: `notan` / `MittSnus81!`. Test bed: **Arwic** surface town (the user can get
there; dense buildings → the cost). Build Release; the user runs + watches FPS.
```powershell
$env:ACDREAM_DAT_DIR="$env:USERPROFILE\Documents\Asheron's Call"; $env:ACDREAM_LIVE="1"
$env:ACDREAM_TEST_HOST="127.0.0.1"; $env:ACDREAM_TEST_PORT="9000"
$env:ACDREAM_TEST_USER="notan"; $env:ACDREAM_TEST_PASS="MittSnus81!"
$env:ACDREAM_FPS_PROF="1" # [FPS-PROF]+[PASS-GPU]; OMIT for the true FPS the user sees
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Release 2>&1 | Tee-Object -FilePath "fps.log"
```
Title bar shows `fps | ms` (vsync off by default). `[FPS-PROF]`/`[PASS-GPU]` flush
~1 s. Logs are UTF-16 (Tee-Object) — read via PowerShell `Get-Content | Select-String`.
---
## 7. Do-not-retry / lessons
- The cells fix is correct + verified — do NOT revert. Both phases shipped.
- Distance-degrade is NOT the cause — dead lead, don't re-pursue.
- glFinish per-call inflates per-pass absolutes — relative only; whole-frame `gpu`
query is the reliable total; RenderDoc for true per-draw.
- The `FAR_RADIUS=4` build had streaming side-effects — its numbers (incl. the resize
test) are suspect; re-verify on a clean build.
- Don't declare FPS "fixed" on a sub-metric — only on the total frame time the user
sees + user eyes (the prior session's mistake; honored this session).