//! monolith — the Glitching Monolith mandelbulb on a deep void. //! //! Premise → implementation: //! · 40–80 Hz sub-bass / kicks → macro structure & gravity (sp_scale spring //! breathes the bulb; lowf shader scalar darkens the outer void → "the //! 808 pulls the picture in"). //! · IDM mids / pads → fluid + colour (swirl phase rotates the //! feedback trail, midf shader scalar drifts radial ink-in-water; accent //! hue eases toward the dominant chroma class). //! · Breakcore high-mids / snares → glitch + stutter + camera (glitch_env //! drives the shader's coarse-cell UV-shove grid; a `stutter` FSM holds //! the camera/scale/glow for ~120 ms on a snare-flux burst while the //! shader's feedback decay floor pins near 1.0 → the previous frame //! "freezes" — looks like the render dropped to ~10 fps for a beat). //! //! Fingerprint mapping (committed once at startup or M-cycle): //! chroma_dom % 3 → neon accent class (cyan / magenta / acid green) //! centroid_mean → base hue along a deep-blue↔purple arc //! tonality → palette saturation + edge softness //! dyn_range → motion scale (low DR = slow, wide DR = punchy) //! tempo_class → camera orbit rate baseline //! //! Per-run novelty (wall-clock seed in live; deterministic in `--render`): //! camera start angles + initial swirl phase. //! //! Determinism: `Rng` and the stutter/glitch FSMs advance only in `update` //! (one call per frame, live and render). The shader is a pure function of //! the UBO + hash(fragCoord, frame). Same input + same seed = same frame. //! //! WGSL coupling (non-negotiable): the header is **9** std140 rows so the //! UBO is exactly **36** f32 (`UBO_LEN`). No nodes array — the form is the //! DE itself. use crate::audio::Bands; use crate::viz::curve::Rng; use crate::viz::fingerprint::Fingerprint; use crate::viz::math::{Spring, angle_to}; use crate::viz::palette::{Palette, oklch}; use crate::viz::post::read_texture_rgba; use crate::viz::shader::ShaderPipeline; use crate::viz::structure::{Arch, Structure}; use nannou::wgpu; /// UBO length in f32: **10** std140 rows (40). Header layout is duplicated /// in the WGSL `struct U`; changing one without the other silently mis-reads /// every uniform. Row 9 (`col2`) carries a secondary neon accent so the /// shader can paint two contrasting hues across one frame (cyan body / /// magenta rim, etc). const UBO_LEN: usize = 40; const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8UnormSrgb; const TAU: f32 = std::f32::consts::TAU; /// Neon-accent class — picks the violent-flash hue. Derived from the /// committed fingerprint's dominant chroma class % 3. #[derive(Clone, Copy, PartialEq, Debug)] enum Accent { Cyan, Magenta, AcidGreen, } impl Accent { fn from_chroma(c: usize) -> Self { match c % 3 { 0 => Accent::Cyan, 1 => Accent::Magenta, _ => Accent::AcidGreen, } } /// Centre hue (radians) for this neon class — OKLCH wheel convention /// shared with `palette::from_audio`. fn hue(self) -> f32 { match self { // Eyeballed in OKLCH so the chroma lands near max-vibrance for // each named neon (cyan ≈ 195°, magenta ≈ 325°, acid ≈ 130°). Accent::Cyan => 3.40, Accent::Magenta => 5.67, Accent::AcidGreen => 2.27, } } /// Partner-class hue — the secondary neon paired with this one for /// per-frame contrast (cyan↔magenta, magenta↔acid, acid↔cyan). Keeps two /// hues in frame at once so the picture has actual colour, not a wash. fn partner(self) -> Accent { match self { Accent::Cyan => Accent::Magenta, Accent::Magenta => Accent::AcidGreen, Accent::AcidGreen => Accent::Cyan, } } } pub struct Monolith { pub seed: u64, rng: Rng, fp: Fingerprint, fp_committed: bool, accent_class: Accent, structure: Structure, // springs sp_scale: Spring, // sub-bass breath (bulb world scale) sp_glow: Spring, sp_power: Spring, // bulb power n (mid drift + fingerprint bias) sp_dist: Spring, // camera distance (kick pulls back into void) // stutter FSM — snare-flux burst freezes camera/scale/glow for ~120 ms, // shader pins feedback decay near 1.0 so the prev frame survives. stutter_w: f32, stutter_gate: f32, held_yaw: f32, held_pitch: f32, held_roll: f32, held_dist: f32, held_scale: f32, held_glow: f32, // smoothed glitch envelope feeding the shader's coarse-cell UV-shove glitch_env: f32, // mid-band ink-in-water feedback swirl accumulator swirl_phase: f32, // camera + smoothed colour yaw: f32, pitch: f32, roll: f32, hue_b: f32, // base hue (deep blue↔purple arc) hue_a: f32, // primary accent hue hue_a2: f32, // secondary accent hue (partner neon) t: f32, frame: u32, b: Bands, rw: u32, rh: u32, gpu: ShaderPipeline<[f32; UBO_LEN]>, } impl Monolith { /// `w`×`h` is the raymarch target size (output × supersample); any aspect. pub fn new(seed: u64, device: &wgpu::Device, w: u32, h: u32) -> Self { let mut rng = Rng::new(seed ^ 0xC0FF_EEFA_CADE_B00C_u64); // Per-run novelty: nudge camera start so each run isn't identical. let yaw0 = rng.range(0.0, TAU); let pitch0 = rng.range(-0.25, 0.25); let swirl0 = rng.range(0.0, TAU); Monolith { seed, rng, fp: Fingerprint::default(), fp_committed: false, accent_class: Accent::Cyan, structure: Structure::new(), sp_scale: Spring { x: 1.0, v: 0.0 }, sp_glow: Spring { x: 0.55, v: 0.0 }, sp_power: Spring { x: 8.0, v: 0.0 }, sp_dist: Spring { x: 2.8, v: 0.0 }, stutter_w: 0.0, stutter_gate: 0.0, held_yaw: yaw0, held_pitch: pitch0, held_roll: 0.0, held_dist: 2.8, held_scale: 1.0, held_glow: 0.55, glitch_env: 0.0, swirl_phase: swirl0, yaw: yaw0, pitch: pitch0, roll: 0.0, hue_b: 4.6, hue_a: 3.4, hue_a2: 5.67, t: 0.0, frame: 0, b: Bands::default(), rw: w, rh: h, gpu: ShaderPipeline::new(device, include_str!("monolith.wgsl"), w, h, FMT), } } pub fn reseed(&mut self, seed: u64) { self.seed = seed; self.rng = Rng::new(seed ^ 0xC0FF_EEFA_CADE_B00C_u64); self.yaw = self.rng.range(0.0, TAU); self.pitch = self.rng.range(-0.25, 0.25); self.roll = 0.0; self.swirl_phase = self.rng.range(0.0, TAU); self.stutter_w = 0.0; self.stutter_gate = 0.0; self.glitch_env = 0.0; } /// "Element count" for the HUD — there is no element list (the form is /// the DE), so report the bulb iteration count instead. pub fn element_count(&self) -> usize { 8 } /// Install a fingerprint and re-derive accent class / palette bias. /// Cheap; idempotent — safe to call repeatedly. pub fn install_fingerprint(&mut self, fp: Fingerprint) { self.fp = fp; self.fp_committed = true; self.accent_class = Accent::from_chroma(fp.chroma_dom); } pub fn fingerprint_committed(&self) -> bool { self.fp_committed } pub fn update(&mut self, b: &Bands, dt: f32) { let dt = dt.clamp(0.0, 0.05); self.t += dt; self.frame = self.frame.wrapping_add(1); self.b = *b; self.structure.update(b, dt); let arch = self.structure.arch(); let release = self.structure.release(); // Dynamic-range scaled motion (premise: quiet songs move slowly). let dyn_m = (0.40 + 0.85 * self.fp.dyn_range).clamp(0.40, 1.20); // Quadratic shape of the audio levels so light hats / room tone // contribute near zero, and only real hits move the picture. This is // the single biggest "proportionality" lever — a linear `b.high` of // 0.2 becomes 0.04 here, a `b.high` of 0.8 stays 0.64. let low_q = b.low * b.low; let mid_q = b.mid * b.mid; let high_q = b.high * b.high; let flux_q = b.flux * b.flux; // --- sub-bass breath: sp_scale grows on low²; the kick still reads, // a mid-range hum doesn't. let scale_t = 1.0 + 0.40 * low_q + 0.22 * b.low_on; self.sp_scale.step(scale_t, 5.0 * dyn_m, dt); // --- distance: kick pulls back into void; release kick-back shove. let dist_t = 2.8 + 0.35 * low_q + 0.15 * b.low_on; self.sp_dist.step(dist_t, 4.0 * dyn_m, dt); self.sp_dist.x += 1.2 * release * release; // Immediate shove on drop // --- bulb power: small drift around 8 — mid² nudges it gently and // the fingerprint's tonality biases the resting point. let power_t = 7.8 + 0.5 * self.fp.tonality + 0.2 * (mid_q - 0.25); self.sp_power.step(power_t.clamp(6.5, 9.5), 2.0, dt); // --- glow: rides loudness² + flux². Quiet floor sits low so the // bulb is a dark silhouette by default, only loud moments lift it. self.sp_glow.step( 0.28 + 0.35 * b.loud * b.loud + 0.20 * flux_q, 5.5 * dyn_m, dt, ); // --- glitch envelope: smoothed hi-band onset / flux with a deadband. let raw = 0.7 * b.high_on * b.high_on + 0.35 * flux_q; let glitch_target = (raw - 0.10).clamp(0.0, 1.2); let g_tau = if glitch_target > self.glitch_env { 0.08 } else { 0.40 }; let a_g = 1.0 - (-dt / g_tau).exp(); self.glitch_env += (glitch_target - self.glitch_env) * a_g; // --- stutter FSM (the "drops to 12 fps" simulation). Triggered only // by real snare-flux bursts. self.stutter_gate = (self.stutter_gate - dt).max(0.0); self.stutter_w = (self.stutter_w - dt / 0.08).max(0.0); let snare_burst = (b.high_on > 0.75 && b.flux > 0.70) || b.flux > 0.94 || b.high_on > 0.88; // Gate stutter more aggressively during drops/builds so it doesn't "stuck" the energy. let stutter_cooldown = match arch { Arch::Drop | Arch::Build => 0.65, _ => 0.45, }; if snare_burst && self.stutter_gate <= 0.0 { self.stutter_w = 1.0; self.stutter_gate = stutter_cooldown; self.held_yaw = self.yaw; self.held_pitch = self.pitch; self.held_roll = self.roll; self.held_dist = self.sp_dist.x; self.held_scale = self.sp_scale.x; self.held_glow = self.sp_glow.x; } // --- camera: Section-aware rotation rate. let energy_mult = match arch { Arch::Drop => 2.4, Arch::Build => 1.4 + self.structure.tension(), Arch::Ambient | Arch::Breakdown => 0.45, _ => 1.0, }; let base_rate = 0.05 + 0.10 * self.fp.tempo_class; let rate = base_rate * dyn_m * energy_mult; self.yaw += rate * dt + 0.15 * b.beat * b.beat * dt; self.pitch += (0.12 * low_q - 0.04) * rate * dt; self.roll += 0.04 * high_q * dt; // --- swirl: mid² accumulates the feedback rotation. self.swirl_phase += (0.15 + 0.6 * mid_q) * 0.30 * dt; // --- colour inertia. let base_t = (5.0 - b.centroid * 1.4).rem_euclid(TAU); let acc_t = self.accent_class.hue() + (self.fp.chroma_dom as f32) / 12.0 * 0.3 + 0.15 * (mid_q - 0.25); let acc2_t = self.accent_class.partner().hue() + 0.15 * (high_q - 0.25); let ha = 1.0 - (-dt / 0.5).exp(); self.hue_b = angle_to(self.hue_b, base_t, ha); self.hue_a = angle_to(self.hue_a, acc_t, ha); self.hue_a2 = angle_to(self.hue_a2, acc2_t, ha); } /// Render this frame into the target and return it. #[allow(clippy::too_many_arguments)] pub fn render( &mut self, device: &wgpu::Device, queue: &wgpu::Queue, pal: &Palette, scale: f32, _warp: f32, feedback: bool, fade: f32, ca_px: f32, drive: f32, march_cap: u32, ) -> &wgpu::Texture { let dr = drive.clamp(0.0, 3.0); let dyn_m = (0.40 + 0.85 * self.fp.dyn_range).clamp(0.40, 1.20); let arch = self.structure.arch(); // Held-vs-live blending let s = self.stutter_w.clamp(0.0, 1.0); let yaw = self.yaw * (1.0 - s) + self.held_yaw * s; let pitch = self.pitch * (1.0 - s) + self.held_pitch * s; let roll = self.roll * (1.0 - s) + self.held_roll * s; let dist = self.sp_dist.x * (1.0 - s) + self.held_dist * s; let scl = self.sp_scale.x * (1.0 - s) + self.held_scale * s; let glow = self.sp_glow.x * (1.0 - s) + self.held_glow * s; // Palette let lo = self.b.loud; let base = oklch( (0.14 + 0.14 * lo * lo).min(0.42), (0.035 + 0.015 * self.fp.tonality).min(0.06), self.hue_b, ); let acc_sat = (0.18 + 0.05 * lo) * (0.65 + 0.40 * self.fp.tonality); let acc = oklch((0.68 + 0.20 * lo).min(0.92), acc_sat, self.hue_a); let acc2 = oklch( (0.66 + 0.20 * lo).min(0.90), (acc_sat * 0.85).min(0.30), self.hue_a2, ); let mut u = [0.0f32; UBO_LEN]; // row0 cam u[0] = yaw; u[1] = pitch; u[2] = roll; u[3] = dist.clamp(1.8, 4.5); // row1 p0 = scale, glow_gain, ca_px, edge_softness u[4] = (scale * scl).clamp(0.4, 2.2); u[5] = glow.clamp(0.18, 0.85); u[6] = ca_px * (0.6 + 0.4 * dr) * (1.0 + 0.6 * s); u[7] = (0.50 + 0.45 * (1.0 - self.fp.tonality) + 0.15 * self.b.flatness).clamp(0.30, 1.10); // row2 col0 = base.rgb, fade u[8] = base[0]; u[9] = base[1]; u[10] = base[2]; u[11] = (fade * (1.0 - 0.25 * (1.0 - dyn_m))).clamp(0.02, 1.0); // row3 col1 = accent.rgb, flash u[12] = acc[0]; u[13] = acc[1]; u[14] = acc[2]; u[15] = pal.flash; // row4 p1 = res_w, res_h, frame, time u[16] = self.rw as f32; u[17] = self.rh as f32; u[18] = (self.frame & 0xffff) as f32; u[19] = self.t; // row5 p2 = march_steps, power_n, feedback_on, world_r u[20] = (20.0 + 7.0 * dr).clamp(16.0, march_cap.min(96) as f32); u[21] = self.sp_power.x.clamp(6.5, 9.5); u[22] = if feedback && self.gpu.primed() { 1.0 } else { 0.0 }; u[23] = (1.30 * scl + 0.20).clamp(1.0, 3.5); // row6 p3 = grain, glitch_a, fog, beat // Gated grain: ambient/breakdown reduces grain base to near zero. let g_base = match arch { Arch::Ambient | Arch::Breakdown => 0.001, _ => 0.005, }; u[24] = (g_base + 0.012 * self.b.flux * dr).clamp(0.0, 0.022); u[25] = (self.glitch_env * dr).clamp(0.0, 1.2); u[26] = (0.30 + 0.35 * self.b.loud).clamp(0.20, 0.75); u[27] = self.b.beat; // row7 p4 = loud, low, mid, high u[28] = self.b.loud; u[29] = self.b.low; u[30] = self.b.mid; u[31] = self.b.high; // row8 p5 = stutter_w, swirl, aspect, tonality u[32] = s; u[33] = self.swirl_phase % TAU; u[34] = self.rw as f32 / self.rh.max(1) as f32; u[35] = self.fp.tonality.clamp(0.0, 1.0); // row9 col2 = secondary accent.rgb, release u[36] = acc2[0]; u[37] = acc2[1]; u[38] = acc2[2]; u[39] = self.structure.release(); self.gpu.render(device, queue, &u) } pub fn current(&self) -> &wgpu::Texture { self.gpu.current() } pub fn capture_raw( &self, device: &wgpu::Device, queue: &wgpu::Queue, ) -> anyhow::Result> { read_texture_rgba(device, queue, self.gpu.current(), self.rw, self.rh) } }