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>
This commit is contained in:
parent
376f4e6190
commit
1473e4dbf9
1 changed files with 193 additions and 0 deletions
193
docs/research/2026-06-23-dense-town-fps-deepdive-handoff.md
Normal file
193
docs/research/2026-06-23-dense-town-fps-deepdive-handoff.md
Normal file
|
|
@ -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).
|
||||||
Loading…
Add table
Add a link
Reference in a new issue