added monolith
This commit is contained in:
@@ -0,0 +1,453 @@
|
||||
//! 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 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,
|
||||
|
||||
// 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,
|
||||
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;
|
||||
|
||||
// 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. Range now ≈ 1.0..1.20 instead of ≈1.0..1.55.
|
||||
let scale_t = 1.0 + 0.20 * low_q + 0.12 * b.low_on;
|
||||
self.sp_scale.step(scale_t, 4.0 * dyn_m, dt);
|
||||
let dist_t = 2.8 + 0.25 * low_q + 0.10 * b.low_on;
|
||||
self.sp_dist.step(dist_t, 4.0 * dyn_m, dt);
|
||||
|
||||
// --- bulb power: small drift around 8 — mid² nudges it gently and
|
||||
// the fingerprint's tonality biases the resting point (tonal music
|
||||
// keeps a cleaner low-power bulb, noisy/atonal goes higher).
|
||||
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
|
||||
// (subtract `min_trigger` so a steady noise floor produces nothing).
|
||||
// Slow attack so a single hi-hat tap doesn't ping the grid; slow
|
||||
// decay so a snare roll holds the effect through the burst.
|
||||
let raw = 0.7 * b.high_on * b.high_on + 0.35 * flux_q;
|
||||
let glitch_target = (raw - 0.10).max(0.0).min(1.2);
|
||||
let a_g = if glitch_target > self.glitch_env {
|
||||
0.18
|
||||
} else {
|
||||
0.04
|
||||
};
|
||||
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 — both bands strong, or one very strong.
|
||||
// Gate is wide (0.45 s) so a busy fill can't latch repeatedly; the
|
||||
// hold itself is short (~80 ms) so the freeze reads as a tic, not a
|
||||
// dropped section.
|
||||
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.72 && b.flux > 0.65) || b.flux > 0.90 || b.high_on > 0.85;
|
||||
if snare_burst && self.stutter_gate <= 0.0 {
|
||||
self.stutter_w = 1.0;
|
||||
self.stutter_gate = 0.45;
|
||||
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 (BPM-paced + small audio jitter). No constant baseline
|
||||
// beyond tempo — true silence keeps it nearly still. All terms
|
||||
// gated quadratically so a quiet passage holds a steady frame.
|
||||
let base_rate = 0.05 + 0.10 * self.fp.tempo_class;
|
||||
let rate = base_rate * dyn_m;
|
||||
self.yaw += rate * dt + 0.10 * b.beat * b.beat * dt;
|
||||
self.pitch += (0.06 * low_q - 0.02) * rate * dt;
|
||||
self.roll += 0.02 * high_q * dt;
|
||||
|
||||
// --- swirl: mid² accumulates the feedback rotation. Sustained pad
|
||||
// = a slow drift; sparse mids = nearly stationary trail.
|
||||
self.swirl_phase += (0.15 + 0.6 * mid_q) * 0.30 * dt;
|
||||
|
||||
// --- colour inertia. Base hue drifts blue↔purple with centroid; the
|
||||
// two accents ease toward the neon class + its partner so contrast
|
||||
// stays alive. Each accent picks up a small audio nudge (mid drifts
|
||||
// the primary, high drifts the secondary) so the hues breathe with
|
||||
// the music instead of being statically committed.
|
||||
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. Bin tunables match
|
||||
/// the other modes' contract so cfg keys stay shared (`fade`/`ca_px`/
|
||||
/// `drive`/`march_cap`); `warp` is unused — monolith has its own
|
||||
/// audio-driven swirl so the bin's noise-warp slot is a no-op here.
|
||||
#[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);
|
||||
|
||||
// Held-vs-live blending: while stutter is high, upload the held
|
||||
// values so the picture freezes; the shader also pins the feedback
|
||||
// floor near 1.0 from `stutter_w` so the trail survives unchanged.
|
||||
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 — base lands deep dark silver/blue; two accents on
|
||||
// contrasting neon classes so a frame never reads as one hue.
|
||||
// Lightness rides loud² so quiet stays dark; saturation rides
|
||||
// tonality. Capped low so neither accent can wash the frame.
|
||||
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);
|
||||
// Secondary accent slightly less saturated so the body's primary tint
|
||||
// still reads — the partner is a *contrast*, not a competing colour.
|
||||
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 — held during stutter (see above)
|
||||
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
|
||||
// `scale` (caller's expressive multiplier) rides on top of breath.
|
||||
u[4] = (scale * scl).clamp(0.4, 1.8);
|
||||
u[5] = glow.clamp(0.18, 0.85);
|
||||
// CA: small base, stutter lifts it modestly (the smear during freeze
|
||||
// reads as signal corruption — but not screen-wide prism shimmer).
|
||||
u[6] = ca_px * (0.6 + 0.4 * dr) * (1.0 + 0.6 * s);
|
||||
// Tonal music keeps a crisp particle edge; noisy/atonal softens.
|
||||
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];
|
||||
// Wider fade (longer trail) when dyn_motion is low (calm tracks leave
|
||||
// more wake). Drive doesn't pull it shorter — the stutter does.
|
||||
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 (bounding)
|
||||
// Mandelbulb takes ~8 iters/step — heavier than the capsule field, so
|
||||
// hold the request slightly lower than breakcore's.
|
||||
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 };
|
||||
// Bulb fits in r ≈ 1.25; pad for breath + sub-bass extension.
|
||||
u[23] = (1.30 * scl + 0.20).clamp(1.0, 3.0);
|
||||
|
||||
// row6 p3 = grain, glitch_a, fog, beat
|
||||
u[24] = (0.006 + 0.014 * 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, _ — paints surface contrast
|
||||
u[36] = acc2[0];
|
||||
u[37] = acc2[1];
|
||||
u[38] = acc2[2];
|
||||
u[39] = 0.0;
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user