313 lines
14 KiB
WebGPU Shading Language
313 lines
14 KiB
WebGPU Shading Language
// monolith — "Glitching Monolith" mandelbulb raymarcher.
|
||
//
|
||
// A power-N mandelbulb DE raymarched as particle-dithered surface + soft halo
|
||
// on a deep void; sub-bass breathes its scale (CPU side), mid-band swirls the
|
||
// feedback trail, hi-band drives a coarse-cell UV-displacement glitch grid +
|
||
// a simulated frame-rate stutter (the feedback decay floor pins near 1.0 so
|
||
// the previous frame survives unchanged — looks like the renderer dropped to
|
||
// ~10 fps for a few frames). Particle aesthetic comes from a per-pixel hash
|
||
// threshold against the surface intensity: each pixel "is" a particle that
|
||
// either shows or doesn't, sold by dense fine grain on top.
|
||
//
|
||
// Cost bounded the same way breakcore is: bounding-sphere ray test discards
|
||
// background pixels in one op, march is sphere-traced with the shader's hard
|
||
// 96-step ceiling, normal (6× map) is gated to pixels actually on-surface.
|
||
// Bulb iteration count is fixed at 8 (taste — higher melts detail, lower
|
||
// reads blocky). Pure function of UBO + hash(fragCoord, frame) so `--render`
|
||
// is bit-reproducible.
|
||
//
|
||
// UBO header is **10** std140 rows (40 f32). Rust↔WGSL coupled — change one
|
||
// ⇒ change the other. Naga only validates this WGSL at pipeline-create on a
|
||
// real GPU. No nodes array: the form is the DE itself. `col2` carries a
|
||
// secondary neon accent so the surface can paint two contrasting hues at
|
||
// once — the bulb never reads as one wash.
|
||
|
||
const PI: f32 = 3.14159265;
|
||
const BULB_ITERS: i32 = 8;
|
||
|
||
struct U {
|
||
cam: vec4<f32>, // yaw, pitch, roll, dist
|
||
p0: vec4<f32>, // scale, glow_gain, ca_px, edge_softness
|
||
col0: vec4<f32>, // base.rgb, fade
|
||
col1: vec4<f32>, // accent.rgb (primary neon), flash
|
||
p1: vec4<f32>, // res_w, res_h, frame, time
|
||
p2: vec4<f32>, // march_steps, power_n, feedback_on, world_r
|
||
p3: vec4<f32>, // grain, glitch_a, fog, beat
|
||
p4: vec4<f32>, // loud, low, mid, high
|
||
p5: vec4<f32>, // stutter_w, swirl, aspect, tonality
|
||
col2: vec4<f32>, // accent2.rgb (secondary neon), release
|
||
};
|
||
|
||
@group(0) @binding(0) var<uniform> u: U;
|
||
@group(0) @binding(1) var prev_tex: texture_2d<f32>;
|
||
@group(0) @binding(2) var prev_smp: sampler;
|
||
|
||
struct VsOut {
|
||
@builtin(position) pos: vec4<f32>,
|
||
@location(0) uv: vec2<f32>,
|
||
};
|
||
|
||
@vertex
|
||
fn vs_main(@builtin(vertex_index) vi: u32) -> VsOut {
|
||
var o: VsOut;
|
||
let x = f32((vi << 1u) & 2u);
|
||
let y = f32(vi & 2u);
|
||
o.uv = vec2<f32>(x, y);
|
||
o.pos = vec4<f32>(x * 2.0 - 1.0, 1.0 - y * 2.0, 0.0, 1.0);
|
||
return o;
|
||
}
|
||
|
||
fn hash21(p: vec2<f32>) -> f32 {
|
||
var q = fract(p * vec2<f32>(123.34, 345.45));
|
||
q = q + dot(q, q + 34.345);
|
||
return fract(q.x * q.y);
|
||
}
|
||
|
||
// yaw(Y) -> pitch(X) -> roll(Z), shared convention with breakcore.
|
||
fn rot(v: vec3<f32>) -> vec3<f32> {
|
||
let sy = sin(u.cam.x); let cy = cos(u.cam.x);
|
||
let sp = sin(u.cam.y); let cp = cos(u.cam.y);
|
||
let sr = sin(u.cam.z); let cr = cos(u.cam.z);
|
||
let x1 = v.x * cy - v.z * sy;
|
||
let z1 = v.x * sy + v.z * cy;
|
||
let y2 = v.y * cp - z1 * sp;
|
||
let z2 = v.y * sp + z1 * cp;
|
||
let x3 = x1 * cr - y2 * sr;
|
||
let y3 = x1 * sr + y2 * cr;
|
||
return vec3<f32>(x3, y3, z2);
|
||
}
|
||
|
||
// Mandelbulb distance estimator (Quilez / Hart). Iterates z_{n+1} = z_n^P + c
|
||
// in spherical coords; the running `dr` tracks |dz/dz0| so we can return a
|
||
// proper distance bound 0.5·log(r)·r/dr.
|
||
fn de_bulb(p0: vec3<f32>, power: f32) -> f32 {
|
||
var z = p0;
|
||
var dr = 1.0;
|
||
var r = 0.0;
|
||
for (var i = 0; i < BULB_ITERS; i = i + 1) {
|
||
r = length(z);
|
||
if (r > 2.0) { break; }
|
||
let theta = acos(clamp(z.z / max(r, 1e-6), -1.0, 1.0));
|
||
let phi = atan2(z.y, z.x);
|
||
dr = pow(r, power - 1.0) * power * dr + 1.0;
|
||
let zr = pow(r, power);
|
||
let t2 = theta * power;
|
||
let p2 = phi * power;
|
||
z = zr * vec3<f32>(sin(t2) * cos(p2), sin(t2) * sin(p2), cos(t2));
|
||
z = z + p0;
|
||
}
|
||
return 0.5 * log(max(r, 1e-6)) * r / max(dr, 1e-6);
|
||
}
|
||
|
||
// Scene = scaled bulb. `scl` is the sub-bass-breath spring multiplier.
|
||
fn map(p: vec3<f32>) -> f32 {
|
||
let scl = u.p0.x;
|
||
return de_bulb(p * (1.0 / max(scl, 1e-3)), u.p2.y) * scl;
|
||
}
|
||
|
||
fn calc_normal(p: vec3<f32>) -> vec3<f32> {
|
||
let e = vec2<f32>(0.0022, 0.0);
|
||
return normalize(vec3<f32>(
|
||
map(p + e.xyy) - map(p - e.xyy),
|
||
map(p + e.yxy) - map(p - e.yxy),
|
||
map(p + e.yyx) - map(p - e.yyx),
|
||
));
|
||
}
|
||
|
||
@fragment
|
||
fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
|
||
let res_w = u.p1.x;
|
||
let res_h = u.p1.y;
|
||
let frame = u.p1.z;
|
||
let glow = u.p0.y;
|
||
let ca_px = u.p0.z;
|
||
let edge = u.p0.w;
|
||
let base = u.col0.xyz;
|
||
let accent = u.col1.xyz;
|
||
let accent2 = u.col2.xyz;
|
||
let fade = u.col0.w;
|
||
let flash = u.col1.w;
|
||
let time = u.p1.w;
|
||
let rb = u.p2.w; // bounding-sphere radius
|
||
let grain_a = u.p3.x;
|
||
let glitch = u.p3.y; // 0..~1.2 coarse-cell UV shove (hi-band)
|
||
let fog = u.p3.z;
|
||
let beat = u.p3.w;
|
||
let loud = u.p4.x;
|
||
let lowf = u.p4.y; // [0..1] sub-bass level — gravity well weight
|
||
let midf = u.p4.z; // [0..1] mid level — swirl gain
|
||
let highf = u.p4.w; // [0..1] hi/snare level — rim flicker
|
||
let stut = u.p5.x; // 0..1 simulated-stutter weight
|
||
let swirl = u.p5.y; // accumulated swirl angle (rad)
|
||
let aspect = u.p5.z;
|
||
let tonal = u.p5.w;
|
||
let release = u.col2.w;
|
||
|
||
// --- hi-band glitch grid
|
||
// : coarse-cell UV displacement of the FEEDBACK
|
||
// sample only — the bulb itself stays geometrically stable so a busy hat
|
||
// pattern can't tear the whole picture. Deadband below 0.35 so quiet
|
||
// music produces no glitch at all; only a small fraction of cells shove
|
||
// (threshold 0.85) so the effect is sparse, not screen-filling.
|
||
let glitch_eff = max(clamp(glitch, 0.0, 1.0) - 0.35, 0.0) / 0.65;
|
||
var glitch_off = vec2<f32>(0.0);
|
||
if (glitch_eff > 0.001) {
|
||
let cells = 18.0 + 14.0 * glitch_eff;
|
||
let cy = floor(in.uv.y * cells);
|
||
let cx = floor(in.uv.x * cells);
|
||
let hold = max(3.0 + 5.0 * (1.0 - glitch_eff), 1.0);
|
||
let stuck_frame = floor(frame / hold);
|
||
let pick = step(0.85, hash21(vec2<f32>(cx + cy * 7.0, stuck_frame)));
|
||
let sx = (hash21(vec2<f32>(cx * 3.7, cy + stuck_frame)) - 0.5)
|
||
* 0.045 * glitch_eff * pick;
|
||
let sy = (hash21(vec2<f32>(cx + cy * 11.0, stuck_frame * 2.0)) - 0.5)
|
||
* 0.020 * glitch_eff * pick;
|
||
glitch_off = vec2<f32>(sx, sy);
|
||
}
|
||
|
||
let uv = in.uv;
|
||
let ndc = vec2<f32>(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0);
|
||
let dist = u.cam.w;
|
||
let focal = 1.6 + 0.4 * release * release; // FOV warp on drop
|
||
let ro = vec3<f32>(0.0, 0.0, -dist);
|
||
let rd = normalize(vec3<f32>(ndc.x * aspect, ndc.y, focal));
|
||
|
||
// Ray vs bounding sphere
|
||
let b = dot(ro, rd);
|
||
let c = dot(ro, ro) - rb * rb;
|
||
let disc = b * b - c;
|
||
|
||
var col = vec3<f32>(0.0);
|
||
var inten = 0.0;
|
||
var hit_t = -1.0;
|
||
var dmin = 1e9;
|
||
if (disc > 0.0) {
|
||
let sq = sqrt(disc);
|
||
var t = max(-b - sq, 0.0);
|
||
let t_end = -b + sq;
|
||
let min_step = max(t_end - t, 1e-3) / 96.0;
|
||
let steps = min(i32(u.p2.x), 96);
|
||
for (var s = 0; s < steps; s = s + 1) {
|
||
let d = map(rot(ro + rd * t));
|
||
if (d < dmin) { dmin = d; }
|
||
if (d < 0.0018) { hit_t = t; break; }
|
||
t = t + max(d * 0.80, min_step);
|
||
if (t > t_end) { break; }
|
||
}
|
||
// Tight dust halo from closest approach (not unbounded accumulation,
|
||
// so dense regions can't white-out). The wider tail is intentionally
|
||
// dim — a noisy/atonal song widens it via `edge`, tonal stays crisp.
|
||
let dl = max(dmin, 0.0);
|
||
let halo = exp(-dl * dl * 7000.0 * edge) + 0.02 * exp(-dl * 30.0);
|
||
let tt = select(t, hit_t, hit_t > 0.0);
|
||
let depth = clamp((tt + b) / max(2.0 * sq, 1e-3), 0.0, 1.0);
|
||
inten = clamp(halo * glow * (1.0 - fog * depth), 0.0, 1.0);
|
||
|
||
if (inten > 0.015) {
|
||
let rp = rot(ro + rd * tt);
|
||
// Position-dependent **tint mix** across the bulb surface — a
|
||
// slow standing wave through (x,y,z) so neighbouring regions
|
||
// read as different neons. Drifts on time so the colour
|
||
// distribution evolves without snapping. This is what keeps the
|
||
// frame from being one hue: half the bulb sits closer to
|
||
// `accent`, the other half closer to `accent2`.
|
||
let tint = 0.5 + 0.5 * sin(rp.x * 3.2 + rp.y * 2.4 + rp.z * 1.8
|
||
+ time * 0.30);
|
||
let acc_mix = mix(accent, accent2, tint);
|
||
|
||
// Surface shade — gated to genuinely on-surface pixels so the
|
||
// 6×map() normal cost can't run over the entire halo screen.
|
||
// Accent contributions are deliberately small: the body stays
|
||
// brutalist-grey, the neon shows only where it earns its keep
|
||
// (fresnel edge + hi-band onset rim).
|
||
var body = base * 0.55;
|
||
if (dmin < 0.010) {
|
||
let n = calc_normal(rp);
|
||
let vdir = normalize(rot(-rd));
|
||
let lambert = clamp(dot(n, normalize(vec3<f32>(0.4, 0.7, -0.55))), 0.0, 1.0);
|
||
let fres = pow(1.0 - clamp(dot(n, vdir), 0.0, 1.0), 3.0);
|
||
// Body diffuse → silver; fresnel edge → position-mixed neon
|
||
// (the two-colour bulb effect lives here).
|
||
body = base * (0.20 + 0.80 * lambert) + acc_mix * fres * 0.28;
|
||
// Hi-band rim → **secondary** accent² (not the body's main)
|
||
// so a snare flashes a contrasting hue against the body's
|
||
// base accent. Quadratic in highf so light hats stay near
|
||
// zero, only strong snares light the edge.
|
||
body = body + accent2 * highf * highf * fres * fres * 0.40;
|
||
}
|
||
// Particle-dither: less hectic in quiet parts
|
||
let dither_thresh = clamp(inten * 2.2 - 0.08, 0.0, 1.0);
|
||
let pcell = hash21(uv * vec2<f32>(res_w, res_h) * 0.85
|
||
+ vec2<f32>(frame * 0.013, frame * 0.029));
|
||
let pkeep = step(pcell, dither_thresh);
|
||
col = body * inten * (0.50 + 0.55 * pkeep);
|
||
// Core punch — uses the per-pixel tint so the bulb's deep core
|
||
// glows different shades in different regions, not one hot dot.
|
||
col = col + acc_mix * pow(inten, 8.0) * 0.20;
|
||
// Onset spark — gated by flash² and uses accent2 (the snare
|
||
// colour) so onsets actively introduce the contrast hue.
|
||
col = col + accent2 * flash * flash * pow(inten, 3.0) * 0.25;
|
||
}
|
||
}
|
||
|
||
// Sub-bass gravity-well: radial darkening of the outer void, gated by
|
||
// sub-bass loudness². Quadratic so a sustained low hum reads heavy but
|
||
// doesn't reach the threshold without a real 808. A faint warm tint
|
||
// (wine, mixed from accent + base) bleeds into the dark ring so a kick
|
||
// colours the periphery instead of just dimming it.
|
||
let r2 = dot(ndc, ndc);
|
||
let gw = lowf * lowf * smoothstep(0.0, 1.6, r2);
|
||
col = col * (1.0 - 0.28 * gw);
|
||
let warm = mix(base, accent * 0.6, 0.45);
|
||
col = col + warm * gw * 0.05;
|
||
|
||
// --- mid-band ink-in-water swirl: feedback sample comes from a small
|
||
// rotation around centre + the glitch grid's per-cell shove. Pad swells
|
||
// make ribbons drift; a snare burst makes a sparse subset of cells jump.
|
||
// Both effects are sub-pixel-ish in magnitude so the trail doesn't tear.
|
||
var fb_uv = uv + glitch_off;
|
||
if (u.p2.z > 0.5) {
|
||
var c2 = fb_uv - vec2<f32>(0.5);
|
||
let cs = cos(swirl * 0.5); let sn = sin(swirl * 0.5);
|
||
c2 = vec2<f32>(c2.x * cs - c2.y * sn, c2.x * sn + c2.y * cs);
|
||
let zr = 1.0 + 0.004 * (midf - 0.3);
|
||
c2 = c2 * zr;
|
||
fb_uv = clamp(c2 + vec2<f32>(0.5), vec2<f32>(0.0), vec2<f32>(1.0));
|
||
}
|
||
|
||
// Dense fine grain — sells the "millions of particles" texture.
|
||
col = max(col + (hash21(uv * vec2<f32>(res_w, res_h)
|
||
+ vec2<f32>(frame * 1.1, frame * 1.7)) - 0.5) * grain_a,
|
||
vec3<f32>(0.0));
|
||
|
||
// Phosphor feedback + datamosh. Stutter raises the decay floor briefly
|
||
// so the previous frame survives a beat (reads as a dropped frame), but
|
||
// capped well below 1.0 so the trail still drains — no runaway wash.
|
||
// CA across the tap is small by default; only the active stutter window
|
||
// lifts it.
|
||
if (u.p2.z > 0.5) {
|
||
let dec_base = clamp(1.0 - 3.5 * fade, 0.30, 0.85);
|
||
let stutter_floor = mix(dec_base, 0.90, clamp(stut, 0.0, 1.0));
|
||
let decay = clamp(stutter_floor + 0.10 * beat, 0.30, 0.94);
|
||
let ca = ca_px / max(res_w, 1.0);
|
||
let off = (fb_uv - vec2<f32>(0.5)) * ca;
|
||
let pr = textureSampleLevel(prev_tex, prev_smp, fb_uv + off, 0.0).r;
|
||
let pg = textureSampleLevel(prev_tex, prev_smp, fb_uv, 0.0).g;
|
||
let pb = textureSampleLevel(prev_tex, prev_smp, fb_uv - off, 0.0).b;
|
||
col = max(col, vec3<f32>(pr, pg, pb) * decay);
|
||
}
|
||
|
||
// Cold void floor — silver/blue, very small so it cannot accumulate
|
||
// through the feedback trail. A tonality-gated mix toward `accent2`
|
||
// gives the void itself a faint partner-neon cast so the corners of the
|
||
// frame aren't pure black. Quadratic loudness gating so the floor only
|
||
// lifts on real energy, not analyser noise.
|
||
let vig = max(1.0 - 0.85 * length(ndc), 0.0);
|
||
let bgt_cold = mix(vec3<f32>(0.015, 0.020, 0.035),
|
||
vec3<f32>(0.04, 0.05, 0.10), tonal);
|
||
let bgt = mix(bgt_cold, accent2 * 0.08, 0.25 * tonal);
|
||
col = col + bgt * vig * (0.005 + 0.010 * loud * loud);
|
||
|
||
return vec4<f32>(min(col, vec3<f32>(1.0)), 1.0);
|
||
}
|