breakcore visualizer

This commit is contained in:
2026-05-19 13:39:53 +02:00
parent 689c70b530
commit c7428762ea
6 changed files with 977 additions and 169 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

+62
View File
@@ -75,6 +75,13 @@ pub struct Bands {
pub flux: f32, pub flux: f32,
/// Overall loudness (mean magnitude), AGC-normalised -> lightness/scale. /// Overall loudness (mean magnitude), AGC-normalised -> lightness/scale.
pub loud: f32, 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. /// Relative pitch-class energy (max-normalised) -> harmonic accent hues.
pub chroma: [f32; CHROMA_N], pub chroma: [f32; CHROMA_N],
/// Decimated raw waveform (latest `FFT_SIZE` window, un-windowed, ~-1..1). /// Decimated raw waveform (latest `FFT_SIZE` window, un-windowed, ~-1..1).
@@ -95,6 +102,9 @@ impl Default for Bands {
centroid: 0.0, centroid: 0.0,
flux: 0.0, flux: 0.0,
loud: 0.0, loud: 0.0,
beat: 0.0,
beat_phase: 0.0,
flatness: 0.0,
chroma: [0.0; CHROMA_N], chroma: [0.0; CHROMA_N],
wave: [0.0; WAVE_N], wave: [0.0; WAVE_N],
} }
@@ -466,6 +476,13 @@ pub struct Analyzer {
pop: [f32; 3], // low/mid/high onset envelopes pop: [f32; 3], // low/mid/high onset envelopes
broad_pop: f32, // broadband onset envelope broad_pop: f32, // broadband onset envelope
spec_edges: [(usize, usize); SPEC_N], 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 { fn norm(v: f32, c: &mut f32) -> f32 {
@@ -520,6 +537,11 @@ impl Analyzer {
pop: [0.0; 3], pop: [0.0; 3],
broad_pop: 0.0, broad_pop: 0.0,
spec_edges, 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; *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. // Advance prev_mag now that flux is computed.
self.prev_mag.copy_from_slice(&mags); self.prev_mag.copy_from_slice(&mags);
@@ -666,6 +702,32 @@ impl Analyzer {
self.env.mid_on = self.pop[1]; self.env.mid_on = self.pop[1];
self.env.high_on = self.pop[2]; self.env.high_on = self.pop[2];
self.env.flux = self.broad_pop; 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 // Raw waveform tap: decimate the un-windowed sample window so the scope
// mode has a real time-domain trace. Same numbers live + offline. // mode has a real time-domain trace. Same numbers live + offline.
+32 -8
View File
@@ -33,7 +33,7 @@ use nannou::prelude::*;
const W: f32 = 1080.0; const W: f32 = 1080.0;
const H: 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; const SEED: u64 = 0x5C1_6E1_5EED;
// x264 speed/quality preset. Copy so `Gains` stays Copy. // 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) fade: f32, // feedback decay per frame (0 endless .. 1 none)
zoom: f32, // feedback bloom expansion (~1.006) zoom: f32, // feedback bloom expansion (~1.006)
ca: f32, // chromatic aberration px at full broadband flux 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) seg: usize, // Catmull-Rom samples per control segment (quality)
glow: bool, // faux-glow halo passes glow: bool, // faux-glow halo passes
feedback: bool, // feedback/bloom post (vs. direct draw) feedback: bool, // feedback/bloom post (vs. direct draw)
@@ -101,10 +102,11 @@ impl Default for Gains {
fade: 0.11, fade: 0.11,
zoom: 1.006, zoom: 1.006,
ca: 7.0, ca: 7.0,
drive: 1.0,
seg: 9, seg: 9,
glow: true, glow: true,
feedback: true, feedback: true,
out_scale: 0, out_scale: 1080,
crf: 18, crf: 18,
x264: Preset::Slow, x264: Preset::Slow,
} }
@@ -128,6 +130,7 @@ impl Gains {
"fade" => g.fade = v.parse().unwrap_or(g.fade), "fade" => g.fade = v.parse().unwrap_or(g.fade),
"zoom" => g.zoom = v.parse().unwrap_or(g.zoom), "zoom" => g.zoom = v.parse().unwrap_or(g.zoom),
"ca" => g.ca = v.parse().unwrap_or(g.ca), "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), "seg" => g.seg = v.parse().unwrap_or(g.seg),
"glow" => g.glow = v.parse().unwrap_or(g.glow), "glow" => g.glow = v.parse().unwrap_or(g.glow),
"feedback" => g.feedback = v.parse().unwrap_or(g.feedback), "feedback" => g.feedback = v.parse().unwrap_or(g.feedback),
@@ -138,6 +141,7 @@ impl Gains {
} }
} }
g.seg = g.seg.clamp(2, 24); g.seg = g.seg.clamp(2, 24);
g.drive = g.drive.clamp(0.0, 3.0);
g.crf = g.crf.clamp(0, 51); g.crf = g.crf.clamp(0, 51);
if g.out_scale != 0 { if g.out_scale != 0 {
g.out_scale &= !1; // x264 yuv420p needs even dimensions g.out_scale &= !1; // x264 yuv420p needs even dimensions
@@ -146,13 +150,14 @@ impl Gains {
} }
fn save(&self, path: &PathBuf) { fn save(&self, path: &PathBuf) {
let s = format!( 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", out_scale={}\ncrf={}\nx264_preset={}\n",
self.low, self.low,
self.warp, self.warp,
self.fade, self.fade,
self.zoom, self.zoom,
self.ca, self.ca,
self.drive,
self.seg, self.seg,
self.glow, self.glow,
self.feedback, self.feedback,
@@ -270,10 +275,11 @@ impl Visual {
feedback: bool, feedback: bool,
fade: f32, fade: f32,
ca_px: f32, ca_px: f32,
drive: f32,
) -> &'a nannou::wgpu::Texture { ) -> &'a nannou::wgpu::Texture {
match self { match self {
Visual::Breakcore(s) => { 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"), _ => 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()))) .map(|p| p.join(format!("{}_{:016x}.png", m.visual.name(), m.visual.seed())))
.unwrap_or_else(|_| PathBuf::from("sigil.png")); .unwrap_or_else(|_| PathBuf::from("sigil.png"));
let window = app.main_window(); 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()), Ok(()) => println!("saved {}", path.display()),
Err(e) => eprintln!("save failed: {e}"), 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::Key0 => g.ca += 1.0,
Key::Minus => g.seg = g.seg.saturating_sub(1).max(2), Key::Minus => g.seg = g.seg.saturating_sub(1).max(2),
Key::Equals => g.seg = (g.seg + 1).min(24), 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() { if m.visual.is_gpu() {
// Breakcore renders through its own raymarch pipeline; no Draw/Post. // Breakcore renders through its own raymarch pipeline; no Draw/Post.
m.visual m.visual.render_gpu(
.render_gpu(device, queue, &pal, scale, warp, m.g.feedback, m.g.fade, ca_px); device, queue, &pal, scale, warp, m.g.feedback, m.g.fade, ca_px, m.g.drive,
);
} else { } else {
// Build the scene off-screen, then push it through the feedback chain. // Build the scene off-screen, then push it through the feedback chain.
let scene = Draw::new(); let scene = Draw::new();
@@ -720,7 +743,7 @@ fn view(app: &App, m: &Model, frame: Frame) {
Mode::Live(_) => format!("fps {:.0}", app.fps()), Mode::Live(_) => format!("fps {:.0}", app.fps()),
}; };
let txt = format!( 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.name(),
m.visual.seed(), m.visual.seed(),
m.visual.count(), m.visual.count(),
@@ -729,6 +752,7 @@ fn view(app: &App, m: &Model, frame: Frame) {
g.fade, g.fade,
g.zoom, g.zoom,
g.ca, g.ca,
g.drive,
g.seg, g.seg,
g.glow, g.glow,
g.feedback, g.feedback,
+697 -110
View File
@@ -1,22 +1,30 @@
//! breakcore — chaotic-IDM energy on a smooth, dark cybersigil. //! breakcore — chaotic-IDM energy on a smooth, dark cybersigil.
//! //!
//! Premise → implementation map: //! Premise → implementation map:
//! §1 geometry : a Lorenz/Rössler strange attractor (chaotic break //! §1 geometry : the 64 capsule points are **partitioned** into four
//! sections) cross-faded with a distorted parametric //! co-existing structures (no longer one wire):
//! torus-knot (held sections), both sampled into `NP` //! · spine — Lorenz/Rössler attractor ⇄ distorted
//! capsule control points. //! torus-knot, the connected backbone;
//! §2 audio : derived entirely from [`Bands`] (low/mid/high split, //! · ribs — a spectral shell, 8 struts whose length +
//! spectral-flux onsets, centroid, loudness) — `audio.rs` //! girth track log-spectrum bands;
//! already does the FFT; this never touches it. //! · 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`]; //! §3 smoothing: every reactive scalar is a critically-damped [`Spring`];
//! audio sets the *target*, never the value directly, so a //! audio sets the *target*, never the value directly.
//! kick snaps out and glides back. //! §4 render : a hand-written wgpu raymarcher (`breakcore.wgsl`) — a
//! §4 render : a hand-written wgpu raymarcher (`breakcore.wgsl`) — an //! grouped SDF capsule chain unioned with a polynomial
//! SDF capsule chain unioned with a polynomial smooth-min so //! smooth-min, accumulated as volumetric glow over black.
//! folds melt, accumulated as volumetric glow over black. //! §5 structure: a feature-vector classifier ([`Structure`]) — slow EMAs
//! §5 sections : long- vs short-term loudness EMAs; a spike past threshold //! of energy/brightness/bass/busyness + a chroma-novelty
//! (cooldown-gated) flips attractor⇄knot and reseeds, the //! term — sorts the track into [`Arch`] archetypes
//! two point-sets cross-faded over ~2.6 s. //! (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` //! 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* //! 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 //! function of the uniform block + hash(fragCoord, frame). So `--render` stays
//! bit-reproducible and there is no per-frame chaos. //! 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::curve::{Rng, fbm};
use crate::viz::palette::Palette; use crate::viz::palette::{Palette, oklch};
use crate::viz::post::read_texture_rgba; use crate::viz::post::read_texture_rgba;
use nannou::prelude::*; use nannou::prelude::*;
use nannou::wgpu; use nannou::wgpu;
/// Capsule control points. **MUST** equal the `array<vec4, N>` size and the /// Capsule control points. **MUST** equal the `array<vec4, N>` size in
/// loop bound in `breakcore.wgsl` (flat-f32 UBO layout depends on it). Kept /// `breakcore.wgsl`. Kept low: shader cost is O(pixels · march_steps · NP).
/// low: shader cost is O(pixels · march_steps · NP).
pub const NP: usize = 64; 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 FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8UnormSrgb;
const TAU: f32 = std::f32::consts::TAU;
fn smoothstep(t: f32) -> f32 { fn smoothstep(t: f32) -> f32 {
let t = t.clamp(0.0, 1.0); let t = t.clamp(0.0, 1.0);
t * t * (3.0 - 2.0 * t) 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 /// Critically-damped spring (premise §3). `omega` is the natural frequency
/// (stiffness); damping is fixed at ζ=1 so it never overshoots — a kick /// (stiffness); damping is fixed at ζ=1 so it never overshoots — a kick
/// expands instantly then glides back with no ring. /// 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)] #[derive(Clone, Copy, PartialEq)]
enum Kind { enum Kind {
Attractor, Attractor,
@@ -137,7 +180,7 @@ impl Knot {
} }
} }
fn at(&self, u: f32) -> Vec3 { 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; let r = (self.q * th).cos() + 2.0;
vec3( vec3(
r * (self.p * th).cos(), 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 struct Breakcore {
pub seed: u64, pub seed: u64,
rng: Rng, rng: Rng,
@@ -177,15 +473,25 @@ pub struct Breakcore {
from: Kind, from: Kind,
to: Kind, to: Kind,
morph: f32, // 0..1 from→to (1 = settled) morph: f32, // 0..1 from→to (1 = settled)
lte: f32, // long-term loudness EMA
ste: f32, // short-term loudness EMA
cooldown: f32, cooldown: f32,
idle: 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_scale: Spring,
sp_tube: Spring, sp_tube: Spring,
sp_dist: Spring, sp_dist: Spring,
sp_glow: 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, yaw: f32,
pitch: f32, pitch: f32,
roll: f32, roll: f32,
@@ -211,14 +517,23 @@ impl Breakcore {
from: Kind::Knot, from: Kind::Knot,
to: Kind::Knot, to: Kind::Knot,
morph: 1.0, morph: 1.0,
lte: 0.0,
ste: 0.0,
cooldown: 0.0, cooldown: 0.0,
idle: 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_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_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, yaw: 0.0,
pitch: 0.0, pitch: 0.0,
roll: 0.0, roll: 0.0,
@@ -240,24 +555,65 @@ impl Breakcore {
self.head = vec3(0.1, 0.0, 0.0); self.head = vec3(0.1, 0.0, 0.0);
self.trail = [Vec3::ZERO; NP]; self.trail = [Vec3::ZERO; NP];
self.idle = 0.0; self.idle = 0.0;
self.deb = [Frag::default(); N_DEB];
self.st = Structure::new();
} }
pub fn point_count(&self) -> usize { pub fn point_count(&self) -> usize {
NP NP
} }
/// Begin a section change: flip kind, reseed both configs, restart morph. /// Begin a section change: pick the new backbone kind from the archetype's
fn restructure(&mut self) { /// bias, reseed both configs, restart the morph, fire the swap burst.
fn restructure(&mut self, prefer_knot: bool) {
self.from = self.to; self.from = self.to;
self.to = match self.to { self.to = if prefer_knot {
Kind::Attractor => Kind::Knot, Kind::Knot
Kind::Knot => Kind::Attractor, } else {
Kind::Attractor
}; };
self.attr = Attr::random(&mut self.rng); self.attr = Attr::random(&mut self.rng);
self.knot = Knot::random(&mut self.rng); self.knot = Knot::random(&mut self.rng);
self.morph = 0.0; self.morph = 0.0;
self.cooldown = 2.0; self.cooldown = 1.4;
self.idle = 0.0; 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) { pub fn update(&mut self, b: &Bands, dt: f32) {
@@ -266,23 +622,78 @@ impl Breakcore {
self.frame = self.frame.wrapping_add(1); self.frame = self.frame.wrapping_add(1);
self.b = *b; self.b = *b;
// §3 springs — audio sets targets, motion stays buttery. // §5 structure model first — the regime drives every target below.
self.sp_scale let arch_changed = self.st.update(b, dt);
.step(1.0 + 0.55 * b.low + 0.5 * b.low_on, 14.0, 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 self.sp_tube
.step(0.016 + 0.05 * b.mid + 0.02 * b.mid_on, 11.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); // sub → macro/FOV self.sp_dist.step(3.4 - 0.9 * b.low, 6.0, dt);
self.sp_glow self.sp_glow.step(
.step(0.45 + 0.5 * b.loud + 0.4 * b.flux, 9.0, dt); rg.glow + 0.45 * b.loud + 0.4 * b.flux + 0.35 * tn,
9.0,
dt,
);
// Smooth music-locked rotation (no random snaps). // Music-locked rotation. No constant baseline — true silence leaves
self.yaw += (0.12 + 0.7 * b.mid) * dt; // the field still; every term is audio · regime · tension. A snare/hat
self.pitch += (0.05 + 0.4 * b.low) * dt + 0.02 * dt; // onset adds a quick rotational *jolt* (decays, so it reads as a kick).
self.roll += 0.035 * dt + 0.35 * b.high * dt; 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 // Beat/kick camera dolly-punch + tension FOV breathing. The lens
// thread surges on heavy lows. Push the rolling trajectory. // widens through a build then snaps back on the drop.
let speed = 0.45 + 1.4 * b.low + 0.5 * b.low_on; 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); let h = (speed * dt).clamp(0.0, 0.03);
for _ in 0..6 { for _ in 0..6 {
self.head = self.attr.rk4(self.head, h); self.head = self.attr.rk4(self.head, h);
@@ -293,78 +704,199 @@ impl Breakcore {
self.trail.copy_within(1..NP, 0); self.trail.copy_within(1..NP, 0);
self.trail[NP - 1] = self.head; self.trail[NP - 1] = self.head;
// §5 section state machine: long vs short loudness EMAs. self.update_debris(rg.deb, dt);
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);
if self.morph < 1.0 { 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 { if self.morph >= 1.0 {
self.from = self.to; self.from = self.to;
} }
} }
self.trans = (self.trans - dt / 0.5).max(0.0);
self.cooldown = (self.cooldown - dt).max(0.0); self.cooldown = (self.cooldown - dt).max(0.0);
self.idle += dt; 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) { // A swap fires on a real structure event — an archetype commit or a
self.restructure(); // 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). /// Shared world scale: the backbone spring plus the broadband radial
fn build_points(&self) -> [[f32; 4]; NP] { /// shock, so every structure pulses out together on a big hit and the
let knot = { /// bounding sphere (which uses this too) never clips them.
let mut v = [Vec3::ZERO; NP]; fn world_scale(&self) -> f32 {
for (i, slot) in v.iter_mut().enumerate() { self.sp_scale.x.clamp(0.4, 2.4) * (1.0 + 0.12 * self.shock)
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
};
let pick = |k: Kind| -> &[Vec3; NP] { /// Sample the backbone into the spine slots, blending from→to with the
match k { /// morph and displacing each point along a noise normal by its spectrum
Kind::Attractor => &attr, /// band (premise §1 spine + axis D). A beat-phase gaussian "kick-wave"
Kind::Knot => &knot, /// 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 e = smoothstep(self.morph);
let from = pick(self.from); let from = sel(self.from);
let to = pick(self.to); let to = sel(self.to);
let scale = self.sp_scale.x.clamp(0.4, 2.4); let rg = self.st.arch.regime();
let mut out = [[0.0f32; 4]; NP]; let warpd = rg.warpd + 0.10 * self.st.tension;
for i in 0..NP { let scale = self.world_scale();
let p = (from[i] + (to[i] - from[i]) * e) * scale; let s = self.seed as u32;
// Radius: tube spring + per-point energy bump from the spectrum. let bp = self.b.beat_phase;
let band = self.b.spec[(i * crate::audio::SPEC_N) / NP]; let beat_amp = 0.4 + 0.6 * self.b.beat;
let r = (self.sp_tube.x * (0.5 + 0.8 * band)).clamp(0.003, 0.022);
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]; 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 out
} }
/// Render this frame's raymarch into the target and return it. Mirrors the /// Render this frame's raymarch into the target and return it. Mirrors the
/// other modes' tunables: `scale`/`warp` come from the live gain keys, /// other modes' tunables: `scale`/`warp` come from the live gain keys,
/// `fade` is the phosphor persistence, `ca_px` the aberration amount. /// `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)] #[allow(clippy::too_many_arguments)]
pub fn render( pub fn render(
&mut self, &mut self,
@@ -376,27 +908,54 @@ impl Breakcore {
feedback: bool, feedback: bool,
fade: f32, fade: f32,
ca_px: f32, ca_px: f32,
drive: f32,
) -> &wgpu::Texture { ) -> &wgpu::Texture {
let pts = self.build_points(); let pts = self.build_points();
let base = pal.bone(0.0); let rg = self.st.arch.regime();
let acc = pal.stroke(1.0, 0.85, 0.0); 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]; let mut u = [0.0f32; UBO_LEN];
// row0 cam // row0 cam
u[0] = self.yaw; u[0] = self.yaw;
u[1] = self.pitch; u[1] = self.pitch;
u[2] = self.roll; 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 // row1 scale,tube,glow,ca
u[4] = scale; u[4] = scale;
u[5] = self.sp_tube.x; u[5] = self.sp_tube.x;
u[6] = self.sp_glow.x.clamp(0.35, 1.4); // closest-approach glow ≤1.4 u[6] = self.sp_glow.x.clamp(0.40, 1.2);
u[7] = ca_px; // 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 // row2 base.rgb, fade
u[8] = base[0]; u[8] = base[0];
u[9] = base[1]; u[9] = base[1];
u[10] = base[2]; 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 // row3 accent.rgb, flash
u[12] = acc[0]; u[12] = acc[0];
u[13] = acc[1]; u[13] = acc[1];
@@ -411,14 +970,42 @@ impl Breakcore {
// Steps are also hard-capped at 40 in the shader; keep this modest — // 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. // 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[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); // Tension fuses the folds (higher melt_k) so a build melts to a core.
// first frame has no valid history; gate it like Post::primed 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: normalized curve (0.92·scale) + max tube. // bounding-sphere radius: covers spine (0.92) + ribs/spokes/debris
u[23] = 0.92 * self.sp_scale.x.clamp(0.4, 2.4) + 0.14; // (≤~1.05·scale) + tube, and grows with the shock so a pulse never
// points // 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() { for (i, p) in pts.iter().enumerate() {
let o = 24 + 4 * i; let o = 40 + 4 * i;
u[o] = p[0]; u[o] = p[0];
u[o + 1] = p[1]; u[o + 1] = p[1];
u[o + 2] = p[2]; u[o + 2] = p[2];
+186 -51
View File
@@ -1,16 +1,34 @@
// breakcore raymarch — dark volumetric cybersigil. // breakcore raymarch — dark volumetric cybersigil.
// //
// A capsule chain through `pts` (a CPU-integrated strange-attractor / // A partitioned capsule field through `pts` (CPU-built strange-attractor /
// distorted-torus-knot curve) unioned with a polynomial smin so folds melt. // torus-knot backbone + spectral ribs + transient debris + harmonic spokes)
// Cost is bounded hard: a ray/bounding-sphere test discards background pixels // unioned with a polynomial smin so folds melt. Cost is bounded hard: a
// in ~one op, the march is sphere-traced with a low step cap, and brightness // ray/bounding-sphere test discards background pixels in ~one op, the march
// is a *closest-approach* falloff (not unbounded accumulation) so the field // is sphere-traced with a low step cap, and brightness is a *closest-approach*
// stays black with a crisp neon tube + soft halo — no white-out, no GPU hang. // 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, // Pure function of the uniform block + hash(fragCoord, frame): no wall-clock,
// no per-pixel state — so `--render` is bit-reproducible. `NP` (64) MUST // 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 // 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). // 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 { struct U {
cam: vec4<f32>, // yaw, pitch, roll, dist cam: vec4<f32>, // yaw, pitch, roll, dist
@@ -19,6 +37,10 @@ struct U {
col1: vec4<f32>, // accent.rgb, flash col1: vec4<f32>, // accent.rgb, flash
p1: vec4<f32>, // res, frame, n_pts, time p1: vec4<f32>, // res, frame, n_pts, time
p2: vec4<f32>, // march_steps, melt_k, feedback_on, world_r p2: vec4<f32>, // march_steps, melt_k, feedback_on, world_r
p3: vec4<f32>, // trans_pulse, glitch, scan, grain
p4: vec4<f32>, // heat, tension, release, focal
p5: vec4<f32>, // high_on, flatness, beat_phase, fog
p6: vec4<f32>, // swirl_zoom, swirl_rot, bg_glow, beat
pts: array<vec4<f32>, 64>, // xyz = point, w = capsule radius pts: array<vec4<f32>, 64>, // xyz = point, w = capsule radius
}; };
@@ -62,7 +84,8 @@ fn sd_capsule(p: vec3<f32>, a: vec3<f32>, b: vec3<f32>, r: f32) -> f32 {
} }
// yaw(Y) -> pitch(X) -> roll(Z), matching the scope mode's convention. // 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<f32>) -> vec3<f32> { fn rot(v: vec3<f32>) -> vec3<f32> {
let sy = sin(u.cam.x); let cy = cos(u.cam.x); let sy = sin(u.cam.x); let cy = cos(u.cam.x);
let sp = sin(u.cam.y); let cp = cos(u.cam.y); let sp = sin(u.cam.y); let cp = cos(u.cam.y);
@@ -76,35 +99,89 @@ fn rot(v: vec3<f32>) -> vec3<f32> {
return vec3<f32>(x3, y3, z2); return vec3<f32>(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>) -> f32 { fn map(p: vec3<f32>) -> f32 {
let n = i32(u.p1.z);
let k = u.p2.y; let k = u.p2.y;
var d = 1e9; 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 a = u.pts[i];
let c = u.pts[i + 1]; 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); d = smin(d, sd_capsule(p, a.xyz, c.xyz, r), k);
} }
return d; 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>) -> 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<f32>) -> vec3<f32> {
let e = vec2<f32>(0.0016, 0.0);
return normalize(vec3<f32>(
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 @fragment
fn fs_main(in: VsOut) -> @location(0) vec4<f32> { fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
let res = u.p1.x; let res = u.p1.x;
let frame = u.p1.y; let frame = u.p1.y;
let gain = u.p0.z; let gain = u.p0.z;
let ca_px = u.p0.w; let ca_px = u.p0.w;
let base = u.col0.xyz; let base = u.col0.xyz;
let accent = u.col1.xyz; let accent = u.col1.xyz;
let flash = u.col1.w; let flash = u.col1.w;
let rb = u.p2.w; // bounding-sphere radius (curve extent + tube) 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<f32>(in.uv.x * 2.0 - 1.0, 1.0 - in.uv.y * 2.0); let ndc = vec2<f32>(in.uv.x * 2.0 - 1.0, 1.0 - in.uv.y * 2.0);
let dist = u.cam.w; let dist = u.cam.w;
let ro = vec3<f32>(0.0, 0.0, -dist); let ro = vec3<f32>(0.0, 0.0, -dist);
let rd = normalize(vec3<f32>(ndc.x, ndc.y, 1.6)); let rd = normalize(vec3<f32>(ndc.x, ndc.y, focal));
// Ray vs bounding sphere — discards every background pixel in ~one op, // Ray vs bounding sphere — discards every background pixel in ~one op,
// which is what keeps this from melting the GPU. // which is what keeps this from melting the GPU.
@@ -112,57 +189,115 @@ fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
let c = dot(ro, ro) - rb * rb; let c = dot(ro, ro) - rb * rb;
let disc = b * b - c; let disc = b * b - c;
var glow = 0.0; var inten = 0.0;
var depth = 0.0; var depth = 0.0;
var col = vec3<f32>(0.0);
if (disc > 0.0) { if (disc > 0.0) {
let sq = sqrt(disc); let sq = sqrt(disc);
var t = max(-b - sq, 0.0); var t = max(-b - sq, 0.0);
let t_end = -b + sq; let t_end = -b + sq;
let span = max(t_end - t, 1e-3); let min_step = max(t_end - t, 1e-3) / 40.0; // march always finishes
let min_step = span / 40.0; // guarantees the march finishes
let steps = min(i32(u.p2.x), 40); let steps = min(i32(u.p2.x), 40);
var dmin = 1e9; var dmin = 1e9;
var hit_t = -1.0;
for (var s = 0; s < steps; s = s + 1) { for (var s = 0; s < steps; s = s + 1) {
let d = map(rot(ro + rd * t)); let d = map(rot(ro + rd * t));
dmin = min(dmin, d); if (d < dmin) { dmin = d; }
if (d < 0.004) { if (d < 0.0015) { hit_t = t; break; } // perf only — no white snap
dmin = 0.0;
depth = clamp((t + b) / max(2.0 * sq, 1e-3), 0.0, 1.0);
break;
}
t = t + max(d * 0.85, min_step); t = t + max(d * 0.85, min_step);
if (t > t_end) { break; } if (t > t_end) { break; }
} }
// Closest-approach falloff. A near-Gaussian core gives a *thin* // Soft *thin* filament straight from the closest approach — NOT a
// filament; a small, fast-decaying halo is the only volumetric // binary hit→white. Bright within ~0.012, gone by ~0.03, so a dense
// spill. Everything more than ~0.1 from the curve is pure black — // tangle reads as separated glowing wires, not a filled slab.
// that's what kills the wash. Bounded in [0, ~1.1]. let dl = max(dmin, 0.0);
let core = exp(-dmin * dmin * 900.0); inten = exp(-dl * dl * 6000.0) + 0.10 * exp(-dl * 30.0);
let halo = 0.22 * exp(-dmin * 24.0); let tt = select(t, hit_t, hit_t > 0.0);
glow = clamp((core + halo) * gain, 0.0, 1.2); 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<f32>(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<f32>(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<f32>(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<f32>(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<f32>(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 // Section change: shove the hue hard toward the accent WITHOUT adding
// glow alone, so off-line pixels are black, not grey haze. // luminance — accent*peak can't exceed the current peak channel, so the
var col = mix(base, accent, depth) * (0.45 + 0.55 * depth) * glow; // swap recolours violently but can never white-flash.
col = col + accent * flash * glow * glow * 0.4; let pk = max(col.r, max(col.g, col.b));
// Faint grain so the black field is alive (very low amplitude). col = mix(col, accent * pk, trans * 0.6);
col = max(col + (hash21(in.uv * res + vec2<f32>(frame, frame * 1.7)) - 0.5) * 0.006, col = max(col + (hash21(in.uv * res + vec2<f32>(frame, frame * 1.7)) - 0.5) * grain,
vec3<f32>(0.0)); vec3<f32>(0.0));
// Phosphor persistence: a *decaying* trail via max() — can never brighten // Phosphor persistence: a *decaying* trail via max() — can never brighten
// past the fresh frame, so no additive runaway to white. Cheap radial // past the fresh frame, so no additive runaway to white. The trail carries
// chromatic aberration on the trail term only. // 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) { if (u.p2.z > 0.5) {
// Shorter than Post's trail: a long phosphor tail on a fat glow reads let dec0 = clamp(1.0 - 3.0 * u.col0.w, 0.30, 0.90);
// as smear/wash. fade 0.11 -> ~0.67 decay (~a dozen frames). let decay = clamp(dec0 + 0.45 * beat, 0.30, 0.95);
let decay = clamp(1.0 - 3.0 * u.col0.w, 0.30, 0.90); // Horizontal slice tear: random rows shift sideways (datamosh feel).
let off = (in.uv - vec2<f32>(0.5)) * (ca_px / max(res, 1.0)); let g_amt = clamp(u.p3.y + 0.9 * trans, 0.0, 1.2);
let pr = textureSample(prev_tex, prev_smp, in.uv + off).r; let row = floor(in.uv.y * 40.0);
let pg = textureSample(prev_tex, prev_smp, in.uv).g; let pick = step(0.80, hash21(vec2<f32>(row, floor(frame / 3.0))));
let pb = textureSample(prev_tex, prev_smp, in.uv - off).b; let slice = pick * (hash21(vec2<f32>(row, frame)) - 0.5) * 0.14 * g_amt;
var suv = vec2<f32>(fract(in.uv.x + slice), in.uv.y);
// Swirl: zoom + rotate the sampled coords around centre.
var cuv = suv - vec2<f32>(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<f32>(cuv.x * cs - cuv.y * sn, cuv.x * sn + cuv.y * cs) * zr;
suv = cuv + vec2<f32>(0.5);
let ca = ca_px * (1.0 + 5.0 * trans) / max(res, 1.0);
let off = (suv - vec2<f32>(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<f32>(pr, pg, pb) * decay); col = max(col, vec3<f32>(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<f32>(min(col, vec3<f32>(1.0)), 1.0); return vec4<f32>(min(col, vec3<f32>(1.0)), 1.0);
} }