Files
sigil/src/viz/monolith.wgsl
T
2026-05-20 17:03:22 +02:00

313 lines
14 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.
// 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);
}