diff --git a/src/bin/sigil.rs b/src/bin/sigil.rs index 4cd0117..0e672c6 100644 --- a/src/bin/sigil.rs +++ b/src/bin/sigil.rs @@ -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), // 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> { - match self { - Visual::Breakcore(s) => s.capture_raw(device, queue), - _ => unreachable!("capture_raw on a Draw-based visual"), - } - } -} - struct Model { - visual: Visual, + visual: Box, 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 = 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,12 +505,15 @@ 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| { - nannou::image::RgbaImage::from_raw(res, res, px) - .ok_or_else(|| anyhow::anyhow!("image buffer size mismatch"))? - .save(&path) - .map_err(Into::into) - }) + 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) + .map_err(Into::into) + }) } else { m.post.capture_png(device, queue, &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, diff --git a/src/viz/breakcore.rs b/src/viz/breakcore.rs index 2c9532a..5f636ad 100644 --- a/src/viz/breakcore.rs +++ b/src/viz/breakcore.rs @@ -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,12 +227,8 @@ 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, - trans: f32, // 0..1 section-change pulse (drives the swap burst) + morph: MorphState, // backbone-kind hold-and-cross-fade + trans: f32, // 0..1 section-change pulse (drives the swap burst) st: Structure, deb: [Frag; N_DEB], @@ -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. diff --git a/src/viz/core.rs b/src/viz/core.rs new file mode 100644 index 0000000..e5f30d2 --- /dev/null +++ b/src/viz/core.rs @@ -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` 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`. +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>> { + 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>> { + 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 { + 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)), + } +} diff --git a/src/viz/math.rs b/src/viz/math.rs index 15f820a..f56c2ad 100644 --- a/src/viz/math.rs +++ b/src/viz/math.rs @@ -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 { + /// 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 MorphState { + /// 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) + } +} diff --git a/src/viz/mod.rs b/src/viz/mod.rs index 4249f2c..8d6b4da 100644 --- a/src/viz/mod.rs +++ b/src/viz/mod.rs @@ -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; diff --git a/src/viz/scope.rs b/src/viz/scope.rs index 7daf4be..24de51c 100644 --- a/src/viz/scope.rs +++ b/src/viz/scope.rs @@ -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
, // 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 diff --git a/src/viz/shader.rs b/src/viz/shader.rs new file mode 100644 index 0000000..59c4167 --- /dev/null +++ b/src/viz/shader.rs @@ -0,0 +1,191 @@ +//! Reusable raw-wgpu raymarch/post pipeline base. +//! +//! [`ShaderPipeline`] 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` ↔ 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: &U) -> &[u8] { + unsafe { + std::slice::from_raw_parts((u as *const U).cast::(), std::mem::size_of::()) + } +} + +/// 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 { + 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, +} + +impl ShaderPipeline { + /// 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::() 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 + } +}