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::viz::breakcore::Breakcore;
|
||||
use audio_visualizer::viz::core::{RenderContext, Visualizer, next_visual};
|
||||
use audio_visualizer::viz::palette::Palette;
|
||||
use audio_visualizer::viz::post::{Post, ADDITIVE};
|
||||
use audio_visualizer::viz::scope::Scope;
|
||||
@@ -200,127 +201,8 @@ enum Mode {
|
||||
},
|
||||
}
|
||||
|
||||
/// The active visualiser. `Sigil`/`Scope` are `Draw`-based and share the draw
|
||||
/// signature so the chromatic-aberration channel passes dispatch uniformly;
|
||||
/// `Breakcore` owns its own wgpu raymarch target instead (see `is_gpu`).
|
||||
#[allow(clippy::large_enum_variant)] // Sigil/Scope are inline; Breakcore is boxed
|
||||
enum Visual {
|
||||
Sigil(Sigil),
|
||||
Scope(Scope),
|
||||
Breakcore(Box<Breakcore>), // owns its wgpu pipeline -> boxed
|
||||
}
|
||||
|
||||
impl Visual {
|
||||
fn update(&mut self, b: &Bands, dt: f32) {
|
||||
match self {
|
||||
Visual::Sigil(s) => s.update(b, dt),
|
||||
Visual::Scope(s) => s.update(b, dt),
|
||||
Visual::Breakcore(s) => s.update(b, dt),
|
||||
}
|
||||
}
|
||||
fn reseed(&mut self, seed: u64) {
|
||||
match self {
|
||||
Visual::Sigil(s) => s.reseed(seed),
|
||||
Visual::Scope(s) => s.reseed(seed),
|
||||
Visual::Breakcore(s) => s.reseed(seed),
|
||||
}
|
||||
}
|
||||
fn seed(&self) -> u64 {
|
||||
match self {
|
||||
Visual::Sigil(s) => s.seed,
|
||||
Visual::Scope(s) => s.seed,
|
||||
Visual::Breakcore(s) => s.seed,
|
||||
}
|
||||
}
|
||||
fn count(&self) -> usize {
|
||||
match self {
|
||||
Visual::Sigil(s) => s.tendril_count(),
|
||||
Visual::Scope(s) => s.point_count(),
|
||||
Visual::Breakcore(s) => s.point_count(),
|
||||
}
|
||||
}
|
||||
fn name(&self) -> &'static str {
|
||||
match self {
|
||||
Visual::Sigil(_) => "sigil",
|
||||
Visual::Scope(_) => "scope",
|
||||
Visual::Breakcore(_) => "breakcore",
|
||||
}
|
||||
}
|
||||
/// `Breakcore` renders through its own wgpu pipeline, not the shared
|
||||
/// `Draw` → `Post` path.
|
||||
fn is_gpu(&self) -> bool {
|
||||
matches!(self, Visual::Breakcore(_))
|
||||
}
|
||||
/// Cycle visualisers, keeping the current seed (live only). `Breakcore`
|
||||
/// needs the device to (re)build its pipeline.
|
||||
fn cycle(&mut self, device: &nannou::wgpu::Device) {
|
||||
let s = self.seed();
|
||||
*self = match self {
|
||||
Visual::Sigil(_) => Visual::Scope(Scope::new(s)),
|
||||
Visual::Scope(_) => Visual::Breakcore(Box::new(Breakcore::new(s, device))),
|
||||
Visual::Breakcore(_) => Visual::Sigil(Sigil::new(s)),
|
||||
};
|
||||
}
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn draw(
|
||||
&self,
|
||||
d: &Draw,
|
||||
pal: &Palette,
|
||||
fit: f32,
|
||||
scale: f32,
|
||||
warp: f32,
|
||||
glow: bool,
|
||||
seg: usize,
|
||||
tint: [f32; 3],
|
||||
) {
|
||||
match self {
|
||||
Visual::Sigil(s) => s.draw(d, pal, fit, scale, warp, glow, seg, tint),
|
||||
Visual::Scope(s) => s.draw(d, pal, fit, scale, warp, glow, seg, tint),
|
||||
Visual::Breakcore(_) => {} // rendered via its own pipeline
|
||||
}
|
||||
}
|
||||
|
||||
// --- gpu path (Breakcore only); the other arms never run these ---------
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn render_gpu<'a>(
|
||||
&'a mut self,
|
||||
device: &nannou::wgpu::Device,
|
||||
queue: &nannou::wgpu::Queue,
|
||||
pal: &Palette,
|
||||
scale: f32,
|
||||
warp: f32,
|
||||
feedback: bool,
|
||||
fade: f32,
|
||||
ca_px: f32,
|
||||
drive: f32,
|
||||
) -> &'a nannou::wgpu::Texture {
|
||||
match self {
|
||||
Visual::Breakcore(s) => {
|
||||
s.render(device, queue, pal, scale, warp, feedback, fade, ca_px, drive)
|
||||
}
|
||||
_ => unreachable!("render_gpu on a Draw-based visual"),
|
||||
}
|
||||
}
|
||||
fn current_tex(&self) -> &nannou::wgpu::Texture {
|
||||
match self {
|
||||
Visual::Breakcore(s) => s.current(),
|
||||
_ => unreachable!("current_tex on a Draw-based visual"),
|
||||
}
|
||||
}
|
||||
fn capture_raw(
|
||||
&self,
|
||||
device: &nannou::wgpu::Device,
|
||||
queue: &nannou::wgpu::Queue,
|
||||
) -> anyhow::Result<Vec<u8>> {
|
||||
match self {
|
||||
Visual::Breakcore(s) => s.capture_raw(device, queue),
|
||||
_ => unreachable!("capture_raw on a Draw-based visual"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Model {
|
||||
visual: Visual,
|
||||
visual: Box<dyn Visualizer>,
|
||||
post: Post,
|
||||
mode: Mode,
|
||||
g: Gains,
|
||||
@@ -573,12 +455,12 @@ fn model(app: &App) -> Model {
|
||||
};
|
||||
let seed = SEED ^ src_name.map(fnv1a).unwrap_or(0);
|
||||
|
||||
let visual = match mode_sel.as_deref() {
|
||||
Some("scope") => Visual::Scope(Scope::new(seed)),
|
||||
let visual: Box<dyn Visualizer> = match mode_sel.as_deref() {
|
||||
Some("scope") => Box::new(Scope::new(seed)),
|
||||
Some("breakcore") => {
|
||||
Visual::Breakcore(Box::new(Breakcore::new(seed, app.main_window().device())))
|
||||
Box::new(Breakcore::new(seed, app.main_window().device()))
|
||||
}
|
||||
Some("sigil") | None => Visual::Sigil(Sigil::new(seed)),
|
||||
Some("sigil") | None => Box::new(Sigil::new(seed)),
|
||||
Some(other) => die(format!("unknown --mode {other:?} (sigil|scope|breakcore)")),
|
||||
};
|
||||
|
||||
@@ -608,7 +490,7 @@ fn key_pressed(app: &App, m: &mut Model, key: Key) {
|
||||
Key::M => {
|
||||
// Mode switch only makes sense live; a render is a fixed pass.
|
||||
if matches!(m.mode, Mode::Live(_)) {
|
||||
m.visual.cycle(app.main_window().device());
|
||||
m.visual = next_visual(m.visual.as_ref(), app.main_window().device());
|
||||
println!("mode = {}", m.visual.name());
|
||||
}
|
||||
}
|
||||
@@ -623,7 +505,10 @@ fn key_pressed(app: &App, m: &mut Model, key: Key) {
|
||||
// (never-written) Post accumulator.
|
||||
let res = Post::res() as u32;
|
||||
let saved = if m.visual.is_gpu() {
|
||||
m.visual.capture_raw(device, queue).and_then(|px| {
|
||||
m.visual
|
||||
.capture_raw(device, queue)
|
||||
.unwrap_or_else(|| Err(anyhow::anyhow!("gpu visual produced no frame")))
|
||||
.and_then(|px| {
|
||||
nannou::image::RgbaImage::from_raw(res, res, px)
|
||||
.ok_or_else(|| anyhow::anyhow!("image buffer size mismatch"))?
|
||||
.save(&path)
|
||||
@@ -697,11 +582,23 @@ fn update(app: &App, m: &mut Model, upd: Update) {
|
||||
let device = window.device();
|
||||
let queue = window.queue();
|
||||
|
||||
let ctx = RenderContext {
|
||||
pal: &pal,
|
||||
fit,
|
||||
scale,
|
||||
warp,
|
||||
glow: m.g.glow,
|
||||
seg: m.g.seg,
|
||||
tint: [1.0; 3],
|
||||
feedback: m.g.feedback,
|
||||
fade: m.g.fade,
|
||||
ca_px,
|
||||
drive: m.g.drive,
|
||||
};
|
||||
|
||||
if m.visual.is_gpu() {
|
||||
// Breakcore renders through its own raymarch pipeline; no Draw/Post.
|
||||
m.visual.render_gpu(
|
||||
device, queue, &pal, scale, warp, m.g.feedback, m.g.fade, ca_px, m.g.drive,
|
||||
);
|
||||
m.visual.render_gpu(device, queue, &ctx);
|
||||
} else {
|
||||
// Build the scene off-screen, then push it through the feedback chain.
|
||||
let scene = Draw::new();
|
||||
@@ -714,12 +611,10 @@ fn update(app: &App, m: &mut Model, upd: Update) {
|
||||
([0.0, 0.0, 1.0], ca_px),
|
||||
] {
|
||||
let d = add.xy(vec2(dx, 0.0));
|
||||
m.visual
|
||||
.draw(&d, &pal, fit, scale, warp, m.g.glow, m.g.seg, tint);
|
||||
m.visual.draw(&d, &RenderContext { tint, ..ctx });
|
||||
}
|
||||
} else {
|
||||
m.visual
|
||||
.draw(&scene, &pal, fit, scale, warp, m.g.glow, m.g.seg, [1.0; 3]);
|
||||
m.visual.draw(&scene, &ctx);
|
||||
}
|
||||
if m.g.feedback {
|
||||
m.post
|
||||
@@ -732,7 +627,9 @@ fn update(app: &App, m: &mut Model, upd: Update) {
|
||||
// Offline: read this frame back synchronously and stream it into ffmpeg.
|
||||
if matches!(m.mode, Mode::Render { .. }) {
|
||||
let cap = if m.visual.is_gpu() {
|
||||
m.visual.capture_raw(device, queue)
|
||||
m.visual
|
||||
.capture_raw(device, queue)
|
||||
.unwrap_or_else(|| Err(anyhow::anyhow!("gpu visual produced no frame")))
|
||||
} else {
|
||||
m.post.capture_raw(device, queue)
|
||||
};
|
||||
@@ -768,7 +665,9 @@ fn view(app: &App, m: &Model, frame: Frame) {
|
||||
// correct; transparent regions (direct mode) fall through to bg.
|
||||
// Breakcore presents its own raymarch target instead of the Post chain.
|
||||
let tex = if m.visual.is_gpu() {
|
||||
m.visual.current_tex()
|
||||
m.visual
|
||||
.current_tex()
|
||||
.expect("gpu visual has a current texture")
|
||||
} else {
|
||||
m.post.current()
|
||||
};
|
||||
@@ -798,7 +697,7 @@ fn view(app: &App, m: &Model, frame: Frame) {
|
||||
"{} · seed {:#x} · n {}\nlow {:.2} warp {:.2} fade {:.2} bloom {:.3} ca {:.0} drive {:.1} seg {}\nglow {} feedback {} {}",
|
||||
m.visual.name(),
|
||||
m.visual.seed(),
|
||||
m.visual.count(),
|
||||
m.visual.element_count(),
|
||||
g.low,
|
||||
g.warp,
|
||||
g.fade,
|
||||
|
||||
+38
-183
@@ -15,9 +15,10 @@
|
||||
//! the FFT; this never touches it.
|
||||
//! §3 smoothing: every reactive scalar is a critically-damped [`Spring`];
|
||||
//! audio sets the *target*, never the value directly.
|
||||
//! §4 render : a hand-written wgpu raymarcher (`breakcore.wgsl`) — a
|
||||
//! grouped SDF capsule chain unioned with a polynomial
|
||||
//! smooth-min, accumulated as volumetric glow over black.
|
||||
//! §4 render : a wgpu raymarcher (`breakcore.wgsl`, driven through the
|
||||
//! shared [`ShaderPipeline`] base) — a grouped SDF capsule
|
||||
//! chain unioned with a polynomial smooth-min, accumulated
|
||||
//! as volumetric glow over black.
|
||||
//! §5 structure: a feature-vector classifier ([`Structure`]) — slow EMAs
|
||||
//! of energy/brightness/bass/busyness + a chroma-novelty
|
||||
//! term — sorts the track into [`Arch`] archetypes
|
||||
@@ -26,10 +27,10 @@
|
||||
//! `tension` ramp that *integrates* rising brightness/density
|
||||
//! while the bass is suppressed and *releases* on the drop.
|
||||
//!
|
||||
//! This is the one module that owns a hand-written wgpu pipeline; `post.rs`
|
||||
//! and the other visualisers stay on nannou's validated renderer. It is *not*
|
||||
//! a `Draw`-based `Visual`: the bin renders it through [`Breakcore::render`]
|
||||
//! and presents/captures its target texture directly.
|
||||
//! This is the first/only client of the shared raw-wgpu [`ShaderPipeline`]
|
||||
//! base; `post.rs` and the other visualisers stay on nannou's validated
|
||||
//! renderer. It is *not* a `Draw`-based `Visual`: the bin renders it through
|
||||
//! [`Breakcore::render`] and presents/captures its target texture directly.
|
||||
//!
|
||||
//! Determinism: `Rng` and all integration advance only in [`Breakcore::update`]
|
||||
//! (one call per frame, live and `--render` alike); the shader is a pure
|
||||
@@ -39,9 +40,10 @@
|
||||
use crate::audio::{Bands, CHROMA_N, SPEC_N};
|
||||
use crate::viz::curve::{Rng, fbm};
|
||||
use crate::viz::geometry::{Attr, Knot, normalize};
|
||||
use crate::viz::math::{Spring, angle_to, smoothstep};
|
||||
use crate::viz::math::{MorphState, Spring, angle_to};
|
||||
use crate::viz::palette::{Palette, oklch};
|
||||
use crate::viz::post::read_texture_rgba;
|
||||
use crate::viz::shader::ShaderPipeline;
|
||||
use crate::viz::structure::{Arch, Structure};
|
||||
use nannou::prelude::*;
|
||||
use nannou::wgpu;
|
||||
@@ -76,6 +78,7 @@ const PARK: f32 = 60.0;
|
||||
const PARKED: [f32; 4] = [PARK, PARK, PARK, 0.001];
|
||||
|
||||
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8UnormSrgb;
|
||||
const RES: u32 = crate::viz::post::RES; // raymarch target = post supersample
|
||||
const TAU: f32 = std::f32::consts::TAU;
|
||||
|
||||
// `Spring` (premise §3), `smoothstep` and `angle_to` are generic visual math
|
||||
@@ -224,11 +227,7 @@ pub struct Breakcore {
|
||||
trail: [Vec3; NP], // rolling attractor trajectory (oldest..newest)
|
||||
head: Vec3, // current attractor state
|
||||
|
||||
from: Kind,
|
||||
to: Kind,
|
||||
morph: f32, // 0..1 from→to (1 = settled)
|
||||
cooldown: f32,
|
||||
idle: f32,
|
||||
morph: MorphState<Kind>, // backbone-kind hold-and-cross-fade
|
||||
trans: f32, // 0..1 section-change pulse (drives the swap burst)
|
||||
|
||||
st: Structure,
|
||||
@@ -253,7 +252,7 @@ pub struct Breakcore {
|
||||
frame: u32,
|
||||
|
||||
b: Bands,
|
||||
gpu: Gpu,
|
||||
gpu: ShaderPipeline<[f32; UBO_LEN]>,
|
||||
}
|
||||
|
||||
impl Breakcore {
|
||||
@@ -268,11 +267,7 @@ impl Breakcore {
|
||||
knot,
|
||||
trail: [Vec3::ZERO; NP],
|
||||
head: vec3(0.1, 0.0, 0.0),
|
||||
from: Kind::Knot,
|
||||
to: Kind::Knot,
|
||||
morph: 1.0,
|
||||
cooldown: 0.0,
|
||||
idle: 0.0,
|
||||
morph: MorphState::new(Kind::Knot),
|
||||
trans: 0.0,
|
||||
st: Structure::new(),
|
||||
deb: [Frag::default(); N_DEB],
|
||||
@@ -294,7 +289,7 @@ impl Breakcore {
|
||||
t: 0.0,
|
||||
frame: 0,
|
||||
b: Bands::default(),
|
||||
gpu: Gpu::new(device),
|
||||
gpu: ShaderPipeline::new(device, include_str!("breakcore.wgsl"), RES, FMT),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -303,12 +298,15 @@ impl Breakcore {
|
||||
self.rng = Rng::new(seed ^ 0xB17E_C0DE_B17E_C0DE);
|
||||
self.attr = Attr::random(&mut self.rng);
|
||||
self.knot = Knot::random(&mut self.rng);
|
||||
self.from = Kind::Knot;
|
||||
self.to = Kind::Knot;
|
||||
self.morph = 1.0;
|
||||
// Settle the morph back to a held knot. Deliberately leaves
|
||||
// `morph.cooldown`/`trans` as-is, exactly as the pre-extraction code
|
||||
// did (reseed is live-only `R`, never on the `--render` path).
|
||||
self.morph.from = Kind::Knot;
|
||||
self.morph.to = Kind::Knot;
|
||||
self.morph.t = 1.0;
|
||||
self.morph.idle = 0.0;
|
||||
self.head = vec3(0.1, 0.0, 0.0);
|
||||
self.trail = [Vec3::ZERO; NP];
|
||||
self.idle = 0.0;
|
||||
self.deb = [Frag::default(); N_DEB];
|
||||
self.st = Structure::new();
|
||||
}
|
||||
@@ -320,17 +318,16 @@ impl Breakcore {
|
||||
/// Begin a section change: pick the new backbone kind from the archetype's
|
||||
/// bias, reseed both configs, restart the morph, fire the swap burst.
|
||||
fn restructure(&mut self, prefer_knot: bool) {
|
||||
self.from = self.to;
|
||||
self.to = if prefer_knot {
|
||||
let to = if prefer_knot {
|
||||
Kind::Knot
|
||||
} else {
|
||||
Kind::Attractor
|
||||
};
|
||||
// rng order (attr then knot) is load-bearing for `--render`
|
||||
// bit-identity; `begin` advances no rng.
|
||||
self.attr = Attr::random(&mut self.rng);
|
||||
self.knot = Knot::random(&mut self.rng);
|
||||
self.morph = 0.0;
|
||||
self.cooldown = 1.4;
|
||||
self.idle = 0.0;
|
||||
self.morph.begin(to, 1.4);
|
||||
self.trans = 1.0;
|
||||
}
|
||||
|
||||
@@ -460,21 +457,14 @@ impl Breakcore {
|
||||
|
||||
self.update_debris(rg.deb, dt);
|
||||
|
||||
if self.morph < 1.0 {
|
||||
self.morph = (self.morph + dt / 1.8).min(1.0);
|
||||
if self.morph >= 1.0 {
|
||||
self.from = self.to;
|
||||
}
|
||||
}
|
||||
self.morph.advance(dt, 1.8); // also ticks cooldown/idle
|
||||
self.trans = (self.trans - dt / 0.5).max(0.0);
|
||||
self.cooldown = (self.cooldown - dt).max(0.0);
|
||||
self.idle += dt;
|
||||
|
||||
// A swap fires on a real structure event — an archetype commit or a
|
||||
// strong harmonic novelty — gated by morph/cooldown. The idle
|
||||
// fallback is a rare safety so a flat drone still eventually evolves.
|
||||
let event = arch_changed || self.st.novelty() > 0.75;
|
||||
if self.morph >= 1.0 && self.cooldown <= 0.0 && (event || self.idle > 20.0) {
|
||||
if self.morph.ready() && (event || self.morph.idle > 20.0) {
|
||||
self.restructure(rg.prefer_knot);
|
||||
}
|
||||
}
|
||||
@@ -516,9 +506,9 @@ impl Breakcore {
|
||||
Kind::Attractor => &pick_attr,
|
||||
Kind::Knot => &pick_knot,
|
||||
};
|
||||
let e = smoothstep(self.morph);
|
||||
let from = sel(self.from);
|
||||
let to = sel(self.to);
|
||||
let e = self.morph.factor();
|
||||
let from = sel(self.morph.from);
|
||||
let to = sel(self.morph.to);
|
||||
|
||||
let rg = self.st.arch().regime();
|
||||
let warpd = rg.warpd + 0.10 * self.st.tension();
|
||||
@@ -719,7 +709,7 @@ impl Breakcore {
|
||||
u[14] = acc[2];
|
||||
u[15] = pal.flash;
|
||||
// row4 res, frame, n_pts, time
|
||||
u[16] = Gpu::RES as f32;
|
||||
u[16] = RES as f32;
|
||||
u[17] = (self.frame & 0xffff) as f32;
|
||||
u[18] = NP as f32;
|
||||
u[19] = self.t;
|
||||
@@ -729,7 +719,7 @@ impl Breakcore {
|
||||
u[20] = (22.0 + 9.0 * warp.min(2.0)).clamp(16.0, 40.0);
|
||||
// Tension fuses the folds (higher melt_k) so a build melts to a core.
|
||||
u[21] = (0.004 + 0.008 * self.b.loud + 0.010 * tn * rg.melt).clamp(0.003, 0.018);
|
||||
u[22] = if feedback && self.gpu.primed { 1.0 } else { 0.0 };
|
||||
u[22] = if feedback && self.gpu.primed() { 1.0 } else { 0.0 };
|
||||
// bounding-sphere radius: covers spine (0.92) + ribs/spokes/debris
|
||||
// (≤~1.05·scale) + tube, and grows with the shock so a pulse never
|
||||
// clips. Parked capsules sit far outside and contribute zero glow.
|
||||
@@ -785,143 +775,8 @@ impl Breakcore {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Isolated wgpu raymarch pipeline. The deliberate exception to the codebase's
|
||||
// "no hand-written wgpu pipelines" rule; everything risky is contained here.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct Gpu {
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
ubo: wgpu::Buffer,
|
||||
tex: [wgpu::Texture; 2],
|
||||
view: [wgpu::TextureViewHandle; 2],
|
||||
bind: [wgpu::BindGroup; 2], // bind[w]: writes view[w], samples view[1-w]
|
||||
cur: usize, // index last written / presented
|
||||
primed: bool,
|
||||
}
|
||||
|
||||
fn as_bytes(v: &[f32]) -> &[u8] {
|
||||
// A flat f32 slice has no padding; reinterpret as bytes for write_buffer.
|
||||
unsafe { std::slice::from_raw_parts(v.as_ptr() as *const u8, std::mem::size_of_val(v)) }
|
||||
}
|
||||
|
||||
impl Gpu {
|
||||
const RES: u32 = crate::viz::post::RES;
|
||||
|
||||
fn new(device: &wgpu::Device) -> Self {
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("breakcore-shader"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("breakcore.wgsl").into()),
|
||||
});
|
||||
|
||||
let mk = || {
|
||||
wgpu::TextureBuilder::new()
|
||||
.size([Self::RES, Self::RES])
|
||||
.format(FMT)
|
||||
.usage(
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING
|
||||
| wgpu::TextureUsages::COPY_SRC,
|
||||
)
|
||||
.build(device)
|
||||
};
|
||||
let tex = [mk(), mk()];
|
||||
let view = [
|
||||
tex[0].create_view(&wgpu::TextureViewDescriptor::default()),
|
||||
tex[1].create_view(&wgpu::TextureViewDescriptor::default()),
|
||||
];
|
||||
|
||||
// Bindings (order = WGSL @binding 0/1/2): uniform, prev texture, sampler.
|
||||
let bgl = wgpu::BindGroupLayoutBuilder::new()
|
||||
.uniform_buffer(wgpu::ShaderStages::FRAGMENT, false)
|
||||
.texture_from(wgpu::ShaderStages::FRAGMENT, &tex[0])
|
||||
.sampler(wgpu::ShaderStages::FRAGMENT, true)
|
||||
.build(device);
|
||||
|
||||
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("breakcore-pl"),
|
||||
bind_group_layouts: &[&bgl],
|
||||
push_constant_ranges: &[],
|
||||
});
|
||||
|
||||
// Fullscreen triangle: no vertex buffers; default triangle-list.
|
||||
let pipeline = wgpu::RenderPipelineBuilder::from_layout(&layout, &shader)
|
||||
.vertex_entry_point("vs_main")
|
||||
.fragment_shader(&shader)
|
||||
.fragment_entry_point("fs_main")
|
||||
.color_format(FMT)
|
||||
.build(device);
|
||||
|
||||
let ubo = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("breakcore-ubo"),
|
||||
size: (UBO_LEN * 4) as u64,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let sampler = wgpu::SamplerBuilder::new()
|
||||
.address_mode(wgpu::AddressMode::ClampToEdge)
|
||||
.mag_filter(wgpu::FilterMode::Linear)
|
||||
.min_filter(wgpu::FilterMode::Linear)
|
||||
.mipmap_filter(wgpu::FilterMode::Nearest)
|
||||
.build(device);
|
||||
|
||||
let mk_bind = |w: usize| {
|
||||
wgpu::BindGroupBuilder::new()
|
||||
.binding(ubo.as_entire_binding())
|
||||
.texture_view(&view[1 - w])
|
||||
.sampler(&sampler)
|
||||
.build(device, &bgl)
|
||||
};
|
||||
let bind = [mk_bind(0), mk_bind(1)];
|
||||
|
||||
Gpu {
|
||||
pipeline,
|
||||
ubo,
|
||||
tex,
|
||||
view,
|
||||
bind,
|
||||
cur: 0,
|
||||
primed: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn render(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
ubo: &[f32; UBO_LEN],
|
||||
) -> &wgpu::Texture {
|
||||
let w = 1 - self.cur; // write target; sample the last-written (self.cur)
|
||||
queue.write_buffer(&self.ubo, 0, as_bytes(ubo));
|
||||
|
||||
let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
||||
label: Some("breakcore-enc"),
|
||||
});
|
||||
{
|
||||
let mut rp = enc.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("breakcore-pass"),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &self.view[w],
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
||||
store: true,
|
||||
},
|
||||
})],
|
||||
depth_stencil_attachment: None,
|
||||
});
|
||||
rp.set_pipeline(&self.pipeline);
|
||||
rp.set_bind_group(0, &self.bind[w], &[]);
|
||||
rp.draw(0..3, 0..1);
|
||||
}
|
||||
queue.submit(Some(enc.finish()));
|
||||
|
||||
self.cur = w;
|
||||
self.primed = true;
|
||||
&self.tex[self.cur]
|
||||
}
|
||||
|
||||
fn current(&self) -> &wgpu::Texture {
|
||||
&self.tex[self.cur]
|
||||
}
|
||||
}
|
||||
// The isolated wgpu raymarch pipeline that breakcore pioneered now lives in
|
||||
// the shared, validated `crate::viz::shader::ShaderPipeline` base (imported
|
||||
// above). breakcore stays its first/only client; the SDF + UBO layout (NP /
|
||||
// `UBO_LEN`) and the shader-side march-step cap remain the non-negotiable
|
||||
// guardrails — see `breakcore.wgsl` and the `NP` doc above.
|
||||
|
||||
+187
@@ -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
|
||||
}
|
||||
|
||||
/// 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
|
||||
//! living hybrid cyber-organic sigil, and the feedback/bloom post stack.
|
||||
//!
|
||||
//! `breakcore` is the one module that owns a hand-written wgpu raymarch
|
||||
//! pipeline; `post` and everything else stay on nannou's validated renderer.
|
||||
//! Raw-wgpu fragment pipelines go through the shared, validated
|
||||
//! [`shader::ShaderPipeline`] base (`breakcore` is its first client); `post`
|
||||
//! and the `Draw`-based visualisers stay on nannou's validated renderer.
|
||||
|
||||
pub mod breakcore;
|
||||
pub mod core;
|
||||
pub mod curve;
|
||||
pub mod geometry;
|
||||
pub mod math;
|
||||
pub mod palette;
|
||||
pub mod post;
|
||||
pub mod scope;
|
||||
pub mod shader;
|
||||
pub mod sigil;
|
||||
pub mod structure;
|
||||
|
||||
+12
-32
@@ -26,7 +26,7 @@
|
||||
use crate::audio::{Bands, WAVE_N};
|
||||
use crate::viz::curve::{Rng, flow};
|
||||
use crate::viz::geometry::Figure;
|
||||
use crate::viz::math::smoothstep;
|
||||
use crate::viz::math::MorphState;
|
||||
use crate::viz::palette::Palette;
|
||||
use nannou::prelude::*;
|
||||
|
||||
@@ -50,16 +50,12 @@ fn h01(a: u32, b: u32) -> f32 {
|
||||
pub struct Scope {
|
||||
pub seed: u64,
|
||||
rng: Rng,
|
||||
cur: Figure,
|
||||
tgt: Figure,
|
||||
morph: f32, // 0..1 cur->tgt (1 = settled)
|
||||
morph: MorphState<Figure>, // hold-and-cross-fade between seeded figures
|
||||
yaw: f32,
|
||||
pitch: f32,
|
||||
roll: f32,
|
||||
breathe: f32,
|
||||
restruct_cd: f32,
|
||||
prev_flux: f32,
|
||||
idle: f32, // seconds since last change (quiet-track fallback)
|
||||
wave: [f32; WAVE_N],
|
||||
loud: f32,
|
||||
flux: f32,
|
||||
@@ -70,20 +66,16 @@ pub struct Scope {
|
||||
impl Scope {
|
||||
pub fn new(seed: u64) -> Self {
|
||||
let mut rng = Rng::new(seed ^ 0x05C0_BE11);
|
||||
let cur = Figure::random(&mut rng);
|
||||
let fig = Figure::random(&mut rng);
|
||||
Scope {
|
||||
seed,
|
||||
rng,
|
||||
cur,
|
||||
tgt: cur,
|
||||
morph: 1.0,
|
||||
morph: MorphState::new(fig),
|
||||
yaw: 0.0,
|
||||
pitch: 0.0,
|
||||
roll: 0.0,
|
||||
breathe: 0.0,
|
||||
restruct_cd: 0.0,
|
||||
prev_flux: 0.0,
|
||||
idle: 0.0,
|
||||
wave: [0.0; WAVE_N],
|
||||
loud: 0.0,
|
||||
flux: 0.0,
|
||||
@@ -102,9 +94,8 @@ impl Scope {
|
||||
|
||||
/// Begin a morph into a freshly-seeded figure.
|
||||
fn restructure(&mut self) {
|
||||
self.tgt = Figure::random(&mut self.rng);
|
||||
self.morph = 0.0;
|
||||
self.idle = 0.0;
|
||||
let fig = Figure::random(&mut self.rng);
|
||||
self.morph.begin(fig, 1.2);
|
||||
}
|
||||
|
||||
pub fn update(&mut self, b: &Bands, dt: f32) {
|
||||
@@ -121,24 +112,13 @@ impl Scope {
|
||||
self.roll += 0.04 * dt + 0.35 * b.high * dt;
|
||||
self.breathe += dt * (0.25 + 1.1 * b.mid + 0.5 * b.low);
|
||||
|
||||
// Advance an in-flight morph; settle onto the target.
|
||||
if self.morph < 1.0 {
|
||||
self.morph = (self.morph + dt / MORPH_SECS).min(1.0);
|
||||
if self.morph >= 1.0 {
|
||||
self.cur = self.tgt;
|
||||
}
|
||||
}
|
||||
// Advance an in-flight morph; also ticks the cooldown/idle timers.
|
||||
self.morph.advance(dt, MORPH_SECS);
|
||||
|
||||
// Change figure on a rising broadband transient (cooldown-gated), or
|
||||
// on a long idle so quiet passages still evolve.
|
||||
self.restruct_cd = (self.restruct_cd - dt).max(0.0);
|
||||
self.idle += dt;
|
||||
let rising = b.flux > 0.6 && self.prev_flux <= 0.6;
|
||||
if self.morph >= 1.0
|
||||
&& self.restruct_cd <= 0.0
|
||||
&& (rising || self.idle > 12.0)
|
||||
{
|
||||
self.restruct_cd = 1.2;
|
||||
if self.morph.ready() && (rising || self.morph.idle > 12.0) {
|
||||
self.restructure();
|
||||
}
|
||||
self.prev_flux = b.flux;
|
||||
@@ -174,7 +154,7 @@ impl Scope {
|
||||
let (sr, cr) = self.roll.sin_cos();
|
||||
let dist = FIELD * 1.7;
|
||||
let amp = FIELD * 0.40 * (0.85 + 0.30 * scale.min(1.6) + 0.10 * self.breathe.sin());
|
||||
let e = smoothstep(self.morph);
|
||||
let e = self.morph.factor();
|
||||
// Slow figure-character drift from spectral brightness, beam-noise from
|
||||
// the live waveform + flux — subtle, so the shape stays coherent.
|
||||
let drift = 1.0 + 0.10 * (self.centroid - 0.5);
|
||||
@@ -182,9 +162,9 @@ impl Scope {
|
||||
|
||||
let project = |i: usize| -> (Vec2, f32) {
|
||||
let u = i as f32 / N as f32;
|
||||
let a = self.cur.at(u);
|
||||
let a = self.morph.from.at(u);
|
||||
let mut q = if e < 1.0 {
|
||||
let bpt = self.tgt.at(u);
|
||||
let bpt = self.morph.to.at(u);
|
||||
a + (bpt - a) * e
|
||||
} else {
|
||||
a
|
||||
|
||||
@@ -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