refactor
This commit is contained in:
+35
-136
@@ -25,6 +25,7 @@ use std::process::{Child, ChildStdin, Command, Stdio};
|
|||||||
|
|
||||||
use audio_visualizer::audio::{self, AudioHandle, Bands, Source, Timeline};
|
use audio_visualizer::audio::{self, AudioHandle, Bands, Source, Timeline};
|
||||||
use audio_visualizer::viz::breakcore::Breakcore;
|
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::palette::Palette;
|
||||||
use audio_visualizer::viz::post::{Post, ADDITIVE};
|
use audio_visualizer::viz::post::{Post, ADDITIVE};
|
||||||
use audio_visualizer::viz::scope::Scope;
|
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 {
|
struct Model {
|
||||||
visual: Visual,
|
visual: Box<dyn Visualizer>,
|
||||||
post: Post,
|
post: Post,
|
||||||
mode: Mode,
|
mode: Mode,
|
||||||
g: Gains,
|
g: Gains,
|
||||||
@@ -573,12 +455,12 @@ fn model(app: &App) -> Model {
|
|||||||
};
|
};
|
||||||
let seed = SEED ^ src_name.map(fnv1a).unwrap_or(0);
|
let seed = SEED ^ src_name.map(fnv1a).unwrap_or(0);
|
||||||
|
|
||||||
let visual = match mode_sel.as_deref() {
|
let visual: Box<dyn Visualizer> = match mode_sel.as_deref() {
|
||||||
Some("scope") => Visual::Scope(Scope::new(seed)),
|
Some("scope") => Box::new(Scope::new(seed)),
|
||||||
Some("breakcore") => {
|
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)")),
|
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 => {
|
Key::M => {
|
||||||
// Mode switch only makes sense live; a render is a fixed pass.
|
// Mode switch only makes sense live; a render is a fixed pass.
|
||||||
if matches!(m.mode, Mode::Live(_)) {
|
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());
|
println!("mode = {}", m.visual.name());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -623,7 +505,10 @@ fn key_pressed(app: &App, m: &mut Model, key: Key) {
|
|||||||
// (never-written) Post accumulator.
|
// (never-written) Post accumulator.
|
||||||
let res = Post::res() as u32;
|
let res = Post::res() as u32;
|
||||||
let saved = if m.visual.is_gpu() {
|
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)
|
nannou::image::RgbaImage::from_raw(res, res, px)
|
||||||
.ok_or_else(|| anyhow::anyhow!("image buffer size mismatch"))?
|
.ok_or_else(|| anyhow::anyhow!("image buffer size mismatch"))?
|
||||||
.save(&path)
|
.save(&path)
|
||||||
@@ -697,11 +582,23 @@ fn update(app: &App, m: &mut Model, upd: Update) {
|
|||||||
let device = window.device();
|
let device = window.device();
|
||||||
let queue = window.queue();
|
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() {
|
if m.visual.is_gpu() {
|
||||||
// Breakcore renders through its own raymarch pipeline; no Draw/Post.
|
// Breakcore renders through its own raymarch pipeline; no Draw/Post.
|
||||||
m.visual.render_gpu(
|
m.visual.render_gpu(device, queue, &ctx);
|
||||||
device, queue, &pal, scale, warp, m.g.feedback, m.g.fade, ca_px, m.g.drive,
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
// Build the scene off-screen, then push it through the feedback chain.
|
// Build the scene off-screen, then push it through the feedback chain.
|
||||||
let scene = Draw::new();
|
let scene = Draw::new();
|
||||||
@@ -714,12 +611,10 @@ fn update(app: &App, m: &mut Model, upd: Update) {
|
|||||||
([0.0, 0.0, 1.0], ca_px),
|
([0.0, 0.0, 1.0], ca_px),
|
||||||
] {
|
] {
|
||||||
let d = add.xy(vec2(dx, 0.0));
|
let d = add.xy(vec2(dx, 0.0));
|
||||||
m.visual
|
m.visual.draw(&d, &RenderContext { tint, ..ctx });
|
||||||
.draw(&d, &pal, fit, scale, warp, m.g.glow, m.g.seg, tint);
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
m.visual
|
m.visual.draw(&scene, &ctx);
|
||||||
.draw(&scene, &pal, fit, scale, warp, m.g.glow, m.g.seg, [1.0; 3]);
|
|
||||||
}
|
}
|
||||||
if m.g.feedback {
|
if m.g.feedback {
|
||||||
m.post
|
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.
|
// Offline: read this frame back synchronously and stream it into ffmpeg.
|
||||||
if matches!(m.mode, Mode::Render { .. }) {
|
if matches!(m.mode, Mode::Render { .. }) {
|
||||||
let cap = if m.visual.is_gpu() {
|
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 {
|
} else {
|
||||||
m.post.capture_raw(device, queue)
|
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.
|
// correct; transparent regions (direct mode) fall through to bg.
|
||||||
// Breakcore presents its own raymarch target instead of the Post chain.
|
// Breakcore presents its own raymarch target instead of the Post chain.
|
||||||
let tex = if m.visual.is_gpu() {
|
let tex = if m.visual.is_gpu() {
|
||||||
m.visual.current_tex()
|
m.visual
|
||||||
|
.current_tex()
|
||||||
|
.expect("gpu visual has a current texture")
|
||||||
} else {
|
} else {
|
||||||
m.post.current()
|
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 {} {}",
|
"{} · seed {:#x} · n {}\nlow {:.2} warp {:.2} fade {:.2} bloom {:.3} ca {:.0} drive {:.1} seg {}\nglow {} feedback {} {}",
|
||||||
m.visual.name(),
|
m.visual.name(),
|
||||||
m.visual.seed(),
|
m.visual.seed(),
|
||||||
m.visual.count(),
|
m.visual.element_count(),
|
||||||
g.low,
|
g.low,
|
||||||
g.warp,
|
g.warp,
|
||||||
g.fade,
|
g.fade,
|
||||||
|
|||||||
+38
-183
@@ -15,9 +15,10 @@
|
|||||||
//! the FFT; this never touches it.
|
//! the FFT; this never touches it.
|
||||||
//! §3 smoothing: every reactive scalar is a critically-damped [`Spring`];
|
//! §3 smoothing: every reactive scalar is a critically-damped [`Spring`];
|
||||||
//! audio sets the *target*, never the value directly.
|
//! audio sets the *target*, never the value directly.
|
||||||
//! §4 render : a hand-written wgpu raymarcher (`breakcore.wgsl`) — a
|
//! §4 render : a wgpu raymarcher (`breakcore.wgsl`, driven through the
|
||||||
//! grouped SDF capsule chain unioned with a polynomial
|
//! shared [`ShaderPipeline`] base) — a grouped SDF capsule
|
||||||
//! smooth-min, accumulated as volumetric glow over black.
|
//! chain unioned with a polynomial smooth-min, accumulated
|
||||||
|
//! as volumetric glow over black.
|
||||||
//! §5 structure: a feature-vector classifier ([`Structure`]) — slow EMAs
|
//! §5 structure: a feature-vector classifier ([`Structure`]) — slow EMAs
|
||||||
//! of energy/brightness/bass/busyness + a chroma-novelty
|
//! of energy/brightness/bass/busyness + a chroma-novelty
|
||||||
//! term — sorts the track into [`Arch`] archetypes
|
//! term — sorts the track into [`Arch`] archetypes
|
||||||
@@ -26,10 +27,10 @@
|
|||||||
//! `tension` ramp that *integrates* rising brightness/density
|
//! `tension` ramp that *integrates* rising brightness/density
|
||||||
//! while the bass is suppressed and *releases* on the drop.
|
//! while the bass is suppressed and *releases* on the drop.
|
||||||
//!
|
//!
|
||||||
//! This is the one module that owns a hand-written wgpu pipeline; `post.rs`
|
//! This is the first/only client of the shared raw-wgpu [`ShaderPipeline`]
|
||||||
//! and the other visualisers stay on nannou's validated renderer. It is *not*
|
//! base; `post.rs` and the other visualisers stay on nannou's validated
|
||||||
//! a `Draw`-based `Visual`: the bin renders it through [`Breakcore::render`]
|
//! renderer. It is *not* a `Draw`-based `Visual`: the bin renders it through
|
||||||
//! and presents/captures its target texture directly.
|
//! [`Breakcore::render`] and presents/captures its target texture directly.
|
||||||
//!
|
//!
|
||||||
//! Determinism: `Rng` and all integration advance only in [`Breakcore::update`]
|
//! Determinism: `Rng` and all integration advance only in [`Breakcore::update`]
|
||||||
//! (one call per frame, live and `--render` alike); the shader is a pure
|
//! (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::audio::{Bands, CHROMA_N, SPEC_N};
|
||||||
use crate::viz::curve::{Rng, fbm};
|
use crate::viz::curve::{Rng, fbm};
|
||||||
use crate::viz::geometry::{Attr, Knot, normalize};
|
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::palette::{Palette, oklch};
|
||||||
use crate::viz::post::read_texture_rgba;
|
use crate::viz::post::read_texture_rgba;
|
||||||
|
use crate::viz::shader::ShaderPipeline;
|
||||||
use crate::viz::structure::{Arch, Structure};
|
use crate::viz::structure::{Arch, Structure};
|
||||||
use nannou::prelude::*;
|
use nannou::prelude::*;
|
||||||
use nannou::wgpu;
|
use nannou::wgpu;
|
||||||
@@ -76,6 +78,7 @@ const PARK: f32 = 60.0;
|
|||||||
const PARKED: [f32; 4] = [PARK, PARK, PARK, 0.001];
|
const PARKED: [f32; 4] = [PARK, PARK, PARK, 0.001];
|
||||||
|
|
||||||
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8UnormSrgb;
|
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;
|
const TAU: f32 = std::f32::consts::TAU;
|
||||||
|
|
||||||
// `Spring` (premise §3), `smoothstep` and `angle_to` are generic visual math
|
// `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)
|
trail: [Vec3; NP], // rolling attractor trajectory (oldest..newest)
|
||||||
head: Vec3, // current attractor state
|
head: Vec3, // current attractor state
|
||||||
|
|
||||||
from: Kind,
|
morph: MorphState<Kind>, // backbone-kind hold-and-cross-fade
|
||||||
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)
|
trans: f32, // 0..1 section-change pulse (drives the swap burst)
|
||||||
|
|
||||||
st: Structure,
|
st: Structure,
|
||||||
@@ -253,7 +252,7 @@ pub struct Breakcore {
|
|||||||
frame: u32,
|
frame: u32,
|
||||||
|
|
||||||
b: Bands,
|
b: Bands,
|
||||||
gpu: Gpu,
|
gpu: ShaderPipeline<[f32; UBO_LEN]>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Breakcore {
|
impl Breakcore {
|
||||||
@@ -268,11 +267,7 @@ impl Breakcore {
|
|||||||
knot,
|
knot,
|
||||||
trail: [Vec3::ZERO; NP],
|
trail: [Vec3::ZERO; NP],
|
||||||
head: vec3(0.1, 0.0, 0.0),
|
head: vec3(0.1, 0.0, 0.0),
|
||||||
from: Kind::Knot,
|
morph: MorphState::new(Kind::Knot),
|
||||||
to: Kind::Knot,
|
|
||||||
morph: 1.0,
|
|
||||||
cooldown: 0.0,
|
|
||||||
idle: 0.0,
|
|
||||||
trans: 0.0,
|
trans: 0.0,
|
||||||
st: Structure::new(),
|
st: Structure::new(),
|
||||||
deb: [Frag::default(); N_DEB],
|
deb: [Frag::default(); N_DEB],
|
||||||
@@ -294,7 +289,7 @@ impl Breakcore {
|
|||||||
t: 0.0,
|
t: 0.0,
|
||||||
frame: 0,
|
frame: 0,
|
||||||
b: Bands::default(),
|
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.rng = Rng::new(seed ^ 0xB17E_C0DE_B17E_C0DE);
|
||||||
self.attr = Attr::random(&mut self.rng);
|
self.attr = Attr::random(&mut self.rng);
|
||||||
self.knot = Knot::random(&mut self.rng);
|
self.knot = Knot::random(&mut self.rng);
|
||||||
self.from = Kind::Knot;
|
// Settle the morph back to a held knot. Deliberately leaves
|
||||||
self.to = Kind::Knot;
|
// `morph.cooldown`/`trans` as-is, exactly as the pre-extraction code
|
||||||
self.morph = 1.0;
|
// 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.head = vec3(0.1, 0.0, 0.0);
|
||||||
self.trail = [Vec3::ZERO; NP];
|
self.trail = [Vec3::ZERO; NP];
|
||||||
self.idle = 0.0;
|
|
||||||
self.deb = [Frag::default(); N_DEB];
|
self.deb = [Frag::default(); N_DEB];
|
||||||
self.st = Structure::new();
|
self.st = Structure::new();
|
||||||
}
|
}
|
||||||
@@ -320,17 +318,16 @@ impl Breakcore {
|
|||||||
/// Begin a section change: pick the new backbone kind from the archetype's
|
/// Begin a section change: pick the new backbone kind from the archetype's
|
||||||
/// bias, reseed both configs, restart the morph, fire the swap burst.
|
/// bias, reseed both configs, restart the morph, fire the swap burst.
|
||||||
fn restructure(&mut self, prefer_knot: bool) {
|
fn restructure(&mut self, prefer_knot: bool) {
|
||||||
self.from = self.to;
|
let to = if prefer_knot {
|
||||||
self.to = if prefer_knot {
|
|
||||||
Kind::Knot
|
Kind::Knot
|
||||||
} else {
|
} else {
|
||||||
Kind::Attractor
|
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.attr = Attr::random(&mut self.rng);
|
||||||
self.knot = Knot::random(&mut self.rng);
|
self.knot = Knot::random(&mut self.rng);
|
||||||
self.morph = 0.0;
|
self.morph.begin(to, 1.4);
|
||||||
self.cooldown = 1.4;
|
|
||||||
self.idle = 0.0;
|
|
||||||
self.trans = 1.0;
|
self.trans = 1.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -460,21 +457,14 @@ impl Breakcore {
|
|||||||
|
|
||||||
self.update_debris(rg.deb, dt);
|
self.update_debris(rg.deb, dt);
|
||||||
|
|
||||||
if self.morph < 1.0 {
|
self.morph.advance(dt, 1.8); // also ticks cooldown/idle
|
||||||
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.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
|
// A swap fires on a real structure event — an archetype commit or a
|
||||||
// strong harmonic novelty — gated by morph/cooldown. The idle
|
// strong harmonic novelty — gated by morph/cooldown. The idle
|
||||||
// fallback is a rare safety so a flat drone still eventually evolves.
|
// fallback is a rare safety so a flat drone still eventually evolves.
|
||||||
let event = arch_changed || self.st.novelty() > 0.75;
|
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);
|
self.restructure(rg.prefer_knot);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -516,9 +506,9 @@ impl Breakcore {
|
|||||||
Kind::Attractor => &pick_attr,
|
Kind::Attractor => &pick_attr,
|
||||||
Kind::Knot => &pick_knot,
|
Kind::Knot => &pick_knot,
|
||||||
};
|
};
|
||||||
let e = smoothstep(self.morph);
|
let e = self.morph.factor();
|
||||||
let from = sel(self.from);
|
let from = sel(self.morph.from);
|
||||||
let to = sel(self.to);
|
let to = sel(self.morph.to);
|
||||||
|
|
||||||
let rg = self.st.arch().regime();
|
let rg = self.st.arch().regime();
|
||||||
let warpd = rg.warpd + 0.10 * self.st.tension();
|
let warpd = rg.warpd + 0.10 * self.st.tension();
|
||||||
@@ -719,7 +709,7 @@ impl Breakcore {
|
|||||||
u[14] = acc[2];
|
u[14] = acc[2];
|
||||||
u[15] = pal.flash;
|
u[15] = pal.flash;
|
||||||
// row4 res, frame, n_pts, time
|
// 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[17] = (self.frame & 0xffff) as f32;
|
||||||
u[18] = NP as f32;
|
u[18] = NP as f32;
|
||||||
u[19] = self.t;
|
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);
|
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.
|
// 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[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
|
// bounding-sphere radius: covers spine (0.92) + ribs/spokes/debris
|
||||||
// (≤~1.05·scale) + tube, and grows with the shock so a pulse never
|
// (≤~1.05·scale) + tube, and grows with the shock so a pulse never
|
||||||
// clips. Parked capsules sit far outside and contribute zero glow.
|
// clips. Parked capsules sit far outside and contribute zero glow.
|
||||||
@@ -785,143 +775,8 @@ impl Breakcore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// The isolated wgpu raymarch pipeline that breakcore pioneered now lives in
|
||||||
// Isolated wgpu raymarch pipeline. The deliberate exception to the codebase's
|
// the shared, validated `crate::viz::shader::ShaderPipeline` base (imported
|
||||||
// "no hand-written wgpu pipelines" rule; everything risky is contained here.
|
// 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.
|
||||||
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]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+187
@@ -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)),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,3 +50,82 @@ pub fn angle_to(cur: f32, target: f32, a: f32) -> f32 {
|
|||||||
}
|
}
|
||||||
cur + d * a
|
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
@@ -1,15 +1,18 @@
|
|||||||
//! Visual layer: organic curve geometry, audio-driven OKLCH colour, the
|
//! Visual layer: organic curve geometry, audio-driven OKLCH colour, the
|
||||||
//! living hybrid cyber-organic sigil, and the feedback/bloom post stack.
|
//! living hybrid cyber-organic sigil, and the feedback/bloom post stack.
|
||||||
//!
|
//!
|
||||||
//! `breakcore` is the one module that owns a hand-written wgpu raymarch
|
//! Raw-wgpu fragment pipelines go through the shared, validated
|
||||||
//! pipeline; `post` and everything else stay on nannou's validated renderer.
|
//! [`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 breakcore;
|
||||||
|
pub mod core;
|
||||||
pub mod curve;
|
pub mod curve;
|
||||||
pub mod geometry;
|
pub mod geometry;
|
||||||
pub mod math;
|
pub mod math;
|
||||||
pub mod palette;
|
pub mod palette;
|
||||||
pub mod post;
|
pub mod post;
|
||||||
pub mod scope;
|
pub mod scope;
|
||||||
|
pub mod shader;
|
||||||
pub mod sigil;
|
pub mod sigil;
|
||||||
pub mod structure;
|
pub mod structure;
|
||||||
|
|||||||
+12
-32
@@ -26,7 +26,7 @@
|
|||||||
use crate::audio::{Bands, WAVE_N};
|
use crate::audio::{Bands, WAVE_N};
|
||||||
use crate::viz::curve::{Rng, flow};
|
use crate::viz::curve::{Rng, flow};
|
||||||
use crate::viz::geometry::Figure;
|
use crate::viz::geometry::Figure;
|
||||||
use crate::viz::math::smoothstep;
|
use crate::viz::math::MorphState;
|
||||||
use crate::viz::palette::Palette;
|
use crate::viz::palette::Palette;
|
||||||
use nannou::prelude::*;
|
use nannou::prelude::*;
|
||||||
|
|
||||||
@@ -50,16 +50,12 @@ fn h01(a: u32, b: u32) -> f32 {
|
|||||||
pub struct Scope {
|
pub struct Scope {
|
||||||
pub seed: u64,
|
pub seed: u64,
|
||||||
rng: Rng,
|
rng: Rng,
|
||||||
cur: Figure,
|
morph: MorphState<Figure>, // hold-and-cross-fade between seeded figures
|
||||||
tgt: Figure,
|
|
||||||
morph: f32, // 0..1 cur->tgt (1 = settled)
|
|
||||||
yaw: f32,
|
yaw: f32,
|
||||||
pitch: f32,
|
pitch: f32,
|
||||||
roll: f32,
|
roll: f32,
|
||||||
breathe: f32,
|
breathe: f32,
|
||||||
restruct_cd: f32,
|
|
||||||
prev_flux: f32,
|
prev_flux: f32,
|
||||||
idle: f32, // seconds since last change (quiet-track fallback)
|
|
||||||
wave: [f32; WAVE_N],
|
wave: [f32; WAVE_N],
|
||||||
loud: f32,
|
loud: f32,
|
||||||
flux: f32,
|
flux: f32,
|
||||||
@@ -70,20 +66,16 @@ pub struct Scope {
|
|||||||
impl Scope {
|
impl Scope {
|
||||||
pub fn new(seed: u64) -> Self {
|
pub fn new(seed: u64) -> Self {
|
||||||
let mut rng = Rng::new(seed ^ 0x05C0_BE11);
|
let mut rng = Rng::new(seed ^ 0x05C0_BE11);
|
||||||
let cur = Figure::random(&mut rng);
|
let fig = Figure::random(&mut rng);
|
||||||
Scope {
|
Scope {
|
||||||
seed,
|
seed,
|
||||||
rng,
|
rng,
|
||||||
cur,
|
morph: MorphState::new(fig),
|
||||||
tgt: cur,
|
|
||||||
morph: 1.0,
|
|
||||||
yaw: 0.0,
|
yaw: 0.0,
|
||||||
pitch: 0.0,
|
pitch: 0.0,
|
||||||
roll: 0.0,
|
roll: 0.0,
|
||||||
breathe: 0.0,
|
breathe: 0.0,
|
||||||
restruct_cd: 0.0,
|
|
||||||
prev_flux: 0.0,
|
prev_flux: 0.0,
|
||||||
idle: 0.0,
|
|
||||||
wave: [0.0; WAVE_N],
|
wave: [0.0; WAVE_N],
|
||||||
loud: 0.0,
|
loud: 0.0,
|
||||||
flux: 0.0,
|
flux: 0.0,
|
||||||
@@ -102,9 +94,8 @@ impl Scope {
|
|||||||
|
|
||||||
/// Begin a morph into a freshly-seeded figure.
|
/// Begin a morph into a freshly-seeded figure.
|
||||||
fn restructure(&mut self) {
|
fn restructure(&mut self) {
|
||||||
self.tgt = Figure::random(&mut self.rng);
|
let fig = Figure::random(&mut self.rng);
|
||||||
self.morph = 0.0;
|
self.morph.begin(fig, 1.2);
|
||||||
self.idle = 0.0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn update(&mut self, b: &Bands, dt: f32) {
|
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.roll += 0.04 * dt + 0.35 * b.high * dt;
|
||||||
self.breathe += dt * (0.25 + 1.1 * b.mid + 0.5 * b.low);
|
self.breathe += dt * (0.25 + 1.1 * b.mid + 0.5 * b.low);
|
||||||
|
|
||||||
// Advance an in-flight morph; settle onto the target.
|
// Advance an in-flight morph; also ticks the cooldown/idle timers.
|
||||||
if self.morph < 1.0 {
|
self.morph.advance(dt, MORPH_SECS);
|
||||||
self.morph = (self.morph + dt / MORPH_SECS).min(1.0);
|
|
||||||
if self.morph >= 1.0 {
|
|
||||||
self.cur = self.tgt;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Change figure on a rising broadband transient (cooldown-gated), or
|
// Change figure on a rising broadband transient (cooldown-gated), or
|
||||||
// on a long idle so quiet passages still evolve.
|
// 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;
|
let rising = b.flux > 0.6 && self.prev_flux <= 0.6;
|
||||||
if self.morph >= 1.0
|
if self.morph.ready() && (rising || self.morph.idle > 12.0) {
|
||||||
&& self.restruct_cd <= 0.0
|
|
||||||
&& (rising || self.idle > 12.0)
|
|
||||||
{
|
|
||||||
self.restruct_cd = 1.2;
|
|
||||||
self.restructure();
|
self.restructure();
|
||||||
}
|
}
|
||||||
self.prev_flux = b.flux;
|
self.prev_flux = b.flux;
|
||||||
@@ -174,7 +154,7 @@ impl Scope {
|
|||||||
let (sr, cr) = self.roll.sin_cos();
|
let (sr, cr) = self.roll.sin_cos();
|
||||||
let dist = FIELD * 1.7;
|
let dist = FIELD * 1.7;
|
||||||
let amp = FIELD * 0.40 * (0.85 + 0.30 * scale.min(1.6) + 0.10 * self.breathe.sin());
|
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
|
// Slow figure-character drift from spectral brightness, beam-noise from
|
||||||
// the live waveform + flux — subtle, so the shape stays coherent.
|
// the live waveform + flux — subtle, so the shape stays coherent.
|
||||||
let drift = 1.0 + 0.10 * (self.centroid - 0.5);
|
let drift = 1.0 + 0.10 * (self.centroid - 0.5);
|
||||||
@@ -182,9 +162,9 @@ impl Scope {
|
|||||||
|
|
||||||
let project = |i: usize| -> (Vec2, f32) {
|
let project = |i: usize| -> (Vec2, f32) {
|
||||||
let u = i as f32 / N as 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 mut q = if e < 1.0 {
|
||||||
let bpt = self.tgt.at(u);
|
let bpt = self.morph.to.at(u);
|
||||||
a + (bpt - a) * e
|
a + (bpt - a) * e
|
||||||
} else {
|
} else {
|
||||||
a
|
a
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user