diff --git a/breakcore_24f6e7fae18a0f67.png b/breakcore_24f6e7fae18a0f67.png new file mode 100644 index 0000000..a24feab Binary files /dev/null and b/breakcore_24f6e7fae18a0f67.png differ diff --git a/breakcore_e74c80da20f21459.png b/breakcore_e74c80da20f21459.png new file mode 100644 index 0000000..6f590f2 Binary files /dev/null and b/breakcore_e74c80da20f21459.png differ diff --git a/src/audio.rs b/src/audio.rs index 318745e..2f073e6 100644 --- a/src/audio.rs +++ b/src/audio.rs @@ -75,6 +75,13 @@ pub struct Bands { pub flux: f32, /// Overall loudness (mean magnitude), AGC-normalised -> lightness/scale. pub loud: f32, + /// Short decaying pulse on a predicted beat (like an onset, but tempo-gated). + pub beat: f32, + /// Sawtooth 0..1: fraction of the predicted beat interval elapsed. Lets + /// the visualiser *anticipate* the next hit instead of reacting late. + pub beat_phase: f32, + /// Spectral flatness 0 (tonal/pad) .. 1 (noisy/break) -> smooth vs jagged. + pub flatness: f32, /// Relative pitch-class energy (max-normalised) -> harmonic accent hues. pub chroma: [f32; CHROMA_N], /// Decimated raw waveform (latest `FFT_SIZE` window, un-windowed, ~-1..1). @@ -95,6 +102,9 @@ impl Default for Bands { centroid: 0.0, flux: 0.0, loud: 0.0, + beat: 0.0, + beat_phase: 0.0, + flatness: 0.0, chroma: [0.0; CHROMA_N], wave: [0.0; WAVE_N], } @@ -466,6 +476,13 @@ pub struct Analyzer { pop: [f32; 3], // low/mid/high onset envelopes broad_pop: f32, // broadband 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). + hop_dt: f32, // seconds advanced per STFT hop + beat_ioi: f32, // smoothed inter-onset interval (s) + beat_clock: f32, // seconds since the last accepted beat + prev_broad: f32, // for the broadband-onset rising edge + beat_pop: f32, // decaying beat pulse } fn norm(v: f32, c: &mut f32) -> f32 { @@ -520,6 +537,11 @@ impl Analyzer { pop: [0.0; 3], broad_pop: 0.0, spec_edges, + hop_dt: HOP as f32 / sample_rate.max(1.0), + beat_ioi: 0.5, // ~120 BPM until the track tells us otherwise + beat_clock: 0.0, + prev_broad: 0.0, + beat_pop: 0.0, } } @@ -615,6 +637,20 @@ impl Analyzer { *c /= cmax; } + // Spectral flatness = geometric mean / arithmetic mean of the + // magnitude spectrum. ~0 for tonal/pad material, ~1 for noise/breaks. + // Bounded 0..1 already, so it is followed (not AGC'd) like a level. + let mut log_sum = 0.0f32; + let mut lin_sum = 0.0f32; + for &m in &mags[1..half] { + log_sum += (m + 1e-9).ln(); + lin_sum += m; + } + 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 }; + // Advance prev_mag now that flux is computed. self.prev_mag.copy_from_slice(&mags); @@ -666,6 +702,32 @@ impl Analyzer { self.env.mid_on = self.pop[1]; self.env.high_on = self.pop[2]; self.env.flux = self.broad_pop; + follow(&mut self.env.flatness, flatness); + + // Predictive beat model. A rising broadband-onset edge past a floor + // (with a refractory gap) is a candidate beat; the inter-onset + // interval is smoothed only when it stays a plausible multiple of the + // current estimate, so a chopped amen break can't ping-pong the tempo + // octave. `beat_phase` then ramps 0->1 across the predicted interval, + // which is what lets the visuals anticipate the next hit. + self.beat_clock += self.hop_dt; + let onset = broad > 0.40 && broad > self.prev_broad && self.beat_clock > 0.12; + self.prev_broad = broad; + if onset { + let obs = self.beat_clock; + 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 }; + self.beat_ioi = (self.beat_ioi + (obs - self.beat_ioi) * k).clamp(0.18, 1.0); + } + self.beat_clock = 0.0; + self.beat_pop = 1.0; + } else { + self.beat_pop *= ONSET_RELEASE; + } + self.env.beat = self.beat_pop; + self.env.beat_phase = (self.beat_clock / self.beat_ioi.max(1e-3)).clamp(0.0, 1.0); // Raw waveform tap: decimate the un-windowed sample window so the scope // mode has a real time-domain trace. Same numbers live + offline. diff --git a/src/bin/sigil.rs b/src/bin/sigil.rs index 74e30e3..4b05742 100644 --- a/src/bin/sigil.rs +++ b/src/bin/sigil.rs @@ -33,7 +33,7 @@ use nannou::prelude::*; const W: f32 = 1080.0; const H: f32 = 1080.0; -const RENDER_FPS: f32 = 60.0; +const RENDER_FPS: f32 = 30.0; const SEED: u64 = 0x5C1_6E1_5EED; // x264 speed/quality preset. Copy so `Gains` stays Copy. @@ -85,6 +85,7 @@ struct Gains { fade: f32, // feedback decay per frame (0 endless .. 1 none) zoom: f32, // feedback bloom expansion (~1.006) ca: f32, // chromatic aberration px at full broadband flux + drive: f32, // breakcore expressive-effect master (heat/glitch/swirl) seg: usize, // Catmull-Rom samples per control segment (quality) glow: bool, // faux-glow halo passes feedback: bool, // feedback/bloom post (vs. direct draw) @@ -101,10 +102,11 @@ impl Default for Gains { fade: 0.11, zoom: 1.006, ca: 7.0, + drive: 1.0, seg: 9, glow: true, feedback: true, - out_scale: 0, + out_scale: 1080, crf: 18, x264: Preset::Slow, } @@ -128,6 +130,7 @@ impl Gains { "fade" => g.fade = v.parse().unwrap_or(g.fade), "zoom" => g.zoom = v.parse().unwrap_or(g.zoom), "ca" => g.ca = v.parse().unwrap_or(g.ca), + "drive" => g.drive = v.parse().unwrap_or(g.drive), "seg" => g.seg = v.parse().unwrap_or(g.seg), "glow" => g.glow = v.parse().unwrap_or(g.glow), "feedback" => g.feedback = v.parse().unwrap_or(g.feedback), @@ -138,6 +141,7 @@ impl Gains { } } g.seg = g.seg.clamp(2, 24); + g.drive = g.drive.clamp(0.0, 3.0); g.crf = g.crf.clamp(0, 51); if g.out_scale != 0 { g.out_scale &= !1; // x264 yuv420p needs even dimensions @@ -146,13 +150,14 @@ impl Gains { } fn save(&self, path: &PathBuf) { let s = format!( - "low={}\nwarp={}\nfade={}\nzoom={}\nca={}\nseg={}\nglow={}\nfeedback={}\n\ + "low={}\nwarp={}\nfade={}\nzoom={}\nca={}\ndrive={}\nseg={}\nglow={}\nfeedback={}\n\ out_scale={}\ncrf={}\nx264_preset={}\n", self.low, self.warp, self.fade, self.zoom, self.ca, + self.drive, self.seg, self.glow, self.feedback, @@ -270,10 +275,11 @@ impl Visual { feedback: bool, fade: f32, ca_px: f32, + drive: f32, ) -> &'a nannou::wgpu::Texture { match self { Visual::Breakcore(s) => { - s.render(device, queue, pal, scale, warp, feedback, fade, ca_px) + s.render(device, queue, pal, scale, warp, feedback, fade, ca_px, drive) } _ => unreachable!("render_gpu on a Draw-based visual"), } @@ -560,7 +566,21 @@ fn key_pressed(app: &App, m: &mut Model, key: Key) { .map(|p| p.join(format!("{}_{:016x}.png", m.visual.name(), m.visual.seed()))) .unwrap_or_else(|_| PathBuf::from("sigil.png")); let window = app.main_window(); - match m.post.capture_png(window.device(), window.queue(), &path) { + let (device, queue) = (window.device(), window.queue()); + // Breakcore bypasses Post — grab its own raymarch target, not the + // (never-written) Post accumulator. + let res = Post::res() as u32; + let saved = if m.visual.is_gpu() { + m.visual.capture_raw(device, queue).and_then(|px| { + nannou::image::RgbaImage::from_raw(res, res, px) + .ok_or_else(|| anyhow::anyhow!("image buffer size mismatch"))? + .save(&path) + .map_err(Into::into) + }) + } else { + m.post.capture_png(device, queue, &path) + }; + match saved { Ok(()) => println!("saved {}", path.display()), Err(e) => eprintln!("save failed: {e}"), } @@ -585,6 +605,8 @@ fn key_pressed(app: &App, m: &mut Model, key: Key) { Key::Key0 => g.ca += 1.0, Key::Minus => g.seg = g.seg.saturating_sub(1).max(2), Key::Equals => g.seg = (g.seg + 1).min(24), + Key::LBracket => g.drive = (g.drive - 0.1).max(0.0), + Key::RBracket => g.drive = (g.drive + 0.1).min(3.0), _ => {} } } @@ -625,8 +647,9 @@ fn update(app: &App, m: &mut Model, upd: Update) { if m.visual.is_gpu() { // Breakcore renders through its own raymarch pipeline; no Draw/Post. - m.visual - .render_gpu(device, queue, &pal, scale, warp, m.g.feedback, m.g.fade, ca_px); + m.visual.render_gpu( + device, queue, &pal, scale, warp, m.g.feedback, m.g.fade, ca_px, m.g.drive, + ); } else { // Build the scene off-screen, then push it through the feedback chain. let scene = Draw::new(); @@ -720,7 +743,7 @@ fn view(app: &App, m: &Model, frame: Frame) { Mode::Live(_) => format!("fps {:.0}", app.fps()), }; let txt = format!( - "{} · seed {:#x} · n {}\nlow {:.2} warp {:.2} fade {:.2} bloom {:.3} ca {:.0} seg {}\nglow {} feedback {} {}", + "{} · seed {:#x} · n {}\nlow {:.2} warp {:.2} fade {:.2} bloom {:.3} ca {:.0} drive {:.1} seg {}\nglow {} feedback {} {}", m.visual.name(), m.visual.seed(), m.visual.count(), @@ -729,6 +752,7 @@ fn view(app: &App, m: &Model, frame: Frame) { g.fade, g.zoom, g.ca, + g.drive, g.seg, g.glow, g.feedback, diff --git a/src/viz/breakcore.rs b/src/viz/breakcore.rs index af3691b..7ece597 100644 --- a/src/viz/breakcore.rs +++ b/src/viz/breakcore.rs @@ -1,22 +1,30 @@ //! breakcore — chaotic-IDM energy on a smooth, dark cybersigil. //! //! Premise → implementation map: -//! §1 geometry : a Lorenz/Rössler strange attractor (chaotic break -//! sections) cross-faded with a distorted parametric -//! torus-knot (held sections), both sampled into `NP` -//! capsule control points. -//! §2 audio : derived entirely from [`Bands`] (low/mid/high split, -//! spectral-flux onsets, centroid, loudness) — `audio.rs` -//! already does the FFT; this never touches it. +//! §1 geometry : the 64 capsule points are **partitioned** into four +//! co-existing structures (no longer one wire): +//! · spine — Lorenz/Rössler attractor ⇄ distorted +//! torus-knot, the connected backbone; +//! · ribs — a spectral shell, 8 struts whose length + +//! girth track log-spectrum bands; +//! · debris — transient capsule shrapnel spawned by +//! high/flux onsets, orbits then decays; +//! · spokes — 3 harmonic nodes on the loudest chroma +//! pitch classes (key/chord changes show). +//! §2 audio : derived entirely from [`Bands`] — `audio.rs` already did +//! the FFT; this never touches it. //! §3 smoothing: every reactive scalar is a critically-damped [`Spring`]; -//! audio sets the *target*, never the value directly, so a -//! kick snaps out and glides back. -//! §4 render : a hand-written wgpu raymarcher (`breakcore.wgsl`) — an -//! SDF capsule chain unioned with a polynomial smooth-min so -//! folds melt, accumulated as volumetric glow over black. -//! §5 sections : long- vs short-term loudness EMAs; a spike past threshold -//! (cooldown-gated) flips attractor⇄knot and reseeds, the -//! two point-sets cross-faded over ~2.6 s. +//! audio sets the *target*, never the value directly. +//! §4 render : a hand-written wgpu raymarcher (`breakcore.wgsl`) — a +//! grouped SDF capsule chain unioned with a polynomial +//! smooth-min, accumulated as volumetric glow over black. +//! §5 structure: a feature-vector classifier ([`Structure`]) — slow EMAs +//! of energy/brightness/bass/busyness + a chroma-novelty +//! term — sorts the track into [`Arch`] archetypes +//! (Ambient/Build/Drop/Breakdown/Groove), each mapping to a +//! distinct visual [`Regime`]. A build-up is a first-class +//! `tension` ramp that *integrates* rising brightness/density +//! while the bass is suppressed and *releases* on the drop. //! //! This is the one module that owns a hand-written wgpu pipeline; `post.rs` //! and the other visualisers stay on nannou's validated renderer. It is *not* @@ -28,27 +36,62 @@ //! function of the uniform block + hash(fragCoord, frame). So `--render` stays //! bit-reproducible and there is no per-frame chaos. -use crate::audio::Bands; +use crate::audio::{Bands, CHROMA_N, SPEC_N}; use crate::viz::curve::{Rng, fbm}; -use crate::viz::palette::Palette; +use crate::viz::palette::{Palette, oklch}; use crate::viz::post::read_texture_rgba; use nannou::prelude::*; use nannou::wgpu; -/// Capsule control points. **MUST** equal the `array` size and the -/// loop bound in `breakcore.wgsl` (flat-f32 UBO layout depends on it). Kept -/// low: shader cost is O(pixels · march_steps · NP). +/// Capsule control points. **MUST** equal the `array` size in +/// `breakcore.wgsl`. Kept low: shader cost is O(pixels · march_steps · NP). pub const NP: usize = 64; -/// UBO length in f32: 6 std140 rows (24) + NP·vec4. -const UBO_LEN: usize = 24 + 4 * NP; + +// --- §1 partition of the 64 points into co-existing structures. The group +// bounds below are **coupled to `breakcore.wgsl`** exactly like `NP`: the +// shader's `map()` hard-codes `G_SPINE`/`G_R0`/`G_S1`. Change here ⇒ change +// there. Layout: [0,SPINE) connected backbone, then [SPINE,NP) is disjoint +// *pairs* (one capsule per pair) for ribs ▸ debris ▸ spokes. +const SPINE: usize = 34; // 0..34 connected attractor/knot chain +const RIBS0: usize = 34; // 34..50 8 spectral struts (pairs) +const RIBS1: usize = 50; +const DEB0: usize = 50; // 50..58 4 transient debris fragments (pairs) +const DEB1: usize = 58; +const SPK0: usize = 58; // 58..64 3 harmonic spokes (pairs) +const SPK1: usize = 64; +const N_RIBS: usize = (RIBS1 - RIBS0) / 2; // 8 +const N_DEB: usize = (DEB1 - DEB0) / 2; // 4 +const N_SPK: usize = (SPK1 - SPK0) / 2; // 3 + +/// UBO length in f32: 10 std140 rows (40) + NP·vec4. +const UBO_LEN: usize = 40 + 4 * NP; + +/// Where a parked (inactive) capsule goes — far outside the bounding sphere +/// so its closest-approach glow is exactly zero (cheaper than a zero radius, +/// which would still draw the line). Stored flat since `Vec3::new` is non-const. +const PARK: f32 = 60.0; +const PARKED: [f32; 4] = [PARK, PARK, PARK, 0.001]; const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8UnormSrgb; +const TAU: f32 = std::f32::consts::TAU; fn smoothstep(t: f32) -> f32 { let t = t.clamp(0.0, 1.0); t * t * (3.0 - 2.0 * t) } +/// Ease an angle toward a target along the *shortest* arc (no 2π flicker +/// when the hue wraps). `a` is the per-frame lerp fraction. +fn angle_to(cur: f32, target: f32, a: f32) -> f32 { + let mut d = (target - cur) % TAU; + if d > std::f32::consts::PI { + d -= TAU; + } else if d < -std::f32::consts::PI { + d += TAU; + } + cur + d * a +} + /// Critically-damped spring (premise §3). `omega` is the natural frequency /// (stiffness); damping is fixed at ζ=1 so it never overshoots — a kick /// expands instantly then glides back with no ring. @@ -67,7 +110,7 @@ impl Spring { } } -/// Which geometry a section shows. +/// Which backbone geometry a section shows. #[derive(Clone, Copy, PartialEq)] enum Kind { Attractor, @@ -137,7 +180,7 @@ impl Knot { } } fn at(&self, u: f32) -> Vec3 { - let th = std::f32::consts::TAU * self.turns * u; + let th = TAU * self.turns * u; let r = (self.q * th).cos() + 2.0; vec3( r * (self.p * th).cos(), @@ -165,6 +208,259 @@ fn normalize(pts: &mut [Vec3]) { } } +// --------------------------------------------------------------------------- +// §5 musical-structure model +// --------------------------------------------------------------------------- + +/// Coarse song-section archetype. The classifier never sees a label in the +/// audio; this is inferred from the slow feature vector in [`Structure`]. +#[derive(Clone, Copy, PartialEq, Debug)] +enum Arch { + Ambient, // sparse / intro / pad + Build, // rising tension (riser, snare roll, filter open) + Drop, // post-build hit / dense break + Breakdown, // energy collapsed after a loud section + Groove, // sustained mid-energy default +} + +/// The per-archetype visual regime: spring/uniform *targets*, never values. +/// 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) + prefer_knot: bool, // bias the next restructure's backbone kind +} + +impl Arch { + fn regime(self) -> Regime { + match self { + // Wide, slow, thin, dim. Knot (ordered) backbone, shell barely on. + Arch::Ambient => Regime { + scale: 0.95, + melt: 0.3, + glow: 0.40, + speed: 0.55, + tube: 0.013, + rib: 0.10, + deb: 0.0, + heat: 0.05, + warpd: 0.04, + ca: 0.8, + fade: 1.0, + prefer_knot: true, + }, + // Contracting toward a dense core; everything ramps with tension + // (see Breakcore::update — these are the *base* before tension). + Arch::Build => Regime { + scale: 0.78, + melt: 0.7, + glow: 0.60, + speed: 0.9, + tube: 0.016, + rib: 0.55, + deb: 0.25, + heat: 0.45, + warpd: 0.12, + ca: 1.2, + fade: 0.7, + prefer_knot: false, + }, + // The hit: explosive, chaotic backbone, debris + shell wide open. + Arch::Drop => Regime { + scale: 0.92, + melt: 0.55, + glow: 0.95, + speed: 1.35, + tube: 0.020, + rib: 0.85, + deb: 1.0, + heat: 0.85, + warpd: 0.16, + ca: 1.4, + fade: 0.6, + prefer_knot: false, + }, + // Hollowed out after a drop: medium, soft, cool, slow. + Arch::Breakdown => Regime { + scale: 0.85, + melt: 0.45, + glow: 0.50, + speed: 0.7, + tube: 0.015, + rib: 0.30, + deb: 0.10, + heat: 0.15, + warpd: 0.07, + ca: 0.9, + fade: 0.85, + prefer_knot: true, + }, + // Balanced sustained default. + Arch::Groove => Regime { + scale: 0.82, + melt: 0.5, + glow: 0.65, + speed: 1.0, + tube: 0.016, + rib: 0.55, + deb: 0.5, + heat: 0.35, + warpd: 0.10, + ca: 1.0, + fade: 0.9, + prefer_knot: false, + }, + } + } +} + +/// Slow feature-vector model of where we are in the track. All inputs are +/// already AGC-normalised 0..1 in [`Bands`]; this just adds time structure. +struct Structure { + e: f32, // energy (loud) — fast EMA + br: f32, // bright (centroid) + ba: f32, // bass (low) + bu: f32, // busy (flux + per-band onsets) — onset density + e_l: f32, // long refs (~8 s) for relative comparison + ba_l: f32, + bu_l: f32, + ch: [f32; CHROMA_N], // chroma EMA → harmonic-novelty distance + prev_br: f32, + prev_bu: f32, + tension: f32, // 0..1 build-up ramp (premise §5 / axis B) + novelty: f32, // 0..1 decaying harmonic-change pulse + release: f32, // 0..1 decaying drop-release pulse + arch: Arch, + cand: Arch, // hysteresis: a candidate must dwell before it commits + cand_t: f32, +} + +impl Structure { + fn new() -> Self { + Structure { + e: 0.0, + br: 0.0, + ba: 0.0, + bu: 0.0, + e_l: 0.0, + ba_l: 0.0, + bu_l: 0.0, + ch: [0.0; CHROMA_N], + prev_br: 0.0, + prev_bu: 0.0, + tension: 0.0, + novelty: 0.0, + release: 0.0, + arch: Arch::Ambient, + cand: Arch::Ambient, + cand_t: 0.0, + } + } + + /// Advance the model one frame. Returns `true` on a committed archetype + /// change (the event the geometry restructures on). + fn update(&mut self, b: &Bands, dt: f32) -> bool { + let inv = 1.0 / dt.max(1e-4); + let a_fast = 1.0 - (-dt / 1.2).exp(); + 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); + self.e += (b.loud - self.e) * a_fast; + self.br += (b.centroid - self.br) * a_fast; + self.ba += (b.low - self.ba) * a_bass; + self.bu += (busy_in - self.bu) * a_fast; + self.e_l += (b.loud - self.e_l) * a_long; + self.ba_l += (b.low - self.ba_l) * a_long; + self.bu_l += (busy_in - self.bu_l) * a_long; + + // Harmonic novelty: L1 drift of the chroma vector vs its slow EMA. + // A chord/key change spikes this even when loudness is flat. + let a_ch = 1.0 - (-dt / 4.0).exp(); + let mut drift = 0.0; + for i in 0..CHROMA_N { + drift += (b.chroma[i] - self.ch[i]).abs(); + self.ch[i] += (b.chroma[i] - self.ch[i]) * a_ch; + } + self.novelty = (self.novelty - dt / 0.6) + .max(0.0) + .max((drift / CHROMA_N as f32 * 3.0).min(1.0)); + + // §B tension: integrate rising brightness + busyness, but only while + // the bass is *suppressed* relative to its long ref (the kick drops + // out under a build-up). Bass back ⇒ tension bleeds off fast. + let d_br = (self.br - self.prev_br) * inv; + let d_bu = (self.bu - self.prev_bu) * inv; + self.prev_br = self.br; + self.prev_bu = self.bu; + let bass_supp = (1.0 - self.ba / (self.ba_l + 1e-3)).clamp(0.0, 1.0); + let rise = (d_br.max(0.0) * 1.4 + d_bu.max(0.0) * 1.0) * (0.35 + 0.65 * bass_supp); + self.tension = (self.tension + (rise * 2.6 - 0.22) * dt).clamp(0.0, 1.0); + if self.ba > 0.85 * self.ba_l { + self.tension = (self.tension - dt * 1.6).max(0.0); + } + + // Release: a primed build collapsing into a bass+flux hit = the drop. + let primed = self.tension > 0.5; + let hit = b.low_on > 0.5 || (self.ba - self.ba_l) > 0.18; + let mut drop_now = false; + if primed && hit && b.flux > 0.4 { + self.release = 1.0; + self.tension = 0.0; + drop_now = true; + } + self.release = (self.release - dt / 0.5).max(0.0); + + // Classify. Drop on release is instant; everything else must dwell to + // beat hysteresis so a section is one change, not a stutter. + let cand = if drop_now { + Arch::Drop + } else if self.tension > 0.45 { + Arch::Build + } else if self.e < 0.32 && self.bu < 0.28 { + Arch::Ambient + } else if self.e > 0.60 && self.ba > 0.52 && self.bu > 0.48 { + Arch::Drop + } else if self.e_l > 0.30 && self.e < 0.52 * self.e_l { + Arch::Breakdown + } else { + Arch::Groove + }; + + if cand == self.cand { + self.cand_t += dt; + } else { + self.cand = cand; + self.cand_t = 0.0; + } + let dwell = if drop_now { 0.0 } else { 0.55 }; + if self.cand != self.arch && self.cand_t >= dwell { + self.arch = self.cand; + return true; + } + false + } +} + +/// One transient debris fragment (premise §1, breakcore "shrapnel"). +#[derive(Clone, Copy, Default)] +struct Frag { + p: Vec3, + v: Vec3, + life: f32, // 1 → 0 +} + pub struct Breakcore { pub seed: u64, rng: Rng, @@ -177,15 +473,25 @@ pub struct Breakcore { from: Kind, to: Kind, morph: f32, // 0..1 from→to (1 = settled) - lte: f32, // long-term loudness EMA - ste: f32, // short-term loudness EMA cooldown: f32, idle: f32, + trans: f32, // 0..1 section-change pulse (drives the swap burst) + + st: Structure, + deb: [Frag; N_DEB], + deb_gate: f32, // debris-spawn cooldown + rib_phase: f32, sp_scale: Spring, sp_tube: Spring, sp_dist: Spring, sp_glow: Spring, + sp_focal: Spring, // lens focal length — tension widens the FOV + sp_dolly: Spring, // beat/kick camera dolly-punch + jolt: f32, // decaying snare/hat rotational kick + shock: f32, // decaying broadband-onset radial pulse + hue_b: f32, // smoothed base hue (rad) — colour inertia + hue_a: f32, // smoothed accent hue (rad) yaw: f32, pitch: f32, roll: f32, @@ -211,14 +517,23 @@ impl Breakcore { from: Kind::Knot, to: Kind::Knot, morph: 1.0, - lte: 0.0, - ste: 0.0, cooldown: 0.0, idle: 0.0, + trans: 0.0, + st: Structure::new(), + deb: [Frag::default(); N_DEB], + deb_gate: 0.0, + rib_phase: 0.0, sp_scale: Spring { x: 1.0, v: 0.0 }, - sp_tube: Spring { x: 0.022, v: 0.0 }, + sp_tube: Spring { x: 0.016, v: 0.0 }, sp_dist: Spring { x: 3.2, v: 0.0 }, - sp_glow: Spring { x: 0.8, v: 0.0 }, + sp_glow: Spring { x: 0.6, v: 0.0 }, + sp_focal: Spring { x: 1.7, v: 0.0 }, + sp_dolly: Spring { x: 0.0, v: 0.0 }, + jolt: 0.0, + shock: 0.0, + hue_b: 2.0, + hue_a: 4.0, yaw: 0.0, pitch: 0.0, roll: 0.0, @@ -240,24 +555,65 @@ impl Breakcore { self.head = vec3(0.1, 0.0, 0.0); self.trail = [Vec3::ZERO; NP]; self.idle = 0.0; + self.deb = [Frag::default(); N_DEB]; + self.st = Structure::new(); } pub fn point_count(&self) -> usize { NP } - /// Begin a section change: flip kind, reseed both configs, restart morph. - fn restructure(&mut self) { + /// Begin a section change: pick the new backbone kind from the archetype's + /// bias, reseed both configs, restart the morph, fire the swap burst. + fn restructure(&mut self, prefer_knot: bool) { self.from = self.to; - self.to = match self.to { - Kind::Attractor => Kind::Knot, - Kind::Knot => Kind::Attractor, + self.to = if prefer_knot { + Kind::Knot + } else { + Kind::Attractor }; self.attr = Attr::random(&mut self.rng); self.knot = Knot::random(&mut self.rng); self.morph = 0.0; - self.cooldown = 2.0; + self.cooldown = 1.4; self.idle = 0.0; + self.trans = 1.0; + } + + fn update_debris(&mut self, deb_presence: f32, dt: f32) { + self.deb_gate = (self.deb_gate - dt).max(0.0); + let trigger = self.b.high_on > 0.45 || self.b.flux > 0.5; + if trigger && self.deb_gate <= 0.0 && deb_presence > 0.15 { + // Replace the most-decayed slot. Spawn on a random unit direction + // near the backbone, fired outward. + let mut slot = 0; + for i in 1..N_DEB { + if self.deb[i].life < self.deb[slot].life { + slot = i; + } + } + let dir = vec3( + self.rng.range(-1.0, 1.0), + self.rng.range(-1.0, 1.0), + self.rng.range(-1.0, 1.0), + ) + .normalize_or_zero(); + let spd = self.rng.range(0.5, 1.3) * (0.4 + 0.6 * deb_presence); + self.deb[slot] = Frag { + p: dir * self.rng.range(0.15, 0.45), + v: dir * spd, + life: 1.0, + }; + self.deb_gate = 0.055; + } + for f in self.deb.iter_mut() { + if f.life <= 0.0 { + continue; + } + f.p += f.v * dt; + f.v *= 1.0 - (2.4 * dt).min(0.9); // air drag → quick settle + f.life = (f.life - dt / 0.5).max(0.0); + } } pub fn update(&mut self, b: &Bands, dt: f32) { @@ -266,23 +622,78 @@ impl Breakcore { self.frame = self.frame.wrapping_add(1); self.b = *b; - // §3 springs — audio sets targets, motion stays buttery. - self.sp_scale - .step(1.0 + 0.55 * b.low + 0.5 * b.low_on, 14.0, dt); + // §5 structure model first — the regime drives every target below. + let arch_changed = self.st.update(b, dt); + let rg = self.st.arch.regime(); + let tn = self.st.tension; + let rel = self.st.release; + + // §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; + self.sp_scale.step(scale_t, 14.0, dt); self.sp_tube - .step(0.016 + 0.05 * b.mid + 0.02 * b.mid_on, 11.0, dt); - self.sp_dist.step(3.4 - 0.9 * b.low, 6.0, dt); // sub → macro/FOV - self.sp_glow - .step(0.45 + 0.5 * b.loud + 0.4 * b.flux, 9.0, dt); + .step(rg.tube + 0.05 * b.mid + 0.02 * b.mid_on, 11.0, dt); + self.sp_dist.step(3.4 - 0.9 * 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, + ); - // Smooth music-locked rotation (no random snaps). - self.yaw += (0.12 + 0.7 * b.mid) * dt; - self.pitch += (0.05 + 0.4 * b.low) * dt + 0.02 * dt; - self.roll += 0.035 * dt + 0.35 * b.high * 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.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; + self.rib_phase += 0.5 * b.mid * dt; - // §1 attractor: RK4 substeps, integration speed tracks sub/bass so the - // thread surges on heavy lows. Push the rolling trajectory. - let speed = 0.45 + 1.4 * b.low + 0.5 * b.low_on; + // Beat/kick camera dolly-punch + tension FOV breathing. The lens + // 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); + + // Broadband-onset radial shock: the whole sigil pulses out per big + // hit, distinct from the section restructure. Decays in ~0.4 s. + self.shock = (self.shock - dt / 0.4).max(0.0); + if b.flux > 0.55 { + self.shock = 1.0; + } + + // Colour inertia: hues ease toward their audio targets along the + // shortest arc (no per-frame flicker). Dual-tone — the accent offset + // depends on the archetype: complementary on a Drop (max contrast), + // analogous when calm (Ambient/Breakdown). + let base_t = (4.4 - b.centroid * 3.1).rem_euclid(TAU); + let (mut dom, mut dv) = (0usize, 0.0f32); + for (i, &cc) in b.chroma.iter().enumerate() { + if cc > dv { + dv = cc; + dom = i; + } + } + let offs = match self.st.arch { + Arch::Drop => std::f32::consts::PI, + Arch::Ambient | Arch::Breakdown => 0.6, + Arch::Build => 2.2, + Arch::Groove => 1.7, + }; + let acc_t = base_t + offs + dom as f32 / CHROMA_N as f32 * TAU * 0.5; + let ha = 1.0 - (-dt / 0.4).exp(); + self.hue_b = angle_to(self.hue_b, base_t, ha); + self.hue_a = angle_to(self.hue_a, acc_t, ha); + + // §1 attractor: RK4 substeps; integration speed tracks sub/bass and + // the regime/tension so the thread surges into the drop. No baseline: + // a silent passage freezes the trajectory (intended stillness). + let speed = (1.4 * b.low + 0.5 * b.low_on) * sp; let h = (speed * dt).clamp(0.0, 0.03); for _ in 0..6 { self.head = self.attr.rk4(self.head, h); @@ -293,78 +704,199 @@ impl Breakcore { self.trail.copy_within(1..NP, 0); self.trail[NP - 1] = self.head; - // §5 section state machine: long vs short loudness EMAs. - let a_l = 1.0 - (-dt / 8.0).exp(); - let a_s = 1.0 - (-dt / 0.22).exp(); - self.lte += (b.loud - self.lte) * a_l; - self.ste += (b.loud - self.ste) * a_s; - let ratio = self.ste / (self.lte + 1e-3); + self.update_debris(rg.deb, dt); if self.morph < 1.0 { - self.morph = (self.morph + dt / 2.6).min(1.0); + self.morph = (self.morph + dt / 1.8).min(1.0); if self.morph >= 1.0 { self.from = self.to; } } + self.trans = (self.trans - dt / 0.5).max(0.0); self.cooldown = (self.cooldown - dt).max(0.0); self.idle += dt; - let drop = ratio > 1.8 && b.flux > 0.55; - if self.morph >= 1.0 && self.cooldown <= 0.0 && (drop || self.idle > 14.0) { - self.restructure(); + + // A swap fires on a real structure event — an archetype commit or a + // strong harmonic novelty — gated by morph/cooldown. The idle + // fallback is a rare safety so a flat drone still eventually evolves. + let event = arch_changed || self.st.novelty > 0.75; + if self.morph >= 1.0 && self.cooldown <= 0.0 && (event || self.idle > 20.0) { + self.restructure(rg.prefer_knot); } } - /// Sample the active geometry into `NP` control points (xyz + radius). - fn build_points(&self) -> [[f32; 4]; NP] { - let knot = { - let mut v = [Vec3::ZERO; NP]; - for (i, slot) in v.iter_mut().enumerate() { - let u = i as f32 / NP as f32; - let mut p = self.knot.at(u); - // §2 highs → high-frequency displacement (jagged sigil edge). - let s = self.seed as u32; - let n = vec3( - fbm(vec2(u * 23.0, 1.0), s), - fbm(vec2(u * 23.0, 5.0), s ^ 0x9E37), - fbm(vec2(u * 23.0, 9.0), s ^ 0x85EB), - ); - p += n * (0.05 + 0.6 * self.b.high + 0.5 * self.b.high_on); - *slot = p; - } - normalize(&mut v); - v - }; - let attr = { - let mut v = self.trail; - normalize(&mut v); - v - }; + /// Shared world scale: the backbone spring plus the broadband radial + /// shock, so every structure pulses out together on a big hit and the + /// bounding sphere (which uses this too) never clips them. + fn world_scale(&self) -> f32 { + self.sp_scale.x.clamp(0.4, 2.4) * (1.0 + 0.12 * self.shock) + } - let pick = |k: Kind| -> &[Vec3; NP] { - match k { - Kind::Attractor => &attr, - Kind::Knot => &knot, + /// Sample the backbone into the spine slots, blending from→to with the + /// morph and displacing each point along a noise normal by its spectrum + /// band (premise §1 spine + axis D). A beat-phase gaussian "kick-wave" + /// travels root→tip each beat, fattening the wire as it passes. + fn build_spine(&self, out: &mut [[f32; 4]; NP]) { + let pick_attr = { + let mut v = [Vec3::ZERO; SPINE]; + for (i, slot) in v.iter_mut().enumerate() { + let j = i * (NP - 1) / (SPINE - 1); + *slot = self.trail[j]; } + normalize(&mut v); + v + }; + let pick_knot = { + let mut v = [Vec3::ZERO; SPINE]; + for (i, slot) in v.iter_mut().enumerate() { + let u = i as f32 / (SPINE - 1) as f32; + *slot = self.knot.at(u); + } + normalize(&mut v); + v + }; + let sel = |k: Kind| match k { + Kind::Attractor => &pick_attr, + Kind::Knot => &pick_knot, }; let e = smoothstep(self.morph); - let from = pick(self.from); - let to = pick(self.to); + let from = sel(self.from); + let to = sel(self.to); - let scale = self.sp_scale.x.clamp(0.4, 2.4); - let mut out = [[0.0f32; 4]; NP]; - for i in 0..NP { - let p = (from[i] + (to[i] - from[i]) * e) * scale; - // Radius: tube spring + per-point energy bump from the spectrum. - let band = self.b.spec[(i * crate::audio::SPEC_N) / NP]; - let r = (self.sp_tube.x * (0.5 + 0.8 * band)).clamp(0.003, 0.022); + let rg = self.st.arch.regime(); + let warpd = rg.warpd + 0.10 * self.st.tension; + let scale = self.world_scale(); + let s = self.seed as u32; + let bp = self.b.beat_phase; + let beat_amp = 0.4 + 0.6 * self.b.beat; + + let mut base = [Vec3::ZERO; SPINE]; + for i in 0..SPINE { + base[i] = from[i] + (to[i] - from[i]) * e; + } + for i in 0..SPINE { + // Tangent from neighbours → a stable normal for displacement. + let prev = base[i.saturating_sub(1)]; + let next = base[(i + 1).min(SPINE - 1)]; + let tan = (next - prev).normalize_or_zero(); + let u = i as f32 / SPINE as f32; + let nz = vec3( + fbm(vec2(u * 19.0, 1.0), s), + fbm(vec2(u * 19.0, 5.0), s ^ 0x9E37), + fbm(vec2(u * 19.0, 9.0), s ^ 0x85EB), + ); + let mut nrm = tan.cross(nz); + if nrm.length_squared() < 1e-6 { + nrm = vec3(0.0, 1.0, 0.0); + } + let nrm = nrm.normalize(); + let band = self.b.spec[(i * SPEC_N) / SPINE]; + // Kick-wave: a narrow gaussian centred at u == beat_phase that + // rides from root to tip across the beat, then snaps back. + let dw = u - bp; + 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); out[i] = [p.x, p.y, p.z, r]; } + } + + /// 8 spectral struts forming a shell around the backbone. Each strut's + /// length + girth tracks a log-spectrum band; a calm band collapses the + /// strut and parks it (invisible). + fn build_ribs(&self, out: &mut [[f32; 4]; NP]) { + let rg = self.st.arch.regime(); + let scale = self.world_scale(); + for k in 0..N_RIBS { + let band = self.b.spec[(k * SPEC_N) / N_RIBS]; + let pres = rg.rib * (0.15 + 0.95 * band); + let i = RIBS0 + 2 * k; + if pres < 0.04 { + out[i] = PARKED; + out[i + 1] = PARKED; + continue; + } + let ang = k as f32 / N_RIBS as f32 * TAU + self.rib_phase; + let el = (k as f32 / N_RIBS as f32 - 0.5) * std::f32::consts::PI * 0.7; + let dir = vec3(el.cos() * ang.cos(), el.sin(), el.cos() * ang.sin()); + let inner = dir * (0.30 * scale); + let outer = dir * ((0.30 + 0.70 * band * rg.rib) * scale); + let r = (self.sp_tube.x * (0.3 + 1.3 * band) * pres).clamp(0.0015, 0.007); + out[i] = [inner.x, inner.y, inner.z, r]; + out[i + 1] = [outer.x, outer.y, outer.z, r]; + } + } + + /// Transient debris fragments — per-hit punch. + fn build_debris(&self, out: &mut [[f32; 4]; NP]) { + let scale = self.world_scale(); + for k in 0..N_DEB { + let f = self.deb[k]; + let i = DEB0 + 2 * k; + if f.life <= 0.0 { + out[i] = PARKED; + out[i + 1] = PARKED; + continue; + } + let a = f.p * scale; + let b = (f.p + f.v * 0.06) * scale; + let r = (self.sp_tube.x * 1.4 * f.life).clamp(0.0015, 0.008); + out[i] = [a.x, a.y, a.z, r]; + out[i + 1] = [b.x, b.y, b.z, r]; + } + } + + /// 3 harmonic spokes on the loudest chroma pitch classes — key/chord + /// changes become visible structure, not just an accent-hue shift. + fn build_spokes(&self, out: &mut [[f32; 4]; NP]) { + let scale = self.world_scale(); + // Indices of the top-N_SPK chroma bins. + let mut order: [usize; CHROMA_N] = [0; CHROMA_N]; + for (i, o) in order.iter_mut().enumerate() { + *o = i; + } + order.sort_by(|&x, &y| { + self.b.chroma[y] + .partial_cmp(&self.b.chroma[x]) + .unwrap_or(std::cmp::Ordering::Equal) + }); + for k in 0..N_SPK { + let pc = order[k]; + let cv = self.b.chroma[pc]; + let i = SPK0 + 2 * k; + if cv < 0.12 { + out[i] = PARKED; + out[i + 1] = PARKED; + continue; + } + 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 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); + out[i] = [inner.x, inner.y, inner.z, r]; + out[i + 1] = [outer.x, outer.y, outer.z, r]; + } + } + + fn build_points(&self) -> [[f32; 4]; NP] { + let mut out = [[0.0f32; 4]; NP]; + self.build_spine(&mut out); + self.build_ribs(&mut out); + self.build_debris(&mut out); + self.build_spokes(&mut out); out } /// Render this frame's raymarch into the target and return it. Mirrors the /// other modes' tunables: `scale`/`warp` come from the live gain keys, /// `fade` is the phosphor persistence, `ca_px` the aberration amount. + /// Build-up `tension`/`release` modulate fade + CA internally so the bin + /// needs no extra plumbing. #[allow(clippy::too_many_arguments)] pub fn render( &mut self, @@ -376,27 +908,54 @@ impl Breakcore { feedback: bool, fade: f32, ca_px: f32, + drive: f32, ) -> &wgpu::Texture { let pts = self.build_points(); - let base = pal.bone(0.0); - let acc = pal.stroke(1.0, 0.85, 0.0); + let rg = self.st.arch.regime(); + let tn = self.st.tension; + let rel = self.st.release; + // `drive` is the expressive-effect master: it scales the *screen-space + // drama* (heat / glitch / grain / CA swing / swirl / scanline) but + // never the geometry, springs or section model, so detection and + // framing stay stable while taste is tunable per track. + let dr = drive.clamp(0.0, 3.0); + let heat = ((rg.heat + 0.7 * tn + 0.5 * rel) * dr).clamp(0.0, 1.0); + + // Colour from the inertia-smoothed hues (set in `update`). Tonal + // material (low flatness) stays lush; noisy/break material desaturates + // a touch so it reads harsher. Lightness rides loudness + tension. + let lo = self.b.loud; + let base = oklch( + (0.55 + 0.30 * lo + 0.12 * tn).min(0.95), + 0.10 + 0.06 * lo, + self.hue_b, + ); + let sat = (0.13 + 0.10 * (lo * 0.5 + self.b.mid * 0.4)) + * (1.0 - 0.30 * 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]; // row0 cam u[0] = self.yaw; u[1] = self.pitch; u[2] = self.roll; - u[3] = self.sp_dist.x.clamp(2.0, 6.0) * (1.0 + 0.05 * (1.0 - scale)); + // Dolly-punch pulls the camera in on the kick (clamped so it never + // crosses the geometry). + let cam_d = (self.sp_dist.x - 0.7 * self.sp_dolly.x).clamp(2.0, 6.0); + u[3] = cam_d * (1.0 + 0.05 * (1.0 - scale)); // row1 scale,tube,glow,ca u[4] = scale; u[5] = self.sp_tube.x; - u[6] = self.sp_glow.x.clamp(0.35, 1.4); // closest-approach glow ≤1.4 - u[7] = ca_px; + u[6] = self.sp_glow.x.clamp(0.40, 1.2); + // build-up creeps the aberration; release spikes it (drive scales + // the swing, not the user's base `ca`). + u[7] = ca_px * rg.ca * (1.0 + (0.7 * tn + 2.0 * rel) * dr); // row2 base.rgb, fade u[8] = base[0]; u[9] = base[1]; u[10] = base[2]; - u[11] = fade.clamp(0.0, 1.0); + // longer trail under tension (smaller fade ⇒ slower decay). + u[11] = (fade * rg.fade * (1.0 - 0.4 * tn)).clamp(0.02, 1.0); // row3 accent.rgb, flash u[12] = acc[0]; u[13] = acc[1]; @@ -411,14 +970,42 @@ impl Breakcore { // Steps are also hard-capped at 40 in the shader; keep this modest — // cost is O(pixels · steps · NP) and a runaway here is a GPU hang. u[20] = (22.0 + 9.0 * warp.min(2.0)).clamp(16.0, 40.0); - u[21] = (0.015 + 0.03 * self.b.loud + 0.02 * self.b.flux).clamp(0.01, 0.05); - // first frame has no valid history; gate it like Post::primed + // 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 }; - // bounding-sphere radius: normalized curve (0.92·scale) + max tube. - u[23] = 0.92 * self.sp_scale.x.clamp(0.4, 2.4) + 0.14; - // points + // 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. + u[23] = 1.05 * self.world_scale() + 0.18; + // row6 trans_pulse, glitch, scan, grain + u[24] = self.trans.max(rel); + // continuous slice glitch from highs/flux + harmonic novelty. + u[25] = ((0.06 * self.b.high + 0.12 * self.b.flux + 0.5 * self.st.novelty) * dr) + .clamp(0.0, 0.30); + // CRT scanline depth: a faint floor that deepens with loudness and + // build-up tension — the "display" powers up with the track. + u[26] = (0.02 + (0.07 * self.b.loud + 0.05 * tn) * dr).clamp(0.0, 0.14); + // Film-grain amplitude: rides broadband flux, spikes on release. + u[27] = (0.003 + (0.020 * self.b.flux + 0.010 * rel) * dr).clamp(0.0, 0.030); + // row7 heat, tension, release, focal + u[28] = heat; + u[29] = tn; + u[30] = rel; + u[31] = self.sp_focal.x.clamp(1.20, 2.50); + // row8 high_on, flatness, beat_phase, fog + u[32] = self.b.high_on; + u[33] = self.b.flatness; + u[34] = self.b.beat_phase; + // hazier through a build, snaps clear on the drop. + u[35] = (0.35 + 0.45 * self.b.loud + 0.20 * tn).clamp(0.30, 0.85); + // row9 swirl_zoom, swirl_rot, bg_glow, beat + u[36] = (0.004 + (0.018 * self.b.loud + 0.020 * self.shock) * dr).clamp(0.0, 0.05); + u[37] = ((0.006 * self.b.mid + 0.020 * tn) * dr).clamp(0.0, 0.05); + u[38] = (0.15 + 0.70 * self.b.loud).clamp(0.0, 1.0); + u[39] = self.b.beat; + // points (after 10 std140 rows = 40 f32) for (i, p) in pts.iter().enumerate() { - let o = 24 + 4 * i; + let o = 40 + 4 * i; u[o] = p[0]; u[o + 1] = p[1]; u[o + 2] = p[2]; diff --git a/src/viz/breakcore.wgsl b/src/viz/breakcore.wgsl index e17657c..8dc475e 100644 --- a/src/viz/breakcore.wgsl +++ b/src/viz/breakcore.wgsl @@ -1,16 +1,34 @@ // breakcore raymarch — dark volumetric cybersigil. // -// A capsule chain through `pts` (a CPU-integrated strange-attractor / -// distorted-torus-knot curve) unioned with a polynomial smin so folds melt. -// Cost is bounded hard: a ray/bounding-sphere test discards background pixels -// in ~one op, the march is sphere-traced with a low step cap, and brightness -// is a *closest-approach* falloff (not unbounded accumulation) so the field -// stays black with a crisp neon tube + soft halo — no white-out, no GPU hang. +// A partitioned capsule field through `pts` (CPU-built strange-attractor / +// torus-knot backbone + spectral ribs + transient debris + harmonic spokes) +// unioned with a polynomial smin so folds melt. Cost is bounded hard: a +// ray/bounding-sphere test discards background pixels in ~one op, the march +// is sphere-traced with a low step cap, and brightness is a *closest-approach* +// falloff (not unbounded accumulation) so the field stays black with a crisp +// neon tube + soft halo — no white-out, no GPU hang. Surface shading (normal, +// rim, specular) and the per-structure hue lookup run ONCE at the shade point +// (gated to lit pixels) — never per march step — so the step·NP budget is +// untouched. // // Pure function of the uniform block + hash(fragCoord, frame): no wall-clock, // no per-pixel state — so `--render` is bit-reproducible. `NP` (64) MUST // equal `breakcore::NP` in the Rust side; the UBO is a flat f32 layout, each // field below is one std140 16-byte row (see Breakcore::render in breakcore.rs). +// +// The 64 points are PARTITIONED (coupled to the consts in breakcore.rs): +// [0, G_SPINE) connected backbone chain (gid 0) +// [G_R0, G_D0) ribs — disjoint pairs (gid 1) +// [G_D0, G_S0) debris — disjoint pairs (gid 2) +// [G_S0, G_S1) spokes — disjoint pairs (gid 3) +// Changing the split here ⇒ change SPINE/RIBS0/DEB0/SPK0 in breakcore.rs. + +const G_SPINE: i32 = 34; // backbone point count (== SPINE) +const G_R0: i32 = 34; // ribs start (== RIBS0) +const G_D0: i32 = 50; // debris start (== DEB0) +const G_S0: i32 = 58; // spokes start (== SPK0) +const G_S1: i32 = 64; // end of pair groups (== SPK1 == NP) +const PI: f32 = 3.14159265; struct U { cam: vec4, // yaw, pitch, roll, dist @@ -19,6 +37,10 @@ struct U { col1: vec4, // accent.rgb, flash p1: vec4, // res, frame, n_pts, time p2: vec4, // march_steps, melt_k, feedback_on, world_r + p3: vec4, // trans_pulse, glitch, scan, grain + p4: vec4, // heat, tension, release, focal + p5: vec4, // high_on, flatness, beat_phase, fog + p6: vec4, // swirl_zoom, swirl_rot, bg_glow, beat pts: array, 64>, // xyz = point, w = capsule radius }; @@ -62,7 +84,8 @@ fn sd_capsule(p: vec3, a: vec3, b: vec3, r: f32) -> f32 { } // yaw(Y) -> pitch(X) -> roll(Z), matching the scope mode's convention. -// Rigid, so it never changes distance-to-origin (bounding sphere stays valid). +// Rigid, so it never changes distance-to-origin (bounding sphere stays valid) +// and is its own basis for both points and directions. fn rot(v: vec3) -> vec3 { let sy = sin(u.cam.x); let cy = cos(u.cam.x); let sp = sin(u.cam.y); let cp = cos(u.cam.y); @@ -76,35 +99,89 @@ fn rot(v: vec3) -> vec3 { return vec3(x3, y3, z2); } -// Scene SDF: smin-union of the capsule chain (already in rotated space). +// Scene SDF: smin-union of the partitioned capsule groups (rotated space). +// The backbone is one connected polyline; the rest are disjoint pairs (one +// capsule each) so ribs/debris/spokes never wire into each other or the +// spine. Total segments (~48) ≤ the old single chain (63) — cost unchanged. fn map(p: vec3) -> f32 { - let n = i32(u.p1.z); let k = u.p2.y; var d = 1e9; - for (var i = 0; i < n - 1; i = i + 1) { + for (var i = 0; i < G_SPINE - 1; i = i + 1) { let a = u.pts[i]; let c = u.pts[i + 1]; - let r = max(0.5 * (a.w + c.w), 0.004); + let r = max(0.5 * (a.w + c.w), 0.0030); + d = smin(d, sd_capsule(p, a.xyz, c.xyz, r), k); + } + for (var i = G_R0; i < G_S1; i = i + 2) { + let a = u.pts[i]; + let c = u.pts[i + 1]; + let r = max(0.5 * (a.w + c.w), 0.0025); d = smin(d, sd_capsule(p, a.xyz, c.xyz, r), k); } return d; } +fn group_of(i: i32) -> f32 { + if (i < G_R0) { return 0.0; } + if (i < G_D0) { return 1.0; } + if (i < G_S0) { return 2.0; } + return 3.0; +} + +// Which structure owns the nearest *raw* capsule (pre-smin) — used once at +// the shade point so the four layers read as distinct colours. +fn nearest_gid(p: vec3) -> f32 { + var best = 1e9; + var gid = 0.0; + for (var i = 0; i < G_SPINE - 1; i = i + 1) { + let a = u.pts[i]; + let c = u.pts[i + 1]; + let d = sd_capsule(p, a.xyz, c.xyz, max(0.5 * (a.w + c.w), 0.0030)); + if (d < best) { best = d; gid = 0.0; } + } + for (var i = G_R0; i < G_S1; i = i + 2) { + let a = u.pts[i]; + let c = u.pts[i + 1]; + let d = sd_capsule(p, a.xyz, c.xyz, max(0.5 * (a.w + c.w), 0.0025)); + if (d < best) { best = d; gid = group_of(i); } + } + return gid; +} + +// Central-difference SDF normal — 6 map() calls, ONCE per lit pixel. +fn calc_normal(p: vec3) -> vec3 { + let e = vec2(0.0016, 0.0); + return normalize(vec3( + 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 { - let res = u.p1.x; - let frame = u.p1.y; - let gain = u.p0.z; - let ca_px = u.p0.w; - let base = u.col0.xyz; - let accent = u.col1.xyz; - let flash = u.col1.w; - let rb = u.p2.w; // bounding-sphere radius (curve extent + tube) + let res = u.p1.x; + let frame = u.p1.y; + let gain = u.p0.z; + let ca_px = u.p0.w; + let base = u.col0.xyz; + let accent = u.col1.xyz; + let flash = u.col1.w; + let rb = u.p2.w; // bounding-sphere radius (curve extent + tube) + let trans = u.p3.x; // 0..1 section-change pulse (geometry swap) + let grain = u.p3.w; // audio-driven film-grain amplitude + let heat = u.p4.x; // 0..1 build-up / energy colour warmth + let focal = u.p4.w; // lens focal length (tension widens the FOV) + let high_on = u.p5.x; // hi-band onset → specular glint + let flat_n = u.p5.y; // spectral flatness (tonal 0 .. noisy 1) + let bphase = u.p5.z; // beat-phase sawtooth 0..1 + let fog = u.p5.w; // volumetric fog density + let beat = u.p6.w; // decaying beat pulse → feedback echo let ndc = vec2(in.uv.x * 2.0 - 1.0, 1.0 - in.uv.y * 2.0); let dist = u.cam.w; let ro = vec3(0.0, 0.0, -dist); - let rd = normalize(vec3(ndc.x, ndc.y, 1.6)); + let rd = normalize(vec3(ndc.x, ndc.y, focal)); // Ray vs bounding sphere — discards every background pixel in ~one op, // which is what keeps this from melting the GPU. @@ -112,57 +189,115 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { let c = dot(ro, ro) - rb * rb; let disc = b * b - c; - var glow = 0.0; + var inten = 0.0; var depth = 0.0; + var col = vec3(0.0); if (disc > 0.0) { let sq = sqrt(disc); var t = max(-b - sq, 0.0); let t_end = -b + sq; - let span = max(t_end - t, 1e-3); - let min_step = span / 40.0; // guarantees the march finishes + let min_step = max(t_end - t, 1e-3) / 40.0; // march always finishes let steps = min(i32(u.p2.x), 40); var dmin = 1e9; + var hit_t = -1.0; for (var s = 0; s < steps; s = s + 1) { let d = map(rot(ro + rd * t)); - dmin = min(dmin, d); - if (d < 0.004) { - dmin = 0.0; - depth = clamp((t + b) / max(2.0 * sq, 1e-3), 0.0, 1.0); - break; - } + if (d < dmin) { dmin = d; } + if (d < 0.0015) { hit_t = t; break; } // perf only — no white snap t = t + max(d * 0.85, min_step); if (t > t_end) { break; } } - // Closest-approach falloff. A near-Gaussian core gives a *thin* - // filament; a small, fast-decaying halo is the only volumetric - // spill. Everything more than ~0.1 from the curve is pure black — - // that's what kills the wash. Bounded in [0, ~1.1]. - let core = exp(-dmin * dmin * 900.0); - let halo = 0.22 * exp(-dmin * 24.0); - glow = clamp((core + halo) * gain, 0.0, 1.2); + // Soft *thin* filament straight from the closest approach — NOT a + // binary hit→white. Bright within ~0.012, gone by ~0.03, so a dense + // tangle reads as separated glowing wires, not a filled slab. + let dl = max(dmin, 0.0); + inten = exp(-dl * dl * 6000.0) + 0.10 * exp(-dl * 30.0); + let tt = select(t, hit_t, hit_t > 0.0); + depth = clamp((tt + b) / max(2.0 * sq, 1e-3), 0.0, 1.0); + // Reactive volumetric fog: near strands brighter than far. + inten = inten * (1.0 - fog * depth); + inten = clamp(inten * gain, 0.0, 1.0); + + if (inten > 0.02) { + // Shade point (local/rotated space, same as map()). + let rp = rot(ro + rd * tt); + // Per-structure hue: each layer keeps its own colour identity. + let gid = nearest_gid(rp); + var wire = base; + if (gid > 2.5) { wire = mix(base, accent, 0.5); } // spokes + else if (gid > 1.5) { wire = mix(accent, vec3(1.0), 0.7); } // debris + else if (gid > 0.5) { wire = accent; } // ribs + // A little depth blend toward accent keeps the form readable in 3D. + let wcol = mix(wire, accent, 0.25 * depth); + + col = wcol * inten; + col = col + mix(accent, vec3(1.0), 0.6) * pow(inten, 6.0) * 0.6; + col = col + accent * flash * pow(inten, 3.0) * 0.35; // onset spark + + // Surface lighting — rim/fresnel + a hi-band specular glint give + // the tube real 3D form instead of a flat glow ribbon. + let n = calc_normal(rp); + let vdir = normalize(rot(-rd)); + let rim_e = mix(4.0, 1.6, flat_n); // noisy → broader rim + let rim = pow(1.0 - clamp(dot(n, vdir), 0.0, 1.0), rim_e); + col = col + wcol * rim * inten * 0.45; + let ldir = normalize(vec3(0.4, 0.7, -0.55)); + let hdir = normalize(ldir + vdir); + let spec = pow(clamp(dot(n, hdir), 0.0, 1.0), 42.0); + col = col + vec3(1.0) * spec * inten * (0.25 + 0.9 * high_on); + + // Build-up heat: warm toward accent + a warm hot core. + col = col + accent * heat * pow(inten, 2.0) * 0.20; + col = col + vec3(1.0, 0.72, 0.42) * heat * pow(inten, 5.0) * 0.16; + } } - // Colour: a saturated, fairly dark hue carries the line; luminance is the - // glow alone, so off-line pixels are black, not grey haze. - var col = mix(base, accent, depth) * (0.45 + 0.55 * depth) * glow; - col = col + accent * flash * glow * glow * 0.4; - // Faint grain so the black field is alive (very low amplitude). - col = max(col + (hash21(in.uv * res + vec2(frame, frame * 1.7)) - 0.5) * 0.006, + // Section change: shove the hue hard toward the accent WITHOUT adding + // luminance — accent*peak can't exceed the current peak channel, so the + // swap recolours violently but can never white-flash. + let pk = max(col.r, max(col.g, col.b)); + col = mix(col, accent * pk, trans * 0.6); + col = max(col + (hash21(in.uv * res + vec2(frame, frame * 1.7)) - 0.5) * grain, vec3(0.0)); // Phosphor persistence: a *decaying* trail via max() — can never brighten - // past the fresh frame, so no additive runaway to white. Cheap radial - // chromatic aberration on the trail term only. + // past the fresh frame, so no additive runaway to white. The trail carries + // the slice-glitch + chromatic aberration (spiked on a section change) and + // a beat-synced zoom/rotate *swirl*; a beat briefly lengthens the trail + // (echo). `decay` stays < 1, so every term remains contractive. if (u.p2.z > 0.5) { - // Shorter than Post's trail: a long phosphor tail on a fat glow reads - // as smear/wash. fade 0.11 -> ~0.67 decay (~a dozen frames). - let decay = clamp(1.0 - 3.0 * u.col0.w, 0.30, 0.90); - let off = (in.uv - vec2(0.5)) * (ca_px / max(res, 1.0)); - let pr = textureSample(prev_tex, prev_smp, in.uv + off).r; - let pg = textureSample(prev_tex, prev_smp, in.uv).g; - let pb = textureSample(prev_tex, prev_smp, in.uv - off).b; + let dec0 = clamp(1.0 - 3.0 * u.col0.w, 0.30, 0.90); + let decay = clamp(dec0 + 0.45 * beat, 0.30, 0.95); + // Horizontal slice tear: random rows shift sideways (datamosh feel). + let g_amt = clamp(u.p3.y + 0.9 * trans, 0.0, 1.2); + let row = floor(in.uv.y * 40.0); + let pick = step(0.80, hash21(vec2(row, floor(frame / 3.0)))); + let slice = pick * (hash21(vec2(row, frame)) - 0.5) * 0.14 * g_amt; + var suv = vec2(fract(in.uv.x + slice), in.uv.y); + // Swirl: zoom + rotate the sampled coords around centre. + var cuv = suv - vec2(0.5); + let zr = 1.0 - u.p6.x; // <1 ⇒ trail expands outward + let ang = u.p6.y; + let cs = cos(ang); let sn = sin(ang); + cuv = vec2(cuv.x * cs - cuv.y * sn, cuv.x * sn + cuv.y * cs) * zr; + suv = cuv + vec2(0.5); + let ca = ca_px * (1.0 + 5.0 * trans) / max(res, 1.0); + let off = (suv - vec2(0.5)) * ca; + let pr = textureSampleLevel(prev_tex, prev_smp, suv + off, 0.0).r; + let pg = textureSampleLevel(prev_tex, prev_smp, suv, 0.0).g; + let pb = textureSampleLevel(prev_tex, prev_smp, suv - off, 0.0).b; col = max(col, vec3(pr, pg, pb) * decay); } + // Faint base-hue background so the void breathes with loudness without + // ever washing (≤0.05, centre-weighted). Added after feedback so it is a + // stable floor, not something the trail can accumulate. + let vig = max(1.0 - 0.75 * length(ndc), 0.0); + col = col + base * u.p6.z * vig * 0.05; + + // CRT scanline — depth is audio-driven (loudness + tension) and the + // lines crawl with the beat phase, so the "display" feels alive. + col = col * (1.0 - u.p3.z * (0.5 + 0.5 * sin(in.uv.y * res * PI + bphase * 6.2832))); + return vec4(min(col, vec3(1.0)), 1.0); }