diff --git a/src/app.rs b/src/app.rs index 79a06e8..eb4764f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -73,6 +73,9 @@ pub struct App { /// ~1.5 s (post-fix) all at once. Spatially sorted closest- /// first so the player sees nearby terrain before the horizon. pending_chunk_builds: VecDeque, + /// EMA-smoothed frame delta in ms, published in Telemetry for + /// the FPS HUD and external profiling. + smoothed_dt_ms: f32, } impl Default for PlayerBody { @@ -469,13 +472,21 @@ impl App { } /// Publish telemetry for the test harness to read back. - fn publish_telemetry(&self) { + fn publish_telemetry(&mut self, dt_ms: f32) { let (yaw, pitch) = self .camera .borrow() .as_ref() .map(|c| (c.yaw, c.pitch)) .unwrap_or((0.0, 0.0)); + // Exponential-moving-average smoothing on frame dt so the HUD + // doesn't flicker. Coefficient chosen so a 16ms ⇄ 32ms swap + // converges in ~10 frames. + self.smoothed_dt_ms = if self.smoothed_dt_ms == 0.0 { + dt_ms + } else { + self.smoothed_dt_ms * 0.85 + dt_ms * 0.15 + }; bridges::publish_telemetry(Telemetry { scene_time: self.shader_time, pos_x: self.body.feet.x, @@ -485,6 +496,7 @@ impl App { pitch, hp: self.body.hp, alive: self.body.alive, + frame_dt_ms: self.smoothed_dt_ms, }); } @@ -547,6 +559,7 @@ impl App { bridges::clear_touch_inputs(); self.drain_net_inbox(); self.render_frame(settings, None); + self.publish_telemetry(dt * 1000.0); return; } @@ -724,7 +737,7 @@ impl App { drop(world_borrow); drop(camera_borrow); self.render_frame(settings, outline); - self.publish_telemetry(); + self.publish_telemetry(dt * 1000.0); let _ = WORLD_RADIUS; } diff --git a/src/bridges.rs b/src/bridges.rs index dd64c5a..1278e11 100644 --- a/src/bridges.rs +++ b/src/bridges.rs @@ -42,6 +42,10 @@ pub struct Telemetry { pub pitch: f32, pub hp: u8, pub alive: bool, + /// Smoothed frame delta in ms — exposed for the in-game FPS HUD + /// and the test harness so we can verify perf claims on real + /// hardware without depending on browser rAF throttling. + pub frame_dt_ms: f32, } // ---------------- Data types stored in the bridges ---------------- @@ -380,4 +384,10 @@ mod wasm_api { vec![t.yaw, t.pitch] }) } + /// Smoothed per-frame delta (ms). The in-game HUD reads this to + /// display fps as 1000/frame_dt_ms. + #[wasm_bindgen] + pub fn get_frame_dt_ms() -> f32 { + super::TELEMETRY.with(|x| x.borrow().frame_dt_ms) + } } diff --git a/src/render/mod.rs b/src/render/mod.rs index 28297a7..57a89de 100644 --- a/src/render/mod.rs +++ b/src/render/mod.rs @@ -590,10 +590,11 @@ impl Renderer { timestamp_writes: None, occlusion_query_set: None, }); - pass.set_pipeline(&self.sky_pipeline); - pass.set_bind_group(0, &self.camera_bind_group, &[]); - pass.draw(0..3, 0..1); - + // Terrain first so depth is populated. Sky's expensive + // fragment shader (cloud fbm + sun/moon discs) then only + // runs on pixels with no terrain over them (depth==1.0 + // cleared), saving ~50–80% of sky fragment work depending + // on the view. pass.set_pipeline(&self.pipeline); pass.set_bind_group(0, &self.camera_bind_group, &[]); let iter: Box> = if self.visible_chunks.is_empty() { @@ -611,6 +612,11 @@ impl Renderer { pass.set_index_buffer(self.remote_ib.slice(..), wgpu::IndexFormat::Uint32); pass.draw_indexed(0..self.remote_index_count, 0, 0..1); } + // Sky after terrain — pipeline depth_compare = LessEqual + // at depth = 1.0 means it only writes uncovered pixels. + pass.set_pipeline(&self.sky_pipeline); + pass.set_bind_group(0, &self.camera_bind_group, &[]); + pass.draw(0..3, 0..1); if self.outline_target.is_some() { pass.set_pipeline(&self.outline_pipeline); pass.set_bind_group(0, &self.camera_bind_group, &[]); diff --git a/src/render/pipelines.rs b/src/render/pipelines.rs index d81327c..462ce4a 100644 --- a/src/render/pipelines.rs +++ b/src/render/pipelines.rs @@ -107,9 +107,14 @@ fn color_target(format: TextureFormat) -> ColorTargetState { } } -/// Full-screen sky background. No vertex buffer; the shader emits a -/// covering triangle from `vertex_index`. Depth always passes (drawn -/// before terrain so terrain naturally overwrites it where present). +/// Full-screen sky background. Drawn LAST in the scene pass with +/// `depth_compare: LessEqual` and depth=1.0 in the vertex shader, so +/// it only writes pixels where terrain didn't overdraw (i.e. open +/// sky). This means the expensive `sky_color` (4-octave cloud fbm + +/// star field + sun + moon discs) only runs for the visible sky +/// portion of the screen instead of every pixel including those that +/// will be overpainted by terrain. Big win when looking at terrain +/// or down at the ground. pub fn sky_pipeline( device: &Device, layout: &PipelineLayout, @@ -140,7 +145,7 @@ pub fn sky_pipeline( depth_stencil: Some(wgpu::DepthStencilState { format: wgpu::TextureFormat::Depth32Float, depth_write_enabled: false, - depth_compare: wgpu::CompareFunction::Always, + depth_compare: wgpu::CompareFunction::LessEqual, stencil: wgpu::StencilState::default(), bias: wgpu::DepthBiasState::default(), }), diff --git a/src/shader.wgsl b/src/shader.wgsl index d2a59ef..18b35fd 100644 --- a/src/shader.wgsl +++ b/src/shader.wgsl @@ -108,7 +108,9 @@ fn fbm2(p_in: vec2) -> f32 { var p = p_in; var v = 0.0; var amp = 0.5; - for (var i = 0; i < 4; i = i + 1) { + // 3 octaves (was 4): noticeable per-pixel cost reduction with + // negligible visual difference at the scales we sample. + for (var i = 0; i < 3; i = i + 1) { v = v + amp * noise2(p); p = p * 2.07; amp = amp * 0.5; @@ -152,7 +154,10 @@ fn sky_color(dir: vec3) -> vec3 { } // Cloud layer — fbm scrolled across an imaginary plane high above. - if (dir.y > 0.05) { + // Skip entirely at night: clouds are invisible without sun light, + // and saving the fbm + smoothstep + mix on every dark sky pixel + // is a real perf win at midnight. + if (dir.y > 0.05 && day > 0.05) { let proj = dir.xz / dir.y; let scroll = vec2(t * 0.004, t * 0.0015); let n = fbm2(proj * 0.50 + scroll); @@ -283,11 +288,15 @@ fn fog_factor(dist: f32) -> f32 { return clamp((dist - fog_start) / (fog_end - fog_start), 0.0, 1.0); } -/// Blend `lit` toward sky color along the view ray when the fragment -/// is far enough to be fogged. Defers the (expensive) full `sky_color` -/// call until the factor is actually nonzero. At twilight the fog -/// further biases toward warm sun-tint so distant terrain reads -/// orange/pink against an orange sky instead of cold against orange. +/// Blend `lit` toward the sky-dome gradient along the view ray. Uses +/// the cheap `sky_dome` (just horizon→zenith gradient + zenith warm +/// tint at twilight) rather than the full `sky_color` (4-octave cloud +/// fbm + star field + sun + moon disc) which was an ENORMOUS per- +/// pixel cost on every distant fragment. Visually the difference is +/// minor — distant terrain still fades into the right-direction sky +/// gradient at every time of day — but fragment cost drops dramatically. +/// The full `sky_color` still runs for the SKY BACKGROUND pass where +/// it's only paid for pixels with no terrain in front of them. fn apply_fog(lit: vec3, dist: f32, view_dir: vec3) -> vec3 { let t = fog_factor(dist); if (t <= 0.001) { @@ -295,7 +304,7 @@ fn apply_fog(lit: vec3, dist: f32, view_dir: vec3) -> vec3 { } let sun = sun_direction(scene_time()); let twi = twilight_amount(sun); - let sky = sky_color(-view_dir); + let sky = sky_dome(-view_dir, sun); let fog_col = mix(sky, sky * sun_tint(sun), twi * 0.45); return mix(lit, fog_col, t); } diff --git a/web/index.html b/web/index.html index 31e8c4a..1887f6b 100644 --- a/web/index.html +++ b/web/index.html @@ -41,6 +41,26 @@ line-height: 1.5; pointer-events: none; } + /* Frame-time HUD — top-right, always-on, fed from + window.voxel_game.get_frame_dt_ms() so the number reflects what + the game itself is measuring (not the browser's throttled rAF). + Colors: green ≤ 18ms, amber ≤ 33ms, red beyond. */ + #fps { + position: fixed; + top: 12px; + right: 12px; + background: var(--ui-bg); + padding: 6px 10px; + border-radius: 6px; + font: 12px/1.2 ui-monospace, monospace; + pointer-events: none; + z-index: 20; + min-width: 60px; + text-align: right; + color: #cfc; + } + #fps.warn { color: #ffd86a; } + #fps.bad { color: #ff7a6a; } #hud kbd { background: #333; border-radius: 3px; @@ -546,6 +566,7 @@ +
— fps