This commit is contained in:
2026-05-19 19:56:25 +02:00
parent 686d538743
commit b818f4bc43
7 changed files with 553 additions and 359 deletions
+35 -136
View File
@@ -25,6 +25,7 @@ use std::process::{Child, ChildStdin, Command, Stdio};
use audio_visualizer::audio::{self, AudioHandle, Bands, Source, Timeline};
use audio_visualizer::viz::breakcore::Breakcore;
use audio_visualizer::viz::core::{RenderContext, Visualizer, next_visual};
use audio_visualizer::viz::palette::Palette;
use audio_visualizer::viz::post::{Post, ADDITIVE};
use audio_visualizer::viz::scope::Scope;
@@ -200,127 +201,8 @@ enum Mode {
},
}
/// The active visualiser. `Sigil`/`Scope` are `Draw`-based and share the draw
/// signature so the chromatic-aberration channel passes dispatch uniformly;
/// `Breakcore` owns its own wgpu raymarch target instead (see `is_gpu`).
#[allow(clippy::large_enum_variant)] // Sigil/Scope are inline; Breakcore is boxed
enum Visual {
Sigil(Sigil),
Scope(Scope),
Breakcore(Box<Breakcore>), // owns its wgpu pipeline -> boxed
}
impl Visual {
fn update(&mut self, b: &Bands, dt: f32) {
match self {
Visual::Sigil(s) => s.update(b, dt),
Visual::Scope(s) => s.update(b, dt),
Visual::Breakcore(s) => s.update(b, dt),
}
}
fn reseed(&mut self, seed: u64) {
match self {
Visual::Sigil(s) => s.reseed(seed),
Visual::Scope(s) => s.reseed(seed),
Visual::Breakcore(s) => s.reseed(seed),
}
}
fn seed(&self) -> u64 {
match self {
Visual::Sigil(s) => s.seed,
Visual::Scope(s) => s.seed,
Visual::Breakcore(s) => s.seed,
}
}
fn count(&self) -> usize {
match self {
Visual::Sigil(s) => s.tendril_count(),
Visual::Scope(s) => s.point_count(),
Visual::Breakcore(s) => s.point_count(),
}
}
fn name(&self) -> &'static str {
match self {
Visual::Sigil(_) => "sigil",
Visual::Scope(_) => "scope",
Visual::Breakcore(_) => "breakcore",
}
}
/// `Breakcore` renders through its own wgpu pipeline, not the shared
/// `Draw` → `Post` path.
fn is_gpu(&self) -> bool {
matches!(self, Visual::Breakcore(_))
}
/// Cycle visualisers, keeping the current seed (live only). `Breakcore`
/// needs the device to (re)build its pipeline.
fn cycle(&mut self, device: &nannou::wgpu::Device) {
let s = self.seed();
*self = match self {
Visual::Sigil(_) => Visual::Scope(Scope::new(s)),
Visual::Scope(_) => Visual::Breakcore(Box::new(Breakcore::new(s, device))),
Visual::Breakcore(_) => Visual::Sigil(Sigil::new(s)),
};
}
#[allow(clippy::too_many_arguments)]
fn draw(
&self,
d: &Draw,
pal: &Palette,
fit: f32,
scale: f32,
warp: f32,
glow: bool,
seg: usize,
tint: [f32; 3],
) {
match self {
Visual::Sigil(s) => s.draw(d, pal, fit, scale, warp, glow, seg, tint),
Visual::Scope(s) => s.draw(d, pal, fit, scale, warp, glow, seg, tint),
Visual::Breakcore(_) => {} // rendered via its own pipeline
}
}
// --- gpu path (Breakcore only); the other arms never run these ---------
#[allow(clippy::too_many_arguments)]
fn render_gpu<'a>(
&'a mut self,
device: &nannou::wgpu::Device,
queue: &nannou::wgpu::Queue,
pal: &Palette,
scale: f32,
warp: f32,
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, drive)
}
_ => unreachable!("render_gpu on a Draw-based visual"),
}
}
fn current_tex(&self) -> &nannou::wgpu::Texture {
match self {
Visual::Breakcore(s) => s.current(),
_ => unreachable!("current_tex on a Draw-based visual"),
}
}
fn capture_raw(
&self,
device: &nannou::wgpu::Device,
queue: &nannou::wgpu::Queue,
) -> anyhow::Result<Vec<u8>> {
match self {
Visual::Breakcore(s) => s.capture_raw(device, queue),
_ => unreachable!("capture_raw on a Draw-based visual"),
}
}
}
struct Model {
visual: Visual,
visual: Box<dyn Visualizer>,
post: Post,
mode: Mode,
g: Gains,
@@ -573,12 +455,12 @@ fn model(app: &App) -> Model {
};
let seed = SEED ^ src_name.map(fnv1a).unwrap_or(0);
let visual = match mode_sel.as_deref() {
Some("scope") => Visual::Scope(Scope::new(seed)),
let visual: Box<dyn Visualizer> = match mode_sel.as_deref() {
Some("scope") => Box::new(Scope::new(seed)),
Some("breakcore") => {
Visual::Breakcore(Box::new(Breakcore::new(seed, app.main_window().device())))
Box::new(Breakcore::new(seed, app.main_window().device()))
}
Some("sigil") | None => Visual::Sigil(Sigil::new(seed)),
Some("sigil") | None => Box::new(Sigil::new(seed)),
Some(other) => die(format!("unknown --mode {other:?} (sigil|scope|breakcore)")),
};
@@ -608,7 +490,7 @@ fn key_pressed(app: &App, m: &mut Model, key: Key) {
Key::M => {
// Mode switch only makes sense live; a render is a fixed pass.
if matches!(m.mode, Mode::Live(_)) {
m.visual.cycle(app.main_window().device());
m.visual = next_visual(m.visual.as_ref(), app.main_window().device());
println!("mode = {}", m.visual.name());
}
}
@@ -623,7 +505,10 @@ fn key_pressed(app: &App, m: &mut Model, key: Key) {
// (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| {
m.visual
.capture_raw(device, queue)
.unwrap_or_else(|| Err(anyhow::anyhow!("gpu visual produced no frame")))
.and_then(|px| {
nannou::image::RgbaImage::from_raw(res, res, px)
.ok_or_else(|| anyhow::anyhow!("image buffer size mismatch"))?
.save(&path)
@@ -697,11 +582,23 @@ fn update(app: &App, m: &mut Model, upd: Update) {
let device = window.device();
let queue = window.queue();
let ctx = RenderContext {
pal: &pal,
fit,
scale,
warp,
glow: m.g.glow,
seg: m.g.seg,
tint: [1.0; 3],
feedback: m.g.feedback,
fade: m.g.fade,
ca_px,
drive: m.g.drive,
};
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.g.drive,
);
m.visual.render_gpu(device, queue, &ctx);
} else {
// Build the scene off-screen, then push it through the feedback chain.
let scene = Draw::new();
@@ -714,12 +611,10 @@ fn update(app: &App, m: &mut Model, upd: Update) {
([0.0, 0.0, 1.0], ca_px),
] {
let d = add.xy(vec2(dx, 0.0));
m.visual
.draw(&d, &pal, fit, scale, warp, m.g.glow, m.g.seg, tint);
m.visual.draw(&d, &RenderContext { tint, ..ctx });
}
} else {
m.visual
.draw(&scene, &pal, fit, scale, warp, m.g.glow, m.g.seg, [1.0; 3]);
m.visual.draw(&scene, &ctx);
}
if m.g.feedback {
m.post
@@ -732,7 +627,9 @@ fn update(app: &App, m: &mut Model, upd: Update) {
// Offline: read this frame back synchronously and stream it into ffmpeg.
if matches!(m.mode, Mode::Render { .. }) {
let cap = if m.visual.is_gpu() {
m.visual.capture_raw(device, queue)
m.visual
.capture_raw(device, queue)
.unwrap_or_else(|| Err(anyhow::anyhow!("gpu visual produced no frame")))
} else {
m.post.capture_raw(device, queue)
};
@@ -768,7 +665,9 @@ fn view(app: &App, m: &Model, frame: Frame) {
// correct; transparent regions (direct mode) fall through to bg.
// Breakcore presents its own raymarch target instead of the Post chain.
let tex = if m.visual.is_gpu() {
m.visual.current_tex()
m.visual
.current_tex()
.expect("gpu visual has a current texture")
} else {
m.post.current()
};
@@ -798,7 +697,7 @@ fn view(app: &App, m: &Model, frame: Frame) {
"{} · 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(),
m.visual.element_count(),
g.low,
g.warp,
g.fade,
+38 -183
View File
@@ -15,9 +15,10 @@
//! the FFT; this never touches it.
//! §3 smoothing: every reactive scalar is a critically-damped [`Spring`];
//! 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.
//! §4 render : a wgpu raymarcher (`breakcore.wgsl`, driven through the
//! shared [`ShaderPipeline`] base) — 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
@@ -26,10 +27,10 @@
//! `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*
//! a `Draw`-based `Visual`: the bin renders it through [`Breakcore::render`]
//! and presents/captures its target texture directly.
//! This is the first/only client of the shared raw-wgpu [`ShaderPipeline`]
//! base; `post.rs` and the other visualisers stay on nannou's validated
//! renderer. It is *not* a `Draw`-based `Visual`: the bin renders it through
//! [`Breakcore::render`] and presents/captures its target texture directly.
//!
//! Determinism: `Rng` and all integration advance only in [`Breakcore::update`]
//! (one call per frame, live and `--render` alike); the shader is a pure
@@ -39,9 +40,10 @@
use crate::audio::{Bands, CHROMA_N, SPEC_N};
use crate::viz::curve::{Rng, fbm};
use crate::viz::geometry::{Attr, Knot, normalize};
use crate::viz::math::{Spring, angle_to, smoothstep};
use crate::viz::math::{MorphState, Spring, angle_to};
use crate::viz::palette::{Palette, oklch};
use crate::viz::post::read_texture_rgba;
use crate::viz::shader::ShaderPipeline;
use crate::viz::structure::{Arch, Structure};
use nannou::prelude::*;
use nannou::wgpu;
@@ -76,6 +78,7 @@ const PARK: f32 = 60.0;
const PARKED: [f32; 4] = [PARK, PARK, PARK, 0.001];
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8UnormSrgb;
const RES: u32 = crate::viz::post::RES; // raymarch target = post supersample
const TAU: f32 = std::f32::consts::TAU;
// `Spring` (premise §3), `smoothstep` and `angle_to` are generic visual math
@@ -224,11 +227,7 @@ pub struct Breakcore {
trail: [Vec3; NP], // rolling attractor trajectory (oldest..newest)
head: Vec3, // current attractor state
from: Kind,
to: Kind,
morph: f32, // 0..1 from→to (1 = settled)
cooldown: f32,
idle: f32,
morph: MorphState<Kind>, // backbone-kind hold-and-cross-fade
trans: f32, // 0..1 section-change pulse (drives the swap burst)
st: Structure,
@@ -253,7 +252,7 @@ pub struct Breakcore {
frame: u32,
b: Bands,
gpu: Gpu,
gpu: ShaderPipeline<[f32; UBO_LEN]>,
}
impl Breakcore {
@@ -268,11 +267,7 @@ impl Breakcore {
knot,
trail: [Vec3::ZERO; NP],
head: vec3(0.1, 0.0, 0.0),
from: Kind::Knot,
to: Kind::Knot,
morph: 1.0,
cooldown: 0.0,
idle: 0.0,
morph: MorphState::new(Kind::Knot),
trans: 0.0,
st: Structure::new(),
deb: [Frag::default(); N_DEB],
@@ -294,7 +289,7 @@ impl Breakcore {
t: 0.0,
frame: 0,
b: Bands::default(),
gpu: Gpu::new(device),
gpu: ShaderPipeline::new(device, include_str!("breakcore.wgsl"), RES, FMT),
}
}
@@ -303,12 +298,15 @@ impl Breakcore {
self.rng = Rng::new(seed ^ 0xB17E_C0DE_B17E_C0DE);
self.attr = Attr::random(&mut self.rng);
self.knot = Knot::random(&mut self.rng);
self.from = Kind::Knot;
self.to = Kind::Knot;
self.morph = 1.0;
// Settle the morph back to a held knot. Deliberately leaves
// `morph.cooldown`/`trans` as-is, exactly as the pre-extraction code
// did (reseed is live-only `R`, never on the `--render` path).
self.morph.from = Kind::Knot;
self.morph.to = Kind::Knot;
self.morph.t = 1.0;
self.morph.idle = 0.0;
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();
}
@@ -320,17 +318,16 @@ impl Breakcore {
/// 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 = if prefer_knot {
let to = if prefer_knot {
Kind::Knot
} else {
Kind::Attractor
};
// rng order (attr then knot) is load-bearing for `--render`
// bit-identity; `begin` advances no rng.
self.attr = Attr::random(&mut self.rng);
self.knot = Knot::random(&mut self.rng);
self.morph = 0.0;
self.cooldown = 1.4;
self.idle = 0.0;
self.morph.begin(to, 1.4);
self.trans = 1.0;
}
@@ -460,21 +457,14 @@ impl Breakcore {
self.update_debris(rg.deb, dt);
if self.morph < 1.0 {
self.morph = (self.morph + dt / 1.8).min(1.0);
if self.morph >= 1.0 {
self.from = self.to;
}
}
self.morph.advance(dt, 1.8); // also ticks cooldown/idle
self.trans = (self.trans - dt / 0.5).max(0.0);
self.cooldown = (self.cooldown - dt).max(0.0);
self.idle += dt;
// 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) {
if self.morph.ready() && (event || self.morph.idle > 20.0) {
self.restructure(rg.prefer_knot);
}
}
@@ -516,9 +506,9 @@ impl Breakcore {
Kind::Attractor => &pick_attr,
Kind::Knot => &pick_knot,
};
let e = smoothstep(self.morph);
let from = sel(self.from);
let to = sel(self.to);
let e = self.morph.factor();
let from = sel(self.morph.from);
let to = sel(self.morph.to);
let rg = self.st.arch().regime();
let warpd = rg.warpd + 0.10 * self.st.tension();
@@ -719,7 +709,7 @@ impl Breakcore {
u[14] = acc[2];
u[15] = pal.flash;
// row4 res, frame, n_pts, time
u[16] = Gpu::RES as f32;
u[16] = RES as f32;
u[17] = (self.frame & 0xffff) as f32;
u[18] = NP as f32;
u[19] = self.t;
@@ -729,7 +719,7 @@ impl Breakcore {
u[20] = (22.0 + 9.0 * warp.min(2.0)).clamp(16.0, 40.0);
// Tension fuses the folds (higher melt_k) so a build melts to a core.
u[21] = (0.004 + 0.008 * self.b.loud + 0.010 * tn * rg.melt).clamp(0.003, 0.018);
u[22] = if feedback && self.gpu.primed { 1.0 } else { 0.0 };
u[22] = if feedback && self.gpu.primed() { 1.0 } else { 0.0 };
// bounding-sphere radius: covers spine (0.92) + ribs/spokes/debris
// (≤~1.05·scale) + tube, and grows with the shock so a pulse never
// clips. Parked capsules sit far outside and contribute zero glow.
@@ -785,143 +775,8 @@ impl Breakcore {
}
}
// ---------------------------------------------------------------------------
// Isolated wgpu raymarch pipeline. The deliberate exception to the codebase's
// "no hand-written wgpu pipelines" rule; everything risky is contained here.
// ---------------------------------------------------------------------------
struct Gpu {
pipeline: wgpu::RenderPipeline,
ubo: wgpu::Buffer,
tex: [wgpu::Texture; 2],
view: [wgpu::TextureViewHandle; 2],
bind: [wgpu::BindGroup; 2], // bind[w]: writes view[w], samples view[1-w]
cur: usize, // index last written / presented
primed: bool,
}
fn as_bytes(v: &[f32]) -> &[u8] {
// A flat f32 slice has no padding; reinterpret as bytes for write_buffer.
unsafe { std::slice::from_raw_parts(v.as_ptr() as *const u8, std::mem::size_of_val(v)) }
}
impl Gpu {
const RES: u32 = crate::viz::post::RES;
fn new(device: &wgpu::Device) -> Self {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("breakcore-shader"),
source: wgpu::ShaderSource::Wgsl(include_str!("breakcore.wgsl").into()),
});
let mk = || {
wgpu::TextureBuilder::new()
.size([Self::RES, Self::RES])
.format(FMT)
.usage(
wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_SRC,
)
.build(device)
};
let tex = [mk(), mk()];
let view = [
tex[0].create_view(&wgpu::TextureViewDescriptor::default()),
tex[1].create_view(&wgpu::TextureViewDescriptor::default()),
];
// Bindings (order = WGSL @binding 0/1/2): uniform, prev texture, sampler.
let bgl = wgpu::BindGroupLayoutBuilder::new()
.uniform_buffer(wgpu::ShaderStages::FRAGMENT, false)
.texture_from(wgpu::ShaderStages::FRAGMENT, &tex[0])
.sampler(wgpu::ShaderStages::FRAGMENT, true)
.build(device);
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("breakcore-pl"),
bind_group_layouts: &[&bgl],
push_constant_ranges: &[],
});
// Fullscreen triangle: no vertex buffers; default triangle-list.
let pipeline = wgpu::RenderPipelineBuilder::from_layout(&layout, &shader)
.vertex_entry_point("vs_main")
.fragment_shader(&shader)
.fragment_entry_point("fs_main")
.color_format(FMT)
.build(device);
let ubo = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("breakcore-ubo"),
size: (UBO_LEN * 4) as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let sampler = wgpu::SamplerBuilder::new()
.address_mode(wgpu::AddressMode::ClampToEdge)
.mag_filter(wgpu::FilterMode::Linear)
.min_filter(wgpu::FilterMode::Linear)
.mipmap_filter(wgpu::FilterMode::Nearest)
.build(device);
let mk_bind = |w: usize| {
wgpu::BindGroupBuilder::new()
.binding(ubo.as_entire_binding())
.texture_view(&view[1 - w])
.sampler(&sampler)
.build(device, &bgl)
};
let bind = [mk_bind(0), mk_bind(1)];
Gpu {
pipeline,
ubo,
tex,
view,
bind,
cur: 0,
primed: false,
}
}
fn render(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
ubo: &[f32; UBO_LEN],
) -> &wgpu::Texture {
let w = 1 - self.cur; // write target; sample the last-written (self.cur)
queue.write_buffer(&self.ubo, 0, as_bytes(ubo));
let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("breakcore-enc"),
});
{
let mut rp = enc.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("breakcore-pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &self.view[w],
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: true,
},
})],
depth_stencil_attachment: None,
});
rp.set_pipeline(&self.pipeline);
rp.set_bind_group(0, &self.bind[w], &[]);
rp.draw(0..3, 0..1);
}
queue.submit(Some(enc.finish()));
self.cur = w;
self.primed = true;
&self.tex[self.cur]
}
fn current(&self) -> &wgpu::Texture {
&self.tex[self.cur]
}
}
// The isolated wgpu raymarch pipeline that breakcore pioneered now lives in
// the shared, validated `crate::viz::shader::ShaderPipeline` base (imported
// above). breakcore stays its first/only client; the SDF + UBO layout (NP /
// `UBO_LEN`) and the shader-side march-step cap remain the non-negotiable
// guardrails — see `breakcore.wgsl` and the `NP` doc above.
+187
View File
@@ -0,0 +1,187 @@
//! The visualiser contract: one trait the bin drives, one render context.
//!
//! `sigil.rs` no longer hard-codes a `Visual { Sigil, Scope, Breakcore }`
//! enum + an inherent `match` per call. It holds a `Box<dyn Visualizer>` and
//! talks to this trait; adding a mode is a new `impl Visualizer` + one arm in
//! [`next_visual`], nothing in the bin's hot path.
//!
//! The CPU/GPU split is *relocated, not deleted* (it is a real dichotomy, not
//! accidental complexity): `Draw`-based modes (`Sigil`/`Scope`) emit through
//! the shared chromatic-aberration channel passes + `Post`; `Breakcore`
//! presents/captures its own raymarch target. The caller still branches on
//! [`Visualizer::is_gpu`] — the trait just standardises the contract and
//! moves the `match` out of the bin.
//!
//! Determinism is unaffected: dispatch is pure indirection; each visualiser's
//! `update` still advances its `Rng`/integration once per frame exactly as
//! before, so `--render` stays bit-reproducible.
use crate::audio::Bands;
use crate::viz::breakcore::Breakcore;
use crate::viz::palette::Palette;
use crate::viz::scope::Scope;
use crate::viz::sigil::Sigil;
use nannou::prelude::Draw;
use nannou::wgpu;
/// Per-frame shared tunables handed to a visualiser. Cheap to copy (it only
/// borrows the palette) so the bin can spin one per chromatic-aberration
/// channel pass with just `tint` swapped (`RenderContext { tint, ..ctx }`).
#[derive(Clone, Copy)]
pub struct RenderContext<'a> {
pub pal: &'a Palette,
pub fit: f32,
pub scale: f32,
pub warp: f32,
pub glow: bool,
pub seg: usize,
pub tint: [f32; 3],
// GPU-path extras (ignored by the Draw-based modes).
pub feedback: bool,
pub fade: f32,
pub ca_px: f32,
pub drive: f32,
}
/// One visualiser mode. `Draw`-based modes implement [`draw`](Self::draw);
/// a GPU mode sets [`is_gpu`](Self::is_gpu) and implements
/// [`render_gpu`](Self::render_gpu)/[`current_tex`](Self::current_tex)/
/// [`capture_raw`](Self::capture_raw) instead — the bin dispatches on
/// `is_gpu()`. Object-safe: held as `Box<dyn Visualizer>`.
pub trait Visualizer {
fn name(&self) -> &'static str;
fn seed(&self) -> u64;
fn reseed(&mut self, seed: u64);
fn update(&mut self, b: &Bands, dt: f32);
/// Element count for the HUD (tendrils / beam points / capsules).
fn element_count(&self) -> usize;
/// `true` ⇒ this mode owns a wgpu pipeline and uses the GPU methods
/// below; `false` ⇒ it draws through `Draw`/`Post`.
fn is_gpu(&self) -> bool {
false
}
/// CPU/`Draw` path. No-op for GPU-driven modes.
fn draw(&self, _d: &Draw, _ctx: &RenderContext) {}
/// GPU path: render this frame's own pipeline target and return it.
/// `None` for `Draw`-based modes (the bin uses the `Post` chain).
fn render_gpu(
&mut self,
_device: &wgpu::Device,
_queue: &wgpu::Queue,
_ctx: &RenderContext,
) -> Option<&wgpu::Texture> {
None
}
/// The texture to present this frame (GPU modes only; else `None` and the
/// bin presents the `Post` accumulator).
fn current_tex(&self) -> Option<&wgpu::Texture> {
None
}
/// Synchronous RGBA readback of the current target (GPU modes only).
fn capture_raw(
&self,
_device: &wgpu::Device,
_queue: &wgpu::Queue,
) -> Option<anyhow::Result<Vec<u8>>> {
None
}
}
impl Visualizer for Sigil {
fn name(&self) -> &'static str {
"sigil"
}
fn seed(&self) -> u64 {
self.seed
}
fn reseed(&mut self, seed: u64) {
Sigil::reseed(self, seed)
}
fn update(&mut self, b: &Bands, dt: f32) {
Sigil::update(self, b, dt)
}
fn element_count(&self) -> usize {
self.tendril_count()
}
fn draw(&self, d: &Draw, c: &RenderContext) {
Sigil::draw(self, d, c.pal, c.fit, c.scale, c.warp, c.glow, c.seg, c.tint)
}
}
impl Visualizer for Scope {
fn name(&self) -> &'static str {
"scope"
}
fn seed(&self) -> u64 {
self.seed
}
fn reseed(&mut self, seed: u64) {
Scope::reseed(self, seed)
}
fn update(&mut self, b: &Bands, dt: f32) {
Scope::update(self, b, dt)
}
fn element_count(&self) -> usize {
self.point_count()
}
fn draw(&self, d: &Draw, c: &RenderContext) {
Scope::draw(self, d, c.pal, c.fit, c.scale, c.warp, c.glow, c.seg, c.tint)
}
}
impl Visualizer for Breakcore {
fn name(&self) -> &'static str {
"breakcore"
}
fn seed(&self) -> u64 {
self.seed
}
fn reseed(&mut self, seed: u64) {
Breakcore::reseed(self, seed)
}
fn update(&mut self, b: &Bands, dt: f32) {
Breakcore::update(self, b, dt)
}
fn element_count(&self) -> usize {
self.point_count()
}
fn is_gpu(&self) -> bool {
true
}
fn render_gpu(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
c: &RenderContext,
) -> Option<&wgpu::Texture> {
Some(Breakcore::render(
self, device, queue, c.pal, c.scale, c.warp, c.feedback, c.fade, c.ca_px, c.drive,
))
}
fn current_tex(&self) -> Option<&wgpu::Texture> {
Some(self.current())
}
fn capture_raw(
&self,
device: &wgpu::Device,
queue: &wgpu::Queue,
) -> Option<anyhow::Result<Vec<u8>>> {
Some(Breakcore::capture_raw(self, device, queue))
}
}
/// Live `M`-key cycle: Sigil → Scope → Breakcore → Sigil, keeping the seed.
/// `Breakcore` needs the device to (re)build its pipeline.
pub fn next_visual(cur: &dyn Visualizer, device: &wgpu::Device) -> Box<dyn Visualizer> {
let s = cur.seed();
match cur.name() {
"sigil" => Box::new(Scope::new(s)),
"scope" => Box::new(Breakcore::new(s, device)),
_ => Box::new(Sigil::new(s)),
}
}
+79
View File
@@ -50,3 +50,82 @@ pub fn angle_to(cur: f32, target: f32, a: f32) -> f32 {
}
cur + d * a
}
/// Hold-and-morph state machine shared by `scope` and `breakcore`.
///
/// Both visualisers do the same dance: hold a settled state, on a musical
/// event lerp `from`→`to` over a fixed time, then settle; gate the *next*
/// change behind a cooldown and an idle timer. This owns exactly that
/// bookkeeping — the *trigger* (scope: cooldown-gated rising-flux / idle;
/// breakcore: archetype-commit / harmonic-novelty) stays per-visualiser, as
/// does any extra geometry reseed that accompanies a [`begin`](Self::begin).
///
/// `T` is a small `Copy` descriptor (a figure, a backbone-kind tag); the
/// actual blend is the caller's, weighted by [`factor`](Self::factor).
///
/// Determinism: holds no `Rng`/clock — `advance`/`begin` are pure functions
/// of (state, dt). Advancing once per frame (the existing discipline) it
/// steps identically live and under `--render`; the caller still owns the
/// `Rng` it threads through `begin`.
#[derive(Clone, Copy)]
pub struct MorphState<T> {
/// Settled state (what to draw when `t == 1`; the `from` of an in-flight
/// morph). Public: the caller blends `from`→`to` itself.
pub from: T,
/// Morph target.
pub to: T,
/// 0..1 progress, `1` = settled (`from` == `to`).
pub t: f32,
/// Gate before the next `begin` is allowed (counts down in `advance`).
pub cooldown: f32,
/// Seconds since the last `begin` (the idle-evolution fallback).
pub idle: f32,
}
impl<T: Copy> MorphState<T> {
/// Settled at `init` (`t = 1`, no cooldown).
pub fn new(init: T) -> Self {
MorphState {
from: init,
to: init,
t: 1.0,
cooldown: 0.0,
idle: 0.0,
}
}
/// Tick the cooldown/idle timers and advance an in-flight morph over
/// `secs`, snapping `from`→`to` the frame it settles. Returns `true` on
/// that settling frame. Call exactly once per frame.
pub fn advance(&mut self, dt: f32, secs: f32) -> bool {
self.cooldown = (self.cooldown - dt).max(0.0);
self.idle += dt;
if self.t < 1.0 {
self.t = (self.t + dt / secs).min(1.0);
if self.t >= 1.0 {
self.from = self.to;
return true;
}
}
false
}
/// Settled and off cooldown — eligible to start a new morph.
pub fn ready(&self) -> bool {
self.t >= 1.0 && self.cooldown <= 0.0
}
/// Begin a morph into `target`, arming the cooldown and resetting idle.
pub fn begin(&mut self, target: T, cooldown: f32) {
self.from = self.to;
self.to = target;
self.t = 0.0;
self.cooldown = cooldown;
self.idle = 0.0;
}
/// Eased 0..1 blend weight (`smoothstep` of `t`).
pub fn factor(&self) -> f32 {
smoothstep(self.t)
}
}
+5 -2
View File
@@ -1,15 +1,18 @@
//! Visual layer: organic curve geometry, audio-driven OKLCH colour, the
//! living hybrid cyber-organic sigil, and the feedback/bloom post stack.
//!
//! `breakcore` is the one module that owns a hand-written wgpu raymarch
//! pipeline; `post` and everything else stay on nannou's validated renderer.
//! Raw-wgpu fragment pipelines go through the shared, validated
//! [`shader::ShaderPipeline`] base (`breakcore` is its first client); `post`
//! and the `Draw`-based visualisers stay on nannou's validated renderer.
pub mod breakcore;
pub mod core;
pub mod curve;
pub mod geometry;
pub mod math;
pub mod palette;
pub mod post;
pub mod scope;
pub mod shader;
pub mod sigil;
pub mod structure;
+12 -32
View File
@@ -26,7 +26,7 @@
use crate::audio::{Bands, WAVE_N};
use crate::viz::curve::{Rng, flow};
use crate::viz::geometry::Figure;
use crate::viz::math::smoothstep;
use crate::viz::math::MorphState;
use crate::viz::palette::Palette;
use nannou::prelude::*;
@@ -50,16 +50,12 @@ fn h01(a: u32, b: u32) -> f32 {
pub struct Scope {
pub seed: u64,
rng: Rng,
cur: Figure,
tgt: Figure,
morph: f32, // 0..1 cur->tgt (1 = settled)
morph: MorphState<Figure>, // hold-and-cross-fade between seeded figures
yaw: f32,
pitch: f32,
roll: f32,
breathe: f32,
restruct_cd: f32,
prev_flux: f32,
idle: f32, // seconds since last change (quiet-track fallback)
wave: [f32; WAVE_N],
loud: f32,
flux: f32,
@@ -70,20 +66,16 @@ pub struct Scope {
impl Scope {
pub fn new(seed: u64) -> Self {
let mut rng = Rng::new(seed ^ 0x05C0_BE11);
let cur = Figure::random(&mut rng);
let fig = Figure::random(&mut rng);
Scope {
seed,
rng,
cur,
tgt: cur,
morph: 1.0,
morph: MorphState::new(fig),
yaw: 0.0,
pitch: 0.0,
roll: 0.0,
breathe: 0.0,
restruct_cd: 0.0,
prev_flux: 0.0,
idle: 0.0,
wave: [0.0; WAVE_N],
loud: 0.0,
flux: 0.0,
@@ -102,9 +94,8 @@ impl Scope {
/// Begin a morph into a freshly-seeded figure.
fn restructure(&mut self) {
self.tgt = Figure::random(&mut self.rng);
self.morph = 0.0;
self.idle = 0.0;
let fig = Figure::random(&mut self.rng);
self.morph.begin(fig, 1.2);
}
pub fn update(&mut self, b: &Bands, dt: f32) {
@@ -121,24 +112,13 @@ impl Scope {
self.roll += 0.04 * dt + 0.35 * b.high * dt;
self.breathe += dt * (0.25 + 1.1 * b.mid + 0.5 * b.low);
// Advance an in-flight morph; settle onto the target.
if self.morph < 1.0 {
self.morph = (self.morph + dt / MORPH_SECS).min(1.0);
if self.morph >= 1.0 {
self.cur = self.tgt;
}
}
// Advance an in-flight morph; also ticks the cooldown/idle timers.
self.morph.advance(dt, MORPH_SECS);
// Change figure on a rising broadband transient (cooldown-gated), or
// on a long idle so quiet passages still evolve.
self.restruct_cd = (self.restruct_cd - dt).max(0.0);
self.idle += dt;
let rising = b.flux > 0.6 && self.prev_flux <= 0.6;
if self.morph >= 1.0
&& self.restruct_cd <= 0.0
&& (rising || self.idle > 12.0)
{
self.restruct_cd = 1.2;
if self.morph.ready() && (rising || self.morph.idle > 12.0) {
self.restructure();
}
self.prev_flux = b.flux;
@@ -174,7 +154,7 @@ impl Scope {
let (sr, cr) = self.roll.sin_cos();
let dist = FIELD * 1.7;
let amp = FIELD * 0.40 * (0.85 + 0.30 * scale.min(1.6) + 0.10 * self.breathe.sin());
let e = smoothstep(self.morph);
let e = self.morph.factor();
// Slow figure-character drift from spectral brightness, beam-noise from
// the live waveform + flux — subtle, so the shape stays coherent.
let drift = 1.0 + 0.10 * (self.centroid - 0.5);
@@ -182,9 +162,9 @@ impl Scope {
let project = |i: usize| -> (Vec2, f32) {
let u = i as f32 / N as f32;
let a = self.cur.at(u);
let a = self.morph.from.at(u);
let mut q = if e < 1.0 {
let bpt = self.tgt.at(u);
let bpt = self.morph.to.at(u);
a + (bpt - a) * e
} else {
a
+191
View File
@@ -0,0 +1,191 @@
//! Reusable raw-wgpu raymarch/post pipeline base.
//!
//! [`ShaderPipeline<U>`] is the self-contained fullscreen-fragment pipeline
//! that `breakcore` pioneered, hoisted so any future GPU visualiser can drive
//! a `.wgsl` shader without re-deriving the wgpu boilerplate. It owns:
//!
//! - WGSL module + fullscreen-triangle render pipeline (no vertex buffers;
//! entry points are the fixed convention `vs_main` / `fs_main`),
//! - a single uniform buffer sized to `U`,
//! - dual `RES²` textures ping-ponged for frame feedback (the shader samples
//! the previous frame at `@binding(1)`, writes the other),
//! - per-frame UBO upload + the bind-group swap.
//!
//! A new GPU visualiser supplies only its UBO type, its `.wgsl`, and the
//! per-frame fill of `U`; the section/geometry/colour logic stays in Rust.
//!
//! **Guardrails (non-negotiable, inherited from breakcore).** This base does
//! *not* abstract away the two things that can hang the GPU: the shader's own
//! hard march-step cap and any `array<vec4, N>` ↔ Rust UBO-length coupling
//! still live in the shader + the caller's `U` layout, and naga only
//! validates the WGSL at pipeline-create on a real device. Keep the cap in
//! the `.wgsl` and keep `U`'s layout in lock-step with the shader's UBO.
//!
//! Determinism: pure GPU plumbing — no `Rng`, no clock, no integration. Given
//! the same `U` bytes it produces the same frame, so a visualiser built on it
//! stays `--render` bit-reproducible exactly as before (the state lives in the
//! caller, advanced once per frame as ever).
use std::marker::PhantomData;
use nannou::wgpu;
/// Reinterpret a `Copy`, packing-free POD (`[f32; N]`, `#[repr(C)]` plain
/// struct) as its raw bytes for `write_buffer`. The crate already relies on
/// exactly this invariant for its one UBO, so this needs no `bytemuck` dep —
/// but `U` MUST stay padding-free and match the shader's UBO layout.
fn as_bytes<U: Copy>(u: &U) -> &[u8] {
unsafe {
std::slice::from_raw_parts((u as *const U).cast::<u8>(), std::mem::size_of::<U>())
}
}
/// Fullscreen fragment pipeline with ping-pong feedback. `U` is the uniform
/// block (one `write_buffer` per frame); see the module guardrails note.
pub struct ShaderPipeline<U> {
pipeline: wgpu::RenderPipeline,
ubo: wgpu::Buffer,
tex: [wgpu::Texture; 2],
view: [wgpu::TextureViewHandle; 2],
bind: [wgpu::BindGroup; 2], // bind[w]: writes view[w], samples view[1-w]
cur: usize, // index last written / presented
primed: bool,
_pd: PhantomData<U>,
}
impl<U: Copy> ShaderPipeline<U> {
/// Build the pipeline for `wgsl` at `res²` in `format`. Bindings, in WGSL
/// `@binding` order, are: `0` uniform `U`, `1` previous-frame texture,
/// `2` sampler. Entry points are `vs_main` / `fs_main`.
pub fn new(
device: &wgpu::Device,
wgsl: &str,
res: u32,
format: wgpu::TextureFormat,
) -> Self {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("shaderpipeline-shader"),
source: wgpu::ShaderSource::Wgsl(wgsl.into()),
});
let mk = || {
wgpu::TextureBuilder::new()
.size([res, res])
.format(format)
.usage(
wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_SRC,
)
.build(device)
};
let tex = [mk(), mk()];
let view = [
tex[0].create_view(&wgpu::TextureViewDescriptor::default()),
tex[1].create_view(&wgpu::TextureViewDescriptor::default()),
];
// Bindings (order = WGSL @binding 0/1/2): uniform, prev texture, sampler.
let bgl = wgpu::BindGroupLayoutBuilder::new()
.uniform_buffer(wgpu::ShaderStages::FRAGMENT, false)
.texture_from(wgpu::ShaderStages::FRAGMENT, &tex[0])
.sampler(wgpu::ShaderStages::FRAGMENT, true)
.build(device);
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("shaderpipeline-pl"),
bind_group_layouts: &[&bgl],
push_constant_ranges: &[],
});
// Fullscreen triangle: no vertex buffers; default triangle-list.
let pipeline = wgpu::RenderPipelineBuilder::from_layout(&layout, &shader)
.vertex_entry_point("vs_main")
.fragment_shader(&shader)
.fragment_entry_point("fs_main")
.color_format(format)
.build(device);
let ubo = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("shaderpipeline-ubo"),
size: std::mem::size_of::<U>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let sampler = wgpu::SamplerBuilder::new()
.address_mode(wgpu::AddressMode::ClampToEdge)
.mag_filter(wgpu::FilterMode::Linear)
.min_filter(wgpu::FilterMode::Linear)
.mipmap_filter(wgpu::FilterMode::Nearest)
.build(device);
let mk_bind = |w: usize| {
wgpu::BindGroupBuilder::new()
.binding(ubo.as_entire_binding())
.texture_view(&view[1 - w])
.sampler(&sampler)
.build(device, &bgl)
};
let bind = [mk_bind(0), mk_bind(1)];
ShaderPipeline {
pipeline,
ubo,
tex,
view,
bind,
cur: 0,
primed: false,
_pd: PhantomData,
}
}
/// Upload `ubo`, raymarch one frame into the unused ping-pong slot
/// (sampling the previous), and return the just-written texture.
pub fn render(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
ubo: &U,
) -> &wgpu::Texture {
let w = 1 - self.cur; // write target; sample the last-written (self.cur)
queue.write_buffer(&self.ubo, 0, as_bytes(ubo));
let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("shaderpipeline-enc"),
});
{
let mut rp = enc.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("shaderpipeline-pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &self.view[w],
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: true,
},
})],
depth_stencil_attachment: None,
});
rp.set_pipeline(&self.pipeline);
rp.set_bind_group(0, &self.bind[w], &[]);
rp.draw(0..3, 0..1);
}
queue.submit(Some(enc.finish()));
self.cur = w;
self.primed = true;
&self.tex[self.cur]
}
/// The most recently written texture (for present/readback).
pub fn current(&self) -> &wgpu::Texture {
&self.tex[self.cur]
}
/// `true` once at least one frame has been rendered (the feedback sample
/// is only valid after the first frame).
pub fn primed(&self) -> bool {
self.primed
}
}