diff --git a/src/lib.rs b/src/lib.rs index 4d87450..d4b37ca 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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; diff --git a/src/shader.wgsl b/src/shader.wgsl index 1334b22..90cdf42 100644 --- a/src/shader.wgsl +++ b/src/shader.wgsl @@ -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, inv_view_proj: mat4x4, eye: vec4, /// .x = scene time in seconds (drives day/night cycle + leaf sway) - misc: vec4, + /// .y/.z/.w reserved + frame: vec4, }; @group(0) @binding(0) var 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 { 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 { let a = (t / DAY_PERIOD + SUN_OFFSET) * 6.28318530718; return normalize(vec3(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 { 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 { 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) -> vec3 { return mix(vec3(1.00, 0.95, 0.85), vec3(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, sun: vec3) -> vec3 { + let day = day_strength(sun); + let twi = twilight_amount(sun); + let zenith_day = vec3(0.30, 0.55, 0.88); + let zenith_night = vec3(0.02, 0.03, 0.10); + let horizon_day = vec3(0.82, 0.92, 0.99); + let horizon_twi = vec3(1.00, 0.55, 0.28); + let horizon_night = vec3(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 { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); @@ -69,27 +103,7 @@ fn fbm2(p_in: vec2) -> f32 { return v; } -// Just the horizon→zenith 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, sun: vec3) -> vec3 { - let day = day_strength(sun); - let twi = twilight_amount(sun); - let zenith_day = vec3(0.30, 0.55, 0.88); - let zenith_night = vec3(0.02, 0.03, 0.10); - let horizon_day = vec3(0.82, 0.92, 0.99); - let horizon_twi = vec3(1.00, 0.55, 0.28); - let horizon_night = vec3(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 { if (dir.y <= 0.0) { return 0.0; } let cell = floor(dir * 220.0); @@ -97,45 +111,30 @@ fn star_field(dir: vec3) -> f32 { return step(0.997, h); } -// ---------------- Sky ---------------- -// -// `dir` is the *view* direction from camera into the scene (unit vector). -// Composes a horizon→zenith 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) -> vec3 { - 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(0.30, 0.55, 0.88); - let zenith_night = vec3(0.02, 0.03, 0.10); - let horizon_day = vec3(0.78, 0.88, 0.96); - let horizon_twi = vec3(1.00, 0.55, 0.28); - let horizon_night = vec3(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(st * night_amt * twinkle); + sky = sky + vec3(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(t * 0.004, t * 0.0015); @@ -147,24 +146,87 @@ fn sky_color(dir: vec3) -> vec3 { 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(0.86, 0.89, 0.96) * (moon_disc + moon_halo) * night_amt; + sky = sky + vec3(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, sun: vec3, day: f32) -> vec3 { + let sky_in_normal = sky_dome(normal, sun); + let earth_day = vec3(0.20, 0.18, 0.14); + let earth_night = vec3(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, sun: vec3) -> 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 { + let n = fract(sin(dot(floor(world_pos * 1.3), vec3(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, dist: f32, view_dir: vec3) -> vec3 { + 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, @@ -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 { - 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(0.20, 0.18, 0.14); - let earth_down_night = vec3(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 - // 10–20% 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(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(color, 1.0); } -// ---- Sky background (full-screen triangle) ---- +// ---------------- 5b. Sky background (full-screen triangle) ---------------- struct SkyOut { @builtin(position) clip: vec4, @@ -290,11 +322,11 @@ fn vs_sky(@builtin(vertex_index) idx: u32) -> SkyOut { fn fs_sky(in: SkyOut) -> @location(0) vec4 { let far_h = camera.inv_view_proj * vec4(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(sky_color(dir), 1.0); } -// ---- Outline ---- +// ---------------- 5c. Outline ---------------- @vertex fn vs_outline(@location(0) pos: vec3) -> @builtin(position) vec4 { @@ -305,4 +337,3 @@ fn vs_outline(@location(0) pos: vec3) -> @builtin(position) vec4 { fn fs_outline() -> @location(0) vec4 { return vec4(0.05, 0.05, 0.07, 1.0); } - diff --git a/src/shader_source.rs b/src/shader_source.rs new file mode 100644 index 0000000..b50547f --- /dev/null +++ b/src/shader_source.rs @@ -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")); + } +} diff --git a/src/state.rs b/src/state.rs index 0dce391..6cdac2e 100644 --- a/src/state.rs +++ b/src/state.rs @@ -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));