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

455 lines
16 KiB
Rust
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 — the Glitching Monolith mandelbulb on a deep void.
//!
//! Premise → implementation:
//! · 4080 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<Vec<u8>> {
read_texture_rgba(device, queue, self.gpu.current(), self.rw, self.rh)
}
}