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)

  1. A full asset re-scan every frame. assets.uploadNewAssets resolves 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.
  2. The prepass issues 3,880 draw calls where the main pass issues 206. prepass.run loops submeshes with a resolveMaterial, 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 reuse gm.indirect_buf.
  3. 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. 4× MSAA, forced. pipeline.pickSampleCount has no override anywhere.
  5. SSAO and SSR at full resolution — 24 AO taps and a 32-step march.
  6. 576 bytes of mostly frame-constant uniform pushed per draw.
  7. 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.main 16.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.quality presets 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.

  • #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
Edited by Bruno Massa