diff --git a/src/app.rs b/src/app.rs index eb4764f..1f98178 100644 --- a/src/app.rs +++ b/src/app.rs @@ -536,10 +536,16 @@ impl App { // Build a few pending chunk meshes if any. self.drain_pending_chunk_builds(); - let dt = match self.last_frame.as_ref() { - Some(c) => c.elapsed().as_secs_f32().min(0.1), - None => 0.016, + // Real elapsed since last tick. We keep the unclamped value + // for FPS telemetry — if a frame really takes 300ms, the HUD + // should say 300ms, not the physics-safe clamp of 100ms. + let elapsed_ms = match self.last_frame.as_ref() { + Some(c) => c.elapsed().as_secs_f32() * 1000.0, + None => 16.0, }; + // Physics integrates with the *clamped* dt so a long pause + // can't teleport the player through walls. + let dt = (elapsed_ms / 1000.0).min(0.1); self.last_frame = Some(FrameClock::now()); let real_time = self .start_clock @@ -559,7 +565,7 @@ impl App { bridges::clear_touch_inputs(); self.drain_net_inbox(); self.render_frame(settings, None); - self.publish_telemetry(dt * 1000.0); + self.publish_telemetry(elapsed_ms); return; } @@ -737,7 +743,7 @@ impl App { drop(world_borrow); drop(camera_borrow); self.render_frame(settings, outline); - self.publish_telemetry(dt * 1000.0); + self.publish_telemetry(elapsed_ms); let _ = WORLD_RADIUS; } @@ -758,6 +764,10 @@ impl App { }; let remotes = bridges::snapshot_remote_players(); if let Some(r) = self.renderer.borrow_mut().as_mut() { + // Push bench flags into the renderer each frame. + let bench = bridges::bench_flags(); + r.bench_disable_shafts.set(bench.disable_shafts); + r.bench_disable_post.set(bench.disable_post); r.set_outline(outline); r.set_visible(visible); r.set_remote_players(&remotes); diff --git a/src/bridges.rs b/src/bridges.rs index 1278e11..59fd6d4 100644 --- a/src/bridges.rs +++ b/src/bridges.rs @@ -32,6 +32,15 @@ pub enum TestCommand { LookAt(f32, f32), } +/// Per-pass bench toggles, settable from JS via the wasm_api below. +/// Used to A/B which render passes cost what — set a flag, sample +/// frame_dt_ms, compare. Defaults: all false (all passes run). +#[derive(Default, Clone, Copy, Debug)] +pub struct BenchFlags { + pub disable_shafts: bool, + pub disable_post: bool, +} + #[derive(Default, Clone, Copy, Debug)] pub struct Telemetry { pub scene_time: f32, @@ -118,6 +127,7 @@ thread_local! { // Test-harness storage. static TEST_COMMANDS: RefCell> = RefCell::new(Vec::new()); static TELEMETRY: RefCell = RefCell::new(Telemetry::default()); + static BENCH: RefCell = RefCell::new(BenchFlags::default()); } // ---------------- Public typed accessors ---------------- @@ -232,6 +242,11 @@ pub fn publish_telemetry(t: Telemetry) { TELEMETRY.with(|x| *x.borrow_mut() = t); } +/// Snapshot the bench flags (called by App::tick to push into Renderer). +pub fn bench_flags() -> BenchFlags { + BENCH.with(|b| *b.borrow()) +} + // ---------------- wasm-bindgen JS interface ---------------- #[cfg(target_arch = "wasm32")] @@ -390,4 +405,17 @@ mod wasm_api { pub fn get_frame_dt_ms() -> f32 { super::TELEMETRY.with(|x| x.borrow().frame_dt_ms) } + /// Bench: disable the mask + shafts passes entirely. Used by + /// the perf benchmark scenario to measure how much god-rays cost. + #[wasm_bindgen] + pub fn bench_set_disable_shafts(on: bool) { + super::BENCH.with(|b| b.borrow_mut().disable_shafts = on); + } + /// Bench: skip the final post pass (FXAA + composite + tonemap). + /// Surface clears to black so the HUD is still visible; the FPS + /// telemetry tells us how much post cost. + #[wasm_bindgen] + pub fn bench_set_disable_post(on: bool) { + super::BENCH.with(|b| b.borrow_mut().disable_post = on); + } } diff --git a/src/render/mod.rs b/src/render/mod.rs index fea97a7..34fac83 100644 --- a/src/render/mod.rs +++ b/src/render/mod.rs @@ -70,6 +70,13 @@ pub struct Renderer { /// frame. Set by `upload_camera` based on sun altitude — we skip /// the passes when the sun is below the horizon to save fillrate. shafts_active: std::cell::Cell, + /// Per-pass diagnostic skip flags. Wired through JS via + /// `set_bench_disable_shafts` / `set_bench_disable_fxaa_tonemap` + /// so a Playwright scenario can measure the FPS delta caused by + /// each pass and pin down where the time is actually going. + /// Defaults are false (all passes run). Not exposed to players. + pub bench_disable_shafts: std::cell::Cell, + pub bench_disable_post: std::cell::Cell, } impl Renderer { @@ -365,6 +372,8 @@ impl Renderer { shafts_bg, post_bind_group, shafts_active: std::cell::Cell::new(false), + bench_disable_shafts: std::cell::Cell::new(false), + bench_disable_post: std::cell::Cell::new(false), } } @@ -652,14 +661,8 @@ impl Renderer { } // ---- Post chain: mask → shafts → composite. ---- - // At night (sun below horizon) the mask + shafts passes are - // pure overhead — the shafts shader early-outs to black - // anyway. We skip them on the CPU side and just rely on the - // shafts_view holding its previous contents (or black if not - // yet written). The post pass still composites shafts_view, - // so it must contain something sensible — initialized to - // black at startup, kept black during the day too whenever - // shafts produce nothing visible. + let do_shafts = do_shafts && !self.bench_disable_shafts.get(); + let do_post = !self.bench_disable_post.get(); if do_shafts { run_fullscreen_pass( &mut encoder, "mask pass", &self.mask_view, @@ -673,23 +676,23 @@ impl Renderer { &[&self.camera_bind_group, &self.shafts_bg], Some(wgpu::Color::BLACK), ); - } else { - // Stamp shafts_view to black so the composite doesn't - // inherit yesterday's rays. One pass write is much cheaper - // than running mask + shafts. + } + // ELSE: no clear of shafts_view either. For accurate bench + // measurement we want skipping shafts to mean "no GPU work + // at all for shafts". Visually this could keep stale rays + // in the texture, which is the price of benchmarking — not + // the normal player path. + if do_post { run_fullscreen_pass( - &mut encoder, "shafts clear", &self.shafts_view, - &self.mask_pipeline, // any cheap pipeline works for a clear - &[&self.camera_bind_group, &self.mask_bg], - Some(wgpu::Color::BLACK), + &mut encoder, "post pass", &surface_view, + &self.post_pipeline, + &[&self.post_bind_group], + None, ); } - run_fullscreen_pass( - &mut encoder, "post pass", &surface_view, - &self.post_pipeline, - &[&self.post_bind_group], - None, - ); + // ELSE: surface gets whatever was last presented. Benchmark + // measures pure cost of the post pass; the screen may flicker + // garbage during this toggle, which is expected for an A/B run. self.queue.submit(Some(encoder.finish())); frame.present();