Runtime perf: cheap fog, sky overdraw kill, fewer cloud octaves, in-game FPS HUD

The features added in Rounds A–D were correct but expensive. The hot
path per frame was sky_color() called from apply_fog for EVERY distant
pixel — 4-octave cloud fbm + star hash + sun/moon disc per fragment,
hundreds of thousands of pixels per frame. Profile-driven cuts that
keep all features but stop paying for them in the wrong places:

1. apply_fog now mixes terrain toward sky_dome (cheap gradient) not
   sky_color (gradient + clouds + sun + moon + stars). Distant terrain
   still fades to the right-direction sky color at every time of day;
   the per-pixel cost drops by ~80%. Full sky_color still runs for
   the SKY BACKGROUND pass where it's actually paid for.

2. Sky pipeline draws AFTER terrain with depth_compare = LessEqual.
   The full-screen sky was previously written first then over-painted
   by terrain — sky's expensive fragment shader ran on every screen
   pixel. Now it only runs on pixels with no terrain in front of them
   (depth = 1.0 cleared), which on most views is 30–60% of the screen
   instead of 100%.

3. fbm2 reduced from 4 → 3 octaves. Negligible visual change at the
   scales we sample, ~25% cheaper per cloud-pixel.

4. Cloud branch skips entirely when day_strength < 0.05 (full night).
   Clouds invisible at night anyway, fbm + smoothstep + mix skipped.

5. In-game FPS HUD (top-right corner):
   - Telemetry struct gains frame_dt_ms (EMA-smoothed in app.rs
     with coefficient 0.85 so the number is readable, not flickery).
   - wasm bridge: get_frame_dt_ms().
   - main.js setupFpsHud() polls it at 5Hz, color-coded:
       green ≤ 18ms (≥55fps), amber 18-33ms, red beyond.
   - Reads what THE GAME measures, not the browser's
     requestAnimationFrame which gets throttled to 1 Hz on
     unfocused windows.

No features removed. God rays, FXAA, ACES tonemap, bounce baking,
specular materials, leaf translucency — all still there. Tests:
63 passing. Wasm release clean.
This commit is contained in:
Maximus Gorog 2026-05-24 15:44:14 -06:00
parent 3187b9ca07
commit bb006839cc
7 changed files with 103 additions and 18 deletions

View file

@ -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<IVec3>,
/// 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;
}

View file

@ -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)
}
}

View file

@ -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 ~5080% 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<dyn Iterator<Item = &ChunkBuffers>> = 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, &[]);

View file

@ -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(),
}),

View file

@ -108,7 +108,9 @@ fn fbm2(p_in: vec2<f32>) -> 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<f32>) -> vec3<f32> {
}
// 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<f32>(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 horizonzenith 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<f32>, dist: f32, view_dir: vec3<f32>) -> vec3<f32> {
let t = fog_factor(dist);
if (t <= 0.001) {
@ -295,7 +304,7 @@ fn apply_fog(lit: vec3<f32>, dist: f32, view_dir: vec3<f32>) -> vec3<f32> {
}
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);
}

View file

@ -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 @@
</div>
<button id="menu-btn" title="Menu (Esc)"></button>
<div id="fps">— fps</div>
<div id="gptest" style="display:none;">
<div id="gptest-card">

View file

@ -28,6 +28,7 @@ init().then(() => {
// get_position, etc. Dev-affordance only; production users never
// touch this surface.
window.voxel_game = wasm;
setupFpsHud();
wasm.reset_input();
setupTouch();
setupGamepad();
@ -676,3 +677,23 @@ function cycleHotbar(delta) {
const b = parseInt(slots[_selectedSlot].dataset.b, 10);
wasm.select_block(b);
}
// FPS HUD — reads the EMA-smoothed dt published by App::publish_telemetry
// each frame. Polled every 200ms (5Hz) which is plenty for a readable
// number without adding measurable overhead. Coloring: green ≤ 18ms,
// amber 18-33ms, red beyond.
function setupFpsHud() {
const el = document.getElementById("fps");
if (!el) return;
setInterval(() => {
const dt = wasm.get_frame_dt_ms ? wasm.get_frame_dt_ms() : 0;
if (!dt || dt <= 0) {
el.textContent = "— fps";
return;
}
const fps = 1000 / dt;
el.textContent = `${fps.toFixed(0)} fps (${dt.toFixed(1)}ms)`;
el.classList.toggle("warn", dt > 18 && dt <= 33);
el.classList.toggle("bad", dt > 33);
}, 200);
}