Perf: instrumentation — headless benchmark mode, per-pass counters, edit-mode profiling

Part of #158. Blocks every other perf issue — no optimization lands before there is a number to move.

Problem

We cannot currently measure the thing we are being asked to fix.

  • ProfilerPanel.tickRecording sets engine.Profiler.enabled = active and g_recordprofiling only happens during Play. The ~20 fps Scene-viewport number in #158 cannot be profiled at all.
  • Otherwise Profiler.enabled defaults to builtin.mode == .Debug, so a compiled release build can't be profiled either.
  • Profiler.Counters are frame-global. With draws coming from the prepass, shadow, main, transparent and probe-bake passes, a single draw_calls number can't be attributed to any of them.
  • Real GPU timing is an opt-in fence stall covering 2 of 8 passes (gpu_timing.zig wraps shadow and cull only).
  • Nothing counts indirect commands issued, so the ADR-0013 zero-instance overhead is invisible.
  • There is no headless way to produce a trace. A backgrounded, unfocused Studio never pumps frames — profiler.capture over the debug protocol returns an empty thread list, and synthetic input is never applied. Only TURIAN_CAPTURE_AFTER_MS forces real frames, because Main.zig:612 caps waitEventTimeout at 16 ms while a capture is pending.

That last point is why #147 (closed)'s acceptance criterion was never met: getting numbers requires a human at a focused window.

Scope

a. Headless benchmark mode.

turian-studio --benchmark <project> [--scene <path>] [--frames N]
              [--camera <name>] [--warmup N] [--features <spec>]

Reuses the capture_pending mechanism in Main.zig:605-613 to keep an unfocused window pumping, drives N frames from a fixed or scripted camera, writes <project>/benchmark/<timestamp>/{trace.json,summary.json} and exits. trace.json is the existing Profiler.writeChromeTrace output (loads straight into Perfetto); summary.json extends writeFrameJson with p50/p95/p99. --features lets one invocation A/B a quality toggle set.

b. Profile outside Play. Replace the active and g_record gate with an explicit off / edit / play source, and honour TURIAN_PROFILER=1 to force the profiler on in release builds.

c. Per-pass counter attribution. Profiler.beginPass(name) / endPass() snapshotting counter deltas into a per-pass row, so draw_calls reads prepass=3880, shadow=824, main=206, transparent=N instead of one lump. Add indirect_commands to Counters.

d. Fence-bracketed GPU timing for every pass. gpu_timing.zig already has the pattern; extend it to prepass, SSAO, SSR, main, transparent, post-process and probe bake behind the existing "detailed GPU timing" toggle. Per #147 (closed) this is the only GPU timing SDL3 permits (no timestamp-query API on any backend), and it is enough to answer "CPU or GPU bound".

Files

studio/Main.zig, studio/main-window/ProfilerPanel.zig, engine/Profiler.zig, subsystems/render/gpu_timing.zig, subsystems/render/root.zig

Acceptance

  • --benchmark produces a Perfetto-loadable trace and a summary JSON from an unfocused window, with no human interaction
  • The Scene viewport can be profiled without entering Play
  • Draw calls, triangles and indirect commands are attributed per pass
  • Detailed GPU timing reports every pass, not two
  • docs/decisions/bistro-perf-baseline.md committed with real Bistro numbers