added monolith
This commit is contained in:
Generated
+128
-1
@@ -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]]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+603
-260
File diff suppressed because it is too large
Load Diff
+28
-13
@@ -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<Vec<u8>> {
|
||||
read_texture_rgba(device, queue, self.gpu.current())
|
||||
read_texture_rgba(device, queue, self.gpu.current(), self.rw, self.rh)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+11
-5
@@ -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<f32>, // heat, tension, release, focal
|
||||
p5: vec4<f32>, // high_on, flatness, beat_phase, fog
|
||||
p6: vec4<f32>, // swirl_zoom, swirl_rot, bg_glow, beat
|
||||
p7: vec4<f32>, // res_w, res_h, aspect (w/h), _
|
||||
pts: array<vec4<f32>, 64>, // xyz = point, w = capsule radius
|
||||
};
|
||||
|
||||
@@ -177,11 +180,14 @@ fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
|
||||
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<f32>(in.uv.x * 2.0 - 1.0, 1.0 - in.uv.y * 2.0);
|
||||
let dist = u.cam.w;
|
||||
let ro = vec3<f32>(0.0, 0.0, -dist);
|
||||
let rd = normalize(vec3<f32>(ndc.x, ndc.y, focal));
|
||||
let rd = normalize(vec3<f32>(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<f32> {
|
||||
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<f32> {
|
||||
// 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<f32>(frame, frame * 1.7)) - 0.5) * grain,
|
||||
col = max(col + (hash21(in.uv * vec2<f32>(res_w, res_h) + vec2<f32>(frame, frame * 1.7)) - 0.5) * grain,
|
||||
vec3<f32>(0.0));
|
||||
|
||||
// Phosphor persistence: a *decaying* trail via max() — can never brighten
|
||||
|
||||
+81
-78
@@ -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<dyn Visualizer>` and
|
||||
//! talks to this trait; adding a mode is a new `impl Visualizer` + one arm in
|
||||
//! [`next_visual`], nothing in the bin's hot path.
|
||||
//! The bin holds a `Box<dyn Visualizer>` 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<dyn Visualizer>`.
|
||||
/// [`capture_raw`](Self::capture_raw); a future `Draw` mode would implement
|
||||
/// [`draw`](Self::draw) instead. Object-safe: held as `Box<dyn Visualizer>`.
|
||||
pub trait Visualizer {
|
||||
fn name(&self) -> &'static str;
|
||||
fn seed(&self) -> u64;
|
||||
fn reseed(&mut self, seed: u64);
|
||||
fn update(&mut self, b: &Bands, dt: f32);
|
||||
/// Element count for the HUD (tendrils / beam points / capsules).
|
||||
/// 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<anyhow::Result<Vec<u8>>> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl Visualizer for Sigil {
|
||||
fn name(&self) -> &'static str {
|
||||
"sigil"
|
||||
}
|
||||
fn seed(&self) -> u64 {
|
||||
self.seed
|
||||
}
|
||||
fn reseed(&mut self, seed: u64) {
|
||||
Sigil::reseed(self, seed)
|
||||
}
|
||||
fn update(&mut self, b: &Bands, dt: f32) {
|
||||
Sigil::update(self, b, dt)
|
||||
}
|
||||
fn element_count(&self) -> usize {
|
||||
self.tendril_count()
|
||||
}
|
||||
fn draw(&self, d: &Draw, c: &RenderContext) {
|
||||
Sigil::draw(self, d, c.pal, c.fit, c.scale, c.warp, c.glow, c.seg, c.tint)
|
||||
}
|
||||
}
|
||||
/// 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<dyn Visualizer> {
|
||||
let s = cur.seed();
|
||||
match cur.name() {
|
||||
"sigil" => Box::new(Scope::new(s)),
|
||||
"scope" => Box::new(Breakcore::new(s, device)),
|
||||
_ => Box::new(Sigil::new(s)),
|
||||
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<anyhow::Result<Vec<u8>>> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
+8
-7
@@ -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;
|
||||
|
||||
@@ -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<Vec<u8>> {
|
||||
read_texture_rgba(device, queue, self.gpu.current(), self.rw, self.rh)
|
||||
}
|
||||
}
|
||||
@@ -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<f32>, // yaw, pitch, roll, dist
|
||||
p0: vec4<f32>, // scale, glow_gain, ca_px, edge_softness
|
||||
col0: vec4<f32>, // base.rgb, fade
|
||||
col1: vec4<f32>, // accent.rgb (primary neon), flash
|
||||
p1: vec4<f32>, // res_w, res_h, frame, time
|
||||
p2: vec4<f32>, // march_steps, power_n, feedback_on, world_r
|
||||
p3: vec4<f32>, // grain, glitch_a, fog, beat
|
||||
p4: vec4<f32>, // loud, low, mid, high
|
||||
p5: vec4<f32>, // stutter_w, swirl, aspect, tonality
|
||||
col2: vec4<f32>, // accent2.rgb (secondary neon), _
|
||||
};
|
||||
|
||||
@group(0) @binding(0) var<uniform> u: U;
|
||||
@group(0) @binding(1) var prev_tex: texture_2d<f32>;
|
||||
@group(0) @binding(2) var prev_smp: sampler;
|
||||
|
||||
struct VsOut {
|
||||
@builtin(position) pos: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
};
|
||||
|
||||
@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<f32>(x, y);
|
||||
o.pos = vec4<f32>(x * 2.0 - 1.0, 1.0 - y * 2.0, 0.0, 1.0);
|
||||
return o;
|
||||
}
|
||||
|
||||
fn hash21(p: vec2<f32>) -> f32 {
|
||||
var q = fract(p * vec2<f32>(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<f32>) -> vec3<f32> {
|
||||
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<f32>(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<f32>, 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<f32>(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>) -> 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<f32>) -> vec3<f32> {
|
||||
let e = vec2<f32>(0.0022, 0.0);
|
||||
return normalize(vec3<f32>(
|
||||
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<f32> {
|
||||
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<f32>(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<f32>(cx + cy * 7.0, stuck_frame)));
|
||||
let sx = (hash21(vec2<f32>(cx * 3.7, cy + stuck_frame)) - 0.5)
|
||||
* 0.045 * glitch_eff * pick;
|
||||
let sy = (hash21(vec2<f32>(cx + cy * 11.0, stuck_frame * 2.0)) - 0.5)
|
||||
* 0.020 * glitch_eff * pick;
|
||||
glitch_off = vec2<f32>(sx, sy);
|
||||
}
|
||||
|
||||
let uv = in.uv;
|
||||
let ndc = vec2<f32>(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0);
|
||||
let dist = u.cam.w;
|
||||
let focal = 1.6;
|
||||
let ro = vec3<f32>(0.0, 0.0, -dist);
|
||||
let rd = normalize(vec3<f32>(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<f32>(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<f32>(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<f32>(res_w, res_h) * 0.85
|
||||
+ vec2<f32>(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<f32>(0.5);
|
||||
let cs = cos(swirl * 0.5); let sn = sin(swirl * 0.5);
|
||||
c2 = vec2<f32>(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<f32>(0.5), vec2<f32>(0.0), vec2<f32>(1.0));
|
||||
}
|
||||
|
||||
// Dense fine grain — sells the "millions of particles" texture.
|
||||
col = max(col + (hash21(uv * vec2<f32>(res_w, res_h)
|
||||
+ vec2<f32>(frame * 1.1, frame * 1.7)) - 0.5) * grain_a,
|
||||
vec3<f32>(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<f32>(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<f32>(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<f32>(0.015, 0.020, 0.035),
|
||||
vec3<f32>(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<f32>(min(col, vec3<f32>(1.0)), 1.0);
|
||||
}
|
||||
+14
-185
@@ -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<f32> {
|
||||
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<Vec<u8>> {
|
||||
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<Vec<u8>> {
|
||||
let (w, h) = (RES, RES);
|
||||
let unpadded = w * 4; // Rgba8
|
||||
let align = 256u32;
|
||||
let padded = unpadded.div_ceil(align) * align;
|
||||
|
||||
@@ -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<Figure>, // 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<Vec2> = 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,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
-6
@@ -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<U> {
|
||||
}
|
||||
|
||||
impl<U: Copy> ShaderPipeline<U> {
|
||||
/// 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<U: Copy> ShaderPipeline<U> {
|
||||
|
||||
let mk = || {
|
||||
wgpu::TextureBuilder::new()
|
||||
.size([res, res])
|
||||
.size([w, h])
|
||||
.format(format)
|
||||
.usage(
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
|
||||
@@ -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<Vec2>,
|
||||
weight: f32,
|
||||
glyph: bool,
|
||||
}
|
||||
|
||||
/// One organic overgrowth strand bound to a spectrum band.
|
||||
struct Tendril {
|
||||
nodes: Vec<Vec2>,
|
||||
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<Bone>,
|
||||
anchors: Vec<Vec2>, // seed points for new tendrils (skeleton extremities)
|
||||
tendrils: Vec<Tendril>,
|
||||
rings: Vec<Ring>,
|
||||
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<Vec2> = 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<Vec2> = 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<Vec2> = 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<Vec2> = 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<f32> {
|
||||
srgba(c[0], c[1], c[2], c[3])
|
||||
}
|
||||
#[inline]
|
||||
fn tinted(c: [f32; 4], t: [f32; 3]) -> nannou::color::Srgba<f32> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user