terainia/src/shader.wgsl
Maximus Gorog bb006839cc 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.
2026-05-24 15:44:14 -06:00

449 lines
17 KiB
WebGPU Shading Language
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Voxel-game world/sky/outline pipelines.
//
// The constants `DAY_PERIOD` and `SUN_OFFSET` are NOT defined here —
// they are injected at the top of this file at module-load time by
// `crate::shader_source::wgsl_constants_header()`. The same values
// drive `crate::sim::lighting`, so the visual sun direction can never
// disagree with the mechanical one consumed by mob burn / plant growth /
// shade pathfinding.
//
// Layout (top to bottom):
// 1. Camera uniform + accessors
// 2. Sky horizon math (one source of truth: sky_dome)
// 3. Atmosphere extras (clouds, sun/moon discs, stars) → sky_color
// 4. Terrain lighting decomposition (ambient, sun, fog, leaf jitter)
// 5. Pipelines: terrain (vs_main/fs_main), sky background, outline.
// ---------------- 1. Camera ----------------
// Camera uniform — layout mirrored in `render::uniform::CameraUniform`.
// The `frame` vec4 is the "per-frame scalars" slot; the accessor
// functions below name each component so callsites read as intent
// rather than `frame.x` / `frame.y` / etc.
//
// frame.x scene_time seconds since session start
// frame.y exposure_bias tonemap multiplier (default 1.0)
// frame.z reserved for future fog density / weather etc.
// frame.w reserved
struct Camera {
view_proj: mat4x4<f32>,
inv_view_proj: mat4x4<f32>,
eye: vec4<f32>,
frame: vec4<f32>,
};
@group(0) @binding(0) var<uniform> camera: Camera;
fn scene_time() -> f32 { return camera.frame.x; }
fn exposure_bias() -> f32 { return camera.frame.y; }
fn eye_world() -> vec3<f32> { return camera.eye.xyz; }
// ---------------- 2. Sky horizon math (shared with ambient) ----------------
fn sun_direction(t: f32) -> vec3<f32> {
let a = (t / DAY_PERIOD + SUN_OFFSET) * 6.28318530718;
return normalize(vec3<f32>(cos(a), sin(a), 0.25));
}
fn day_strength(sun: vec3<f32>) -> f32 {
return smoothstep(-0.05, 0.20, sun.y);
}
fn twilight_amount(sun: vec3<f32>) -> f32 {
let above = smoothstep(-0.10, 0.05, sun.y);
let high = smoothstep(0.05, 0.30, sun.y);
return above - high;
}
fn sun_tint(sun: vec3<f32>) -> vec3<f32> {
let twi = twilight_amount(sun);
return mix(vec3<f32>(1.00, 0.95, 0.85), vec3<f32>(1.00, 0.55, 0.30), twi);
}
// Horizon → zenith gradient. Used by both:
// - the sky background fragment shader (with clouds/sun layered on)
// - terrain hemisphere ambient (sample the dome in surface-normal dir
// so vertical faces inherit the bright daytime horizon).
//
// One function = one source of truth for "what color is the sky at this
// angle?" — when the palette is tuned, both consumers update together.
fn sky_dome(dir: vec3<f32>, sun: vec3<f32>) -> vec3<f32> {
let day = day_strength(sun);
let twi = twilight_amount(sun);
let zenith_day = vec3<f32>(0.30, 0.55, 0.88);
let zenith_night = vec3<f32>(0.02, 0.03, 0.10);
// Dusky-purple zenith during twilight — in reality the entire sky
// tints warm at sunset, not just the horizon. Without this, top
// faces stay cold blue while the horizon visibly burns orange.
let zenith_twi = vec3<f32>(0.45, 0.28, 0.40);
let horizon_day = vec3<f32>(0.82, 0.92, 0.99);
let horizon_twi = vec3<f32>(1.00, 0.55, 0.28);
let horizon_night = vec3<f32>(0.03, 0.04, 0.10);
let zenith_base = mix(zenith_night, zenith_day, day);
let zenith = mix(zenith_base, zenith_twi, twi * 0.65);
let horizon = mix(mix(horizon_night, horizon_day, day), horizon_twi, twi);
let up = clamp(dir.y, -1.0, 1.0);
let gradient_t = pow(max(up, 0.0), 0.55);
return mix(horizon, zenith, gradient_t);
}
// ---------------- 3. Atmosphere extras ----------------
fn hash21(p: vec2<f32>) -> f32 {
return fract(sin(dot(p, vec2<f32>(127.1, 311.7))) * 43758.5453);
}
fn noise2(p: vec2<f32>) -> f32 {
let i = floor(p);
let f = fract(p);
let u = f * f * (3.0 - 2.0 * f);
let a = hash21(i);
let b = hash21(i + vec2<f32>(1.0, 0.0));
let c = hash21(i + vec2<f32>(0.0, 1.0));
let d = hash21(i + vec2<f32>(1.0, 1.0));
return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}
fn fbm2(p_in: vec2<f32>) -> f32 {
var p = p_in;
var v = 0.0;
var amp = 0.5;
// 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;
}
return v;
}
// High-frequency hash on view direction, threshold to keep ~0.2% lit.
fn star_field(dir: vec3<f32>) -> f32 {
if (dir.y <= 0.0) { return 0.0; }
let cell = floor(dir * 220.0);
let h = fract(sin(dot(cell, vec3<f32>(12.9898, 78.233, 37.719))) * 43758.5453);
return step(0.997, h);
}
// Composite sky: dome + below-horizon dim + stars + clouds + sun + moon.
// `dir` is the view direction from camera into the scene (unit vector).
fn sky_color(dir: vec3<f32>) -> vec3<f32> {
let t = scene_time();
let sun = sun_direction(t);
let day = day_strength(sun);
let twi = twilight_amount(sun);
let night = clamp(1.0 - day, 0.0, 1.0);
var sky = sky_dome(dir, sun);
// Below-horizon slight darken so the world below the player still feels grounded.
let up = clamp(dir.y, -1.0, 1.0);
let below = step(up, 0.0) * 0.2;
sky = sky * (1.0 - below);
// Stars: only visible once the sun is well below the horizon. The
// old `1 - day` gate showed stars during twilight (when day < 1 but
// the sky was still bright). The new gate is tied to sun altitude
// directly so stars fade in *after* civil twilight, not during it.
let star_fade = 1.0 - smoothstep(-0.22, 0.04, sun.y);
if (star_fade > 0.05) {
let st = star_field(dir);
let twinkle = 0.7 + 0.3 * sin(t * 6.0 + dir.x * 100.0 + dir.z * 130.0);
sky = sky + vec3<f32>(st * star_fade * twinkle);
}
// Cloud layer — fbm scrolled across an imaginary plane high above.
// 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);
let mask = smoothstep(0.50, 0.78, n);
let cloud_lit = mix(vec3<f32>(0.30, 0.30, 0.35), vec3<f32>(1.00, 0.97, 0.92), day);
let cloud_twi = vec3<f32>(1.00, 0.60, 0.45);
let cloud_col = mix(cloud_lit, cloud_twi, twi * 0.7);
sky = mix(sky, cloud_col, mask * (0.55 + 0.25 * day));
}
// Sun disc + halo. The disc softens and spreads as the sun nears
// the horizon — atmospheric scattering blooms the apparent disc at
// low angles. Sharp pin-point at zenith, big soft circle at dusk.
let sun_col = sun_tint(sun);
let cos_s = max(dot(dir, sun), 0.0);
let alt = clamp(sun.y, 0.0, 1.0);
let disc_sharpness = mix(160.0, 800.0, alt);
let disc_intensity = mix(2.2, 1.5, alt);
let disc = pow(cos_s, disc_sharpness) * disc_intensity * smoothstep(-0.05, 0.05, sun.y);
let halo = pow(cos_s, mix(3.0, 5.0, alt)) * mix(0.35, 0.20, alt) * day;
sky = sky + sun_col * (disc + halo);
// Moon disc — opposite the sun, faint white, night only.
let moon = -sun;
let cos_m = max(dot(dir, moon), 0.0);
let moon_disc = pow(cos_m, 700.0) * 0.9;
let moon_halo = pow(cos_m, 24.0) * 0.06;
sky = sky + vec3<f32>(0.86, 0.89, 0.96) * (moon_disc + moon_halo) * night;
return sky;
}
// ---------------- 4. Terrain lighting decomposition ----------------
//
// Each piece is a named function so `fs_main` reads as a pipeline:
//
// lit = base_color *
// ( ambient_term × ambient_strength + direct_sun_term × sun_color )
// × ao × leaf_jitter
// final = apply_fog( lit, dist, view_dir )
/// Physically-decoupled hemisphere ambient:
///
/// ambient = sky_radiance(N) × sky_vis + bounce × (1 sky_vis)
///
/// Both visibility *and* bounce color are baked per vertex by
/// `sim::lighting::compute_ambience` (same 8 cosine-weighted rays
/// produce sky_vis and average bounce color in one pass). The
/// shader's job is just to combine the baked geometric terms with the
/// time-of-day radiometric terms.
///
/// - Open plain at noon: sky_vis≈1 → full bright sky-color ambient
/// - Deep cave at noon: sky_vis≈0 → bounce_color × sun strength
/// - Red brick wall corner: bounce_color carries the red tint from
/// the wall, giving a faint red glow on
/// adjacent surfaces (real GI hint, free at
/// runtime).
fn ambient_term(normal: vec3<f32>, sun: vec3<f32>, day: f32, sky_vis: f32, bounce_albedo: vec3<f32>) -> vec3<f32> {
let sky = sky_dome(normal, sun);
let bounce_strength = mix(0.04, 1.0, day);
let bounce = bounce_albedo * bounce_strength * sun_tint(sun);
return sky * sky_vis + bounce * (1.0 - sky_vis);
}
/// Material lookup. Keyed by `bounce_mat.a` (set at mesh-build time
/// from `Block::material_id`). Keep in sync with the Rust enum.
struct Material {
specular: f32,
emission: vec3<f32>,
};
fn material_for(id_f: f32) -> Material {
let id = u32(id_f + 0.5);
var m: Material;
m.specular = 0.0;
m.emission = vec3<f32>(0.0);
if (id == 1u) {
// Ice / snow — slight specular highlight.
m.specular = 0.45;
} else if (id == 2u) {
// Reserved: emissive (future glowing blocks).
m.emission = vec3<f32>(1.00, 0.70, 0.40);
}
return m;
}
/// Direct-sun Lambert term, gated by sun visibility above the horizon.
/// Returns a scalar 0..1 — caller multiplies by `sun_tint(sun)` for
/// color.
///
/// NOTE: This is an *unoccluded* Lambert. The mechanical sunlight
/// predicate (`crate::sim::lighting::is_in_direct_sun`) is more
/// accurate — it ray-traces the voxel grid. When real shadows land,
/// this function will multiply by an occlusion factor that approximates
/// that ray test (shadow map / voxel raymarch / etc).
fn direct_sun_term(normal: vec3<f32>, sun: vec3<f32>) -> f32 {
let ndl = max(dot(normal, sun), 0.0);
let sun_visible = smoothstep(-0.05, 0.10, sun.y);
return ndl * sun_visible;
}
/// Per-pixel value-noise jitter on leaf surfaces so the canopy doesn't
/// read as a flat green. 0.88 .. 1.06 range.
fn leaf_jitter(world_pos: vec3<f32>) -> f32 {
let n = fract(sin(dot(floor(world_pos * 1.3), vec3<f32>(12.9898, 78.233, 37.719))) * 43758.5453);
return 0.88 + n * 0.18;
}
/// Approximated leaf translucency. When the sun is behind the leaf
/// (relative to the surface normal), some light transmits through to
/// the viewer's side and the leaf glows softly with sun-tinted color.
/// Cheap stand-in for full subsurface scattering; reads as "sun
/// through canopy" without per-pixel sampling.
fn leaf_translucency(normal: vec3<f32>, sun: vec3<f32>, day: f32) -> f32 {
// peak transmittance when sun rakes across the leaf plane — i.e.
// when sun is roughly perpendicular to the normal (grazing).
let grazing = 1.0 - abs(dot(normal, sun));
// backlit bias: prefer light coming from BEHIND the leaf so we
// don't double-count the front-side Lambert from direct_sun_term.
let back = max(dot(-normal, sun), 0.0);
return grazing * back * 0.55 * day;
}
/// Distance fog. Returns 0 (no fog) → 1 (fully obscured).
fn fog_factor(dist: f32) -> f32 {
let fog_start = 90.0;
let fog_end = 320.0;
return clamp((dist - fog_start) / (fog_end - fog_start), 0.0, 1.0);
}
/// 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<f32>, dist: f32, view_dir: vec3<f32>) -> vec3<f32> {
let t = fog_factor(dist);
if (t <= 0.001) {
return lit;
}
let sun = sun_direction(scene_time());
let twi = twilight_amount(sun);
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);
}
// ---------------- 5a. Terrain pipeline ----------------
struct VsIn {
@location(0) pos: vec3<f32>,
@location(1) color: vec3<f32>,
@location(2) normal: vec3<f32>,
@location(3) leaf: f32,
@location(4) ao: f32,
@location(5) sky_vis: f32,
@location(6) bounce_mat: vec4<f32>, // rgb = baked bounce color, a = material id
};
struct VsOut {
@builtin(position) clip: vec4<f32>,
@location(0) world_pos: vec3<f32>,
@location(1) color: vec3<f32>,
@location(2) normal: vec3<f32>,
@location(3) leaf: f32,
@location(4) ao: f32,
@location(5) sky_vis: f32,
@location(6) bounce_mat: vec4<f32>,
};
@vertex
fn vs_main(in: VsIn) -> VsOut {
var pos = in.pos;
if (in.leaf > 0.5) {
let t = scene_time();
let phase = pos.x * 0.35 + pos.z * 0.27 + pos.y * 0.11;
let sway = sin(t * 1.6 + phase) * 0.045;
let sway2 = cos(t * 1.1 + phase * 1.3) * 0.035;
pos.x = pos.x + sway;
pos.z = pos.z + sway2;
pos.y = pos.y + sway * 0.25;
}
var out: VsOut;
out.clip = camera.view_proj * vec4<f32>(pos, 1.0);
out.world_pos = pos;
out.color = in.color;
out.normal = in.normal;
out.leaf = in.leaf;
out.ao = in.ao;
out.sky_vis = in.sky_vis;
out.bounce_mat = in.bounce_mat;
return out;
}
@fragment
fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
let t = scene_time();
let sun = sun_direction(t);
let day = day_strength(sun);
let n = normalize(in.normal);
// Lighting pipeline (all per-fragment scalars from baked vertex
// attributes + time-of-day functions):
// ambient (sky_vis-weighted + baked bounce_color)
// + direct_sun (Lambert × sun_visible)
// + specular (Phong, gated by material)
// + emission (gated by material)
// then base color × AO × leaf jitter, then fog.
let bounce_albedo = in.bounce_mat.rgb;
let mat = material_for(in.bounce_mat.a);
let ambient = ambient_term(n, sun, day, in.sky_vis, bounce_albedo);
let ambient_strength = mix(0.35, 1.00, day);
let sun_term = direct_sun_term(n, sun);
let sun_col = sun_tint(sun);
// Specular: Phong with half-vector. Gated by material + sun visibility.
let to_eye = normalize(eye_world() - in.world_pos);
let half_v = normalize(sun + to_eye);
let spec_dot = max(dot(n, half_v), 0.0);
let sun_visible = smoothstep(-0.05, 0.10, sun.y);
let specular = pow(spec_dot, 48.0) * mat.specular * sun_visible;
let lighting = ambient * ambient_strength + sun_col * sun_term;
var lit = in.color * lighting * in.ao + sun_col * specular + mat.emission;
if (in.leaf > 0.5) {
lit = lit * leaf_jitter(in.world_pos);
let trans = leaf_translucency(n, sun, day);
lit = lit + in.color * sun_col * trans;
}
// Reuse `to_eye` from the specular block (normalized eye-to-fragment).
// Fog needs the *raw* distance though, so recompute that here.
let to_eye_raw = eye_world() - in.world_pos;
let dist = length(to_eye_raw);
let view_dir = -to_eye_raw / max(dist, 0.0001);
let color = apply_fog(lit, dist, view_dir);
return vec4<f32>(color, 1.0);
}
// ---------------- 5b. Sky background (full-screen triangle) ----------------
struct SkyOut {
@builtin(position) clip: vec4<f32>,
@location(0) ndc: vec2<f32>,
};
@vertex
fn vs_sky(@builtin(vertex_index) idx: u32) -> SkyOut {
var corners = array<vec2<f32>, 3>(
vec2<f32>(-1.0, -1.0),
vec2<f32>( 3.0, -1.0),
vec2<f32>(-1.0, 3.0),
);
let p = corners[idx];
var out: SkyOut;
out.clip = vec4<f32>(p, 1.0, 1.0);
out.ndc = p;
return out;
}
@fragment
fn fs_sky(in: SkyOut) -> @location(0) vec4<f32> {
let far_h = camera.inv_view_proj * vec4<f32>(in.ndc.x, in.ndc.y, 1.0, 1.0);
let world_pos = far_h.xyz / far_h.w;
let dir = normalize(world_pos - eye_world());
// alpha = 0 flags this pixel as "sky" for the god-rays mask pass;
// terrain pipeline writes alpha = 1 where it overdraws.
return vec4<f32>(sky_color(dir), 0.0);
}
// ---------------- 5c. Outline ----------------
@vertex
fn vs_outline(@location(0) pos: vec3<f32>) -> @builtin(position) vec4<f32> {
return camera.view_proj * vec4<f32>(pos, 1.0);
}
@fragment
fn fs_outline() -> @location(0) vec4<f32> {
return vec4<f32>(0.05, 0.05, 0.07, 1.0);
}