terainia/src/shader.wgsl
Maximus Gorog b52c1927cf Render + UI polish since pre-alpha-0.0.1
- Greedy meshing now bakes per-vertex AO with 4-corner sampling and an
  anisotropic-diagonal split when corner AO disagrees.
- WGSL: extracted sky_dome() for hemisphere ambient sampling so vertical
  faces match the sun-side sky tint at day; ambient_strength mixed by
  day strength instead of a flat constant.
- Step-1 post pipeline: render scene into an offscreen color texture,
  pass-through to the surface. Foundation for FXAA/shafts that will
  follow.
- Input bug: merge_held() now recomputes per tick from sticky keyboard +
  live touch bridge, so releasing the joystick actually stops the
  player (previous OR-into-self bug ate playtests).
- Touch UI hit-zones reordered (menu/hotbar above the joystick z-index);
  hotbar widened to 10 slots with tap-to-select on mobile.
- find_safe_spawn anchors on natural_surface_y so spawn is deterministic
  from noise — towers built at spawn no longer climb the spawn point.
- move_axis is sub-stepped (0.45-block max) so high-velocity falls can't
  teleport the player inside terrain.
2026-05-23 18:44:56 -06:00

308 lines
10 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.

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>,
};
@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).
const DAY_PERIOD: f32 = 300.0;
const SUN_OFFSET: f32 = 0.25;
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);
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);
}
// ---------------- Cheap 2D fbm for clouds ----------------
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;
for (var i = 0; i < 4; i = i + 1) {
v = v + amp * noise2(p);
p = p * 2.07;
amp = amp * 0.5;
}
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<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.
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);
}
// ---------------- 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.
fn sky_color(dir: vec3<f32>) -> vec3<f32> {
let t = camera.misc.x;
let sun = sun_direction(t);
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.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);
// Below-horizon slight darken so the world below the player still feels grounded.
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) {
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);
}
// Cloud layer — fbm scrolled across an imaginary plane high above. Only
// visible looking upward (dir.y > 0). Cheap: 4 octaves of value noise.
if (dir.y > 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. Disc only visible in daytime (no sun glow underground).
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.
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;
return sky;
}
// ---------------- Terrain ----------------
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,
};
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,
};
@vertex
fn vs_main(in: VsIn) -> VsOut {
var pos = in.pos;
if (in.leaf > 0.5) {
let t = camera.misc.x;
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;
return out;
}
@fragment
fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
let t = camera.misc.x;
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.
let ambient_strength = mix(0.25, 0.85, day);
let lighting = ambient_col * ambient_strength + sun_col * sun_term;
var lit = in.color * lighting;
// 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);
}
let to_eye = camera.eye.xyz - 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);
}
return vec4<f32>(color, 1.0);
}
// ---- 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 - camera.eye.xyz);
return vec4<f32>(sky_color(dir), 1.0);
}
// ---- 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);
}