breakcore visualizer
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 2.0 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.6 MiB |
@@ -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.
|
||||
|
||||
+32
-8
@@ -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,
|
||||
|
||||
+697
-110
@@ -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<vec4, N>` 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<vec4, N>` 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];
|
||||
|
||||
+186
-51
@@ -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<f32>, // yaw, pitch, roll, dist
|
||||
@@ -19,6 +37,10 @@ struct U {
|
||||
col1: vec4<f32>, // accent.rgb, flash
|
||||
p1: vec4<f32>, // res, frame, n_pts, time
|
||||
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
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
// 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> {
|
||||
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<f32>) -> vec3<f32> {
|
||||
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 {
|
||||
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>) -> 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
|
||||
fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
|
||||
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<f32>(in.uv.x * 2.0 - 1.0, 1.0 - in.uv.y * 2.0);
|
||||
let dist = u.cam.w;
|
||||
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,
|
||||
// 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 disc = b * b - c;
|
||||
|
||||
var glow = 0.0;
|
||||
var inten = 0.0;
|
||||
var depth = 0.0;
|
||||
var col = vec3<f32>(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<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
|
||||
// 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<f32>(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<f32>(frame, frame * 1.7)) - 0.5) * grain,
|
||||
vec3<f32>(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<f32>(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<f32>(row, floor(frame / 3.0))));
|
||||
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);
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user