shader fix

This commit is contained in:
2026-05-20 17:03:22 +02:00
parent 2c3418f608
commit d54621e0b4
13 changed files with 456 additions and 149 deletions
+43 -20
View File
@@ -209,7 +209,10 @@ pub fn print_devices() -> anyhow::Result<()> {
let host = cpal::default_host();
println!("Input devices:");
for (i, d) in host.input_devices()?.enumerate() {
println!(" [{i}] {}", d.name().unwrap_or_else(|_| "<unknown>".into()));
println!(
" [{i}] {}",
d.name().unwrap_or_else(|_| "<unknown>".into())
);
}
println!(
"Tips: pass an index, or `monitor`/`loopback`, or a file path.\n \
@@ -311,7 +314,11 @@ pub fn start(src: Source) -> anyhow::Result<AudioHandle> {
move |data: &[f32], _| {
for f in data.chunks(channels) {
let mid: f32 = f.iter().sum::<f32>() / f.len().max(1) as f32;
let side = if f.len() >= 2 { (f[0] - f[1]) * 0.5 } else { 0.0 };
let side = if f.len() >= 2 {
(f[0] - f[1]) * 0.5
} else {
0.0
};
push_ms(mid, side);
}
},
@@ -410,9 +417,7 @@ fn spawn_file_source(
// back to the device's native rate and linear-resample.
let supports_file_sr = dev
.supported_output_configs()
.map(|mut it| {
it.any(|c| c.min_sample_rate() <= file_sr && file_sr <= c.max_sample_rate())
})
.map(|mut it| it.any(|c| c.min_sample_rate() <= file_sr && file_sr <= c.max_sample_rate()))
.unwrap_or(false);
let (scfg, out_sr) = if supports_file_sr {
(
@@ -466,7 +471,7 @@ fn spawn_file_source(
loop {
let packet = match reader.next_packet() {
Ok(Some(p)) => p,
Ok(None) => break, // EOF -> stop feeding; output silences
Ok(None) => break, // EOF -> stop feeding; output silences
Err(_) => break,
};
if packet.track_id != track_id {
@@ -481,7 +486,11 @@ fn spawn_file_source(
for frame in ilv.chunks(ch) {
let mid = frame.iter().sum::<f32>() / ch as f32;
let side = if ch >= 2 { (frame[0] - frame[1]) * 0.5 } else { 0.0 };
let side = if ch >= 2 {
(frame[0] - frame[1]) * 0.5
} else {
0.0
};
// Emit `resample` output frames per input frame (linear).
frac += resample;
while frac >= 1.0 {
@@ -536,9 +545,9 @@ pub struct Analyzer {
agc_loud: f32,
agc_broad: f32,
agc_csd: f32,
pop: [f32; 3], // low/mid/high onset envelopes
broad_pop: f32, // broadband onset envelope
csd_pop: f32, // complex-domain onset envelope
pop: [f32; 3], // low/mid/high onset envelopes
broad_pop: f32, // broadband onset envelope
csd_pop: f32, // complex-domain onset envelope
spec_edges: [(usize, usize); SPEC_N],
// Predictive-IOI beat model (robust for breakcore's unstable tempo: it
// tracks the running inter-onset interval, not a brittle global BPM).
@@ -651,10 +660,8 @@ impl Analyzer {
let mut dct = vec![0.0f32; MFCC_N * MEL_N];
for k in 1..=MFCC_N {
for j in 0..MEL_N {
dct[(k - 1) * MEL_N + j] = (std::f32::consts::PI * k as f32
* (j as f32 + 0.5)
/ MEL_N as f32)
.cos();
dct[(k - 1) * MEL_N + j] =
(std::f32::consts::PI * k as f32 * (j as f32 + 0.5) / MEL_N as f32).cos();
}
}
@@ -825,7 +832,11 @@ impl Analyzer {
let nbin = (half - 1).max(1) as f32;
let gm = (log_sum / nbin).exp();
let am = lin_sum / nbin;
let flatness = if am > 1e-9 { (gm / am).clamp(0.0, 1.0) } else { 0.0 };
let flatness = if am > 1e-9 {
(gm / am).clamp(0.0, 1.0)
} else {
0.0
};
// MFCC: mel-filterbank energies (magnitude) -> log -> DCT-II. c0
// (overall energy) is dropped; c1.. = pitch-independent timbre.
@@ -874,7 +885,11 @@ impl Analyzer {
for i in 0..SPEC_N {
spec[i] = norm(spec_raw[i], &mut self.agc_spec[i]);
}
let centroid_hz = if cen_den > 1e-6 { cen_num / cen_den } else { 0.0 };
let centroid_hz = if cen_den > 1e-6 {
cen_num / cen_den
} else {
0.0
};
let centroid = norm(centroid_hz, &mut self.agc_centroid);
let loud = norm(loud_sum / half as f32, &mut self.agc_loud);
let broad = norm(broad_flux / half as f32, &mut self.agc_broad);
@@ -941,7 +956,11 @@ impl Analyzer {
if (0.18..1.20).contains(&obs) {
let ratio = obs / self.beat_ioi.max(1e-3);
// In-range: trust it. Way off (real tempo change): adopt slowly.
let k = if (0.55..1.80).contains(&ratio) { 0.15 } else { 0.05 };
let k = if (0.55..1.80).contains(&ratio) {
0.15
} else {
0.05
};
self.beat_ioi = (self.beat_ioi + (obs - self.beat_ioi) * k).clamp(0.18, 1.0);
}
self.beat_clock = 0.0;
@@ -1028,8 +1047,8 @@ impl Analyzer {
if best > ACF_SNAP && !(0.75..1.34).contains(&ratio) {
self.beat_ioi = ioi;
} else {
self.beat_ioi = (self.beat_ioi + (ioi - self.beat_ioi) * (best * 0.20))
.clamp(0.18, 1.0);
self.beat_ioi =
(self.beat_ioi + (ioi - self.beat_ioi) * (best * 0.20)).clamp(0.18, 1.0);
}
}
}
@@ -1092,7 +1111,11 @@ pub fn analyze_file(path: &Path) -> anyhow::Result<Timeline> {
decoded.copy_to_vec_interleaved::<f32>(&mut ilv);
for frame in ilv.chunks(ch) {
let mid = frame.iter().sum::<f32>() / ch as f32;
let side = if ch >= 2 { (frame[0] - frame[1]) * 0.5 } else { 0.0 };
let side = if ch >= 2 {
(frame[0] - frame[1]) * 0.5
} else {
0.0
};
samples += 1;
if let Some(b) = an.push(mid, side) {
frames.push(b);
+23 -11
View File
@@ -38,6 +38,7 @@ use audio_visualizer::viz::core::{RenderContext, Visualizer};
use audio_visualizer::viz::fingerprint::{self, Accum as FpAccum};
use audio_visualizer::viz::monolith::Monolith;
use audio_visualizer::viz::palette::Palette;
use audio_visualizer::viz::underground::Underground;
use clap::{Parser, ValueEnum};
use nannou::app::LoopMode;
use nannou::prelude::*;
@@ -113,6 +114,7 @@ impl Preset {
enum VisMode {
Breakcore,
Monolith,
Underground,
}
impl VisMode {
@@ -120,12 +122,14 @@ impl VisMode {
match self {
VisMode::Breakcore => "breakcore",
VisMode::Monolith => "monolith",
VisMode::Underground => "underground",
}
}
fn parse(s: &str) -> Option<Self> {
Some(match s {
"breakcore" => VisMode::Breakcore,
"monolith" => VisMode::Monolith,
"underground" => VisMode::Underground,
_ => return None,
})
}
@@ -133,7 +137,8 @@ impl VisMode {
fn next(self) -> Self {
match self {
VisMode::Breakcore => VisMode::Monolith,
VisMode::Monolith => VisMode::Breakcore,
VisMode::Monolith => VisMode::Underground,
VisMode::Underground => VisMode::Breakcore,
}
}
}
@@ -710,7 +715,11 @@ fn model(app: &App) -> Model {
if scaled {
cmd.args([
"-vf",
&format!("scale={w}:{h}:flags=lanczos", w = g.render_w, h = g.render_h),
&format!(
"scale={w}:{h}:flags=lanczos",
w = g.render_w,
h = g.render_h
),
]);
}
cmd.args([
@@ -821,6 +830,7 @@ fn build_visualizer(
match mode {
VisMode::Breakcore => Box::new(Breakcore::new(seed, device, rw, rh)),
VisMode::Monolith => Box::new(Monolith::new(seed, device, rw, rh)),
VisMode::Underground => Box::new(Underground::new(device, rw, rh, seed)),
}
}
@@ -829,8 +839,7 @@ fn build_visualizer(
/// the deterministic file-derived seed so the same file looks different per
/// invocation but the chosen *form* still tracks the song fingerprint.
fn wallclock_seed(app: &App) -> u64 {
(app.duration.since_start.as_nanos() as u64)
.wrapping_mul(0x9E37_79B9_7F4A_7C15)
(app.duration.since_start.as_nanos() as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15)
^ 0xD1B5_4A32_D192_ED03
}
@@ -945,18 +954,19 @@ fn update(app: &App, m: &mut Model, upd: Update) {
m.visual.install_fingerprint(fp);
println!(
"fingerprint: centroid={:.2} chroma={} tonal={:.2} dyn={:.2} bpm_cls={:.2}",
fp.centroid_mean,
fp.chroma_dom,
fp.tonality,
fp.dyn_range,
fp.tempo_class,
fp.centroid_mean, fp.chroma_dom, fp.tonality, fp.dyn_range, fp.tempo_class,
);
}
}
// Audio-driven motion. The visual's own update grows/morphs/restructures.
m.visual.update(&b, dt);
m.ca_env += (b.flux - m.ca_env) * if b.flux > m.ca_env { 0.55 } else { 0.10 };
let ca_a = if b.flux > m.ca_env {
1.0 - (-dt / 0.02).exp()
} else {
1.0 - (-dt / 0.16).exp()
};
m.ca_env += (b.flux - m.ca_env) * ca_a;
let pal = Palette::from_audio(&b);
let scale = 1.0 + (b.low * m.g.low).min(0.9) + b.low_on * 0.4;
@@ -1024,7 +1034,9 @@ fn view(app: &App, m: &Model, frame: Frame) {
if m.hud {
let g = m.g;
let extra = match &m.mode {
Mode::Render { frame: f, total, .. } => format!("RENDER {}/{}", f, total),
Mode::Render {
frame: f, total, ..
} => format!("RENDER {}/{}", f, total),
Mode::Live(_) => format!("fps {:.0}", app.fps()),
};
let fp_state = if m.visual.fingerprint_ready() {
+28 -28
View File
@@ -110,17 +110,17 @@ enum Kind {
/// Audio still rides on top of these via the springs (premise §3).
#[derive(Clone, Copy)]
struct Regime {
scale: f32, // backbone normalize-scale target
melt: f32, // smooth-min fuse weight (0..1, scales melt_k)
glow: f32, // filament brightness base
speed: f32, // rotation + attractor integration multiplier
tube: f32, // base capsule radius
rib: f32, // spectral-shell presence 0..1
deb: f32, // debris presence 0..1
heat: f32, // colour-warmth / energy push base 0..1
warpd: f32, // spine spectral-displacement amount
ca: f32, // chromatic-aberration multiplier
fade: f32, // feedback-fade multiplier (smaller ⇒ longer trail)
scale: f32, // backbone normalize-scale target
melt: f32, // smooth-min fuse weight (0..1, scales melt_k)
glow: f32, // filament brightness base
speed: f32, // rotation + attractor integration multiplier
tube: f32, // base capsule radius
rib: f32, // spectral-shell presence 0..1
deb: f32, // debris presence 0..1
heat: f32, // colour-warmth / energy push base 0..1
warpd: f32, // spine spectral-displacement amount
ca: f32, // chromatic-aberration multiplier
fade: f32, // feedback-fade multiplier (smaller ⇒ longer trail)
prefer_knot: bool, // bias the next restructure's backbone kind
}
@@ -388,23 +388,19 @@ impl Breakcore {
// §3 springs — regime sets the base, audio + tension ride on top.
// Build-up contracts the backbone toward a dense core; release kicks
// it back out (spring overshoot makes the drop *punch*).
let scale_t = rg.scale * (1.0 - 0.42 * tn) + 0.30 * b.low + 0.25 * b.low_on
+ 0.85 * rel;
let scale_t = rg.scale * (1.0 - 0.42 * tn) + 0.30 * b.low + 0.25 * b.low_on + 0.85 * rel;
self.sp_scale.step(scale_t, 14.0, dt);
self.sp_tube
.step(rg.tube + 0.05 * b.mid + 0.02 * b.mid_on, 11.0, dt);
self.sp_dist.step(3.2 - 0.85 * b.low, 6.0, dt);
self.sp_glow.step(
rg.glow + 0.45 * b.loud + 0.4 * b.flux + 0.35 * tn,
9.0,
dt,
);
self.sp_glow
.step(rg.glow + 0.45 * b.loud + 0.4 * b.flux + 0.35 * tn, 9.0, dt);
// Music-locked rotation. No constant baseline — true silence leaves
// the field still; every term is audio · regime · tension. A snare/hat
// onset adds a quick rotational *jolt* (decays, so it reads as a kick).
let sp = rg.speed * (1.0 + 0.7 * tn);
self.jolt = (self.jolt * 0.88).max(0.6 * b.high_on + 0.4 * b.mid_on);
self.jolt = (self.jolt * (-7.8 * dt).exp()).max(0.6 * b.high_on + 0.4 * b.mid_on);
self.yaw += (0.7 * b.mid + 0.9 * self.jolt) * sp * dt;
self.pitch += 0.4 * b.low * sp * dt;
self.roll += (0.35 * b.high + 1.4 * self.jolt) * sp * dt;
@@ -414,8 +410,11 @@ impl Breakcore {
// widens through a build then snaps back on the drop.
self.sp_dolly
.step(0.55 * b.low_on + 0.45 * b.beat, 20.0, dt);
self.sp_focal
.step((1.70 + 0.55 * tn - 0.15 * b.loud).clamp(1.25, 2.40), 6.0, dt);
self.sp_focal.step(
(1.70 + 0.55 * tn - 0.15 * b.loud).clamp(1.25, 2.40),
6.0,
dt,
);
// Broadband-onset radial shock: the whole sigil pulses out per big
// hit, distinct from the section restructure. Decays in ~0.4 s.
@@ -550,8 +549,7 @@ impl Breakcore {
let kw = (-(dw * dw) * 60.0).exp() * beat_amp;
let disp = warpd * (0.05 + 0.55 * band + 0.4 * self.b.high_on) + 0.05 * kw;
let p = (base[i] + nrm * disp) * scale;
let r = (self.sp_tube.x * (0.4 + 0.7 * band) * (1.0 + 0.8 * kw))
.clamp(0.0015, 0.0075);
let r = (self.sp_tube.x * (0.4 + 0.7 * band) * (1.0 + 0.8 * kw)).clamp(0.0015, 0.0075);
out[i] = [p.x, p.y, p.z, r];
}
}
@@ -626,8 +624,7 @@ impl Breakcore {
}
let ang = pc as f32 / CHROMA_N as f32 * TAU;
// A separate tilted plane from the ribs so spokes read distinctly.
let dir = vec3(ang.cos(), 0.35 * (ang * 2.0).sin(), ang.sin())
.normalize_or_zero();
let dir = vec3(ang.cos(), 0.35 * (ang * 2.0).sin(), ang.sin()).normalize_or_zero();
let inner = dir * (0.16 * scale);
let outer = dir * ((0.16 + 0.62 * cv) * scale);
let r = (self.sp_tube.x * (0.35 + 1.1 * cv)).clamp(0.0015, 0.0075);
@@ -684,8 +681,7 @@ impl Breakcore {
0.13 + 0.06 * lo,
self.hue_b,
);
let sat = (0.17 + 0.12 * (lo * 0.5 + self.b.mid * 0.4))
* (1.0 - 0.25 * self.b.flatness);
let sat = (0.17 + 0.12 * (lo * 0.5 + self.b.mid * 0.4)) * (1.0 - 0.25 * self.b.flatness);
let acc = oklch((0.62 + 0.28 * lo).min(0.97), sat, self.hue_a);
let mut u = [0.0f32; UBO_LEN];
@@ -727,7 +723,11 @@ impl Breakcore {
u[20] = (22.0 + 9.0 * warp.min(2.0)).clamp(16.0, march_cap.min(96) as f32);
// Tension fuses the folds (higher melt_k) so a build melts to a core.
u[21] = (0.004 + 0.008 * self.b.loud + 0.010 * tn * rg.melt).clamp(0.003, 0.018);
u[22] = if feedback && self.gpu.primed() { 1.0 } else { 0.0 };
u[22] = if feedback && self.gpu.primed() {
1.0
} else {
0.0
};
// bounding-sphere radius: covers spine (0.92) + ribs/spokes/debris
// (≤~1.05·scale) + tube, and grows with the shock so a pulse never
// clips. Parked capsules sit far outside and contribute zero glow.
+20 -2
View File
@@ -125,7 +125,16 @@ impl Visualizer for Breakcore {
c: &RenderContext,
) -> Option<&wgpu::Texture> {
Some(Breakcore::render(
self, device, queue, c.pal, c.scale, c.warp, c.feedback, c.fade, c.ca_px, c.drive,
self,
device,
queue,
c.pal,
c.scale,
c.warp,
c.feedback,
c.fade,
c.ca_px,
c.drive,
c.march_cap,
))
}
@@ -167,7 +176,16 @@ impl Visualizer for Monolith {
c: &RenderContext,
) -> Option<&wgpu::Texture> {
Some(Monolith::render(
self, device, queue, c.pal, c.scale, c.warp, c.feedback, c.fade, c.ca_px, c.drive,
self,
device,
queue,
c.pal,
c.scale,
c.warp,
c.feedback,
c.fade,
c.ca_px,
c.drive,
c.march_cap,
))
}
+1 -2
View File
@@ -83,8 +83,7 @@ fn hash2(mut x: u32) -> f32 {
}
fn grad(ix: i32, iy: i32, seed: u32) -> Vec2 {
let h = (ix as u32)
.wrapping_mul(0x9E37_79B1)
let h = (ix as u32).wrapping_mul(0x9E37_79B1)
^ (iy as u32).wrapping_mul(0x85EB_CA77)
^ seed.wrapping_mul(0xC2B2_AE3D);
let a = hash2(h) * std::f32::consts::TAU;
+6 -4
View File
@@ -44,9 +44,7 @@ impl Attr {
p.x * (rho - p.z) - p.y,
p.x * p.y - beta * p.z,
),
Attr::Rossler { a, b, c } => {
vec3(-p.y - p.z, p.x + a * p.y, b + p.z * (p.x - c))
}
Attr::Rossler { a, b, c } => vec3(-p.y - p.z, p.x + a * p.y, b + p.z * (p.x - c)),
}
}
@@ -172,7 +170,11 @@ impl Figure {
let t = tau * p[3].max(1.0) * u;
let (pn, qn) = (p[0], p[1]);
let r = 1.0 + p[2] * (qn * t).cos();
vec3(r * (pn * t).cos(), r * (pn * t).sin(), p[2] * (qn * t).sin()) * 0.85
vec3(
r * (pn * t).cos(),
r * (pn * t).sin(),
p[2] * (qn * t).sin(),
) * 0.85
}
1 => {
let sf = |ang: f32, m: f32, n1: f32, n2: f32, n3: f32| -> f32 {
+1
View File
@@ -17,3 +17,4 @@ pub mod palette;
pub mod post;
pub mod shader;
pub mod structure;
pub mod underground;
+69 -68
View File
@@ -38,6 +38,7 @@ 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
@@ -98,6 +99,8 @@ pub struct Monolith {
fp_committed: bool,
accent_class: Accent,
structure: Structure,
// springs
sp_scale: Spring, // sub-bass breath (bulb world scale)
sp_glow: Spring,
@@ -152,6 +155,7 @@ impl Monolith {
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 },
@@ -217,6 +221,10 @@ impl Monolith {
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);
@@ -230,15 +238,17 @@ impl Monolith {
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;
// 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 (tonal music
// keeps a cleaner low-power bulb, noisy/atonal goes higher).
// 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);
@@ -250,31 +260,32 @@ impl Monolith {
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.
// --- 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).max(0.0).min(1.2);
let a_g = if glitch_target > self.glitch_env {
0.18
let glitch_target = (raw - 0.10).clamp(0.0, 1.2);
let g_tau = if glitch_target > self.glitch_env {
0.08
} else {
0.04
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 — 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.
// 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.72 && b.flux > 0.65) || b.flux > 0.90 || b.high_on > 0.85;
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 = 0.45;
self.stutter_gate = stutter_cooldown;
self.held_yaw = self.yaw;
self.held_pitch = self.pitch;
self.held_roll = self.roll;
@@ -283,24 +294,24 @@ impl Monolith {
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;
// --- 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,
};
// --- swirl: mid² accumulates the feedback rotation. Sustained pad
// = a slow drift; sparse mids = nearly stationary trail.
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. 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.
// --- 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
@@ -312,10 +323,7 @@ impl Monolith {
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.
/// Render this frame into the target and return it.
#[allow(clippy::too_many_arguments)]
pub fn render(
&mut self,
@@ -332,10 +340,9 @@ impl Monolith {
) -> &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: 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.
// 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;
@@ -344,10 +351,7 @@ impl Monolith {
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.
// Palette
let lo = self.b.loud;
let base = oklch(
(0.14 + 0.14 * lo * lo).min(0.42),
@@ -356,8 +360,6 @@ impl Monolith {
);
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),
@@ -366,29 +368,22 @@ impl Monolith {
let mut u = [0.0f32; UBO_LEN];
// row0 cam — held during stutter (see above)
// 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
// `scale` (caller's expressive multiplier) rides on top of breath.
u[4] = (scale * scl).clamp(0.4, 1.8);
u[4] = (scale * scl).clamp(0.4, 2.2);
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);
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
@@ -403,17 +398,23 @@ impl Monolith {
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.
// 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 };
// Bulb fits in r ≈ 1.25; pad for breath + sub-bass extension.
u[23] = (1.30 * scl + 0.20).clamp(1.0, 3.0);
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
u[24] = (0.006 + 0.014 * self.b.flux * dr).clamp(0.0, 0.022);
// 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;
@@ -430,11 +431,11 @@ impl Monolith {
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
// row9 col2 = secondary accent.rgb, release
u[36] = acc2[0];
u[37] = acc2[1];
u[38] = acc2[2];
u[39] = 0.0;
u[39] = self.structure.release();
self.gpu.render(device, queue, &u)
}
+9 -9
View File
@@ -35,7 +35,7 @@ struct U {
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), _
col2: vec4<f32>, // accent2.rgb (secondary neon), release
};
@group(0) @binding(0) var<uniform> u: U;
@@ -141,8 +141,10 @@ fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
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
// --- 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
@@ -166,12 +168,11 @@ fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
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;
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 — the background-pixel early-out that keeps the
// raymarch from melting the GPU. `rb` is set to (scale + breath + pad).
// Ray vs bounding sphere
let b = dot(ro, rd);
let c = dot(ro, ro) - rb * rb;
let disc = b * b - c;
@@ -234,12 +235,11 @@ fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
// zero, only strong snares light the edge.
body = body + accent2 * highf * highf * fres * fres * 0.40;
}
// Particle-dither: per-pixel hash threshold against intensity.
// Each pixel is a "particle" that either shows or doesn't —
// identifies the form as point-cloud rather than solid surface.
// 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, clamp(inten * 1.65 + 0.12, 0.0, 1.0));
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.
+1 -3
View File
@@ -37,9 +37,7 @@ use nannou::wgpu;
/// exactly this invariant for its one UBO, so this needs no `bytemuck` dep —
/// but `U` MUST stay padding-free and match the shader's UBO layout.
fn as_bytes<U: Copy>(u: &U) -> &[u8] {
unsafe {
std::slice::from_raw_parts((u as *const U).cast::<u8>(), std::mem::size_of::<U>())
}
unsafe { std::slice::from_raw_parts((u as *const U).cast::<u8>(), std::mem::size_of::<U>()) }
}
/// Fullscreen fragment pipeline with ping-pong feedback. `U` is the uniform
+1 -2
View File
@@ -93,8 +93,7 @@ impl Structure {
let a_bass = 1.0 - (-dt / 0.8).exp();
let a_long = 1.0 - (-dt / 8.0).exp();
let busy_in =
(b.flux + 0.5 * (b.low_on + b.mid_on + b.high_on)).min(1.5);
let busy_in = (b.flux + 0.5 * (b.low_on + b.mid_on + b.high_on)).min(1.5);
self.e += (b.loud - self.e) * a_fast;
self.br += (b.centroid - self.br) * a_fast;
self.ba += (b.low - self.ba) * a_bass;
+159
View File
@@ -0,0 +1,159 @@
//! underground — geometric techno/dnb visuals. grid, boxes, strobe.
//!
//! §1 geometry: 8x8 grid of boxes. each box size/rotation tracks band energy.
//! §2 audio: kicks drive global scale + strobe. high-flux drive chromatic aberration.
//! §3 render: raymarcher with box SDF. volumetric glow + post-fx.
use crate::audio::Bands;
use crate::viz::core::{RenderContext, Visualizer};
use crate::viz::math::Spring;
use crate::viz::post::read_texture_rgba;
use crate::viz::shader::ShaderPipeline;
use nannou::wgpu;
const N: usize = 64; // 8x8 grid
#[repr(C)]
#[derive(Clone, Copy)]
struct UndergroundUbo {
// p0..p10: 11 rows of 4 floats (std140)
header: [f32; 44],
// NP * vec4
points: [[f32; 4]; N],
}
pub struct Underground {
seed: u64,
pipe: ShaderPipeline<UndergroundUbo>,
boxes: [BoxState; N],
// Global reactives
strobe: Spring,
scale: Spring,
flux: Spring,
time: f32,
w: u32,
h: u32,
}
struct BoxState {
size: Spring,
rot_v: f32,
rot: f32,
}
impl Underground {
pub fn new(device: &wgpu::Device, w: u32, h: u32, seed: u64) -> Self {
let wgsl = include_str!("underground.wgsl");
let pipe = ShaderPipeline::new(device, wgsl, w, h, wgpu::TextureFormat::Rgba8UnormSrgb);
let mut boxes = Vec::with_capacity(N);
for _ in 0..N {
boxes.push(BoxState {
size: Spring { x: 0.1, v: 0.0 },
rot_v: 0.0,
rot: 0.0,
});
}
Self {
seed,
pipe,
boxes: boxes.try_into().unwrap_or_else(|_| panic!("N mismatch")),
strobe: Spring { x: 0.0, v: 0.0 },
scale: Spring { x: 1.0, v: 0.0 },
flux: Spring { x: 0.0, v: 0.0 },
time: 0.0,
w,
h,
}
}
}
impl Visualizer for Underground {
fn name(&self) -> &'static str {
"underground"
}
fn seed(&self) -> u64 {
self.seed
}
fn reseed(&mut self, seed: u64) {
self.seed = seed;
}
fn update(&mut self, b: &Bands, dt: f32) {
self.time += dt;
// Global reaction
self.scale.step(1.0 + b.low * 0.5, 15.0, dt);
self.strobe
.step(if b.low_on > 0.8 { 1.0 } else { 0.0 }, 30.0, dt);
self.flux.step(b.flux, 10.0, dt);
// Per-box update
for i in 0..N {
let energy = b.spec[i % b.spec.len()];
self.boxes[i].size.step(0.1 + energy * 0.8, 20.0, dt);
self.boxes[i].rot_v += energy * dt * 20.0;
self.boxes[i].rot_v *= 0.95; // friction
self.boxes[i].rot += self.boxes[i].rot_v * dt;
}
}
fn element_count(&self) -> usize {
N
}
fn is_gpu(&self) -> bool {
true
}
fn render_gpu(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
ctx: &RenderContext,
) -> Option<&wgpu::Texture> {
let mut points = [[0.0; 4]; N];
for i in 0..N {
let x = (i % 8) as f32 - 3.5;
let y = (i / 8) as f32 - 3.5;
points[i] = [x, y, self.boxes[i].size.x, self.boxes[i].rot];
}
let mut header = [0.0; 44];
header[0] = self.time;
header[1] = self.scale.x;
header[2] = self.strobe.x;
header[3] = self.flux.x;
header[4] = ctx.scale;
header[5] = ctx.warp;
header[6] = if ctx.feedback { 1.0 } else { 0.0 };
header[7] = ctx.fade;
header[8] = ctx.ca_px;
header[9] = ctx.drive;
header[10] = ctx.march_cap as f32;
header[40] = self.w as f32;
header[41] = self.h as f32;
header[42] = (self.w as f32) / (self.h as f32);
let ubo = UndergroundUbo { header, points };
Some(self.pipe.render(device, queue, &ubo))
}
fn current_tex(&self) -> Option<&wgpu::Texture> {
Some(self.pipe.current())
}
fn capture_raw(
&self,
device: &wgpu::Device,
queue: &wgpu::Queue,
) -> Option<anyhow::Result<Vec<u8>>> {
Some(read_texture_rgba(
device,
queue,
self.pipe.current(),
self.w,
self.h,
))
}
}
+95
View File
@@ -0,0 +1,95 @@
struct Ubo {
header: array<vec4<f32>, 11>,
points: array<vec4<f32>, 64>,
}
@group(0) @binding(0) var<uniform> ubo: Ubo;
@group(0) @binding(1) var prev_tex: texture_2d<f32>;
@group(0) @binding(2) var smp: sampler;
struct VertexOutput {
@builtin(position) position: vec4<f32>,
@location(0) uv: vec2<f32>,
}
@vertex
fn vs_main(@builtin(vertex_index) vi: u32) -> VertexOutput {
var out: VertexOutput;
let uv = vec2<f32>(f32((vi << 1u) & 2u), f32(vi & 2u));
out.uv = uv;
out.position = vec4<f32>(uv * 2.0 - 1.0, 0.0, 1.0);
return out;
}
fn sdBox(p: vec3<f32>, b: vec3<f32>) -> f32 {
let q = abs(p) - b;
return length(max(q, vec3<f32>(0.0))) + min(max(q.x, max(q.y, q.z)), 0.0);
}
fn rotate(p: vec2<f32>, a: f32) -> vec2<f32> {
let s = sin(a);
let c = cos(a);
return vec2<f32>(p.x * c - p.y * s, p.x * s + p.y * c);
}
fn map(p: vec3<f32>) -> f32 {
var d = 1e10;
let grid_size = 1.0;
for (var i = 0; i < 64; i = i + 1) {
let pt = ubo.points[i];
let pos = vec3<f32>(pt.x, pt.y, 0.0);
var q = p - pos;
q.x = rotate(q.xz, pt.w).x; // basic rotate
let size = pt.z * 0.4;
d = min(d, sdBox(q, vec3<f32>(size)));
}
return d;
}
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
let time = ubo.header[0].x;
let scale = ubo.header[0].y;
let strobe = ubo.header[0].z;
let flux = ubo.header[0].w;
let res = vec2<f32>(ubo.header[10].x, ubo.header[10].y);
let aspect = ubo.header[10].z;
var uv = (in.position.xy / res) * 2.0 - 1.0;
uv.x *= aspect;
let ro = vec3<f32>(0.0, 0.0, -8.0 / scale);
let rd = normalize(vec3<f32>(uv, 2.0));
var t = 0.0;
var glow = 0.0;
let march_cap = i32(ubo.header[2].z);
for (var i = 0; i < 96; i = i + 1) {
if (i >= march_cap) { break; }
let p = ro + rd * t;
let d = map(p);
glow += exp(-d * 4.0) * (0.01 + flux * 0.05);
if (d < 0.001 || t > 20.0) { break; }
t += d * 0.8;
}
var col = vec3<f32>(glow);
col *= vec3<f32>(0.2, 0.5, 1.0); // Cyan-ish
if (strobe > 0.5) {
col += 0.2;
}
// Scanlines
col *= 0.8 + 0.2 * sin(in.position.y * 1.5 + time * 10.0);
return vec4<f32>(col, 1.0);
}