From 2c3418f608001da0a50f742d596d5997598b0e2b Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Wed, 20 May 2026 16:08:00 +0200 Subject: [PATCH] added monolith --- Cargo.lock | 129 +++++- Cargo.toml | 1 + sigil.cfg | 31 +- src/bin/sigil.rs | 863 ++++++++++++++++++++++++++++------------- src/viz/breakcore.rs | 41 +- src/viz/breakcore.wgsl | 16 +- src/viz/core.rs | 159 ++++---- src/viz/fingerprint.rs | 199 ++++++++++ src/viz/mod.rs | 15 +- src/viz/monolith.rs | 453 +++++++++++++++++++++ src/viz/monolith.wgsl | 312 +++++++++++++++ src/viz/post.rs | 199 +--------- src/viz/scope.rs | 259 ------------- src/viz/shader.rs | 16 +- src/viz/sigil.rs | 531 ------------------------- 15 files changed, 1872 insertions(+), 1352 deletions(-) create mode 100644 src/viz/fingerprint.rs create mode 100644 src/viz/monolith.rs create mode 100644 src/viz/monolith.wgsl delete mode 100644 src/viz/scope.rs delete mode 100644 src/viz/sigil.rs diff --git a/Cargo.lock b/Cargo.lock index 7ded30c..7e6baf9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -118,6 +118,56 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.102" @@ -165,6 +215,7 @@ name = "audio-visualizer" version = "0.1.0" dependencies = [ "anyhow", + "clap", "cpal", "nannou", "ringbuf", @@ -329,6 +380,46 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + [[package]] name = "codespan-reporting" version = "0.11.1" @@ -345,6 +436,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "com-rs" version = "0.2.1" @@ -943,6 +1040,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hermit-abi" version = "0.5.2" @@ -1006,6 +1109,12 @@ dependencies = [ "web-sys", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itoa" version = "1.0.18" @@ -1800,6 +1909,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "orbclient" version = "0.3.54" @@ -2422,6 +2537,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "symphonia" version = "0.6.0" @@ -2780,6 +2901,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "vec_map" version = "0.8.2" @@ -3094,7 +3221,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 2796b03..0cbc791 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ anyhow = "1" nannou = "0.19" triple_buffer = "9" symphonia = { version = "0.6", features = ["mp3", "isomp4", "aac", "flac", "vorbis", "ogg", "wav", "pcm"] } +clap = { version = "4", features = ["derive"] } [profile.release] opt-level = 3 diff --git a/sigil.cfg b/sigil.cfg index 4d5dc6f..f4c9092 100644 --- a/sigil.cfg +++ b/sigil.cfg @@ -1,12 +1,29 @@ +# breakcore visualiser config. `C` in-app rewrites this file. +# +# quality preset bundles live/render size + internal supersample + march +# budget. Any explicit key below overrides the preset. +# laptop : live 960x540 render 1280x720 ss1.0 march<=40 +# desktop : live 1280x720 render 1920x1080 ss1.0 march<=40 +# ultra : live 1600x900 render 2560x1440 ss1.5 march<=96 +quality=desktop + +# --- explicit overrides (uncomment to win over the preset) --- +# live_w=1280 +# live_h=720 +# render_w=2560 +# render_h=1440 +# supersample=1.5 # internal raymarch buffer = out * ss (1.0..3.0) +# march_cap=72 # breakcore march-step ceiling (16..96; >40 = heavier) +target_fps=60 # live update/render cap (vsync-friendly) + +# --- breakcore expressive effect (live keys 1/2 3/4 5/6 9/0 [ ] and B) --- low=0.85 -warp=1 +warp=1.0 fade=0.11 -zoom=1.006 -ca=7 -drive=1 -seg=12 -glow=true +ca=7.0 +drive=1.0 feedback=true -out_scale=0 + +# --- --render encode --- crf=16 x264_preset=veryslow diff --git a/src/bin/sigil.rs b/src/bin/sigil.rs index 0e672c6..1abf5ee 100644 --- a/src/bin/sigil.rs +++ b/src/bin/sigil.rs @@ -1,45 +1,55 @@ -// Audio-reactive living cyber-organic sigil. +// breakcore — audio-reactive dark volumetric cybersigil (wgpu raymarcher). // -// A fixed cyber skeleton with organic overgrowth that grows / branches / -// withers / restructures with the music, drawn as noise-warped curves in -// audio-driven OKLCH colour, through a feedback-trail + bloom post stack with -// transient chromatic aberration. See src/viz/*. +// A partitioned SDF capsule field (strange-attractor / torus-knot backbone + +// spectral ribs + transient debris + harmonic spokes) raymarched as volumetric +// glow over black, with in-shader chromatic aberration + ping-pong phosphor +// feedback. The only visualiser; future modes plug into the same aspect-aware +// ShaderPipeline base (see src/viz/*). // -// cargo run --release --bin sigil -- [--mode sigil|scope|breakcore] [|monitor|loopback|] -// cargo run --release --bin sigil -- --render [--mode breakcore] [out.mp4] +// cargo run --release --bin sigil -- [] # live +// cargo run --release --bin sigil -- --render [--out out.mp4] +// cargo run --bin sigil -- -- --analyze # smoke +// cargo run --release --bin sigil -- --help # all flags // // --render decodes the whole file and renders every frame at a fixed fps with -// no frame budget, *streaming* each raw frame straight into one long-lived -// ffmpeg over stdin (no PNG sequence / temp dir). Output res/quality is -// cfg-tunable (out_scale / crf / x264_preset). Live mode plays the file/feed -// and reacts in real time. --mode picks the visualiser (default sigil); -// `breakcore` is a wgpu SDF raymarcher that bypasses the Draw/Post path. +// no frame budget, streaming each raw frame into one long-lived ffmpeg over +// stdin (no PNG sequence / temp dir). Live plays the file/feed and reacts in +// real time, capped to `target_fps` (vsync-friendly). Resolution/quality is +// cfg-driven AND CLI-overridable: a `quality` preset (laptop|desktop|ultra) +// bundles separate live/render sizes + an internal-supersample multiplier + +// the march budget. Precedence is defaults → `sigil.cfg` preset → cfg +// explicit keys → `--quality` re-applies its preset → explicit CLI flags +// (`--render-w`, `--march-cap`, `--supersample`, …). The internal raymarch +// target is an arbitrary aspect ratio (the ray is aspect-corrected in-shader). // -// Keys: R reseed · M cycle mode · P save PNG · F fullscreen · H HUD · G glow -// B feedback · C write cfg · 1/2 low-scale · 3/4 warp · 5/6 trail-decay -// 7/8 bloom · 9/0 chroma-aberration · -/= curve quality · Esc quit +// Keys: R reseed · M cycle visualiser · P save PNG · F fullscreen · H HUD · +// B feedback · C write cfg · 1/2 low · 3/4 warp · 5/6 trail-decay · +// 9/0 chroma-ab · [ / ] drive · Esc quit use std::io::Write; use std::path::PathBuf; use std::process::{Child, ChildStdin, Command, Stdio}; +use std::sync::OnceLock; +use std::time::Duration; 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::core::{RenderContext, Visualizer}; +use audio_visualizer::viz::fingerprint::{self, Accum as FpAccum}; +use audio_visualizer::viz::monolith::Monolith; use audio_visualizer::viz::palette::Palette; -use audio_visualizer::viz::post::{Post, ADDITIVE}; -use audio_visualizer::viz::scope::Scope; -use audio_visualizer::viz::sigil::Sigil; +use clap::{Parser, ValueEnum}; +use nannou::app::LoopMode; use nannou::prelude::*; +/// Parsed CLI, stashed by `main` so `model` (whose signature can't carry +/// data) can layer flags over the cfg without re-parsing. +static CLI: OnceLock = OnceLock::new(); + // Hybrid-graphics laptops (Intel/AMD iGPU + NVIDIA/AMD dGPU): export the // vendor opt-in symbols so the driver hands this *process* the discrete GPU -// (RTX 3050) before any wgpu adapter request. This is the driver-level -// guarantee; the wgpu `HighPerformance` hint + non-GL backends below are the -// API-level half. `--export-all-symbols` (set for the windows target in -// .cargo/config.toml) puts these in the PE export table where the driver -// looks. Last-resort manual override: NVIDIA Control Panel → set sigil.exe to -// "High-performance NVIDIA processor". +// before any wgpu adapter request. Driver-level half; the wgpu +// `HighPerformance` hint + non-GL backends below are the API-level half. #[cfg(windows)] #[unsafe(no_mangle)] #[used] @@ -49,13 +59,14 @@ pub static NvOptimusEnablement: u32 = 1; #[used] pub static AmdPowerXpressRequestHighPerformance: i32 = 1; -const W: f32 = 1080.0; -const H: f32 = 1080.0; -const RENDER_FPS: f32 = 30.0; +/// Default `--render` output fps. Override via `--render-fps` or cfg key. +const DEFAULT_RENDER_FPS: f32 = 30.0; const SEED: u64 = 0x5C1_6E1_5EED; -// x264 speed/quality preset. Copy so `Gains` stays Copy. -#[derive(Clone, Copy, PartialEq)] +/// x264 speed/quality preset. Copy so `Gains` stays Copy. `ValueEnum` so it +/// is selectable as `--x264-preset ` on the CLI. +#[derive(Clone, Copy, PartialEq, ValueEnum)] +#[value(rename_all = "lowercase")] enum Preset { Ultrafast, Veryfast, @@ -95,36 +106,203 @@ impl Preset { } } +/// Visualiser mode. Selectable via `--mode`, the `sigil.cfg` `mode=` key, +/// or the live `M` key (cycles in this enum's declaration order). +#[derive(Clone, Copy, PartialEq, ValueEnum)] +#[value(rename_all = "lowercase")] +enum VisMode { + Breakcore, + Monolith, +} + +impl VisMode { + fn as_str(self) -> &'static str { + match self { + VisMode::Breakcore => "breakcore", + VisMode::Monolith => "monolith", + } + } + fn parse(s: &str) -> Option { + Some(match s { + "breakcore" => VisMode::Breakcore, + "monolith" => VisMode::Monolith, + _ => return None, + }) + } + /// Next mode in cycle order (M key). + fn next(self) -> Self { + match self { + VisMode::Breakcore => VisMode::Monolith, + VisMode::Monolith => VisMode::Breakcore, + } + } +} + +/// Quality preset: bundles separate live/render sizes, the internal +/// supersample multiplier, and the breakcore march-step budget. Any explicit +/// cfg key OR CLI flag still overrides the value the preset would set. +#[derive(Clone, Copy, PartialEq, ValueEnum)] +#[value(rename_all = "lowercase")] +enum Quality { + Laptop, + Desktop, + Ultra, +} + +impl Quality { + fn as_str(self) -> &'static str { + match self { + Quality::Laptop => "laptop", + Quality::Desktop => "desktop", + Quality::Ultra => "ultra", + } + } + fn parse(s: &str) -> Option { + Some(match s { + "laptop" => Quality::Laptop, + "desktop" => Quality::Desktop, + "ultra" => Quality::Ultra, + _ => return None, + }) + } + /// `(live_w, live_h, render_w, render_h, supersample, march_cap)`. + /// laptop/desktop request ≤40 march steps (no practical change vs. the + /// old fixed cap); ultra goes up to the shader's absolute 96 ceiling. + fn cfg(self) -> (u32, u32, u32, u32, f32, u32) { + match self { + Quality::Laptop => (960, 540, 1280, 720, 1.0, 40), + Quality::Desktop => (1280, 720, 1920, 1080, 1.0, 40), + Quality::Ultra => (1600, 900, 2560, 1440, 1.5, 96), + } + } +} + +/// CLI — flags layer on top of `sigil.cfg` (defaults → cfg preset → cfg +/// explicit keys → `--quality` re-applies its preset → explicit CLI flags). +/// `--quality ultra` plus `--render-w 3440` therefore gives the ultra preset +/// at an ultrawide width regardless of what `sigil.cfg` said. +#[derive(Parser)] +#[command( + version, + about = "audio-reactive breakcore visualiser", + long_about = None, +)] +struct Cli { + /// Visualiser mode. Cycles live with `M`. + #[arg(long, value_enum)] + mode: Option, + + /// Quality preset (bundles live/render size, supersample, march budget). + #[arg(long, value_enum)] + quality: Option, + + /// Live window width (px, even). + #[arg(long)] + live_w: Option, + /// Live window height (px, even). + #[arg(long)] + live_h: Option, + /// Render output width (px, even). + #[arg(long)] + render_w: Option, + /// Render output height (px, even). + #[arg(long)] + render_h: Option, + /// Internal raymarch buffer multiplier (1.0..3.0). + #[arg(long)] + supersample: Option, + /// breakcore march-step ceiling (16..96 — > 40 is the heavy-GPU path). + #[arg(long)] + march_cap: Option, + /// Live update/render cap fps (vsync-friendly; 15..240). + #[arg(long)] + target_fps: Option, + + /// `--render` output fps (24..120; default 30). Affects both the encoded + /// stream rate and the Timeline frame->audio-time mapping, so a 60 fps + /// render is twice the frames of the same track at 30. + #[arg(long)] + render_fps: Option, + + /// x264 crf (lower = bigger/better; 0..51). + #[arg(long)] + crf: Option, + /// x264 speed/quality preset. + #[arg(long, value_enum)] + x264_preset: Option, + /// Disable the in-shader phosphor feedback. + #[arg(long)] + no_feedback: bool, + + /// Offline render: decode the whole file, render every frame at + /// `render_fps` into a long-lived ffmpeg. `` must be a file. + #[arg(long)] + render: bool, + /// `--render` output path (default: `.mp4`). + #[arg(long)] + out: Option, + /// Headless smoke test: decode + analyse and print timeline stats. + /// No window / GPU / audio device. `` must be a file. + #[arg(long)] + analyze: bool, + + /// Audio source: file path, `monitor`, `loopback`, or numeric device + /// index. Omitted = default capture device (live mode only). + source: Option, +} + +/// Round down to an even value (x264 yuv420p needs even dimensions), min 2. +fn even(n: u32) -> u32 { + (n & !1).max(2) +} + // Runtime-tunable, persisted to /sigil.cfg. #[derive(Clone, Copy)] struct Gains { - low: f32, // low level -> breathing scale - warp: f32, // organic noise-warp amplitude multiplier - fade: f32, // feedback decay per frame (0 endless .. 1 none) - zoom: f32, // feedback bloom expansion (~1.006) - ca: f32, // chromatic aberration px at full broadband flux - drive: f32, // breakcore expressive-effect master (heat/glitch/swirl) - seg: usize, // Catmull-Rom samples per control segment (quality) - glow: bool, // faux-glow halo passes - feedback: bool, // feedback/bloom post (vs. direct draw) - out_scale: u32, // --render: output square px (0 = native RES, no rescale) - crf: u32, // --render: x264 crf (lower = bigger/better; ~16..28) - x264: Preset, // --render: x264 speed/quality preset + // visualiser mode (live `M` cycles) + mode: VisMode, + // breakcore expressive effect (live `1/2 3/4 5/6 9/0 [ ]` + `B`) + low: f32, // low level -> breathing scale + warp: f32, // organic noise-warp amplitude multiplier + fade: f32, // feedback decay per frame (0 endless .. 1 none) + ca: f32, // chromatic aberration px at full broadband flux + drive: f32, // expressive-effect master (heat/glitch/swirl) + feedback: bool, // in-shader phosphor feedback + // quality / resolution + quality: Quality, + live_w: u32, + live_h: u32, + render_w: u32, + render_h: u32, + supersample: f32, // internal raymarch buffer = out · supersample + march_cap: u32, // breakcore march-step ceiling (≤ shader cap 96) + target_fps: f32, // live update/render cap (vsync-friendly) + render_fps: f32, // --render output fps (24..120) + // --render encode + crf: u32, // x264 crf (lower = bigger/better; ~16..28) + x264: Preset, // x264 speed/quality preset } impl Default for Gains { fn default() -> Self { + let (lw, lh, rw, rh, ss, mc) = Quality::Desktop.cfg(); Gains { + mode: VisMode::Breakcore, low: 0.85, warp: 1.0, fade: 0.11, - zoom: 1.006, ca: 7.0, drive: 1.0, - seg: 9, - glow: true, feedback: true, - out_scale: 1080, + quality: Quality::Desktop, + live_w: lw, + live_h: lh, + render_w: rw, + render_h: rh, + supersample: ss, + march_cap: mc, + target_fps: 60.0, + render_fps: DEFAULT_RENDER_FPS, crf: 18, x264: Preset::Slow, } @@ -137,49 +315,86 @@ impl Gains { let Ok(txt) = std::fs::read_to_string(path) else { return g; }; - for line in txt.lines() { - let Some((k, v)) = line.split_once('=') else { - continue; - }; - let (k, v) = (k.trim(), v.trim()); - match k { + let pairs: Vec<(String, String)> = txt + .lines() + .filter_map(|l| { + l.split_once('=') + .map(|(k, v)| (k.trim().to_string(), v.trim().to_string())) + }) + .collect(); + + // Preset first, regardless of where `quality=` sits in the file, so + // explicit keys below always win over the preset's values. + if let Some(q) = pairs + .iter() + .find(|(k, _)| k == "quality") + .and_then(|(_, v)| Quality::parse(v)) + { + g.quality = q; + let (lw, lh, rw, rh, ss, mc) = q.cfg(); + g.live_w = lw; + g.live_h = lh; + g.render_w = rw; + g.render_h = rh; + g.supersample = ss; + g.march_cap = mc; + } + for (k, v) in &pairs { + match k.as_str() { + "quality" => {} // applied above + "mode" => g.mode = VisMode::parse(v).unwrap_or(g.mode), "low" => g.low = v.parse().unwrap_or(g.low), "warp" => g.warp = v.parse().unwrap_or(g.warp), "fade" => g.fade = v.parse().unwrap_or(g.fade), - "zoom" => g.zoom = v.parse().unwrap_or(g.zoom), "ca" => g.ca = v.parse().unwrap_or(g.ca), "drive" => g.drive = v.parse().unwrap_or(g.drive), - "seg" => g.seg = v.parse().unwrap_or(g.seg), - "glow" => g.glow = v.parse().unwrap_or(g.glow), "feedback" => g.feedback = v.parse().unwrap_or(g.feedback), - "out_scale" => g.out_scale = v.parse().unwrap_or(g.out_scale), + "live_w" => g.live_w = v.parse().unwrap_or(g.live_w), + "live_h" => g.live_h = v.parse().unwrap_or(g.live_h), + "render_w" => g.render_w = v.parse().unwrap_or(g.render_w), + "render_h" => g.render_h = v.parse().unwrap_or(g.render_h), + "supersample" => g.supersample = v.parse().unwrap_or(g.supersample), + "march_cap" => g.march_cap = v.parse().unwrap_or(g.march_cap), + "target_fps" => g.target_fps = v.parse().unwrap_or(g.target_fps), + "render_fps" => g.render_fps = v.parse().unwrap_or(g.render_fps), "crf" => g.crf = v.parse().unwrap_or(g.crf), "x264_preset" => g.x264 = Preset::parse(v).unwrap_or(g.x264), _ => {} } } - g.seg = g.seg.clamp(2, 24); g.drive = g.drive.clamp(0.0, 3.0); + g.supersample = g.supersample.clamp(1.0, 3.0); + g.march_cap = g.march_cap.clamp(16, 96); + g.target_fps = g.target_fps.clamp(15.0, 240.0); + g.render_fps = g.render_fps.clamp(24.0, 120.0); g.crf = g.crf.clamp(0, 51); - if g.out_scale != 0 { - g.out_scale &= !1; // x264 yuv420p needs even dimensions - } + g.live_w = even(g.live_w.clamp(64, 7680)); + g.live_h = even(g.live_h.clamp(64, 7680)); + g.render_w = even(g.render_w.clamp(64, 7680)); + g.render_h = even(g.render_h.clamp(64, 7680)); g } fn save(&self, path: &PathBuf) { let s = format!( - "low={}\nwarp={}\nfade={}\nzoom={}\nca={}\ndrive={}\nseg={}\nglow={}\nfeedback={}\n\ - out_scale={}\ncrf={}\nx264_preset={}\n", + "mode={}\nquality={}\nlow={}\nwarp={}\nfade={}\nca={}\ndrive={}\nfeedback={}\n\ + live_w={}\nlive_h={}\nrender_w={}\nrender_h={}\nsupersample={}\n\ + march_cap={}\ntarget_fps={}\nrender_fps={}\ncrf={}\nx264_preset={}\n", + self.mode.as_str(), + self.quality.as_str(), self.low, self.warp, self.fade, - self.zoom, self.ca, self.drive, - self.seg, - self.glow, self.feedback, - self.out_scale, + self.live_w, + self.live_h, + self.render_w, + self.render_h, + self.supersample, + self.march_cap, + self.target_fps, + self.render_fps, self.crf, self.x264.as_str(), ); @@ -187,6 +402,15 @@ impl Gains { println!("wrote {}", path.display()); } } + /// Internal raymarch buffer size for `(out_w, out_h)` at this supersample, + /// forced even and clamped sane. + fn internal(&self, out_w: u32, out_h: u32) -> (u32, u32) { + let ss = self.supersample.clamp(1.0, 3.0); + ( + even(((out_w as f32 * ss).round() as u32).clamp(64, 7680)), + even(((out_h as f32 * ss).round() as u32).clamp(64, 7680)), + ) + } } enum Mode { @@ -195,15 +419,14 @@ enum Mode { tl: Timeline, frame: u64, total: u64, - ff: Child, // long-lived ffmpeg encoder - stdin: Option, // raw-frame pipe (None once closed) + ff: Child, // long-lived ffmpeg encoder + stdin: Option, // raw-frame pipe (None once closed) out: PathBuf, }, } struct Model { visual: Box, - post: Post, mode: Mode, g: Gains, cfg: PathBuf, @@ -211,18 +434,35 @@ struct Model { fullscreen: bool, ca_env: f32, last: Bands, // for the HUD + rw: u32, // internal raymarch target width + rh: u32, // internal raymarch target height + /// Deterministic file-derived seed (no wall-clock). Used by `M`-cycle + /// to reconstruct a fresh visualiser at a stable per-track seed. + det_seed: u64, + /// Live-mode fingerprint accumulator (`None` in `--render`, which + /// pre-commits from the timeline before the first frame). + fp_accum: Option, + /// Deferred-quit flag. `app.quit()` from inside `update` racewith + /// nannou's `windows` RefCell (it `borrow_mut`s; nannou already holds a + /// borrow during callback dispatch — `App::quit()` at `app.rs:935` + /// panics "RefCell already borrowed" if called mid-body). Set this from + /// any branch that wants to quit; the top of the next `update` calls + /// `app.quit()` cleanly, before any other borrow is taken. + quit_pending: bool, } fn main() { + let cli = Cli::parse(); + // Headless diagnostic: decode + analyse a file and print timeline stats. - // No window/GPU/audio device. Useful for validating the analysis path. + // No window/GPU/audio device. Validates the analysis path. // sigil --analyze - let args: Vec = std::env::args().collect(); - if let Some(i) = args.iter().position(|a| a == "--analyze") { - let f = args - .get(i + 1) + if cli.analyze { + let f = cli + .source + .as_deref() .map(PathBuf::from) - .unwrap_or_else(|| die("--analyze needs a file path")); + .unwrap_or_else(|| die("--analyze needs a file path (positional )")); match audio::analyze_file(&f) { Ok(tl) => { let mut peak = Bands::default(); @@ -276,9 +516,12 @@ fn main() { return; } + // Stash for `model` (whose fn-pointer signature can't carry data). + CLI.set(cli).ok(); + // Drop GL from the default backend set: on Optimus laptops the GL path // commonly resolves to the Intel iGPU. Vulkan/DX12 (PRIMARY) + the - // HighPerformance window hint land on the RTX 3050. + // HighPerformance window hint land on the discrete GPU. nannou::app(model) .backends(nannou::wgpu::Backends::PRIMARY) .update(update) @@ -302,86 +545,152 @@ fn fnv1a(s: &str) -> u64 { } fn model(app: &App) -> Model { + // The render flag + chosen source must be known *before* the window is + // built, so size / present mode / loop mode can be mode-specific. clap + // has already parsed them (and stashed `Cli` in `CLI` from `main`). + let cli = CLI.get().expect("CLI not initialised before model()"); + let render = cli.render; + + // Config first — defaults → cfg `quality=` preset → cfg explicit keys. + let cfg = app + .project_path() + .map(|p| p.join("sigil.cfg")) + .unwrap_or_else(|_| PathBuf::from("sigil.cfg")); + let mut g = Gains::load(&cfg); + + // CLI overlay: a CLI `--quality` re-applies its preset (stomping any cfg + // explicit res keys), then individual `--render-w` / `--march-cap` / etc + // win over both. So `--quality ultra --render-w 3440` gives the ultra + // budget at an ultrawide width regardless of what `sigil.cfg` said. + if let Some(m) = cli.mode { + g.mode = m; + } + if let Some(q) = cli.quality { + g.quality = q; + let (lw, lh, rw, rh, ss, mc) = q.cfg(); + g.live_w = lw; + g.live_h = lh; + g.render_w = rw; + g.render_h = rh; + g.supersample = ss; + g.march_cap = mc; + } + if let Some(v) = cli.live_w { + g.live_w = v; + } + if let Some(v) = cli.live_h { + g.live_h = v; + } + if let Some(v) = cli.render_w { + g.render_w = v; + } + if let Some(v) = cli.render_h { + g.render_h = v; + } + if let Some(v) = cli.supersample { + g.supersample = v; + } + if let Some(v) = cli.march_cap { + g.march_cap = v; + } + if let Some(v) = cli.target_fps { + g.target_fps = v; + } + if let Some(v) = cli.render_fps { + g.render_fps = v; + } + if let Some(v) = cli.crf { + g.crf = v; + } + if let Some(p) = cli.x264_preset { + g.x264 = p; + } + if cli.no_feedback { + g.feedback = false; + } + // Re-apply the same clamps `Gains::load` runs (CLI bypassed them). + g.supersample = g.supersample.clamp(1.0, 3.0); + g.march_cap = g.march_cap.clamp(16, 96); + g.target_fps = g.target_fps.clamp(15.0, 240.0); + g.render_fps = g.render_fps.clamp(24.0, 120.0); + g.crf = g.crf.clamp(0, 51); + g.live_w = even(g.live_w.clamp(64, 7680)); + g.live_h = even(g.live_h.clamp(64, 7680)); + g.render_w = even(g.render_w.clamp(64, 7680)); + g.render_h = even(g.render_h.clamp(64, 7680)); + + // Output dimensions for the active mode; the window matches them. + let (out_w, out_h) = if render { + (g.render_w, g.render_h) + } else { + (g.live_w, g.live_h) + }; + let (rw, rh) = g.internal(out_w, out_h); + + // Live: vsync-friendly Fifo present + an update/render cap so the scene + // isn't produced far faster than it's shown. Render: Immediate present so + // the offline encode isn't refresh-bound (keeps "no frame budget"). + let present = if render { + nannou::wgpu::PresentMode::Immediate + } else { + nannou::wgpu::PresentMode::Fifo + }; if let Err(e) = app .new_window() - .size(W as u32, H as u32) - .title("living sigil") + .size(out_w, out_h) + .title(g.mode.as_str()) .power_preference(nannou::wgpu::PowerPreference::HighPerformance) + .surface_conf_builder( + nannou::window::SurfaceConfigurationBuilder::new().present_mode(present), + ) .view(view) .key_pressed(key_pressed) .build() { die(format!("window: {e}")); } - - // Parse args: - // [--render [out.mp4]] [--mode sigil|scope] [|monitor|loopback|] - let args: Vec = std::env::args().skip(1).collect(); - let render = args.iter().any(|a| a == "--render"); - let mode_sel = args - .iter() - .position(|a| a == "--mode") - .and_then(|i| args.get(i + 1)) - .cloned(); - // Drop the flags (and --mode's value) so what remains is [out.mp4?] . - let rest: Vec<&String> = { - let mut skip = false; - args.iter() - .filter(|a| { - if skip { - skip = false; - return false; - } - match a.as_str() { - "--render" => false, - "--mode" => { - skip = true; - false - } - _ => true, - } - }) - .collect() - }; - - // Config (and thus the render encode settings) must load before we spawn - // ffmpeg for a render. - let cfg = app - .project_path() - .map(|p| p.join("sigil.cfg")) - .unwrap_or_else(|_| PathBuf::from("sigil.cfg")); - let g = Gains::load(&cfg); + if !render { + app.set_loop_mode(LoopMode::Rate { + update_interval: Duration::from_secs_f32(1.0 / g.target_fps.clamp(15.0, 240.0)), + }); + } let mode = if render { - // rest = [out.mp4?] . Last arg must be the audio file. - let file = rest - .last() - .map(|s| PathBuf::from(s.as_str())) + // `` is the audio file; `--out` is the (optional) mp4 path. + let file = cli + .source + .as_deref() + .map(PathBuf::from) .filter(|p| p.exists()) - .unwrap_or_else(|| die("--render needs an existing audio file as last arg")); - let out = if rest.len() >= 2 { - PathBuf::from(rest[0].as_str()) - } else { - file.with_extension("mp4") - }; + .unwrap_or_else(|| die("--render needs an existing audio file as ")); + let out = cli + .out + .clone() + .unwrap_or_else(|| file.with_extension("mp4")); eprintln!("analysing {} ...", file.display()); let tl = audio::analyze_file(&file).unwrap_or_else(|e| die(format!("analyze: {e}"))); - let total = (tl.duration() * RENDER_FPS).ceil() as u64; - let res = Post::res() as u32; - let scaled = g.out_scale != 0 && g.out_scale != res; + let fps = g.render_fps; + let total = (tl.duration() * fps).ceil() as u64; + let scaled = (rw, rh) != (g.render_w, g.render_h); eprintln!( - "render: {:.1}s, {} frames @ {} fps -> {} ({}p, crf {}, {})", + "render: {:.1}s, {} frames @ {} fps -> {} ({}x{} internal {}x{}, {} crf {}, march≤{}, {})", tl.duration(), total, - RENDER_FPS as u32, + fps as u32, out.display(), - if scaled { g.out_scale } else { res }, + g.render_w, + g.render_h, + rw, + rh, + g.quality.as_str(), g.crf, + g.march_cap, g.x264.as_str(), ); // One long-lived ffmpeg: raw RGBA frames in over stdin, original audio - // muxed from the file. No PNG sequence / temp dir. + // muxed from the file. The internal supersample buffer is scaled down + // to the final render size (the downscale *is* the anti-aliasing). let mut cmd = Command::new("ffmpeg"); cmd.args([ "-y", @@ -390,16 +699,19 @@ fn model(app: &App) -> Model { "-pix_fmt", "rgba", "-s", - &format!("{res}x{res}"), + &format!("{rw}x{rh}"), "-r", - &format!("{}", RENDER_FPS as u32), + &format!("{}", fps as u32), "-i", "-", ]); cmd.arg("-i").arg(&file); cmd.args(["-map", "0:v:0", "-map", "1:a:0"]); if scaled { - cmd.args(["-vf", &format!("scale={s}:{s}", s = g.out_scale)]); + cmd.args([ + "-vf", + &format!("scale={w}:{h}:flags=lanczos", w = g.render_w, h = g.render_h), + ]); } cmd.args([ "-c:v", @@ -430,7 +742,7 @@ fn model(app: &App) -> Model { out, } } else { - let src = match rest.first().map(|s| s.as_str()) { + let src = match cli.source.as_deref() { None => Source::Capture(None), Some("monitor") => Source::CaptureNamed("monitor".into()), Some("loopback") => Source::CaptureNamed("loopback".into()), @@ -445,30 +757,42 @@ fn model(app: &App) -> Model { // Mix the audio-file name into the seed so each track looks distinct by // default (R still reseeds; capture sources keep the constant seed). let src_name: Option<&str> = if render { - rest.last().map(|s| s.as_str()) + cli.source.as_deref() } else { - match rest.first().map(|s| s.as_str()) { + match cli.source.as_deref() { Some("monitor") | Some("loopback") | None => None, Some(a) if a.parse::().is_ok() => None, Some(a) => Some(a), } }; - let seed = SEED ^ src_name.map(fnv1a).unwrap_or(0); - - let visual: Box = match mode_sel.as_deref() { - Some("scope") => Box::new(Scope::new(seed)), - Some("breakcore") => { - Box::new(Breakcore::new(seed, app.main_window().device())) - } - Some("sigil") | None => Box::new(Sigil::new(seed)), - Some(other) => die(format!("unknown --mode {other:?} (sigil|scope|breakcore)")), + let det_seed = SEED ^ src_name.map(fnv1a).unwrap_or(0); + // Per-run novelty: monolith in live mode mixes wall-clock ns so each run + // picks a fresh camera start / swirl phase even for the same file. + // `--render` keeps the deterministic seed so the bit-reproducibility + // invariant survives (`R` can still reseed at runtime). + let seed = if g.mode == VisMode::Monolith && !render { + det_seed ^ wallclock_seed(app) + } else { + det_seed }; - let post = Post::new(app.main_window().device()); + let visual = build_visualizer(g.mode, seed, app.main_window().device(), rw, rh); + + // Fingerprint plumbing: + // render — fold the timeline once, install before the first frame so + // the visual is committed the moment encoding begins. + // live — start an online accumulator; commit when ready. + let mut visual = visual; + let fp_accum: Option = if let Mode::Render { tl, .. } = &mode { + let fp = fingerprint::from_timeline(tl); + visual.install_fingerprint(fp); + None + } else { + Some(FpAccum::new(5.0)) + }; Model { visual, - post, mode, g, cfg, @@ -476,9 +800,40 @@ fn model(app: &App) -> Model { fullscreen: false, ca_env: 0.0, last: Bands::default(), + rw, + rh, + det_seed, + fp_accum, + quit_pending: false, } } +/// Build a `Box` for the requested mode. Used both at +/// startup and by the live `M`-cycle (which drops the old visualiser and +/// installs a fresh one of the next mode at the current size/seed). +fn build_visualizer( + mode: VisMode, + seed: u64, + device: &nannou::wgpu::Device, + rw: u32, + rh: u32, +) -> Box { + match mode { + VisMode::Breakcore => Box::new(Breakcore::new(seed, device, rw, rh)), + VisMode::Monolith => Box::new(Monolith::new(seed, device, rw, rh)), + } +} + +/// Wall-clock seed for live monolith's "new every run" requirement. +/// Mixes nanoseconds-since-app-start with a fixed odd constant — XOR'd onto +/// the deterministic file-derived seed so the same file looks different per +/// invocation but the chosen *form* still tracks the song fingerprint. +fn wallclock_seed(app: &App) -> u64 { + (app.duration.since_start.as_nanos() as u64) + .wrapping_mul(0x9E37_79B9_7F4A_7C15) + ^ 0xD1B5_4A32_D192_ED03 +} + fn key_pressed(app: &App, m: &mut Model, key: Key) { let g = &mut m.g; match key { @@ -487,36 +842,23 @@ fn key_pressed(app: &App, m: &mut Model, key: Key) { m.visual.reseed(s); println!("seed = {:#018x}", s); } - Key::M => { - // Mode switch only makes sense live; a render is a fixed pass. - if matches!(m.mode, Mode::Live(_)) { - m.visual = next_visual(m.visual.as_ref(), app.main_window().device()); - println!("mode = {}", m.visual.name()); - } - } Key::P => { let path = app .project_path() .map(|p| p.join(format!("{}_{:016x}.png", m.visual.name(), m.visual.seed()))) - .unwrap_or_else(|_| PathBuf::from("sigil.png")); + .unwrap_or_else(|_| PathBuf::from("breakcore.png")); let window = app.main_window(); let (device, queue) = (window.device(), window.queue()); - // Breakcore bypasses Post — grab its own raymarch target, not the - // (never-written) Post accumulator. - let res = Post::res() as u32; - let saved = if m.visual.is_gpu() { - m.visual - .capture_raw(device, queue) - .unwrap_or_else(|| Err(anyhow::anyhow!("gpu visual produced no frame"))) - .and_then(|px| { - nannou::image::RgbaImage::from_raw(res, res, px) - .ok_or_else(|| anyhow::anyhow!("image buffer size mismatch"))? - .save(&path) - .map_err(Into::into) - }) - } else { - m.post.capture_png(device, queue, &path) - }; + let saved = m + .visual + .capture_raw(device, queue) + .unwrap_or_else(|| Err(anyhow::anyhow!("visual produced no frame"))) + .and_then(|px| { + nannou::image::RgbaImage::from_raw(m.rw, m.rh, px) + .ok_or_else(|| anyhow::anyhow!("image buffer size mismatch"))? + .save(&path) + .map_err(Into::into) + }); match saved { Ok(()) => println!("saved {}", path.display()), Err(e) => eprintln!("save failed: {e}"), @@ -527,21 +869,39 @@ fn key_pressed(app: &App, m: &mut Model, key: Key) { app.main_window().set_fullscreen(m.fullscreen); } Key::H => m.hud = !m.hud, - Key::G => g.glow = !g.glow, Key::B => g.feedback = !g.feedback, Key::C => g.save(&m.cfg), + Key::M => { + // Cycle visualiser modes live. Render mode locks in its mode at + // startup because the encoder's already running — switching + // mid-encode would mix two visual styles in one file. + if matches!(m.mode, Mode::Render { .. }) { + eprintln!("M ignored: --render is single-mode per invocation"); + return; + } + g.mode = g.mode.next(); + let seed = if g.mode == VisMode::Monolith { + m.det_seed ^ wallclock_seed(app) + } else { + m.det_seed + }; + let window = app.main_window(); + let device = window.device(); + m.visual = build_visualizer(g.mode, seed, device, m.rw, m.rh); + // Re-arm a fresh live accumulator; the new mode may or may not + // use it, but the warmup window is short enough to be cheap. + m.fp_accum = Some(FpAccum::new(5.0)); + window.set_title(g.mode.as_str()); + println!("mode = {}", g.mode.as_str()); + } Key::Key1 => g.low = (g.low - 0.1).max(0.0), Key::Key2 => g.low += 0.1, Key::Key3 => g.warp = (g.warp - 0.1).max(0.0), Key::Key4 => g.warp += 0.1, Key::Key5 => g.fade = (g.fade - 0.02).max(0.0), Key::Key6 => g.fade = (g.fade + 0.02).min(1.0), - Key::Key7 => g.zoom = (g.zoom - 0.002).max(1.0), - Key::Key8 => g.zoom += 0.002, Key::Key9 => g.ca = (g.ca - 1.0).max(0.0), Key::Key0 => g.ca += 1.0, - Key::Minus => g.seg = g.seg.saturating_sub(1).max(2), - Key::Equals => g.seg = (g.seg + 1).min(24), Key::LBracket => g.drive = (g.drive - 0.1).max(0.0), Key::RBracket => g.drive = (g.drive + 0.1).min(3.0), _ => {} @@ -549,6 +909,12 @@ fn key_pressed(app: &App, m: &mut Model, key: Key) { } fn update(app: &App, m: &mut Model, upd: Update) { + // Drain any pending quit *first*, before any other RefCell-held state in + // this update. See `Model::quit_pending` for why this is deferred. + if m.quit_pending { + app.quit(); + return; + } // Pull this frame's analysis + the simulation time step. let (b, dt) = match &mut m.mode { Mode::Live(h) => (h.bands(), upd.since_last.as_secs_f32().clamp(0.0, 0.05)), @@ -556,24 +922,43 @@ fn update(app: &App, m: &mut Model, upd: Update) { tl, frame, total, .. } => { if *frame >= *total { - // All frames piped — quit; on_exit closes the pipe so ffmpeg - // flushes its trailer and we wait on it there. - app.quit(); + // All frames piped — defer quit so on_exit closes the pipe, + // ffmpeg flushes its trailer, and we wait on it there. + m.quit_pending = true; return; } - let t = *frame as f32 / RENDER_FPS; + let fps = m.g.render_fps; + let t = *frame as f32 / fps; *frame += 1; - (tl.at(t), 1.0 / RENDER_FPS) + (tl.at(t), 1.0 / fps) } }; m.last = b; + // Live fingerprint accumulation: push every frame, commit when the + // accumulator is ready and the visualiser hasn't already committed. + // Render path pre-installed a timeline-derived fingerprint in `model`. + if let Some(acc) = m.fp_accum.as_mut() { + acc.push(&b, dt); + if acc.ready() && !m.visual.fingerprint_ready() { + let fp = acc.snapshot(); + m.visual.install_fingerprint(fp); + println!( + "fingerprint: centroid={:.2} chroma={} tonal={:.2} dyn={:.2} bpm_cls={:.2}", + fp.centroid_mean, + fp.chroma_dom, + fp.tonality, + fp.dyn_range, + fp.tempo_class, + ); + } + } + // Audio-driven motion. The visual's own update grows/morphs/restructures. m.visual.update(&b, dt); m.ca_env += (b.flux - m.ca_env) * if b.flux > m.ca_env { 0.55 } else { 0.10 }; let pal = Palette::from_audio(&b); - let fit = Post::res() / W; let scale = 1.0 + (b.low * m.g.low).min(0.9) + b.low_on * 0.4; let warp = m.g.warp * (5.0 + 24.0 * b.mid + 13.0 * b.low); let ca_px = (m.g.ca * m.ca_env).min(28.0); @@ -584,55 +969,22 @@ fn update(app: &App, m: &mut Model, upd: Update) { 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, + march_cap: m.g.march_cap, }; - - if m.visual.is_gpu() { - // Breakcore renders through its own raymarch pipeline; no Draw/Post. - m.visual.render_gpu(device, queue, &ctx); - } else { - // Build the scene off-screen, then push it through the feedback chain. - let scene = Draw::new(); - if ca_px > 0.4 { - // Per-channel offset passes, summed additively -> RGB split fringe. - let add = scene.color_blend(ADDITIVE); - for (tint, dx) in [ - ([1.0, 0.0, 0.0], -ca_px), - ([0.0, 1.0, 0.0], 0.0), - ([0.0, 0.0, 1.0], ca_px), - ] { - let d = add.xy(vec2(dx, 0.0)); - m.visual.draw(&d, &RenderContext { tint, ..ctx }); - } - } else { - m.visual.draw(&scene, &ctx); - } - if m.g.feedback { - m.post - .render(device, queue, &scene, pal.bg(), m.g.fade, m.g.zoom); - } else { - m.post.render_direct(device, queue, &scene); - } - } + m.visual.render_gpu(device, queue, &ctx); // 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) - .unwrap_or_else(|| Err(anyhow::anyhow!("gpu visual produced no frame"))) - } else { - m.post.capture_raw(device, queue) - }; + let cap = m + .visual + .capture_raw(device, queue) + .unwrap_or_else(|| Err(anyhow::anyhow!("visual produced no frame"))); match cap { Ok(buf) => { if let Mode::Render { stdin, .. } = &mut m.mode { @@ -640,7 +992,7 @@ fn update(app: &App, m: &mut Model, upd: Update) { if let Err(e) = si.write_all(&buf) { eprintln!("ffmpeg pipe broke ({e}); finalising early"); *stdin = None; - app.quit(); + m.quit_pending = true; } } } @@ -653,59 +1005,51 @@ fn update(app: &App, m: &mut Model, upd: Update) { fn view(app: &App, m: &Model, frame: Frame) { let win = app.window_rect(); let bg = match &m.mode { - Mode::Render { tl, frame: f, .. } => Palette::from_audio(&tl.at(*f as f32 / RENDER_FPS)), + Mode::Render { tl, frame: f, .. } => { + Palette::from_audio(&tl.at(*f as f32 / m.g.render_fps)) + } _ => Palette::from_audio(&m.last), } .bg(); let d = app.draw(); d.background().color(srgb(bg[0], bg[1], bg[2])); - // Present the accumulator over the field, downsampled to the window - // (cheap AA). It is a bounded convex composite, so a plain over-draw is - // 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() - .expect("gpu visual has a current texture") - } else { - m.post.current() - }; - d.texture(tex).w_h(win.w(), win.h()); - - // Subtle vignette: concentric edge-stroked rects darkening outward. - for i in 0..6 { - let inset = i as f32 * 26.0; - let a = (i as f32 / 6.0).powi(2) * 0.5; - d.rect() - .x_y(0.0, 0.0) - .w_h(win.w() - inset, win.h() - inset) - .no_fill() - .stroke_weight(28.0) - .stroke(srgba(bg[0], bg[1], bg[2], a)); + // Present breakcore's own raymarch target, scaled to the window. The + // window matches the output aspect, so this fills it exactly (no + // letterbox); the shader does its own vignette/feedback. + if let Some(tex) = m.visual.current_tex() { + d.texture(tex).w_h(win.w(), win.h()); } if m.hud { let g = m.g; let extra = match &m.mode { - Mode::Render { frame: f, total, .. } => { - format!("RENDER {}/{}", f, total) - } + Mode::Render { frame: f, total, .. } => format!("RENDER {}/{}", f, total), Mode::Live(_) => format!("fps {:.0}", app.fps()), }; + let fp_state = if m.visual.fingerprint_ready() { + "FP locked" + } else { + "FP warming" + }; let txt = format!( - "{} · seed {:#x} · n {}\nlow {:.2} warp {:.2} fade {:.2} bloom {:.3} ca {:.0} drive {:.1} seg {}\nglow {} feedback {} {}", + "{} · seed {:#x} · n {} · {}\n{} {}x{} (internal {}x{} ss{:.2}) march≤{}\nlow {:.2} warp {:.2} fade {:.2} ca {:.0} drive {:.1} feedback {} {}", m.visual.name(), m.visual.seed(), m.visual.element_count(), + fp_state, + g.quality.as_str(), + win.w() as u32, + win.h() as u32, + m.rw, + m.rh, + g.supersample, + g.march_cap, g.low, g.warp, g.fade, - g.zoom, g.ca, g.drive, - g.seg, - g.glow, g.feedback, extra, ); @@ -720,10 +1064,9 @@ fn view(app: &App, m: &Model, frame: Frame) { d.to_frame(app, &frame).unwrap(); } -// Finalise a render: close the frame pipe so ffmpeg flushes its trailer, -// then wait for it. (For Live this is a no-op.) Reached both on normal -// completion and on an early quit, so a partial render still produces a -// playable file. +// Finalise a render: close the frame pipe so ffmpeg flushes its trailer, then +// wait for it. (For Live this is a no-op.) Reached both on normal completion +// and on an early quit, so a partial render still produces a playable file. fn on_exit(_app: &App, m: Model) { let Mode::Render { mut ff, stdin, out, .. diff --git a/src/viz/breakcore.rs b/src/viz/breakcore.rs index 5f636ad..d1125b4 100644 --- a/src/viz/breakcore.rs +++ b/src/viz/breakcore.rs @@ -68,8 +68,10 @@ const N_RIBS: usize = (RIBS1 - RIBS0) / 2; // 8 const N_DEB: usize = (DEB1 - DEB0) / 2; // 4 const N_SPK: usize = (SPK1 - SPK0) / 2; // 3 -/// UBO length in f32: 10 std140 rows (40) + NP·vec4. -const UBO_LEN: usize = 40 + 4 * NP; +/// UBO length in f32: 11 std140 rows (44) + NP·vec4. Row 10 (`p7`) carries +/// the non-square render dimensions + aspect (added when breakcore went +/// arbitrary-aspect); the points still start right after the header. +const UBO_LEN: usize = 44 + 4 * NP; /// Where a parked (inactive) capsule goes — far outside the bounding sphere /// so its closest-approach glow is exactly zero (cheaper than a zero radius, @@ -78,7 +80,6 @@ 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 @@ -252,11 +253,14 @@ pub struct Breakcore { frame: u32, b: Bands, + rw: u32, // raymarch target width (out_w · supersample) + rh: u32, // raymarch target height (out_h · supersample) gpu: ShaderPipeline<[f32; UBO_LEN]>, } impl Breakcore { - pub fn new(seed: u64, device: &wgpu::Device) -> Self { + /// `w`×`h` is the raymarch target size (output × supersample); any aspect. + pub fn new(seed: u64, device: &wgpu::Device, w: u32, h: u32) -> Self { let mut rng = Rng::new(seed ^ 0xB17E_C0DE_B17E_C0DE); let attr = Attr::random(&mut rng); let knot = Knot::random(&mut rng); @@ -289,7 +293,9 @@ impl Breakcore { t: 0.0, frame: 0, b: Bands::default(), - gpu: ShaderPipeline::new(device, include_str!("breakcore.wgsl"), RES, FMT), + rw: w, + rh: h, + gpu: ShaderPipeline::new(device, include_str!("breakcore.wgsl"), w, h, FMT), } } @@ -656,6 +662,7 @@ impl Breakcore { fade: f32, ca_px: f32, drive: f32, + march_cap: u32, ) -> &wgpu::Texture { let pts = self.build_points(); let rg = self.st.arch().regime(); @@ -708,15 +715,16 @@ impl Breakcore { u[13] = acc[1]; u[14] = acc[2]; u[15] = pal.flash; - // row4 res, frame, n_pts, time - u[16] = RES as f32; + // row4 res, frame, n_pts, time (res = vertical px → scanline count) + u[16] = self.rh as f32; u[17] = (self.frame & 0xffff) as f32; u[18] = NP as f32; u[19] = self.t; // row5 march_steps, melt_k, feedback_on, world_r - // Steps are also hard-capped at 40 in the shader; keep this modest — - // cost is O(pixels · steps · NP) and a runaway here is a GPU hang. - u[20] = (22.0 + 9.0 * warp.min(2.0)).clamp(16.0, 40.0); + // Steps are also hard-capped at 96 in the shader; `march_cap` is the + // preset/cfg gate (laptop/desktop request ≤40, ultra up to 96). Cost + // is O(pixels · steps · NP) and a runaway here is a GPU device-lost. + u[20] = (22.0 + 9.0 * warp.min(2.0)).clamp(16.0, march_cap.min(96) as f32); // 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 }; @@ -750,9 +758,16 @@ impl Breakcore { u[37] = ((0.006 * self.b.mid + 0.020 * tn) * dr).clamp(0.0, 0.05); u[38] = (0.05 + 0.40 * self.b.loud).clamp(0.0, 1.0); u[39] = self.b.beat; - // points (after 10 std140 rows = 40 f32) + // row10 res_w, res_h, aspect, _ — the non-square render dims; the + // shader aspect-corrects the ray from `aspect` and decorrelates grain + // with (res_w,res_h). aspect = w/h (>1 landscape, widens horiz FOV). + u[40] = self.rw as f32; + u[41] = self.rh as f32; + u[42] = self.rw as f32 / self.rh.max(1) as f32; + u[43] = 0.0; + // points (after 11 std140 rows = 44 f32) for (i, p) in pts.iter().enumerate() { - let o = 40 + 4 * i; + let o = 44 + 4 * i; u[o] = p[0]; u[o + 1] = p[1]; u[o + 2] = p[2]; @@ -771,7 +786,7 @@ impl Breakcore { device: &wgpu::Device, queue: &wgpu::Queue, ) -> anyhow::Result> { - read_texture_rgba(device, queue, self.gpu.current()) + read_texture_rgba(device, queue, self.gpu.current(), self.rw, self.rh) } } diff --git a/src/viz/breakcore.wgsl b/src/viz/breakcore.wgsl index be461b8..e3c3f2e 100644 --- a/src/viz/breakcore.wgsl +++ b/src/viz/breakcore.wgsl @@ -14,7 +14,9 @@ // Pure function of the uniform block + hash(fragCoord, frame): no wall-clock, // no per-pixel state — so `--render` is bit-reproducible. `NP` (64) MUST // equal `breakcore::NP` in the Rust side; the UBO is a flat f32 layout, each -// field below is one std140 16-byte row (see Breakcore::render in breakcore.rs). +// field below is one std140 16-byte row — 11 header rows (p7 = render +// dimensions + aspect; the target is arbitrary-aspect, the ray is +// aspect-corrected from it) then the points (see Breakcore::render). // // The 64 points are PARTITIONED (coupled to the consts in breakcore.rs): // [0, G_SPINE) connected backbone chain (gid 0) @@ -41,6 +43,7 @@ struct U { p4: vec4, // heat, tension, release, focal p5: vec4, // high_on, flatness, beat_phase, fog p6: vec4, // swirl_zoom, swirl_rot, bg_glow, beat + p7: vec4, // res_w, res_h, aspect (w/h), _ pts: array, 64>, // xyz = point, w = capsule radius }; @@ -177,11 +180,14 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { let bphase = u.p5.z; // beat-phase sawtooth 0..1 let fog = u.p5.w; // volumetric fog density let beat = u.p6.w; // decaying beat pulse → feedback echo + let res_w = u.p7.x; // render target width (px) + let res_h = u.p7.y; // render target height (px) + let aspect = u.p7.z; // w/h — widens the horizontal FOV, no stretch let ndc = vec2(in.uv.x * 2.0 - 1.0, 1.0 - in.uv.y * 2.0); let dist = u.cam.w; let ro = vec3(0.0, 0.0, -dist); - let rd = normalize(vec3(ndc.x, ndc.y, focal)); + let rd = normalize(vec3(ndc.x * aspect, ndc.y, focal)); // Ray vs bounding sphere — discards every background pixel in ~one op, // which is what keeps this from melting the GPU. @@ -196,8 +202,8 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { let sq = sqrt(disc); var t = max(-b - sq, 0.0); let t_end = -b + sq; - let min_step = max(t_end - t, 1e-3) / 40.0; // march always finishes - let steps = min(i32(u.p2.x), 40); + let min_step = max(t_end - t, 1e-3) / 96.0; // march always finishes + let steps = min(i32(u.p2.x), 96); // absolute cap (cfg gate ≤ this) var dmin = 1e9; var hit_t = -1.0; for (var s = 0; s < steps; s = s + 1) { @@ -274,7 +280,7 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { // swap recolours violently but can never white-flash. let pk = max(col.r, max(col.g, col.b)); col = mix(col, accent * pk, trans * 0.6); - col = max(col + (hash21(in.uv * res + vec2(frame, frame * 1.7)) - 0.5) * grain, + col = max(col + (hash21(in.uv * vec2(res_w, res_h) + vec2(frame, frame * 1.7)) - 0.5) * grain, vec3(0.0)); // Phosphor persistence: a *decaying* trail via max() — can never brighten diff --git a/src/viz/core.rs b/src/viz/core.rs index e5f30d2..d6dffed 100644 --- a/src/viz/core.rs +++ b/src/viz/core.rs @@ -1,63 +1,58 @@ //! The visualiser contract: one trait the bin drives, one render context. //! -//! `sigil.rs` no longer hard-codes a `Visual { Sigil, Scope, Breakcore }` -//! enum + an inherent `match` per call. It holds a `Box` and -//! talks to this trait; adding a mode is a new `impl Visualizer` + one arm in -//! [`next_visual`], nothing in the bin's hot path. +//! The bin holds a `Box` and talks to this trait instead of +//! hard-coding a mode. `breakcore` is the only mode today; the trait is kept +//! deliberately general so a future GPU visualiser is a new `impl Visualizer` +//! over the shared aspect-aware [`crate::viz::shader::ShaderPipeline`] base — +//! nothing in the bin's hot path changes. //! -//! 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. +//! Every mode is GPU-driven now (`is_gpu` → true): it owns a wgpu pipeline, +//! renders into its own non-square target and presents/captures it directly. +//! The trait still carries the `Draw` no-op default so a future CPU mode can +//! be added without reshaping the contract. //! -//! 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. +//! Determinism is unaffected: dispatch is pure indirection; the visualiser's +//! `update` still advances its `Rng`/integration once per frame, so `--render` +//! stays bit-reproducible. use crate::audio::Bands; use crate::viz::breakcore::Breakcore; +use crate::viz::fingerprint::Fingerprint; +use crate::viz::monolith::Monolith; 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 }`). +/// borrows the palette). #[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, + /// Hard march-step ceiling for this run (preset/cfg gated, ≤ the shader's + /// own absolute cap). The visualiser must not request more. + pub march_cap: u32, } -/// One visualiser mode. `Draw`-based modes implement [`draw`](Self::draw); -/// a GPU mode sets [`is_gpu`](Self::is_gpu) and implements +/// One visualiser mode. GPU modes set [`is_gpu`](Self::is_gpu) and implement /// [`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`. +/// [`capture_raw`](Self::capture_raw); a future `Draw` mode would implement +/// [`draw`](Self::draw) instead. Object-safe: held as `Box`. pub trait Visualizer { fn name(&self) -> &'static str; fn seed(&self) -> u64; fn reseed(&mut self, seed: u64); fn update(&mut self, b: &Bands, dt: f32); - /// Element count for the HUD (tendrils / beam points / capsules). + /// Element count for the HUD (capsule control points). fn element_count(&self) -> usize; /// `true` ⇒ this mode owns a wgpu pipeline and uses the GPU methods - /// below; `false` ⇒ it draws through `Draw`/`Post`. + /// below; `false` ⇒ it draws through `Draw`. fn is_gpu(&self) -> bool { false } @@ -66,7 +61,7 @@ pub trait Visualizer { 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). + /// `None` for `Draw`-based modes. fn render_gpu( &mut self, _device: &wgpu::Device, @@ -76,8 +71,7 @@ pub trait Visualizer { None } - /// The texture to present this frame (GPU modes only; else `None` and the - /// bin presents the `Post` accumulator). + /// The texture to present this frame (GPU modes only). fn current_tex(&self) -> Option<&wgpu::Texture> { None } @@ -90,47 +84,18 @@ pub trait Visualizer { ) -> Option>> { None } -} -impl Visualizer for Sigil { - fn name(&self) -> &'static str { - "sigil" - } - fn seed(&self) -> u64 { - self.seed - } - fn reseed(&mut self, seed: u64) { - Sigil::reseed(self, seed) - } - fn update(&mut self, b: &Bands, dt: f32) { - Sigil::update(self, b, dt) - } - fn element_count(&self) -> usize { - self.tendril_count() - } - fn draw(&self, d: &Draw, c: &RenderContext) { - Sigil::draw(self, d, c.pal, c.fit, c.scale, c.warp, c.glow, c.seg, c.tint) - } -} + /// Commit a song-fingerprint to this visualiser. Modes that pick their + /// archetype from per-track stats use it; modes that don't ignore it. + /// The bin calls this once in `--render` (pre-computed from the + /// `Timeline`) and once in live as soon as the live accumulator is + /// ready. Default = no-op so existing modes need no edits. + fn install_fingerprint(&mut self, _fp: Fingerprint) {} -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) + /// `true` once the visualiser has committed a fingerprint. Stays `true` + /// for modes that don't use one (their archetype is always "ready"). + fn fingerprint_ready(&self) -> bool { + true } } @@ -161,6 +126,7 @@ impl Visualizer for Breakcore { ) -> Option<&wgpu::Texture> { Some(Breakcore::render( self, device, queue, c.pal, c.scale, c.warp, c.feedback, c.fade, c.ca_px, c.drive, + c.march_cap, )) } fn current_tex(&self) -> Option<&wgpu::Texture> { @@ -175,13 +141,50 @@ impl Visualizer for Breakcore { } } -/// Live `M`-key cycle: Sigil → Scope → Breakcore → Sigil, keeping the seed. -/// `Breakcore` needs the device to (re)build its pipeline. -pub fn next_visual(cur: &dyn Visualizer, device: &wgpu::Device) -> Box { - let s = cur.seed(); - match cur.name() { - "sigil" => Box::new(Scope::new(s)), - "scope" => Box::new(Breakcore::new(s, device)), - _ => Box::new(Sigil::new(s)), +impl Visualizer for Monolith { + fn name(&self) -> &'static str { + "monolith" + } + fn seed(&self) -> u64 { + self.seed + } + fn reseed(&mut self, seed: u64) { + Monolith::reseed(self, seed) + } + fn update(&mut self, b: &Bands, dt: f32) { + Monolith::update(self, b, dt) + } + fn element_count(&self) -> usize { + Monolith::element_count(self) + } + fn is_gpu(&self) -> bool { + true + } + fn render_gpu( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + c: &RenderContext, + ) -> Option<&wgpu::Texture> { + Some(Monolith::render( + self, device, queue, c.pal, c.scale, c.warp, c.feedback, c.fade, c.ca_px, c.drive, + c.march_cap, + )) + } + fn current_tex(&self) -> Option<&wgpu::Texture> { + Some(self.current()) + } + fn capture_raw( + &self, + device: &wgpu::Device, + queue: &wgpu::Queue, + ) -> Option>> { + Some(Monolith::capture_raw(self, device, queue)) + } + fn install_fingerprint(&mut self, fp: Fingerprint) { + Monolith::install_fingerprint(self, fp) + } + fn fingerprint_ready(&self) -> bool { + self.fingerprint_committed() } } diff --git a/src/viz/fingerprint.rs b/src/viz/fingerprint.rs new file mode 100644 index 0000000..6b9e5e1 --- /dev/null +++ b/src/viz/fingerprint.rs @@ -0,0 +1,199 @@ +//! Per-run audio fingerprint — a handful of stable scalars that summarise a +//! track's *character* (brightness, tonality, dynamics, dominant key) so a +//! visualiser can pick its archetype / palette ONCE and let live signals only +//! modulate the chosen form. +//! +//! Two collection paths, one output type: +//! · live — [`Accum`] consumes per-frame [`Bands`] via EMAs; `ready()` +//! flips after `warmup_secs` of accumulation, [`snapshot`] +//! returns a [`Fingerprint`]. +//! · offline — [`from_timeline`] folds an entire pre-analysed [`Timeline`] +//! once, *before* the first render frame is produced (so the +//! archetype is locked the moment rendering begins). +//! +//! Determinism: pure math over the already-deterministic [`Bands`] / +//! [`Timeline`] (no `Rng`, no clock), so `--render` stays bit-reproducible. + +use crate::audio::{Bands, CHROMA_N, Timeline}; + +/// A song's archetype-fingerprint — the slow, stable summary stats a +/// visualiser bakes into form/palette at commit time. +#[derive(Clone, Copy, Debug)] +pub struct Fingerprint { + /// Mean spectral centroid 0..1 (dark .. bright). Picks base hue. + pub centroid_mean: f32, + /// Dominant chroma pitch class 0..11 (across the run). Picks accent hue. + pub chroma_dom: usize, + /// Tonality 0..1 (1 == one pitch class dominates; 0 == flat). + pub tonality: f32, + /// Dynamic range proxy 0..1 from loudness percentiles (p95-p10, clamped). + pub dyn_range: f32, + /// Mean loudness 0..1 — energy floor. + pub loud_mean: f32, + /// Mean spectral flatness 0..1 (tonal .. noisy). Controls edge softness. + pub flatness_mean: f32, + /// Tempo class 0..1 from estimated BPM (60..180 -> 0..1, clamped). + pub tempo_class: f32, + /// Mean stereo width 0..1 (mono .. wide). Spatial spread cue. + pub width_mean: f32, +} + +impl Default for Fingerprint { + fn default() -> Self { + // The neutral fingerprint — a visualiser using this gets a sane, + // muted look until real data commits. + Fingerprint { + centroid_mean: 0.4, + chroma_dom: 0, + tonality: 0.0, + dyn_range: 0.4, + loud_mean: 0.0, + flatness_mean: 0.3, + tempo_class: 0.4, + width_mean: 0.0, + } + } +} + +/// Live-path accumulator. One `push` per analysis frame; once `t >= warmup` +/// (default ~5 s) `ready()` flips and `snapshot()` returns a usable +/// fingerprint. Resilient to early silence — the chroma/centroid sums weight +/// by loudness so a quiet intro can't dominate the archetype choice. +pub struct Accum { + t: f32, + warmup: f32, + // weighted sums over loud-frames + sum_w: f32, + sum_centroid: f32, + sum_flatness: f32, + sum_width: f32, + sum_chroma: [f32; CHROMA_N], + // unweighted (loudness itself + percentile bucket) + sum_loud: f32, + n_frames: u32, + // crude dynamic-range histogram (16 buckets over loud 0..1) + hist: [u32; 16], + // last seen tempo (median-ish: track the smoothed BPM) + bpm_ema: f32, +} + +impl Accum { + pub fn new(warmup_secs: f32) -> Self { + Accum { + t: 0.0, + warmup: warmup_secs.max(0.5), + sum_w: 0.0, + sum_centroid: 0.0, + sum_flatness: 0.0, + sum_width: 0.0, + sum_chroma: [0.0; CHROMA_N], + sum_loud: 0.0, + n_frames: 0, + hist: [0; 16], + bpm_ema: 0.0, + } + } + + /// True once enough audio has been observed to derive a stable archetype. + pub fn ready(&self) -> bool { + self.t >= self.warmup && self.n_frames > 8 + } + + pub fn elapsed(&self) -> f32 { + self.t + } + + /// Fold one analysis frame. Spectral stats are loudness-weighted so the + /// archetype reflects the playing material, not the silent intro. + pub fn push(&mut self, b: &Bands, dt: f32) { + self.t += dt; + self.n_frames = self.n_frames.saturating_add(1); + self.sum_loud += b.loud; + let bucket = ((b.loud * 16.0) as usize).min(15); + self.hist[bucket] = self.hist[bucket].saturating_add(1); + if b.bpm > 0.0 { + // Slow drift toward the analyser's already-stabilised tempo. + let a = 1.0 - (-dt / 4.0).exp(); + self.bpm_ema += (b.bpm - self.bpm_ema) * a; + } + // Weighted bins — silence contributes zero. + let w = b.loud.max(0.02); + self.sum_w += w; + self.sum_centroid += b.centroid * w; + self.sum_flatness += b.flatness * w; + self.sum_width += b.width * w; + for (s, &c) in self.sum_chroma.iter_mut().zip(b.chroma.iter()) { + *s += c * w; + } + } + + /// The fingerprint as-of-now. Safe to call before `ready()` — returns + /// neutral defaults if no audio has been observed. + pub fn snapshot(&self) -> Fingerprint { + if self.sum_w < 1e-3 || self.n_frames == 0 { + return Fingerprint::default(); + } + let inv_w = 1.0 / self.sum_w; + let inv_n = 1.0 / self.n_frames as f32; + // Dominant chroma + tonality (the rest of mass vs the peak). + let mut dom = 0usize; + let mut dv = -1.0f32; + let mut tot = 0.0f32; + for (i, &s) in self.sum_chroma.iter().enumerate() { + tot += s; + if s > dv { + dv = s; + dom = i; + } + } + let tonality = if tot > 1e-6 { + ((dv / tot) * CHROMA_N as f32 - 1.0).max(0.0) / (CHROMA_N as f32 - 1.0) + } else { + 0.0 + }; + Fingerprint { + centroid_mean: (self.sum_centroid * inv_w).clamp(0.0, 1.0), + chroma_dom: dom, + tonality: tonality.clamp(0.0, 1.0), + dyn_range: dyn_range_from_hist(&self.hist), + loud_mean: (self.sum_loud * inv_n).clamp(0.0, 1.0), + flatness_mean: (self.sum_flatness * inv_w).clamp(0.0, 1.0), + tempo_class: ((self.bpm_ema - 60.0) / 120.0).clamp(0.0, 1.0), + width_mean: (self.sum_width * inv_w).clamp(0.0, 1.0), + } + } +} + +/// Offline fold: build the fingerprint from a fully-pre-analysed timeline in +/// one pass. Used by `--render` so the archetype is committed *before* the +/// first frame and the visual never "decides" mid-render. +pub fn from_timeline(tl: &Timeline) -> Fingerprint { + let mut a = Accum::new(0.0); // batch mode: no warmup gate + let dt = 1.0 / tl.rate_hz.max(1.0); + for b in &tl.frames { + a.push(b, dt); + } + a.snapshot() +} + +/// Crude p95 - p10 over a 16-bucket loudness histogram, mapped to 0..1. +fn dyn_range_from_hist(h: &[u32; 16]) -> f32 { + let total: u32 = h.iter().sum(); + if total == 0 { + return 0.4; + } + let p = |frac: f32| -> f32 { + let cut = ((total as f32) * frac).floor() as u32; + let mut acc = 0u32; + for (i, &c) in h.iter().enumerate() { + acc += c; + if acc >= cut { + return (i as f32 + 0.5) / 16.0; + } + } + 1.0 + }; + let p10 = p(0.10); + let p95 = p(0.95); + (p95 - p10).clamp(0.0, 1.0) +} diff --git a/src/viz/mod.rs b/src/viz/mod.rs index 8d6b4da..0921323 100644 --- a/src/viz/mod.rs +++ b/src/viz/mod.rs @@ -1,18 +1,19 @@ -//! Visual layer: organic curve geometry, audio-driven OKLCH colour, the -//! living hybrid cyber-organic sigil, and the feedback/bloom post stack. +//! Visual layer: the breakcore wgpu raymarcher and its shared infrastructure. //! -//! 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. +//! Raw-wgpu fragment pipelines go through the shared, validated, aspect-aware +//! [`shader::ShaderPipeline`] base (`breakcore` is its first client). `post` +//! now holds only the leak-safe texture readback. The earlier `Draw`-based +//! `sigil`/`scope` modes were removed: breakcore is the sole visualiser and +//! [`core::Visualizer`] is the contract future (aspect-aware) modes implement. pub mod breakcore; pub mod core; pub mod curve; +pub mod fingerprint; pub mod geometry; pub mod math; +pub mod monolith; pub mod palette; pub mod post; -pub mod scope; pub mod shader; -pub mod sigil; pub mod structure; diff --git a/src/viz/monolith.rs b/src/viz/monolith.rs new file mode 100644 index 0000000..5fdca8c --- /dev/null +++ b/src/viz/monolith.rs @@ -0,0 +1,453 @@ +//! monolith — the Glitching Monolith mandelbulb on a deep void. +//! +//! Premise → implementation: +//! · 40–80 Hz sub-bass / kicks → macro structure & gravity (sp_scale spring +//! breathes the bulb; lowf shader scalar darkens the outer void → "the +//! 808 pulls the picture in"). +//! · IDM mids / pads → fluid + colour (swirl phase rotates the +//! feedback trail, midf shader scalar drifts radial ink-in-water; accent +//! hue eases toward the dominant chroma class). +//! · Breakcore high-mids / snares → glitch + stutter + camera (glitch_env +//! drives the shader's coarse-cell UV-shove grid; a `stutter` FSM holds +//! the camera/scale/glow for ~120 ms on a snare-flux burst while the +//! shader's feedback decay floor pins near 1.0 → the previous frame +//! "freezes" — looks like the render dropped to ~10 fps for a beat). +//! +//! Fingerprint mapping (committed once at startup or M-cycle): +//! chroma_dom % 3 → neon accent class (cyan / magenta / acid green) +//! centroid_mean → base hue along a deep-blue↔purple arc +//! tonality → palette saturation + edge softness +//! dyn_range → motion scale (low DR = slow, wide DR = punchy) +//! tempo_class → camera orbit rate baseline +//! +//! Per-run novelty (wall-clock seed in live; deterministic in `--render`): +//! camera start angles + initial swirl phase. +//! +//! Determinism: `Rng` and the stutter/glitch FSMs advance only in `update` +//! (one call per frame, live and render). The shader is a pure function of +//! the UBO + hash(fragCoord, frame). Same input + same seed = same frame. +//! +//! WGSL coupling (non-negotiable): the header is **9** std140 rows so the +//! UBO is exactly **36** f32 (`UBO_LEN`). No nodes array — the form is the +//! DE itself. + +use crate::audio::Bands; +use crate::viz::curve::Rng; +use crate::viz::fingerprint::Fingerprint; +use crate::viz::math::{Spring, angle_to}; +use crate::viz::palette::{Palette, oklch}; +use crate::viz::post::read_texture_rgba; +use crate::viz::shader::ShaderPipeline; +use nannou::wgpu; + +/// UBO length in f32: **10** std140 rows (40). Header layout is duplicated +/// in the WGSL `struct U`; changing one without the other silently mis-reads +/// every uniform. Row 9 (`col2`) carries a secondary neon accent so the +/// shader can paint two contrasting hues across one frame (cyan body / +/// magenta rim, etc). +const UBO_LEN: usize = 40; + +const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8UnormSrgb; +const TAU: f32 = std::f32::consts::TAU; + +/// Neon-accent class — picks the violent-flash hue. Derived from the +/// committed fingerprint's dominant chroma class % 3. +#[derive(Clone, Copy, PartialEq, Debug)] +enum Accent { + Cyan, + Magenta, + AcidGreen, +} + +impl Accent { + fn from_chroma(c: usize) -> Self { + match c % 3 { + 0 => Accent::Cyan, + 1 => Accent::Magenta, + _ => Accent::AcidGreen, + } + } + /// Centre hue (radians) for this neon class — OKLCH wheel convention + /// shared with `palette::from_audio`. + fn hue(self) -> f32 { + match self { + // Eyeballed in OKLCH so the chroma lands near max-vibrance for + // each named neon (cyan ≈ 195°, magenta ≈ 325°, acid ≈ 130°). + Accent::Cyan => 3.40, + Accent::Magenta => 5.67, + Accent::AcidGreen => 2.27, + } + } + /// Partner-class hue — the secondary neon paired with this one for + /// per-frame contrast (cyan↔magenta, magenta↔acid, acid↔cyan). Keeps two + /// hues in frame at once so the picture has actual colour, not a wash. + fn partner(self) -> Accent { + match self { + Accent::Cyan => Accent::Magenta, + Accent::Magenta => Accent::AcidGreen, + Accent::AcidGreen => Accent::Cyan, + } + } +} + +pub struct Monolith { + pub seed: u64, + rng: Rng, + + fp: Fingerprint, + fp_committed: bool, + accent_class: Accent, + + // springs + sp_scale: Spring, // sub-bass breath (bulb world scale) + sp_glow: Spring, + sp_power: Spring, // bulb power n (mid drift + fingerprint bias) + sp_dist: Spring, // camera distance (kick pulls back into void) + + // stutter FSM — snare-flux burst freezes camera/scale/glow for ~120 ms, + // shader pins feedback decay near 1.0 so the prev frame survives. + stutter_w: f32, + stutter_gate: f32, + held_yaw: f32, + held_pitch: f32, + held_roll: f32, + held_dist: f32, + held_scale: f32, + held_glow: f32, + + // smoothed glitch envelope feeding the shader's coarse-cell UV-shove + glitch_env: f32, + + // mid-band ink-in-water feedback swirl accumulator + swirl_phase: f32, + + // camera + smoothed colour + yaw: f32, + pitch: f32, + roll: f32, + hue_b: f32, // base hue (deep blue↔purple arc) + hue_a: f32, // primary accent hue + hue_a2: f32, // secondary accent hue (partner neon) + + t: f32, + frame: u32, + b: Bands, + + rw: u32, + rh: u32, + gpu: ShaderPipeline<[f32; UBO_LEN]>, +} + +impl Monolith { + /// `w`×`h` is the raymarch target size (output × supersample); any aspect. + pub fn new(seed: u64, device: &wgpu::Device, w: u32, h: u32) -> Self { + let mut rng = Rng::new(seed ^ 0xC0FF_EEFA_CADE_B00C_u64); + // Per-run novelty: nudge camera start so each run isn't identical. + let yaw0 = rng.range(0.0, TAU); + let pitch0 = rng.range(-0.25, 0.25); + let swirl0 = rng.range(0.0, TAU); + Monolith { + seed, + rng, + fp: Fingerprint::default(), + fp_committed: false, + accent_class: Accent::Cyan, + sp_scale: Spring { x: 1.0, v: 0.0 }, + sp_glow: Spring { x: 0.55, v: 0.0 }, + sp_power: Spring { x: 8.0, v: 0.0 }, + sp_dist: Spring { x: 2.8, v: 0.0 }, + stutter_w: 0.0, + stutter_gate: 0.0, + held_yaw: yaw0, + held_pitch: pitch0, + held_roll: 0.0, + held_dist: 2.8, + held_scale: 1.0, + held_glow: 0.55, + glitch_env: 0.0, + swirl_phase: swirl0, + yaw: yaw0, + pitch: pitch0, + roll: 0.0, + hue_b: 4.6, + hue_a: 3.4, + hue_a2: 5.67, + t: 0.0, + frame: 0, + b: Bands::default(), + rw: w, + rh: h, + gpu: ShaderPipeline::new(device, include_str!("monolith.wgsl"), w, h, FMT), + } + } + + pub fn reseed(&mut self, seed: u64) { + self.seed = seed; + self.rng = Rng::new(seed ^ 0xC0FF_EEFA_CADE_B00C_u64); + self.yaw = self.rng.range(0.0, TAU); + self.pitch = self.rng.range(-0.25, 0.25); + self.roll = 0.0; + self.swirl_phase = self.rng.range(0.0, TAU); + self.stutter_w = 0.0; + self.stutter_gate = 0.0; + self.glitch_env = 0.0; + } + + /// "Element count" for the HUD — there is no element list (the form is + /// the DE), so report the bulb iteration count instead. + pub fn element_count(&self) -> usize { + 8 + } + + /// Install a fingerprint and re-derive accent class / palette bias. + /// Cheap; idempotent — safe to call repeatedly. + pub fn install_fingerprint(&mut self, fp: Fingerprint) { + self.fp = fp; + self.fp_committed = true; + self.accent_class = Accent::from_chroma(fp.chroma_dom); + } + + pub fn fingerprint_committed(&self) -> bool { + self.fp_committed + } + + pub fn update(&mut self, b: &Bands, dt: f32) { + let dt = dt.clamp(0.0, 0.05); + self.t += dt; + self.frame = self.frame.wrapping_add(1); + self.b = *b; + + // Dynamic-range scaled motion (premise: quiet songs move slowly). + let dyn_m = (0.40 + 0.85 * self.fp.dyn_range).clamp(0.40, 1.20); + + // Quadratic shape of the audio levels so light hats / room tone + // contribute near zero, and only real hits move the picture. This is + // the single biggest "proportionality" lever — a linear `b.high` of + // 0.2 becomes 0.04 here, a `b.high` of 0.8 stays 0.64. + let low_q = b.low * b.low; + let mid_q = b.mid * b.mid; + let high_q = b.high * b.high; + let flux_q = b.flux * b.flux; + + // --- sub-bass breath: sp_scale grows on low²; the kick still reads, + // a mid-range hum doesn't. Range now ≈ 1.0..1.20 instead of ≈1.0..1.55. + let scale_t = 1.0 + 0.20 * low_q + 0.12 * b.low_on; + self.sp_scale.step(scale_t, 4.0 * dyn_m, dt); + let dist_t = 2.8 + 0.25 * low_q + 0.10 * b.low_on; + self.sp_dist.step(dist_t, 4.0 * dyn_m, dt); + + // --- bulb power: small drift around 8 — mid² nudges it gently and + // the fingerprint's tonality biases the resting point (tonal music + // keeps a cleaner low-power bulb, noisy/atonal goes higher). + let power_t = 7.8 + 0.5 * self.fp.tonality + 0.2 * (mid_q - 0.25); + self.sp_power.step(power_t.clamp(6.5, 9.5), 2.0, dt); + + // --- glow: rides loudness² + flux². Quiet floor sits low so the + // bulb is a dark silhouette by default, only loud moments lift it. + self.sp_glow.step( + 0.28 + 0.35 * b.loud * b.loud + 0.20 * flux_q, + 5.5 * dyn_m, + dt, + ); + + // --- glitch envelope: smoothed hi-band onset / flux with a deadband + // (subtract `min_trigger` so a steady noise floor produces nothing). + // Slow attack so a single hi-hat tap doesn't ping the grid; slow + // decay so a snare roll holds the effect through the burst. + let raw = 0.7 * b.high_on * b.high_on + 0.35 * flux_q; + let glitch_target = (raw - 0.10).max(0.0).min(1.2); + let a_g = if glitch_target > self.glitch_env { + 0.18 + } else { + 0.04 + }; + self.glitch_env += (glitch_target - self.glitch_env) * a_g; + + // --- stutter FSM (the "drops to 12 fps" simulation). Triggered only + // by real snare-flux bursts — both bands strong, or one very strong. + // Gate is wide (0.45 s) so a busy fill can't latch repeatedly; the + // hold itself is short (~80 ms) so the freeze reads as a tic, not a + // dropped section. + self.stutter_gate = (self.stutter_gate - dt).max(0.0); + self.stutter_w = (self.stutter_w - dt / 0.08).max(0.0); + let snare_burst = + (b.high_on > 0.72 && b.flux > 0.65) || b.flux > 0.90 || b.high_on > 0.85; + if snare_burst && self.stutter_gate <= 0.0 { + self.stutter_w = 1.0; + self.stutter_gate = 0.45; + self.held_yaw = self.yaw; + self.held_pitch = self.pitch; + self.held_roll = self.roll; + self.held_dist = self.sp_dist.x; + self.held_scale = self.sp_scale.x; + self.held_glow = self.sp_glow.x; + } + + // --- camera (BPM-paced + small audio jitter). No constant baseline + // beyond tempo — true silence keeps it nearly still. All terms + // gated quadratically so a quiet passage holds a steady frame. + let base_rate = 0.05 + 0.10 * self.fp.tempo_class; + let rate = base_rate * dyn_m; + self.yaw += rate * dt + 0.10 * b.beat * b.beat * dt; + self.pitch += (0.06 * low_q - 0.02) * rate * dt; + self.roll += 0.02 * high_q * dt; + + // --- swirl: mid² accumulates the feedback rotation. Sustained pad + // = a slow drift; sparse mids = nearly stationary trail. + self.swirl_phase += (0.15 + 0.6 * mid_q) * 0.30 * dt; + + // --- colour inertia. Base hue drifts blue↔purple with centroid; the + // two accents ease toward the neon class + its partner so contrast + // stays alive. Each accent picks up a small audio nudge (mid drifts + // the primary, high drifts the secondary) so the hues breathe with + // the music instead of being statically committed. + let base_t = (5.0 - b.centroid * 1.4).rem_euclid(TAU); + let acc_t = self.accent_class.hue() + + (self.fp.chroma_dom as f32) / 12.0 * 0.3 + + 0.15 * (mid_q - 0.25); + let acc2_t = self.accent_class.partner().hue() + 0.15 * (high_q - 0.25); + let ha = 1.0 - (-dt / 0.5).exp(); + self.hue_b = angle_to(self.hue_b, base_t, ha); + self.hue_a = angle_to(self.hue_a, acc_t, ha); + self.hue_a2 = angle_to(self.hue_a2, acc2_t, ha); + } + + /// Render this frame into the target and return it. Bin tunables match + /// the other modes' contract so cfg keys stay shared (`fade`/`ca_px`/ + /// `drive`/`march_cap`); `warp` is unused — monolith has its own + /// audio-driven swirl so the bin's noise-warp slot is a no-op here. + #[allow(clippy::too_many_arguments)] + pub fn render( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + pal: &Palette, + scale: f32, + _warp: f32, + feedback: bool, + fade: f32, + ca_px: f32, + drive: f32, + march_cap: u32, + ) -> &wgpu::Texture { + let dr = drive.clamp(0.0, 3.0); + let dyn_m = (0.40 + 0.85 * self.fp.dyn_range).clamp(0.40, 1.20); + + // Held-vs-live blending: while stutter is high, upload the held + // values so the picture freezes; the shader also pins the feedback + // floor near 1.0 from `stutter_w` so the trail survives unchanged. + let s = self.stutter_w.clamp(0.0, 1.0); + let yaw = self.yaw * (1.0 - s) + self.held_yaw * s; + let pitch = self.pitch * (1.0 - s) + self.held_pitch * s; + let roll = self.roll * (1.0 - s) + self.held_roll * s; + let dist = self.sp_dist.x * (1.0 - s) + self.held_dist * s; + let scl = self.sp_scale.x * (1.0 - s) + self.held_scale * s; + let glow = self.sp_glow.x * (1.0 - s) + self.held_glow * s; + + // Palette — base lands deep dark silver/blue; two accents on + // contrasting neon classes so a frame never reads as one hue. + // Lightness rides loud² so quiet stays dark; saturation rides + // tonality. Capped low so neither accent can wash the frame. + let lo = self.b.loud; + let base = oklch( + (0.14 + 0.14 * lo * lo).min(0.42), + (0.035 + 0.015 * self.fp.tonality).min(0.06), + self.hue_b, + ); + let acc_sat = (0.18 + 0.05 * lo) * (0.65 + 0.40 * self.fp.tonality); + let acc = oklch((0.68 + 0.20 * lo).min(0.92), acc_sat, self.hue_a); + // Secondary accent slightly less saturated so the body's primary tint + // still reads — the partner is a *contrast*, not a competing colour. + let acc2 = oklch( + (0.66 + 0.20 * lo).min(0.90), + (acc_sat * 0.85).min(0.30), + self.hue_a2, + ); + + let mut u = [0.0f32; UBO_LEN]; + + // row0 cam — held during stutter (see above) + u[0] = yaw; + u[1] = pitch; + u[2] = roll; + u[3] = dist.clamp(1.8, 4.5); + + // row1 p0 = scale, glow_gain, ca_px, edge_softness + // `scale` (caller's expressive multiplier) rides on top of breath. + u[4] = (scale * scl).clamp(0.4, 1.8); + u[5] = glow.clamp(0.18, 0.85); + // CA: small base, stutter lifts it modestly (the smear during freeze + // reads as signal corruption — but not screen-wide prism shimmer). + u[6] = ca_px * (0.6 + 0.4 * dr) * (1.0 + 0.6 * s); + // Tonal music keeps a crisp particle edge; noisy/atonal softens. + u[7] = (0.50 + 0.45 * (1.0 - self.fp.tonality) + 0.15 * self.b.flatness) + .clamp(0.30, 1.10); + + // row2 col0 = base.rgb, fade + u[8] = base[0]; + u[9] = base[1]; + u[10] = base[2]; + // Wider fade (longer trail) when dyn_motion is low (calm tracks leave + // more wake). Drive doesn't pull it shorter — the stutter does. + u[11] = (fade * (1.0 - 0.25 * (1.0 - dyn_m))).clamp(0.02, 1.0); + + // row3 col1 = accent.rgb, flash + u[12] = acc[0]; + u[13] = acc[1]; + u[14] = acc[2]; + u[15] = pal.flash; + + // row4 p1 = res_w, res_h, frame, time + u[16] = self.rw as f32; + u[17] = self.rh as f32; + u[18] = (self.frame & 0xffff) as f32; + u[19] = self.t; + + // row5 p2 = march_steps, power_n, feedback_on, world_r (bounding) + // Mandelbulb takes ~8 iters/step — heavier than the capsule field, so + // hold the request slightly lower than breakcore's. + u[20] = (20.0 + 7.0 * dr).clamp(16.0, march_cap.min(96) as f32); + u[21] = self.sp_power.x.clamp(6.5, 9.5); + u[22] = if feedback && self.gpu.primed() { 1.0 } else { 0.0 }; + // Bulb fits in r ≈ 1.25; pad for breath + sub-bass extension. + u[23] = (1.30 * scl + 0.20).clamp(1.0, 3.0); + + // row6 p3 = grain, glitch_a, fog, beat + u[24] = (0.006 + 0.014 * self.b.flux * dr).clamp(0.0, 0.022); + u[25] = (self.glitch_env * dr).clamp(0.0, 1.2); + u[26] = (0.30 + 0.35 * self.b.loud).clamp(0.20, 0.75); + u[27] = self.b.beat; + + // row7 p4 = loud, low, mid, high + u[28] = self.b.loud; + u[29] = self.b.low; + u[30] = self.b.mid; + u[31] = self.b.high; + + // row8 p5 = stutter_w, swirl, aspect, tonality + u[32] = s; + u[33] = self.swirl_phase % TAU; + u[34] = self.rw as f32 / self.rh.max(1) as f32; + u[35] = self.fp.tonality.clamp(0.0, 1.0); + + // row9 col2 = secondary accent.rgb, _ — paints surface contrast + u[36] = acc2[0]; + u[37] = acc2[1]; + u[38] = acc2[2]; + u[39] = 0.0; + + self.gpu.render(device, queue, &u) + } + + pub fn current(&self) -> &wgpu::Texture { + self.gpu.current() + } + + pub fn capture_raw( + &self, + device: &wgpu::Device, + queue: &wgpu::Queue, + ) -> anyhow::Result> { + read_texture_rgba(device, queue, self.gpu.current(), self.rw, self.rh) + } +} diff --git a/src/viz/monolith.wgsl b/src/viz/monolith.wgsl new file mode 100644 index 0000000..7b8e99f --- /dev/null +++ b/src/viz/monolith.wgsl @@ -0,0 +1,312 @@ +// monolith — "Glitching Monolith" mandelbulb raymarcher. +// +// A power-N mandelbulb DE raymarched as particle-dithered surface + soft halo +// on a deep void; sub-bass breathes its scale (CPU side), mid-band swirls the +// feedback trail, hi-band drives a coarse-cell UV-displacement glitch grid + +// a simulated frame-rate stutter (the feedback decay floor pins near 1.0 so +// the previous frame survives unchanged — looks like the renderer dropped to +// ~10 fps for a few frames). Particle aesthetic comes from a per-pixel hash +// threshold against the surface intensity: each pixel "is" a particle that +// either shows or doesn't, sold by dense fine grain on top. +// +// Cost bounded the same way breakcore is: bounding-sphere ray test discards +// background pixels in one op, march is sphere-traced with the shader's hard +// 96-step ceiling, normal (6× map) is gated to pixels actually on-surface. +// Bulb iteration count is fixed at 8 (taste — higher melts detail, lower +// reads blocky). Pure function of UBO + hash(fragCoord, frame) so `--render` +// is bit-reproducible. +// +// UBO header is **10** std140 rows (40 f32). Rust↔WGSL coupled — change one +// ⇒ change the other. Naga only validates this WGSL at pipeline-create on a +// real GPU. No nodes array: the form is the DE itself. `col2` carries a +// secondary neon accent so the surface can paint two contrasting hues at +// once — the bulb never reads as one wash. + +const PI: f32 = 3.14159265; +const BULB_ITERS: i32 = 8; + +struct U { + cam: vec4, // yaw, pitch, roll, dist + p0: vec4, // scale, glow_gain, ca_px, edge_softness + col0: vec4, // base.rgb, fade + col1: vec4, // accent.rgb (primary neon), flash + p1: vec4, // res_w, res_h, frame, time + p2: vec4, // march_steps, power_n, feedback_on, world_r + p3: vec4, // grain, glitch_a, fog, beat + p4: vec4, // loud, low, mid, high + p5: vec4, // stutter_w, swirl, aspect, tonality + col2: vec4, // accent2.rgb (secondary neon), _ +}; + +@group(0) @binding(0) var u: U; +@group(0) @binding(1) var prev_tex: texture_2d; +@group(0) @binding(2) var prev_smp: sampler; + +struct VsOut { + @builtin(position) pos: vec4, + @location(0) uv: vec2, +}; + +@vertex +fn vs_main(@builtin(vertex_index) vi: u32) -> VsOut { + var o: VsOut; + let x = f32((vi << 1u) & 2u); + let y = f32(vi & 2u); + o.uv = vec2(x, y); + o.pos = vec4(x * 2.0 - 1.0, 1.0 - y * 2.0, 0.0, 1.0); + return o; +} + +fn hash21(p: vec2) -> f32 { + var q = fract(p * vec2(123.34, 345.45)); + q = q + dot(q, q + 34.345); + return fract(q.x * q.y); +} + +// yaw(Y) -> pitch(X) -> roll(Z), shared convention with breakcore. +fn rot(v: vec3) -> vec3 { + let sy = sin(u.cam.x); let cy = cos(u.cam.x); + let sp = sin(u.cam.y); let cp = cos(u.cam.y); + let sr = sin(u.cam.z); let cr = cos(u.cam.z); + let x1 = v.x * cy - v.z * sy; + let z1 = v.x * sy + v.z * cy; + let y2 = v.y * cp - z1 * sp; + let z2 = v.y * sp + z1 * cp; + let x3 = x1 * cr - y2 * sr; + let y3 = x1 * sr + y2 * cr; + return vec3(x3, y3, z2); +} + +// Mandelbulb distance estimator (Quilez / Hart). Iterates z_{n+1} = z_n^P + c +// in spherical coords; the running `dr` tracks |dz/dz0| so we can return a +// proper distance bound 0.5·log(r)·r/dr. +fn de_bulb(p0: vec3, power: f32) -> f32 { + var z = p0; + var dr = 1.0; + var r = 0.0; + for (var i = 0; i < BULB_ITERS; i = i + 1) { + r = length(z); + if (r > 2.0) { break; } + let theta = acos(clamp(z.z / max(r, 1e-6), -1.0, 1.0)); + let phi = atan2(z.y, z.x); + dr = pow(r, power - 1.0) * power * dr + 1.0; + let zr = pow(r, power); + let t2 = theta * power; + let p2 = phi * power; + z = zr * vec3(sin(t2) * cos(p2), sin(t2) * sin(p2), cos(t2)); + z = z + p0; + } + return 0.5 * log(max(r, 1e-6)) * r / max(dr, 1e-6); +} + +// Scene = scaled bulb. `scl` is the sub-bass-breath spring multiplier. +fn map(p: vec3) -> f32 { + let scl = u.p0.x; + return de_bulb(p * (1.0 / max(scl, 1e-3)), u.p2.y) * scl; +} + +fn calc_normal(p: vec3) -> vec3 { + let e = vec2(0.0022, 0.0); + return normalize(vec3( + map(p + e.xyy) - map(p - e.xyy), + map(p + e.yxy) - map(p - e.yxy), + map(p + e.yyx) - map(p - e.yyx), + )); +} + +@fragment +fn fs_main(in: VsOut) -> @location(0) vec4 { + let res_w = u.p1.x; + let res_h = u.p1.y; + let frame = u.p1.z; + let glow = u.p0.y; + let ca_px = u.p0.z; + let edge = u.p0.w; + let base = u.col0.xyz; + let accent = u.col1.xyz; + let accent2 = u.col2.xyz; + let fade = u.col0.w; + let flash = u.col1.w; + let time = u.p1.w; + let rb = u.p2.w; // bounding-sphere radius + let grain_a = u.p3.x; + let glitch = u.p3.y; // 0..~1.2 coarse-cell UV shove (hi-band) + let fog = u.p3.z; + let beat = u.p3.w; + let loud = u.p4.x; + let lowf = u.p4.y; // [0..1] sub-bass level — gravity well weight + let midf = u.p4.z; // [0..1] mid level — swirl gain + let highf = u.p4.w; // [0..1] hi/snare level — rim flicker + let stut = u.p5.x; // 0..1 simulated-stutter weight + let swirl = u.p5.y; // accumulated swirl angle (rad) + let aspect = u.p5.z; + let tonal = u.p5.w; + + // --- hi-band glitch grid: coarse-cell UV displacement of the FEEDBACK + // sample only — the bulb itself stays geometrically stable so a busy hat + // pattern can't tear the whole picture. Deadband below 0.35 so quiet + // music produces no glitch at all; only a small fraction of cells shove + // (threshold 0.85) so the effect is sparse, not screen-filling. + let glitch_eff = max(clamp(glitch, 0.0, 1.0) - 0.35, 0.0) / 0.65; + var glitch_off = vec2(0.0); + if (glitch_eff > 0.001) { + let cells = 18.0 + 14.0 * glitch_eff; + let cy = floor(in.uv.y * cells); + let cx = floor(in.uv.x * cells); + let hold = max(3.0 + 5.0 * (1.0 - glitch_eff), 1.0); + let stuck_frame = floor(frame / hold); + let pick = step(0.85, hash21(vec2(cx + cy * 7.0, stuck_frame))); + let sx = (hash21(vec2(cx * 3.7, cy + stuck_frame)) - 0.5) + * 0.045 * glitch_eff * pick; + let sy = (hash21(vec2(cx + cy * 11.0, stuck_frame * 2.0)) - 0.5) + * 0.020 * glitch_eff * pick; + glitch_off = vec2(sx, sy); + } + + let uv = in.uv; + let ndc = vec2(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0); + let dist = u.cam.w; + let focal = 1.6; + let ro = vec3(0.0, 0.0, -dist); + let rd = normalize(vec3(ndc.x * aspect, ndc.y, focal)); + + // Ray vs bounding sphere — the background-pixel early-out that keeps the + // raymarch from melting the GPU. `rb` is set to (scale + breath + pad). + let b = dot(ro, rd); + let c = dot(ro, ro) - rb * rb; + let disc = b * b - c; + + var col = vec3(0.0); + var inten = 0.0; + var hit_t = -1.0; + var dmin = 1e9; + if (disc > 0.0) { + let sq = sqrt(disc); + var t = max(-b - sq, 0.0); + let t_end = -b + sq; + let min_step = max(t_end - t, 1e-3) / 96.0; + let steps = min(i32(u.p2.x), 96); + for (var s = 0; s < steps; s = s + 1) { + let d = map(rot(ro + rd * t)); + if (d < dmin) { dmin = d; } + if (d < 0.0018) { hit_t = t; break; } + t = t + max(d * 0.80, min_step); + if (t > t_end) { break; } + } + // Tight dust halo from closest approach (not unbounded accumulation, + // so dense regions can't white-out). The wider tail is intentionally + // dim — a noisy/atonal song widens it via `edge`, tonal stays crisp. + let dl = max(dmin, 0.0); + let halo = exp(-dl * dl * 7000.0 * edge) + 0.02 * exp(-dl * 30.0); + let tt = select(t, hit_t, hit_t > 0.0); + let depth = clamp((tt + b) / max(2.0 * sq, 1e-3), 0.0, 1.0); + inten = clamp(halo * glow * (1.0 - fog * depth), 0.0, 1.0); + + if (inten > 0.015) { + let rp = rot(ro + rd * tt); + // Position-dependent **tint mix** across the bulb surface — a + // slow standing wave through (x,y,z) so neighbouring regions + // read as different neons. Drifts on time so the colour + // distribution evolves without snapping. This is what keeps the + // frame from being one hue: half the bulb sits closer to + // `accent`, the other half closer to `accent2`. + let tint = 0.5 + 0.5 * sin(rp.x * 3.2 + rp.y * 2.4 + rp.z * 1.8 + + time * 0.30); + let acc_mix = mix(accent, accent2, tint); + + // Surface shade — gated to genuinely on-surface pixels so the + // 6×map() normal cost can't run over the entire halo screen. + // Accent contributions are deliberately small: the body stays + // brutalist-grey, the neon shows only where it earns its keep + // (fresnel edge + hi-band onset rim). + var body = base * 0.55; + if (dmin < 0.010) { + let n = calc_normal(rp); + let vdir = normalize(rot(-rd)); + let lambert = clamp(dot(n, normalize(vec3(0.4, 0.7, -0.55))), 0.0, 1.0); + let fres = pow(1.0 - clamp(dot(n, vdir), 0.0, 1.0), 3.0); + // Body diffuse → silver; fresnel edge → position-mixed neon + // (the two-colour bulb effect lives here). + body = base * (0.20 + 0.80 * lambert) + acc_mix * fres * 0.28; + // Hi-band rim → **secondary** accent² (not the body's main) + // so a snare flashes a contrasting hue against the body's + // base accent. Quadratic in highf so light hats stay near + // zero, only strong snares light the edge. + body = body + accent2 * highf * highf * fres * fres * 0.40; + } + // Particle-dither: per-pixel hash threshold against intensity. + // Each pixel is a "particle" that either shows or doesn't — + // identifies the form as point-cloud rather than solid surface. + let pcell = hash21(uv * vec2(res_w, res_h) * 0.85 + + vec2(frame * 0.013, frame * 0.029)); + let pkeep = step(pcell, clamp(inten * 1.65 + 0.12, 0.0, 1.0)); + col = body * inten * (0.50 + 0.55 * pkeep); + // Core punch — uses the per-pixel tint so the bulb's deep core + // glows different shades in different regions, not one hot dot. + col = col + acc_mix * pow(inten, 8.0) * 0.20; + // Onset spark — gated by flash² and uses accent2 (the snare + // colour) so onsets actively introduce the contrast hue. + col = col + accent2 * flash * flash * pow(inten, 3.0) * 0.25; + } + } + + // Sub-bass gravity-well: radial darkening of the outer void, gated by + // sub-bass loudness². Quadratic so a sustained low hum reads heavy but + // doesn't reach the threshold without a real 808. A faint warm tint + // (wine, mixed from accent + base) bleeds into the dark ring so a kick + // colours the periphery instead of just dimming it. + let r2 = dot(ndc, ndc); + let gw = lowf * lowf * smoothstep(0.0, 1.6, r2); + col = col * (1.0 - 0.28 * gw); + let warm = mix(base, accent * 0.6, 0.45); + col = col + warm * gw * 0.05; + + // --- mid-band ink-in-water swirl: feedback sample comes from a small + // rotation around centre + the glitch grid's per-cell shove. Pad swells + // make ribbons drift; a snare burst makes a sparse subset of cells jump. + // Both effects are sub-pixel-ish in magnitude so the trail doesn't tear. + var fb_uv = uv + glitch_off; + if (u.p2.z > 0.5) { + var c2 = fb_uv - vec2(0.5); + let cs = cos(swirl * 0.5); let sn = sin(swirl * 0.5); + c2 = vec2(c2.x * cs - c2.y * sn, c2.x * sn + c2.y * cs); + let zr = 1.0 + 0.004 * (midf - 0.3); + c2 = c2 * zr; + fb_uv = clamp(c2 + vec2(0.5), vec2(0.0), vec2(1.0)); + } + + // Dense fine grain — sells the "millions of particles" texture. + col = max(col + (hash21(uv * vec2(res_w, res_h) + + vec2(frame * 1.1, frame * 1.7)) - 0.5) * grain_a, + vec3(0.0)); + + // Phosphor feedback + datamosh. Stutter raises the decay floor briefly + // so the previous frame survives a beat (reads as a dropped frame), but + // capped well below 1.0 so the trail still drains — no runaway wash. + // CA across the tap is small by default; only the active stutter window + // lifts it. + if (u.p2.z > 0.5) { + let dec_base = clamp(1.0 - 3.5 * fade, 0.30, 0.85); + let stutter_floor = mix(dec_base, 0.90, clamp(stut, 0.0, 1.0)); + let decay = clamp(stutter_floor + 0.10 * beat, 0.30, 0.94); + let ca = ca_px / max(res_w, 1.0); + let off = (fb_uv - vec2(0.5)) * ca; + let pr = textureSampleLevel(prev_tex, prev_smp, fb_uv + off, 0.0).r; + let pg = textureSampleLevel(prev_tex, prev_smp, fb_uv, 0.0).g; + let pb = textureSampleLevel(prev_tex, prev_smp, fb_uv - off, 0.0).b; + col = max(col, vec3(pr, pg, pb) * decay); + } + + // Cold void floor — silver/blue, very small so it cannot accumulate + // through the feedback trail. A tonality-gated mix toward `accent2` + // gives the void itself a faint partner-neon cast so the corners of the + // frame aren't pure black. Quadratic loudness gating so the floor only + // lifts on real energy, not analyser noise. + let vig = max(1.0 - 0.85 * length(ndc), 0.0); + let bgt_cold = mix(vec3(0.015, 0.020, 0.035), + vec3(0.04, 0.05, 0.10), tonal); + let bgt = mix(bgt_cold, accent2 * 0.08, 0.25 * tonal); + col = col + bgt * vig * (0.005 + 0.010 * loud * loud); + + return vec4(min(col, vec3(1.0)), 1.0); +} diff --git a/src/viz/post.rs b/src/viz/post.rs index 6e94cb4..d84f9e5 100644 --- a/src/viz/post.rs +++ b/src/viz/post.rs @@ -1,198 +1,27 @@ -//! Frame-feedback + bloom post stack, built only from nannou's own validated -//! Draw + offscreen renderer (no hand-written render pipelines). +//! Leak-safe synchronous texture readback. //! -//! Per frame, at a fixed internal resolution (super-sampled for cheap AA): -//! 1. the sigil is rendered into `scene`; -//! 2. a composite pass writes `acc_next = fade(acc_prev) + ADD(scene)` — the -//! previous accumulator, dimmed toward the background by drawing a -//! translucent rect over it (nannou textures can't be tinted, so decay is -//! done this way), with the fresh scene added on top. A slight zoom on the -//! fed-back copy makes trails bloom outward instead of just smearing. -//! 3. the bin presents the accumulator to the window (downsampled → AA) and -//! can capture it for the offline video. -//! -//! Chromatic aberration is done at draw time in the bin (per-channel offset -//! passes) because nannou's texture primitive ignores vertex colour. +//! Once the home of a nannou feedback/bloom `Post` stack; now that `breakcore` +//! (a wgpu raymarcher with its own in-shader feedback) is the only visualiser, +//! the single thing worth keeping here is the readback path used by the `P` +//! screenshot and the `--render` frame pipe. It lives once so the leak-safe +//! `device.poll(Wait)` map (vs. nannou's async `capture_frame`, which +//! leaks/cancels its callback when the app loop tears the device down) is not +//! duplicated. -use nannou::draw::{Renderer, RendererBuilder}; -use nannou::prelude::*; use nannou::wgpu; -/// Internal render resolution (square; super-sampled vs. the 960² design). -pub const RES: u32 = 1440; -// sRGB8 so a frame can be read straight back to a PNG without HDR/f16 -// conversion. Blending on an sRGB target is done in linear space by the GPU, -// so additive bloom still behaves. -const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8UnormSrgb; - -/// Plain additive blend (`dst + src`). nannou's `blend::ADD` is -/// `src·src + dst·dst`, which is *not* what we want for HDR accumulation. -pub const ADDITIVE: wgpu::BlendComponent = wgpu::BlendComponent { - src_factor: wgpu::BlendFactor::One, - dst_factor: wgpu::BlendFactor::One, - operation: wgpu::BlendOperation::Add, -}; - -/// Fully-transparent clear so render-to-texture starts each pass blank -/// (nannou loads existing contents when a Draw has no background). -fn clear() -> nannou::color::Srgba { - srgba(0.0, 0.0, 0.0, 0.0) -} - -pub struct Post { - renderer: Renderer, - scene: wgpu::Texture, - acc: [wgpu::Texture; 2], - cur: usize, - primed: bool, // false until the accumulator holds real (non-garbage) data -} - -fn make_tex(device: &wgpu::Device) -> wgpu::Texture { - wgpu::TextureBuilder::new() - .size([RES, RES]) - .format(FMT) - .usage( - wgpu::TextureUsages::RENDER_ATTACHMENT - | wgpu::TextureUsages::TEXTURE_BINDING - | wgpu::TextureUsages::COPY_SRC, - ) - .build(device) -} - -impl Post { - pub fn res() -> f32 { - RES as f32 - } - - pub fn new(device: &wgpu::Device) -> Self { - let renderer = RendererBuilder::new().build(device, [RES, RES], 1.0, 1, FMT); - Post { - renderer, - scene: make_tex(device), - acc: [make_tex(device), make_tex(device)], - cur: 0, - primed: false, - } - } - - /// Render `scene_draw` through the feedback chain. `bg` is the field - /// colour the trails decay toward, `fade` the per-frame decay (0 = endless - /// trails, 1 = none), `zoom` the feedback bloom expansion (~1.004). - /// Returns the texture to present this frame. - pub fn render( - &mut self, - device: &wgpu::Device, - queue: &wgpu::Queue, - scene_draw: &Draw, - bg: [f32; 3], - fade: f32, - zoom: f32, - ) -> &wgpu::Texture { - let prev = self.cur; - let next = 1 - self.cur; - let s = RES as f32; - - // Pass 1: sigil -> scene texture (cleared transparent first). - scene_draw.background().color(clear()); - let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { - label: Some("sigil-post"), - }); - self.renderer - .render_to_texture(device, &mut enc, scene_draw, &self.scene); - - // Pass 2: composite -> acc[next]. Every step is a convex over-blend so - // the buffer can never exceed 1.0 (no additive runaway to white): - // clear - // -> previous accumulator, slightly zoomed (trail, opaque) - // -> bg rect at alpha=fade (decays trail toward bg) - // -> fresh scene composited over (new figure on trails) - // Trail length ~ 1/fade frames; bloom comes from the zoom spread plus - // the per-stroke faux-glow halos, not from unbounded accumulation. - let comp = Draw::new(); - if self.primed { - comp.background().color(clear()); - comp.texture(&self.acc[prev]).w_h(s * zoom, s * zoom); - comp.rect() - .w_h(s, s) - .color(srgba(bg[0], bg[1], bg[2], fade.clamp(0.0, 1.0))); - } else { - // First frame: no valid history yet — start from the bg field - // instead of the texture's uninitialised garbage. - comp.background().color(srgba(bg[0], bg[1], bg[2], 1.0)); - self.primed = true; - } - comp.texture(&self.scene).w_h(s, s); - self.renderer - .render_to_texture(device, &mut enc, &comp, &self.acc[next]); - - queue.submit(Some(enc.finish())); - self.cur = next; - &self.acc[self.cur] - } - - /// Most recent accumulator (for the no-feedback bypass / direct present). - pub fn current(&self) -> &wgpu::Texture { - &self.acc[self.cur] - } - - /// Synchronously read the current accumulator back to the CPU as tightly - /// packed RGBA8. The per-frame source for both the PNG path and the - /// streaming-to-ffmpeg render path. See [`read_texture_rgba`]. - pub fn capture_raw( - &self, - device: &wgpu::Device, - queue: &wgpu::Queue, - ) -> anyhow::Result> { - read_texture_rgba(device, queue, &self.acc[self.cur]) - } - - /// Read the current accumulator back and write it as a PNG (manual `P` - /// screenshot). Thin wrapper over [`Self::capture_raw`]. - pub fn capture_png( - &self, - device: &wgpu::Device, - queue: &wgpu::Queue, - path: &std::path::Path, - ) -> anyhow::Result<()> { - let pixels = self.capture_raw(device, queue)?; - let img = nannou::image::RgbaImage::from_raw(RES, RES, pixels) - .ok_or_else(|| anyhow::anyhow!("image buffer size mismatch"))?; - img.save(path)?; - Ok(()) - } - - /// Render `scene_draw` straight into the accumulator (feedback bypass). - pub fn render_direct( - &mut self, - device: &wgpu::Device, - queue: &wgpu::Queue, - scene_draw: &Draw, - ) -> &wgpu::Texture { - let next = 1 - self.cur; - scene_draw.background().color(clear()); - let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { - label: Some("sigil-direct"), - }); - self.renderer - .render_to_texture(device, &mut enc, scene_draw, &self.acc[next]); - queue.submit(Some(enc.finish())); - self.cur = next; - &self.acc[self.cur] - } -} - -/// Synchronously read a `RES`×`RES` `Rgba8UnormSrgb` texture (must carry -/// `COPY_SRC`) back to the CPU as tightly packed RGBA8 (`RES*RES*4` bytes, no -/// row padding). Uses an explicit `device.poll(Wait)` so the buffer map always +/// Synchronously read a `w`×`h` `Rgba8UnormSrgb` texture (must carry +/// `COPY_SRC`) back to the CPU as tightly packed RGBA8 (`w*h*4` bytes, no row +/// padding). Uses an explicit `device.poll(Wait)` so the buffer map always /// resolves — unlike nannou's async `capture_frame`, which leaks/cancels its -/// map callbacks when the app loop tears the device down. Shared by [`Post`] -/// and the `breakcore` raymarch target so the leak-safe path lives once. +/// map callbacks when the app loop tears the device down. pub fn read_texture_rgba( device: &wgpu::Device, queue: &wgpu::Queue, tex: &wgpu::Texture, + w: u32, + h: u32, ) -> anyhow::Result> { - let (w, h) = (RES, RES); let unpadded = w * 4; // Rgba8 let align = 256u32; let padded = unpadded.div_ceil(align) * align; diff --git a/src/viz/scope.rs b/src/viz/scope.rs deleted file mode 100644 index 24de51c..0000000 --- a/src/viz/scope.rs +++ /dev/null @@ -1,259 +0,0 @@ -//! Oscilloscope *art* — vector-display structures in the spirit of -//! oscilloscope-music visuals (Jerobeam Fenderson / sakr / OsciStudio). -//! -//! A phosphor beam traces a deterministic 3D wireframe **figure** — a torus -//! knot, a Gielis supershape, a 3D Lissajous, a harmonograph, a rose-helix — -//! whose parameters are seeded so every track/seed yields a distinct object. -//! The figure is not chaotic frame-to-frame: it holds, and *morphs* into a -//! freshly-seeded figure on a strong broadband transient (cooldown-gated, like -//! the sigil's restructure), the two point-sets lerped so the change reads as -//! the music turning a corner rather than a glitch. -//! -//! Audio drives it continuously: rotation from mid/low, a breathing scale from -//! low/loud, slow figure-parameter drift from spectral brightness, and a gentle -//! beam-noise wobble from the live waveform + flux — so it captures what is -//! playing *now* while staying a coherent shape. -//! -//! Rendering is vector-display: a faint continuous beam, brightened where the -//! beam moves slowly (the real-scope intensity trick), dithered into dots, over -//! a faint CRT grain, near-monochrome (the palette desaturated so the hue still -//! drifts with timbre). The post stack's feedback gives the phosphor decay. -//! -//! Determinism: `Rng` is only advanced in `update` (figure selection); the -//! dither/grain are pure hashes of (index, frame). `update` runs once per -//! frame, so live and `--render` stay bit-identical per seed + timeline. - -use crate::audio::{Bands, WAVE_N}; -use crate::viz::curve::{Rng, flow}; -use crate::viz::geometry::Figure; -use crate::viz::math::MorphState; -use crate::viz::palette::Palette; -use nannou::prelude::*; - -const FIELD: f32 = 960.0; // design-space extent (matches sigil/post) -const N: usize = 1600; // beam samples per figure -const MORPH_SECS: f32 = 0.85; // figure cross-fade time - -/// Stateless hash -> 0..1 (ordered dither + grain; deterministic per frame). -fn h01(a: u32, b: u32) -> f32 { - let mut x = a.wrapping_mul(0x9E37_79B1) ^ b.wrapping_mul(0x85EB_CA77) ^ 0xC2B2_AE3D; - x ^= x >> 15; - x = x.wrapping_mul(0x2545_F491); - x ^= x >> 13; - (x >> 9) as f32 / (1u32 << 23) as f32 -} - -// `Figure` (torus knot / Gielis supershape / 3D Lissajous / harmonograph / -// rose-helix) is shared geometry — see `crate::viz::geometry` (imported -// above). The scope only seeds (`Figure::random`) and samples (`.at`) it. - -pub struct Scope { - pub seed: u64, - rng: Rng, - morph: MorphState
, // hold-and-cross-fade between seeded figures - yaw: f32, - pitch: f32, - roll: f32, - breathe: f32, - prev_flux: f32, - wave: [f32; WAVE_N], - loud: f32, - flux: f32, - centroid: f32, - t: f32, -} - -impl Scope { - pub fn new(seed: u64) -> Self { - let mut rng = Rng::new(seed ^ 0x05C0_BE11); - let fig = Figure::random(&mut rng); - Scope { - seed, - rng, - morph: MorphState::new(fig), - yaw: 0.0, - pitch: 0.0, - roll: 0.0, - breathe: 0.0, - prev_flux: 0.0, - wave: [0.0; WAVE_N], - loud: 0.0, - flux: 0.0, - centroid: 0.0, - t: 0.0, - } - } - - pub fn reseed(&mut self, seed: u64) { - *self = Scope::new(seed); - } - - pub fn point_count(&self) -> usize { - N - } - - /// Begin a morph into a freshly-seeded figure. - fn restructure(&mut self) { - let fig = Figure::random(&mut self.rng); - self.morph.begin(fig, 1.2); - } - - pub fn update(&mut self, b: &Bands, dt: f32) { - let dt = dt.clamp(0.0, 0.05); - self.t += dt; - self.wave = b.wave; - self.loud = b.loud; - self.flux = b.flux; - self.centroid = b.centroid; - - // Smooth, music-locked motion (no random snaps). - self.yaw += (0.14 + 0.85 * b.mid) * dt; - self.pitch += (0.06 + 0.45 * b.low) * dt + 0.025 * dt; - 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; 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. - let rising = b.flux > 0.6 && self.prev_flux <= 0.6; - if self.morph.ready() && (rising || self.morph.idle > 12.0) { - self.restructure(); - } - self.prev_flux = b.flux; - } - - /// Near-monochrome phosphor: keep the palette's hue drift but pull most of - /// the chroma out and lift luminance so it reads as a vector display. - fn phosphor(c: [f32; 4]) -> [f32; 4] { - let lum = 0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2]; - let mix = 0.62; - [ - ((c[0] * (1.0 - mix) + lum * mix) * 1.18).min(1.0), - ((c[1] * (1.0 - mix) + lum * mix) * 1.18).min(1.0), - ((c[2] * (1.0 - mix) + lum * mix) * 1.18).min(1.0), - c[3], - ] - } - - #[allow(clippy::too_many_arguments)] - pub fn draw( - &self, - draw: &Draw, - pal: &Palette, - fit: f32, - scale: f32, - warp: f32, - glow: bool, - _seg: usize, - tint: [f32; 3], - ) { - let (sy, cy) = self.yaw.sin_cos(); - let (sp, cp) = self.pitch.sin_cos(); - 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 = 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); - let beam_amp = (0.012 + 0.05 * self.flux) * warp.max(0.2); - - let project = |i: usize| -> (Vec2, f32) { - let u = i as f32 / N as f32; - let a = self.morph.from.at(u); - let mut q = if e < 1.0 { - let bpt = self.morph.to.at(u); - a + (bpt - a) * e - } else { - a - }; - q *= drift * amp; - // beam-signal wobble: the actual waveform perturbs the trace - let wv = self.wave[(i * WAVE_N / N) % WAVE_N]; - let nz = flow(vec2(q.x, q.y), self.t, self.seed as u32); - q.x += nz.x * amp * beam_amp + wv * amp * beam_amp * 1.5; - q.y += nz.y * amp * beam_amp; - // rotate yaw(Y) -> pitch(X) -> roll(Z) - let (x1, z1) = (q.x * cy - q.z * sy, q.x * sy + q.z * cy); - let (y2, z2) = (q.y * cp - z1 * sp, q.y * sp + z1 * cp); - let (x3, y3) = (x1 * cr - y2 * sr, x1 * sr + y2 * cr); - let f = dist / (dist + z2.max(-dist * 0.9)); - (vec2(x3 * f, y3 * f) * fit, z2) - }; - - // Build the screen path + per-segment beam speed (for brightness). - let mut scr: Vec = Vec::with_capacity(N); - for i in 0..N { - scr.push(project(i).0); - } - - let roll_h = self.roll.rem_euclid(std::f32::consts::TAU) / std::f32::consts::TAU; - let base = Self::phosphor(pal.stroke(0.5, (0.5 + 0.5 * self.loud).min(1.0), roll_h)); - let put = |a: Vec2, c: Vec2, w: f32, col: [f32; 4]| { - draw.polyline() - .weight(w) - .points([a, c]) - .color(srgba( - col[0] * tint[0], - col[1] * tint[1], - col[2] * tint[2], - col[3], - )); - }; - - // Faint continuous beam for path continuity (phosphor base + halo). - if glow { - draw.polyline() - .weight(5.0) - .points(scr.iter().cloned()) - .color(srgba( - base[0] * tint[0], - base[1] * tint[1], - base[2] * tint[2], - 0.035, - )); - } - draw.polyline() - .weight(1.0) - .points(scr.iter().cloned()) - .color(srgba( - base[0] * tint[0], - base[1] * tint[1], - base[2] * tint[2], - 0.10, - )); - - // Dithered beam: bright where it moves slowly (real-scope intensity), - // gated by an ordered dither so it reads as grain, not a solid line. - let fr = (self.t * 60.0) as u32; - let s32 = self.seed as u32; - for i in 1..N { - let (a, c) = (scr[i - 1], scr[i]); - let len = (c - a).length().max(1e-3); - // slow beam -> bright; fast beam -> dim (energy spreads over px) - let inten = (10.0 / (1.0 + 0.05 * len)).min(1.0); - let dith = h01(s32 ^ i as u32, fr ^ (i as u32 >> 3)); - if inten < dith * 0.85 { - continue; - } - let mut col = Self::phosphor(pal.stroke(i as f32 / N as f32, 0.6 + 0.4 * self.loud, roll_h)); - col[3] = (0.18 + 0.55 * inten) * (0.7 + 0.3 * self.loud); - put(a, c, 1.0 + 1.4 * inten, col); - } - - // Faint CRT grain so the field is alive even between strokes. - let grain = 90 + (self.loud * 140.0) as usize; - for k in 0..grain { - let gx = (h01(s32 ^ 0x00A1 ^ k as u32, fr) - 0.5) * FIELD * fit; - let gy = (h01(s32 ^ 0x005C ^ k as u32, fr.wrapping_add(7)) - 0.5) * FIELD * fit; - draw.rect().x_y(gx, gy).w_h(1.0, 1.0).color(srgba( - base[0] * tint[0], - base[1] * tint[1], - base[2] * tint[2], - 0.05, - )); - } - } -} diff --git a/src/viz/shader.rs b/src/viz/shader.rs index 59c4167..025958a 100644 --- a/src/viz/shader.rs +++ b/src/viz/shader.rs @@ -7,8 +7,10 @@ //! - 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), +//! - dual `w×h` textures ping-ponged for frame feedback (the shader samples +//! the previous frame at `@binding(1)`, writes the other) — the size is an +//! arbitrary aspect ratio, not a square; the client is responsible for +//! aspect-correcting its ray/projection from the dimensions it passes, //! - per-frame UBO upload + the bind-group swap. //! //! A new GPU visualiser supplies only its UBO type, its `.wgsl`, and the @@ -54,13 +56,15 @@ pub struct ShaderPipeline { } impl ShaderPipeline { - /// Build the pipeline for `wgsl` at `res²` in `format`. Bindings, in WGSL + /// Build the pipeline for `wgsl` at `w×h` in `format`. Bindings, in WGSL /// `@binding` order, are: `0` uniform `U`, `1` previous-frame texture, - /// `2` sampler. Entry points are `vs_main` / `fs_main`. + /// `2` sampler. Entry points are `vs_main` / `fs_main`. `w`/`h` may be any + /// aspect ratio; aspect correction is the client's responsibility. pub fn new( device: &wgpu::Device, wgsl: &str, - res: u32, + w: u32, + h: u32, format: wgpu::TextureFormat, ) -> Self { let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { @@ -70,7 +74,7 @@ impl ShaderPipeline { let mk = || { wgpu::TextureBuilder::new() - .size([res, res]) + .size([w, h]) .format(format) .usage( wgpu::TextureUsages::RENDER_ATTACHMENT diff --git a/src/viz/sigil.rs b/src/viz/sigil.rs deleted file mode 100644 index 40d3d7c..0000000 --- a/src/viz/sigil.rs +++ /dev/null @@ -1,531 +0,0 @@ -//! The living hybrid cyber-organic sigil. -//! -//! A fixed *cyber skeleton* (spine + mirrored branch walks + rings + glyph -//! nodes) gives a stable occult identity. Over it crawls *organic overgrowth*: -//! tendrils that grow along the music, each bound to one log-spectrum band — -//! they extend and branch while their band is loud, wither and retract when it -//! goes quiet, and the whole population is periodically restructured by -//! broadband transients. Everything is rendered as noise-warped Catmull-Rom -//! curves, so the figure breathes and morphs rather than rigidly transforming. - -use crate::audio::{Bands, SPEC_N}; -use nannou::prelude::*; - -use crate::viz::curve::{Rng, catmull_rom, flow, smoothstep}; -use crate::viz::palette::Palette; - -const FIELD: f32 = 960.0; // design-space extent (matches old W/H) -const R_MAX: f32 = FIELD * 0.475; -const SOFT_CAP: usize = 88; // tendril population the field settles toward -const MAX_NODES: usize = 30; - -const TURNS: [f32; 7] = [ - -PI / 3.0, - -PI / 6.0, - -PI / 12.0, - 0.0, - PI / 12.0, - PI / 6.0, - PI / 3.0, -]; - -/// A skeleton stroke (control points, smoothed at draw time). -struct Bone { - ctrl: Vec, - weight: f32, - glyph: bool, -} - -/// One organic overgrowth strand bound to a spectrum band. -struct Tendril { - nodes: Vec, - band: usize, - hue_off: f32, - curl: f32, - width: f32, - vigor: f32, // 0..1 health; drives growth, decays when band quiet - budget: f32, // accumulated growth credit - quiet: f32, // seconds the band has been quiet (-> retract) - depth: u8, -} - -/// Expanding shockwave ring spawned by a big transient. -struct Ring { - r: f32, - speed: f32, - life: f32, - hue_off: f32, -} - -pub struct Sigil { - pub seed: u64, - bones: Vec, - anchors: Vec, // seed points for new tendrils (skeleton extremities) - tendrils: Vec, - rings: Vec, - rng: Rng, - rot: f32, - breathe: f32, - restruct_cd: f32, - prev_flux: f32, -} - -impl Sigil { - pub fn new(seed: u64) -> Self { - let mut s = Sigil { - seed, - bones: Vec::new(), - anchors: Vec::new(), - tendrils: Vec::new(), - rings: Vec::new(), - rng: Rng::new(seed), - rot: 0.0, - breathe: 0.0, - restruct_cd: 0.0, - prev_flux: 0.0, - }; - s.build_skeleton(); - s - } - - pub fn reseed(&mut self, seed: u64) { - *self = Sigil::new(seed); - } - - pub fn tendril_count(&self) -> usize { - self.tendrils.len() - } - - // --- generation -------------------------------------------------------- - - fn build_skeleton(&mut self) { - let mut rng = Rng::new(self.seed ^ 0xB1FF_5EED); - let spine_h = FIELD * 0.40; - let segs = 5 + rng.idx(4); - let mut spine = Vec::with_capacity(segs + 1); - let dy = (2.0 * spine_h) / segs as f32; - let mut y = -spine_h; - for _ in 0..=segs { - spine.push(vec2(rng.range(-22.0, 22.0), y)); - y += dy; - } - self.bones.push(Bone { - ctrl: spine.clone(), - weight: 3.0, - glyph: true, - }); - - let walks = 4 + rng.idx(4); - for _ in 0..walks { - let anchor = spine[1 + rng.idx(spine.len() - 2)]; - let mut p = anchor; - let mut ang = rng.range(-PI / 2.5, PI / 2.5); - let steps = 3 + rng.idx(6); - let mut walk = vec![p]; - for _ in 0..steps { - ang += TURNS[rng.idx(TURNS.len())]; - let len = rng.range(30.0, 95.0); - let mut np = p + vec2(ang.cos(), ang.sin()) * len; - np.x = np.x.clamp(2.0, FIELD * 0.46); - np.y = np.y.clamp(-FIELD * 0.46, FIELD * 0.46); - walk.push(np); - p = np; - } - let tip = *walk.last().unwrap(); - self.anchors.push(tip); - self.anchors.push(vec2(-tip.x, tip.y)); - let w = rng.range(1.2, 2.0); - let mirror: Vec = walk.iter().map(|v| vec2(-v.x, v.y)).collect(); - self.bones.push(Bone { - ctrl: walk, - weight: w, - glyph: false, - }); - self.bones.push(Bone { - ctrl: mirror, - weight: w, - glyph: false, - }); - } - - // Closed ring arcs — full circles read as smooth curves. - let rings = 2 + rng.idx(2); - for _ in 0..rings { - let r = rng.range(FIELD * 0.30, R_MAX); - let mut arc = Vec::with_capacity(33); - for i in 0..=32 { - let th = TAU * i as f32 / 32.0; - arc.push(vec2(r * th.cos(), r * th.sin())); - } - self.bones.push(Bone { - ctrl: arc, - weight: rng.range(0.9, 1.6), - glyph: false, - }); - } - - // A few anchors on the spine itself so growth also erupts from the core. - for &p in spine.iter().skip(1).step_by(2) { - self.anchors.push(p); - } - // Seed an initial sparse population. - for _ in 0..18 { - self.spawn_from_anchor(0.4); - } - } - - fn spawn_from_anchor(&mut self, vigor: f32) { - if self.anchors.is_empty() { - return; - } - let a = self.anchors[self.rng.idx(self.anchors.len())]; - let band = self.rng.idx(SPEC_N); - let out = a.normalize_or_zero(); - let dir = if out.length() < 0.01 { - let t = self.rng.range(0.0, TAU); - vec2(t.cos(), t.sin()) - } else { - out - }; - self.tendrils.push(Tendril { - nodes: vec![a, a + dir * 6.0], - band, - hue_off: self.rng.range(-0.6, 0.6), - curl: self.rng.range(-0.5, 0.5), - width: self.rng.range(0.9, 2.1), - vigor, - budget: 0.0, - quiet: 0.0, - depth: 0, - }); - } - - // --- per-frame growth -------------------------------------------------- - - pub fn update(&mut self, b: &Bands, dt: f32) { - let dt = dt.clamp(0.0, 0.05); - self.rot += b.mid * 0.55 * dt + 0.04 * dt; - self.breathe += dt * (0.3 + b.mid * 1.4 + b.low * 0.6); - - // Map each band to its onset group for branching decisions. - let group_on = |band: usize| -> f32 { - let f = band as f32 / SPEC_N as f32; - if f < 0.33 { - b.low_on - } else if f < 0.66 { - b.mid_on - } else { - b.high_on - } - }; - - let mut spawn_children: Vec<(usize, usize)> = Vec::new(); - for (ti, t) in self.tendrils.iter_mut().enumerate() { - let drive = b.spec[t.band]; - let target = smoothstep(0.04, 0.55, drive) * (0.5 + 0.5 * b.loud); - let k = if target > t.vigor { 0.16 } else { 0.05 }; - t.vigor += (target - t.vigor) * k; - - if drive < 0.06 { - t.quiet += dt; - } else { - t.quiet = (t.quiet - dt * 2.0).max(0.0); - } - - // Grow: spend an energy budget into new curved nodes. - t.budget += (drive * t.vigor) * 26.0 * dt; - while t.budget >= 1.0 && t.nodes.len() < MAX_NODES { - t.budget -= 1.0; - let n = t.nodes.len(); - let prev = t.nodes[n - 2]; - let last = t.nodes[n - 1]; - let mut d = (last - prev).normalize_or_zero(); - if d.length() < 0.01 { - d = vec2(1.0, 0.0); - } - let ang = d.y.atan2(d.x) - + t.curl * 0.35 - + flow(last, self.breathe, self.seed as u32) .x * 0.06; - let step = 9.0 + 26.0 * drive + 4.0 * t.vigor; - let mut np = last + vec2(ang.cos(), ang.sin()) * step; - let rl = np.length(); - if rl > R_MAX { - np *= R_MAX / rl; // curl back along the boundary - t.curl = -t.curl; - } - t.nodes.push(np); - } - - // Wither: sustained quiet retracts the strand tip-first. - if t.quiet > 0.5 && t.nodes.len() > 2 && self.rng.chance(0.20) { - t.nodes.pop(); - } - - // Branch on a strong onset in this band's group. - if t.depth < 2 - && t.nodes.len() > 6 - && group_on(t.band) > 0.6 - && self.rng.chance(0.05 + 0.10 * t.vigor) - { - spawn_children.push((ti, 4 + self.rng.idx(t.nodes.len() - 5))); - } - } - - // Spawn queued children from a mid-node of the parent. - for (pi, ni) in spawn_children { - let (origin, band, hue, depth, width) = { - let p = &self.tendrils[pi]; - ( - p.nodes[ni.min(p.nodes.len() - 1)], - (p.band + 1).min(SPEC_N - 1), - p.hue_off, - p.depth + 1, - p.width * 0.7, - ) - }; - let a = self.rng.range(0.0, TAU); - self.tendrils.push(Tendril { - nodes: vec![origin, origin + vec2(a.cos(), a.sin()) * 6.0], - band, - hue_off: hue + self.rng.range(-0.4, 0.4), - curl: self.rng.range(-0.8, 0.8), - width, - vigor: 0.6, - budget: 0.0, - quiet: 0.0, - depth, - }); - } - - // Cull dead strands (fully withered). - self.tendrils - .retain(|t| !(t.vigor < 0.05 && t.nodes.len() <= 2)); - - // Restructure: a broadband transient erupts new growth + a shockwave, - // and prunes the weakest if the field is overgrown. - self.restruct_cd = (self.restruct_cd - dt).max(0.0); - let rising = b.flux > 0.62 && self.prev_flux <= 0.62; - if rising && self.restruct_cd <= 0.0 { - self.restruct_cd = 0.45; - let burst = 5 + self.rng.idx(7); - for _ in 0..burst { - self.spawn_from_anchor(0.7 + 0.3 * b.loud); - } - self.rings.push(Ring { - r: FIELD * 0.06, - speed: 220.0 + 360.0 * b.loud, - life: 1.0, - hue_off: self.rng.range(0.0, 1.0), - }); - if self.tendrils.len() > SOFT_CAP { - self.tendrils - .sort_by(|a, c| a.vigor.partial_cmp(&c.vigor).unwrap()); - let drop = self.tendrils.len() - SOFT_CAP; - self.tendrils.drain(0..drop); - } - } - self.prev_flux = b.flux; - - // Keep a living minimum so quiet passages still shimmer faintly. - while self.tendrils.len() < 14 { - self.spawn_from_anchor(0.3); - } - - for r in &mut self.rings { - r.r += r.speed * dt; - r.life -= dt * 0.9; - } - self.rings.retain(|r| r.life > 0.0 && r.r < FIELD); - } - - // --- rendering --------------------------------------------------------- - - fn xf(&self, p: Vec2, scale: f32, fit: f32, warp: f32) -> Vec2 { - let s = p * scale; - let (sn, cs) = self.rot.sin_cos(); - let r = vec2(s.x * cs - s.y * sn, s.x * sn + s.y * cs); - let w = flow(r, self.breathe, self.seed as u32 ^ 0x51A6) * warp; - (r + w) * fit - } - - /// Draw the whole sigil into `draw` (design space is `±FIELD/2`, scaled by - /// `fit` to the target). `scale` is the audio breathing scale, `warp` the - /// organic displacement amplitude in design px, `glow` toggles haloing, - /// `tint` is a per-channel RGB multiplier (used for the chromatic- - /// aberration channel passes; pass `[1.0; 3]` normally). - #[allow(clippy::too_many_arguments)] - pub fn draw( - &self, - draw: &Draw, - pal: &Palette, - fit: f32, - scale: f32, - warp: f32, - glow: bool, - seg: usize, - tint: [f32; 3], - ) { - // Skeleton — bright, structural, slight breathing only. - for bone in &self.bones { - let sm = catmull_rom(&bone.ctrl, seg); - let pts: Vec = sm - .iter() - .map(|&p| self.xf(p, scale, fit, warp * 0.45)) - .collect(); - colored_path(draw, &pts, bone.weight, glow, tint, |t| pal.bone(t)); - if bone.glyph { - for (i, &c) in bone.ctrl.iter().enumerate().skip(1) { - if i % 2 == 0 { - continue; - } - self.glyph(draw, c, scale, fit, warp, pal, tint); - } - } - } - - // Expanding shockwave rings. - for ring in &self.rings { - let mut pts = Vec::with_capacity(49); - for i in 0..=48 { - let th = TAU * i as f32 / 48.0; - pts.push(self.xf( - vec2(ring.r * th.cos(), ring.r * th.sin()), - 1.0, - fit, - warp * 0.3, - )); - } - let mut c = pal.bone(ring.hue_off.fract()); - c[3] = ring.life * ring.life * 0.5; - stroke(draw, &pts, 2.0 * ring.life + 0.5, c, glow, tint); - } - - // Organic overgrowth — colour travels root->tip, alpha by vigor. - for t in &self.tendrils { - if t.nodes.len() < 2 { - continue; - } - let sm = catmull_rom(&t.nodes, seg); - let pts: Vec = sm - .iter() - .map(|&p| self.xf(p, scale, fit, warp)) - .collect(); - let w = t.width * (0.4 + 0.6 * t.vigor); - let v = t.vigor; - let ho = t.hue_off; - colored_path(draw, &pts, w, glow && v > 0.4, tint, |tt| { - pal.stroke(tt, v, ho) - }); - // Tip spark on lively strands. - if v > 0.55 { - if let Some(&tip) = pts.last() { - let mut c = pal.stroke(1.0, v, ho); - c[3] = (v - 0.55) * 1.6; - draw.ellipse() - .xy(tip) - .radius(1.5 + 2.5 * v) - .color(srgba(c[0] * tint[0], c[1] * tint[1], c[2] * tint[2], c[3])); - } - } - } - } - - #[allow(clippy::too_many_arguments)] - fn glyph( - &self, - draw: &Draw, - c: Vec2, - scale: f32, - fit: f32, - warp: f32, - pal: &Palette, - tint: [f32; 3], - ) { - let s = 5.0; - let dia = [ - c + vec2(0.0, s), - c + vec2(s, 0.0), - c + vec2(0.0, -s), - c + vec2(-s, 0.0), - c + vec2(0.0, s), - ]; - let pts: Vec = dia - .iter() - .map(|&p| self.xf(p, scale, fit, warp * 0.4)) - .collect(); - stroke(draw, &pts, 1.0, pal.bone(0.5), false, tint); - } -} - -// --- low-level stroke helpers --------------------------------------------- - -#[inline] -fn tint4(c: [f32; 4], t: [f32; 3]) -> [f32; 4] { - [c[0] * t[0], c[1] * t[1], c[2] * t[2], c[3]] -} -#[inline] -fn col4(c: [f32; 4]) -> nannou::color::Srgba { - srgba(c[0], c[1], c[2], c[3]) -} -#[inline] -fn tinted(c: [f32; 4], t: [f32; 3]) -> nannou::color::Srgba { - col4(tint4(c, t)) -} - -/// One polyline with optional faux-glow halo (wide low-alpha passes). -fn stroke(draw: &Draw, pts: &[Vec2], w: f32, c: [f32; 4], glow: bool, tn: [f32; 3]) { - if pts.len() < 2 { - return; - } - if glow { - draw.polyline() - .weight(w * 3.4) - .points(pts.iter().cloned()) - .color(tinted([c[0], c[1], c[2], c[3] * 0.05], tn)); - draw.polyline() - .weight(w * 1.9) - .points(pts.iter().cloned()) - .color(tinted([c[0], c[1], c[2], c[3] * 0.10], tn)); - } - draw.polyline() - .weight(w) - .points(pts.iter().cloned()) - .color(tinted(c, tn)); -} - -/// Polyline whose colour varies along its length (per-segment), tapering the -/// weight root->tip. `col(t)` returns gamma-sRGB rgba for arc-fraction `t`. -fn colored_path( - draw: &Draw, - pts: &[Vec2], - w: f32, - glow: bool, - tn: [f32; 3], - col: impl Fn(f32) -> [f32; 4], -) { - let n = pts.len(); - if n < 2 { - return; - } - if glow { - // Cheap halo: a couple of wide low-alpha passes at mid colour. - let c = col(0.5); - draw.polyline() - .weight(w * 3.2) - .points(pts.iter().cloned()) - .color(tinted([c[0], c[1], c[2], c[3] * 0.045], tn)); - draw.polyline() - .weight(w * 1.8) - .points(pts.iter().cloned()) - .color(tinted([c[0], c[1], c[2], c[3] * 0.09], tn)); - } - for i in 0..n - 1 { - let t = i as f32 / (n - 1) as f32; - let c = col(t); - let ww = (w * (1.0 - 0.55 * t)).max(0.4); - draw.polyline() - .weight(ww) - .points([pts[i], pts[i + 1]]) - .color(tinted(c, tn)); - } -}