Phase 2: shader decomposition + Rust-shared constants

shader.wgsl restructured:
  - Section headers: Camera → Sky horizon → Atmosphere extras → Terrain
    lighting decomposition → Pipelines.
  - sky_dome() is now the single source of truth for the horizon→zenith
    mix; sky_color() builds on it instead of duplicating the gradient.
  - fs_main decomposed into named helpers:
      ambient_term(n, sun, day)    hemisphere ambient via sky_dome lookup
      direct_sun_term(n, sun)      Lambert × visibility ramp
      leaf_jitter(world_pos)       per-pixel canopy variation
      fog_factor(dist)             0..1 fog ramp
      apply_fog(lit, dist, view)   defers sky_color call until needed
  - Camera.misc renamed to Camera.frame with named accessors
    scene_time() / eye_world() so callsites read intent.
  - DAY_PERIOD / SUN_OFFSET REMOVED from the shader — injected at the
    top by shader_source::wgsl_constants_header(). One source of truth
    in sim::lighting now drives both Rust and WGSL; mob burn and
    visible sun direction can never disagree.

New src/shader_source.rs:
  - wgsl_constants_header()  Rust → WGSL constants prelude
  - terrain_shader_source()  header + shader.wgsl, used at Renderer::new
  - post_shader_source()     pass-through

state.rs CameraUniform:
  - misc → frame to match the renamed WGSL field
  - Renderer uses the assembled shader source instead of include_str! directly

Tests: 53 passing (added 2 shader_source assembly tests). Native build,
wasm release build, server build all green.
This commit is contained in:
Maximus Gorog 2026-05-23 23:15:25 -06:00
parent 989de4f43d
commit accbf67bf2
4 changed files with 206 additions and 115 deletions

View file

@ -2,6 +2,7 @@ pub mod camera;
pub mod mesh;
pub mod net;
pub mod proto;
pub mod shader_source;
pub mod sim;
pub mod state;
pub mod world;

View file

@ -1,34 +1,46 @@
// 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 ----------------
struct Camera {
view_proj: mat4x4<f32>,
inv_view_proj: mat4x4<f32>,
eye: vec4<f32>,
/// .x = scene time in seconds (drives day/night cycle + leaf sway)
misc: vec4<f32>,
/// .y/.z/.w reserved
frame: vec4<f32>,
};
@group(0) @binding(0) var<uniform> camera: Camera;
// ---------------- Time-of-day primitives ----------------
//
// One in-game day takes DAY_PERIOD seconds. The sun sweeps an east-to-west
// arc (cos/sin on the same plane) with a small constant tilt on Z so it
// isn't dead-flat. Game starts at noon (offset = 0.25 cycles).
fn scene_time() -> f32 { return camera.frame.x; }
fn eye_world() -> vec3<f32> { return camera.eye.xyz; }
const DAY_PERIOD: f32 = 300.0;
const SUN_OFFSET: f32 = 0.25;
// ---------------- 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));
}
// Smooth 0..1 going from -0.05 (sun barely under horizon, blue hour) up
// to 0.20 (clearly above the horizon, full daylight).
fn day_strength(sun: vec3<f32>) -> f32 {
return smoothstep(-0.05, 0.20, sun.y);
}
// Twilight peaks while the sun is near the horizon sunrise + sunset.
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);
@ -40,7 +52,29 @@ fn sun_tint(sun: vec3<f32>) -> vec3<f32> {
return mix(vec3<f32>(1.00, 0.95, 0.85), vec3<f32>(1.00, 0.55, 0.30), twi);
}
// ---------------- Cheap 2D fbm for clouds ----------------
// 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);
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 = mix(zenith_night, zenith_day, day);
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);
@ -69,27 +103,7 @@ fn fbm2(p_in: vec2<f32>) -> f32 {
return v;
}
// Just the horizonzenith gradient no clouds, no sun, no stars. Used by
// the terrain shader to compute hemisphere ambient: each fragment samples
// the dome in its surface-normal direction so vertical faces inherit the
// bright daytime horizon instead of a flat dim ambient.
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);
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 = mix(zenith_night, zenith_day, day);
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);
}
// Cheap "stars" high-frequency hash on view direction, threshold to
// keep only ~0.2% of cells lit.
// 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);
@ -97,45 +111,30 @@ fn star_field(dir: vec3<f32>) -> f32 {
return step(0.997, h);
}
// ---------------- Sky ----------------
//
// `dir` is the *view* direction from camera into the scene (unit vector).
// Composes a horizonzenith gradient that re-tones with sun height,
// twinklers + cloud streaks + sun + moon discs.
// 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 = camera.misc.x;
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);
let zenith_day = vec3<f32>(0.30, 0.55, 0.88);
let zenith_night = vec3<f32>(0.02, 0.03, 0.10);
let horizon_day = vec3<f32>(0.78, 0.88, 0.96);
let horizon_twi = vec3<f32>(1.00, 0.55, 0.28);
let horizon_night = vec3<f32>(0.03, 0.04, 0.10);
let zenith = mix(zenith_night, zenith_day, day);
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);
var sky = mix(horizon, zenith, gradient_t);
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: fade in as day strength drops. Slight twinkle via time-based jitter.
let night_amt = clamp(1.0 - day, 0.0, 1.0);
if (night_amt > 0.05) {
// Stars: fade in at night with slight twinkle.
if (night > 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 * night_amt * twinkle);
sky = sky + vec3<f32>(st * night * twinkle);
}
// Cloud layer fbm scrolled across an imaginary plane high above. Only
// visible looking upward (dir.y > 0). Cheap: 4 octaves of value noise.
// Cloud layer fbm scrolled across an imaginary plane high above.
if (dir.y > 0.05) {
let proj = dir.xz / dir.y;
let scroll = vec2<f32>(t * 0.004, t * 0.0015);
@ -147,24 +146,87 @@ fn sky_color(dir: vec3<f32>) -> vec3<f32> {
sky = mix(sky, cloud_col, mask * (0.55 + 0.25 * day));
}
// Sun disc + halo. Disc only visible in daytime (no sun glow underground).
// Sun disc + halo.
let sun_col = sun_tint(sun);
let cos_s = max(dot(dir, sun), 0.0);
let disc = pow(cos_s, 800.0) * 1.5 * smoothstep(-0.05, 0.05, sun.y);
let halo = pow(cos_s, 5.0) * 0.20 * day;
sky = sky + sun_col * (disc + halo);
// Moon disc opposite the sun, faint white. Only at night.
// 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_amt;
sky = sky + vec3<f32>(0.86, 0.89, 0.96) * (moon_disc + moon_halo) * night;
return sky;
}
// ---------------- Terrain ----------------
// ---------------- 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 )
/// Hemisphere ambient samples the sky dome in the surface-normal
/// direction so vertical faces inherit the bright horizon during day.
/// Bottom faces fade toward an earth-bounce color so they're not dead
/// black.
fn ambient_term(normal: vec3<f32>, sun: vec3<f32>, day: f32) -> vec3<f32> {
let sky_in_normal = sky_dome(normal, sun);
let earth_day = vec3<f32>(0.20, 0.18, 0.14);
let earth_night = vec3<f32>(0.03, 0.03, 0.04);
let earth = mix(earth_night, earth_day, day);
let face_up = clamp(normal.y * 0.5 + 0.5, 0.0, 1.0);
return mix(earth, sky_in_normal, face_up);
}
/// 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;
}
/// 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 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.
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 sky = sky_color(-view_dir);
return mix(lit, sky, t);
}
// ---------------- 5a. Terrain pipeline ----------------
struct VsIn {
@location(0) pos: vec3<f32>,
@ -187,9 +249,9 @@ struct VsOut {
fn vs_main(in: VsIn) -> VsOut {
var pos = in.pos;
if (in.leaf > 0.5) {
let t = camera.misc.x;
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 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;
@ -207,65 +269,35 @@ fn vs_main(in: VsIn) -> VsOut {
@fragment
fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
let t = camera.misc.x;
let t = scene_time();
let sun = sun_direction(t);
let day = day_strength(sun);
let sun_col = sun_tint(sun);
let n = normalize(in.normal);
let ndl = max(dot(n, sun), 0.0);
let sun_visible = smoothstep(-0.05, 0.10, sun.y);
let sun_term = ndl * sun_visible;
// Hemisphere ambient *sample* the sky dome in the normal direction
// instead of lerping two constants. A vertical face (n.y 0) picks up
// the bright horizon, a top face (n.y 1) the (darker) zenith, a
// bottom face the earth-bounce. This is the cheap analogue of an
// integrated environment light and is what makes daytime sides not
// look like night.
let sky_in_normal = sky_dome(n, sun);
let earth_down_day = vec3<f32>(0.20, 0.18, 0.14);
let earth_down_night = vec3<f32>(0.03, 0.03, 0.04);
let earth_down = mix(earth_down_night, earth_down_day, day);
let face_up = clamp(n.y * 0.5 + 0.5, 0.0, 1.0);
let ambient_col = mix(earth_down, sky_in_normal, face_up);
// Higher strength than before outdoor diffuse skylight is roughly
// 1020% of direct sun in reality. The old 0.45 cap was making sides
// read as if it were dusk during the day.
// Lighting pipeline:
// ambient + direct_sun modulated by base color AO leaf jitter fog
let ambient = ambient_term(n, sun, day);
let ambient_strength = mix(0.25, 0.85, day);
let sun_term = direct_sun_term(n, sun);
let sun_col = sun_tint(sun);
let lighting = ambient_col * ambient_strength + sun_col * sun_term;
var lit = in.color * lighting;
let lighting = ambient * ambient_strength + sun_col * sun_term;
var lit = in.color * lighting * in.ao;
// Bake-time per-vertex ambient occlusion.
lit = lit * in.ao;
// Per-pixel value noise on leaves so the canopy doesn't look uniform.
if (in.leaf > 0.5) {
let n2 = fract(sin(dot(floor(in.world_pos * 1.3), vec3<f32>(12.9898, 78.233, 37.719))) * 43758.5453);
lit = lit * (0.88 + n2 * 0.18);
lit = lit * leaf_jitter(in.world_pos);
}
let to_eye = camera.eye.xyz - in.world_pos;
let dist = length(to_eye);
let to_eye = eye_world() - in.world_pos;
let dist = length(to_eye);
let view_dir = -to_eye / max(dist, 0.0001);
let fog_start = 90.0;
let fog_end = 320.0;
let fog_t = clamp((dist - fog_start) / (fog_end - fog_start), 0.0, 1.0);
var color = lit;
if (fog_t > 0.001) {
// Only pay for the full sky lookup if the fragment is actually
// fogged enough to read it. Saves the cloud/fbm cost on near
// geometry.
let sky = sky_color(-view_dir);
color = mix(lit, sky, fog_t);
}
let color = apply_fog(lit, dist, view_dir);
return vec4<f32>(color, 1.0);
}
// ---- Sky background (full-screen triangle) ----
// ---------------- 5b. Sky background (full-screen triangle) ----------------
struct SkyOut {
@builtin(position) clip: vec4<f32>,
@ -290,11 +322,11 @@ fn vs_sky(@builtin(vertex_index) idx: u32) -> SkyOut {
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 - camera.eye.xyz);
let dir = normalize(world_pos - eye_world());
return vec4<f32>(sky_color(dir), 1.0);
}
// ---- Outline ----
// ---------------- 5c. Outline ----------------
@vertex
fn vs_outline(@location(0) pos: vec3<f32>) -> @builtin(position) vec4<f32> {
@ -305,4 +337,3 @@ fn vs_outline(@location(0) pos: vec3<f32>) -> @builtin(position) vec4<f32> {
fn fs_outline() -> @location(0) vec4<f32> {
return vec4<f32>(0.05, 0.05, 0.07, 1.0);
}

54
src/shader_source.rs Normal file
View file

@ -0,0 +1,54 @@
//! Assembles WGSL shader source by prepending a constants header
//! generated from Rust. Single source of truth for `DAY_PERIOD` and
//! `SUN_OFFSET`: change them in `sim::lighting` and both Rust *and* the
//! shader update together.
//!
//! No build script needed — the header is built at runtime when the
//! shader module is created (once per Renderer init, negligible cost).
use crate::sim::lighting::{DAY_PERIOD, SUN_OFFSET};
/// WGSL header injected at the top of `shader.wgsl`. Mirrors the Rust
/// constants in `sim::lighting`.
pub fn wgsl_constants_header() -> String {
format!(
"// ---- AUTO-INJECTED FROM sim::lighting (see shader_source.rs) ----\n\
const DAY_PERIOD: f32 = {day:.6};\n\
const SUN_OFFSET: f32 = {off:.6};\n\
// ---- end injected constants ----\n\n",
day = DAY_PERIOD,
off = SUN_OFFSET,
)
}
/// Full WGSL source for the world/sky/outline pipelines: injected
/// constants header + the static `shader.wgsl` body.
pub fn terrain_shader_source() -> String {
format!("{}{}", wgsl_constants_header(), include_str!("shader.wgsl"))
}
/// Post-process pipeline source. Doesn't depend on the shared
/// constants; passed through for symmetry with `terrain_shader_source`.
pub fn post_shader_source() -> &'static str {
include_str!("post.wgsl")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn header_contains_the_constants() {
let h = wgsl_constants_header();
assert!(h.contains("DAY_PERIOD"));
assert!(h.contains("SUN_OFFSET"));
assert!(h.contains(&format!("{:.6}", DAY_PERIOD)));
}
#[test]
fn terrain_shader_source_contains_header_and_body() {
let src = terrain_shader_source();
assert!(src.contains("DAY_PERIOD"));
assert!(src.contains("fn sun_direction"));
assert!(src.contains("fn fs_main"));
}
}

View file

@ -40,7 +40,10 @@ struct CameraUniform {
view_proj: [[f32; 4]; 4],
inv_view_proj: [[f32; 4]; 4],
eye: [f32; 4],
misc: [f32; 4],
/// `.x` = scene time in seconds (drives day/night cycle + leaf sway).
/// `.y/.z/.w` reserved. Layout mirrored in `shader.wgsl` as
/// `camera.frame` with a `scene_time()` accessor.
frame: [f32; 4],
}
#[repr(C)]
@ -414,7 +417,9 @@ impl Renderer {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("shader"),
source: wgpu::ShaderSource::Wgsl(include_str!("shader.wgsl").into()),
source: wgpu::ShaderSource::Wgsl(
crate::shader_source::terrain_shader_source().into(),
),
});
let camera_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
@ -423,7 +428,7 @@ impl Renderer {
view_proj: Mat4::IDENTITY.to_cols_array_2d(),
inv_view_proj: Mat4::IDENTITY.to_cols_array_2d(),
eye: [0.0; 4],
misc: [0.0; 4],
frame: [0.0; 4],
}),
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
});
@ -644,7 +649,7 @@ impl Renderer {
let post_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("post shader"),
source: wgpu::ShaderSource::Wgsl(include_str!("post.wgsl").into()),
source: wgpu::ShaderSource::Wgsl(crate::shader_source::post_shader_source().into()),
});
let post_pl = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
@ -839,7 +844,7 @@ impl Renderer {
view_proj: vp.to_cols_array_2d(),
inv_view_proj: inv.to_cols_array_2d(),
eye: [camera.position.x, camera.position.y, camera.position.z, 1.0],
misc: [time, 0.0, 0.0, 0.0],
frame: [time, 0.0, 0.0, 0.0],
};
self.queue
.write_buffer(&self.camera_buffer, 0, bytemuck::bytes_of(&uni));