- 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.
308 lines
10 KiB
WebGPU Shading Language
308 lines
10 KiB
WebGPU Shading Language
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
|
||
// 10–20% 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);
|
||
}
|
||
|