From 1473e4dbf9abb7f1ee562d57af26bc8763c79a2a Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 23 Jun 2026 18:23:53 +0200 Subject: [PATCH] =?UTF-8?q?docs:=20handoff=20=E2=80=94=20dense-town=20FPS?= =?UTF-8?q?=20deep-dive=20(push=20past=2075fps)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- ...6-06-23-dense-town-fps-deepdive-handoff.md | 193 ++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 docs/research/2026-06-23-dense-town-fps-deepdive-handoff.md diff --git a/docs/research/2026-06-23-dense-town-fps-deepdive-handoff.md b/docs/research/2026-06-23-dense-town-fps-deepdive-handoff.md new file mode 100644 index 00000000..1d5f8645 --- /dev/null +++ b/docs/research/2026-06-23-dense-town-fps-deepdive-handoff.md @@ -0,0 +1,193 @@ +# 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 ~12–13 ms + is **diffuse** — particles ~3 ms, aperture punch/seal ~3 ms, ~5.5 ms scattered + (sky/entities/clear/weather), plus periodic frame **spikes** to 30–40 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: 92–96 % 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).