Perf epic: Bistro-scale frame cost
Goal
Close #132's remaining acceptance criterion — "free-fly navigation through the scene in Studio at interactive framerate" — and give the engine a scalability story it currently does not have.
Reported today on ../turian-samples/bistro: ~20 fps in the Scene viewport, 9–20 fps in Play, only marginally better from a compiled build. A real game has to fit physics, gameplay, audio and scripting into the same frame, so the renderer needs a lot more headroom than that.
Full analysis and the ordered plan: docs/decisions/bistro-performance-132.md.
The scene, measured
Parsed from the cooked boot scene and the TMSH v2 mesh headers:
| Bistro Exterior | Bistro Interior | Total | |
|---|---|---|---|
| triangles | 2,832,120 | 1,320,323 | 4,152,443 |
| submeshes | 1,591 | 2,289 | 3,880 |
| material slots | 132 | 74 | 206 |
18 nodes total — the whole scene is two mesh_renderers plus 5 lights, 5 cameras, an environment, a post-process volume, 2 reflection probes and 4 scripts. There is no LOD system (#39), so all 4.15M triangles are submitted at every distance, from every pass.
What the frame does (read from code)
- A full asset re-scan every frame.
assets.uploadNewAssetsresolves all 206 material GUIDs and 5 map slots each through three O(n) linear caches — ~3×10⁵ string comparisons per frame to learn nothing changed. - The prepass issues 3,880 draw calls where the main pass issues 206.
prepass.runloops submeshes with aresolveMaterial, two uniform pushes and a sampler bind each; the main pass draws the same geometry with 206 indirect multi-draws. The prepass runs before the cull dispatch, so it can't reusegm.indirect_buf. - Four shadow cascades re-submit the whole scene every frame — 824 indirect multi-draws walking 15,520 indirect commands, for a static scene under a static sun.
- 4× MSAA, forced.
pipeline.pickSampleCounthas no override anywhere. - SSAO and SSR at full resolution — 24 AO taps and a 32-step march.
- 576 bytes of mostly frame-constant uniform pushed per draw.
- More than one whole-scene render per editor frame in some layouts, plus the 10 Hz camera-preview inset.
Status
115.7 ms → 12.8 ms (8.6 → 78 fps), 9.0x, from #162 and #161 (closed) alone — no fidelity change, both verified pixel-identical. Current numbers and per-pass split: docs/decisions/bistro-perf-baseline.md.
The shape has changed enough that the ordering below was re-derived after re-baselining: render.scene is now 5.62 ms of a 10.04 ms CPU frame, editor UI is the other ~4.4 ms, and the transparent pass (#168 (closed)) is the largest single render cost.
Original measurement (2026-08-23)
docs/decisions/bistro-perf-baseline.md, from the #159 (closed) harness. Bistro, elevated terrace, 1280x720, Debug, RX 6700 XT:
p50 = 115.7 ms — 8.6 fps. 4,006 draw calls/frame. The frame is CPU-bound: 112.9 ms of CPU against a 118.1 ms period (96 %).
| pass | CPU | draws | indirect | tris |
|---|---|---|---|---|
| render.prepass | 71.86 ms | 2,435 | 0 | 2,479,823 |
| render.main | 16.39 ms | 194 | 3,271 | 3,734,581 |
| render.upload | 12.39 ms | 0 | 0 | 0 |
| render.shadow | 7.44 ms | 776 | 13,084 | 14,938,324 |
| render.transparent | 2.60 ms | 601 | 0 | 416,834 |
| ssao / ssr / cull / postprocess | < 0.1 ms each | 0 | 0 | 0 |
The list above was written from code reading; the measurement confirms items 1 and 2 and reorders everything else. Two findings changed the plan:
- Per-pass GPU fences (#159 (closed) item 0d) are off the critical path — no point timing a GPU that idles for 96 % of the frame.
- #162 and #161 (closed) are 73 % of the frame between them, with no fidelity change, so they move ahead of the quality toggles.
Phases
Phase 0 — instrumentation. Done.
- #159 (closed) Headless benchmark mode, per-pass counters, edit-mode profiling. Output:
docs/decisions/bistro-perf-baseline.md. (Item 0d, per-pass GPU fences, deferred — see below.)
Phase 1 — the free wins (no fidelity change). Done — 115.7 ms → 12.8 ms, 9.0x.
- #162 Prepass issued 2,435 draw calls where the main pass issued 194 — 71.9 ms → 0.38 ms
- #161 (closed) Per-frame asset re-scan and linear GUID lookups — 12.4 ms → 0.26 ms, and
render.main16.4 → 3.9 ms - #163 (closed) Frame-constant data re-pushed in every 576-byte per-draw uniform — now small
- #164 (closed) Studio renders the whole scene more than once per editor frame — now small
Phase 1b — what the re-baseline exposed.
- #168 (closed) Transparent pass is 601 per-submesh draws, now the largest render cost at 2.47 ms (44 % of
render.scene)
Phase 2 — a quality dial.
- #160 (closed) Per-feature rendering toggles +
graphics.qualitypresets that finally mean something. - #169 Player-facing settings screen in the shipped game (builds on #160 (closed))
Phase 3 — shadow cost.
- #165 Cascades re-submit the whole scene 4x every frame
Phase 4 — cut the geometry at source.
- #39 LOD system — the only thing that addresses 4.15M triangles at source
Follow-up, unscheduled.
- #167 GPU occlusion culling (HZB over the prepass depth)
Diffuse GI (#166) is scheduled after all of the above — it adds frame cost to a scene that can't afford it yet.
Related
- #132 Bistro sample — the driving use case
- #147 (closed) Frame performance analysis — built the fence-timing tooling, never got live numbers
- #143 (closed) Frustum culling, ADR-0013 GPU-driven culling — the previous rounds
- #39 LOD system — phase 4