This commit is contained in:
2026-05-19 01:04:39 +02:00
commit 689c70b530
15 changed files with 7226 additions and 0 deletions
+583
View File
@@ -0,0 +1,583 @@
//! breakcore — chaotic-IDM energy on a smooth, dark cybersigil.
//!
//! Premise → implementation map:
//! §1 geometry : a Lorenz/Rössler strange attractor (chaotic break
//! sections) cross-faded with a distorted parametric
//! torus-knot (held sections), both sampled into `NP`
//! capsule control points.
//! §2 audio : derived entirely from [`Bands`] (low/mid/high split,
//! spectral-flux onsets, centroid, loudness) — `audio.rs`
//! already does the FFT; this never touches it.
//! §3 smoothing: every reactive scalar is a critically-damped [`Spring`];
//! audio sets the *target*, never the value directly, so a
//! kick snaps out and glides back.
//! §4 render : a hand-written wgpu raymarcher (`breakcore.wgsl`) — an
//! SDF capsule chain unioned with a polynomial smooth-min so
//! folds melt, accumulated as volumetric glow over black.
//! §5 sections : long- vs short-term loudness EMAs; a spike past threshold
//! (cooldown-gated) flips attractor⇄knot and reseeds, the
//! two point-sets cross-faded over ~2.6 s.
//!
//! This is the one module that owns a hand-written wgpu pipeline; `post.rs`
//! and the other visualisers stay on nannou's validated renderer. It is *not*
//! a `Draw`-based `Visual`: the bin renders it through [`Breakcore::render`]
//! and presents/captures its target texture directly.
//!
//! Determinism: `Rng` and all integration advance only in [`Breakcore::update`]
//! (one call per frame, live and `--render` alike); the shader is a pure
//! function of the uniform block + hash(fragCoord, frame). So `--render` stays
//! bit-reproducible and there is no per-frame chaos.
use crate::audio::Bands;
use crate::viz::curve::{Rng, fbm};
use crate::viz::palette::Palette;
use crate::viz::post::read_texture_rgba;
use nannou::prelude::*;
use nannou::wgpu;
/// Capsule control points. **MUST** equal the `array<vec4, N>` size and the
/// loop bound in `breakcore.wgsl` (flat-f32 UBO layout depends on it). Kept
/// low: shader cost is O(pixels · march_steps · NP).
pub const NP: usize = 64;
/// UBO length in f32: 6 std140 rows (24) + NP·vec4.
const UBO_LEN: usize = 24 + 4 * NP;
const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8UnormSrgb;
fn smoothstep(t: f32) -> f32 {
let t = t.clamp(0.0, 1.0);
t * t * (3.0 - 2.0 * t)
}
/// Critically-damped spring (premise §3). `omega` is the natural frequency
/// (stiffness); damping is fixed at ζ=1 so it never overshoots — a kick
/// expands instantly then glides back with no ring.
#[derive(Clone, Copy, Default)]
struct Spring {
x: f32,
v: f32,
}
impl Spring {
fn step(&mut self, target: f32, omega: f32, dt: f32) {
// Semi-implicit Euler of x'' = -ω²(x-target) - 2ω x' (ζ = 1).
let a = -(self.x - target) * omega * omega - 2.0 * omega * self.v;
self.v += a * dt;
self.x += self.v * dt;
}
}
/// Which geometry a section shows.
#[derive(Clone, Copy, PartialEq)]
enum Kind {
Attractor,
Knot,
}
/// Lorenz or Rössler — both chaotic, integrated by RK4.
#[derive(Clone, Copy)]
enum Attr {
Lorenz { sigma: f32, rho: f32, beta: f32 },
Rossler { a: f32, b: f32, c: f32 },
}
impl Attr {
fn random(rng: &mut Rng) -> Self {
if rng.chance(0.5) {
Attr::Lorenz {
sigma: 10.0,
rho: rng.range(26.0, 32.0),
beta: 8.0 / 3.0,
}
} else {
Attr::Rossler {
a: rng.range(0.1, 0.22),
b: rng.range(0.1, 0.3),
c: rng.range(4.5, 9.0),
}
}
}
fn deriv(&self, p: Vec3) -> Vec3 {
match *self {
Attr::Lorenz { sigma, rho, beta } => vec3(
sigma * (p.y - p.x),
p.x * (rho - p.z) - p.y,
p.x * p.y - beta * p.z,
),
Attr::Rossler { a, b, c } => {
vec3(-p.y - p.z, p.x + a * p.y, b + p.z * (p.x - c))
}
}
}
fn rk4(&self, p: Vec3, h: f32) -> Vec3 {
let k1 = self.deriv(p);
let k2 = self.deriv(p + k1 * (h * 0.5));
let k3 = self.deriv(p + k2 * (h * 0.5));
let k4 = self.deriv(p + k3 * h);
p + (k1 + k2 * 2.0 + k3 * 2.0 + k4) * (h / 6.0)
}
}
/// One torus-knot config: coprime-ish (p,q) + turns, distorted by highs.
#[derive(Clone, Copy)]
struct Knot {
p: f32,
q: f32,
turns: f32,
}
impl Knot {
fn random(rng: &mut Rng) -> Self {
Knot {
p: (2 + rng.idx(6)) as f32,
q: (1 + rng.idx(7)) as f32,
turns: rng.range(2.0, 4.0),
}
}
fn at(&self, u: f32) -> Vec3 {
let th = std::f32::consts::TAU * self.turns * u;
let r = (self.q * th).cos() + 2.0;
vec3(
r * (self.p * th).cos(),
r * (self.p * th).sin(),
-(self.q * th).sin(),
)
}
}
/// Normalise a point set to ~unit radius so framing stays readable whatever
/// the attractor/knot extent (premise: chaotic but never an unreadable mess).
fn normalize(pts: &mut [Vec3]) {
let mut c = Vec3::ZERO;
for p in pts.iter() {
c += *p;
}
c /= pts.len().max(1) as f32;
let mut m = 1e-6f32;
for p in pts.iter() {
m = m.max((*p - c).length());
}
let s = 0.92 / m;
for p in pts.iter_mut() {
*p = (*p - c) * s;
}
}
pub struct Breakcore {
pub seed: u64,
rng: Rng,
attr: Attr,
knot: Knot,
trail: [Vec3; NP], // rolling attractor trajectory (oldest..newest)
head: Vec3, // current attractor state
from: Kind,
to: Kind,
morph: f32, // 0..1 from→to (1 = settled)
lte: f32, // long-term loudness EMA
ste: f32, // short-term loudness EMA
cooldown: f32,
idle: f32,
sp_scale: Spring,
sp_tube: Spring,
sp_dist: Spring,
sp_glow: Spring,
yaw: f32,
pitch: f32,
roll: f32,
t: f32,
frame: u32,
b: Bands,
gpu: Gpu,
}
impl Breakcore {
pub fn new(seed: u64, device: &wgpu::Device) -> Self {
let mut rng = Rng::new(seed ^ 0xB17E_C0DE_B17E_C0DE);
let attr = Attr::random(&mut rng);
let knot = Knot::random(&mut rng);
Breakcore {
seed,
rng,
attr,
knot,
trail: [Vec3::ZERO; NP],
head: vec3(0.1, 0.0, 0.0),
from: Kind::Knot,
to: Kind::Knot,
morph: 1.0,
lte: 0.0,
ste: 0.0,
cooldown: 0.0,
idle: 0.0,
sp_scale: Spring { x: 1.0, v: 0.0 },
sp_tube: Spring { x: 0.022, v: 0.0 },
sp_dist: Spring { x: 3.2, v: 0.0 },
sp_glow: Spring { x: 0.8, v: 0.0 },
yaw: 0.0,
pitch: 0.0,
roll: 0.0,
t: 0.0,
frame: 0,
b: Bands::default(),
gpu: Gpu::new(device),
}
}
pub fn reseed(&mut self, seed: u64) {
self.seed = seed;
self.rng = Rng::new(seed ^ 0xB17E_C0DE_B17E_C0DE);
self.attr = Attr::random(&mut self.rng);
self.knot = Knot::random(&mut self.rng);
self.from = Kind::Knot;
self.to = Kind::Knot;
self.morph = 1.0;
self.head = vec3(0.1, 0.0, 0.0);
self.trail = [Vec3::ZERO; NP];
self.idle = 0.0;
}
pub fn point_count(&self) -> usize {
NP
}
/// Begin a section change: flip kind, reseed both configs, restart morph.
fn restructure(&mut self) {
self.from = self.to;
self.to = match self.to {
Kind::Attractor => Kind::Knot,
Kind::Knot => Kind::Attractor,
};
self.attr = Attr::random(&mut self.rng);
self.knot = Knot::random(&mut self.rng);
self.morph = 0.0;
self.cooldown = 2.0;
self.idle = 0.0;
}
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;
// §3 springs — audio sets targets, motion stays buttery.
self.sp_scale
.step(1.0 + 0.55 * b.low + 0.5 * b.low_on, 14.0, dt);
self.sp_tube
.step(0.016 + 0.05 * b.mid + 0.02 * b.mid_on, 11.0, dt);
self.sp_dist.step(3.4 - 0.9 * b.low, 6.0, dt); // sub → macro/FOV
self.sp_glow
.step(0.45 + 0.5 * b.loud + 0.4 * b.flux, 9.0, dt);
// Smooth music-locked rotation (no random snaps).
self.yaw += (0.12 + 0.7 * b.mid) * dt;
self.pitch += (0.05 + 0.4 * b.low) * dt + 0.02 * dt;
self.roll += 0.035 * dt + 0.35 * b.high * dt;
// §1 attractor: RK4 substeps, integration speed tracks sub/bass so the
// thread surges on heavy lows. Push the rolling trajectory.
let speed = 0.45 + 1.4 * b.low + 0.5 * b.low_on;
let h = (speed * dt).clamp(0.0, 0.03);
for _ in 0..6 {
self.head = self.attr.rk4(self.head, h);
if !self.head.is_finite() {
self.head = vec3(0.1, 0.0, 0.0);
}
}
self.trail.copy_within(1..NP, 0);
self.trail[NP - 1] = self.head;
// §5 section state machine: long vs short loudness EMAs.
let a_l = 1.0 - (-dt / 8.0).exp();
let a_s = 1.0 - (-dt / 0.22).exp();
self.lte += (b.loud - self.lte) * a_l;
self.ste += (b.loud - self.ste) * a_s;
let ratio = self.ste / (self.lte + 1e-3);
if self.morph < 1.0 {
self.morph = (self.morph + dt / 2.6).min(1.0);
if self.morph >= 1.0 {
self.from = self.to;
}
}
self.cooldown = (self.cooldown - dt).max(0.0);
self.idle += dt;
let drop = ratio > 1.8 && b.flux > 0.55;
if self.morph >= 1.0 && self.cooldown <= 0.0 && (drop || self.idle > 14.0) {
self.restructure();
}
}
/// Sample the active geometry into `NP` control points (xyz + radius).
fn build_points(&self) -> [[f32; 4]; NP] {
let knot = {
let mut v = [Vec3::ZERO; NP];
for (i, slot) in v.iter_mut().enumerate() {
let u = i as f32 / NP as f32;
let mut p = self.knot.at(u);
// §2 highs → high-frequency displacement (jagged sigil edge).
let s = self.seed as u32;
let n = vec3(
fbm(vec2(u * 23.0, 1.0), s),
fbm(vec2(u * 23.0, 5.0), s ^ 0x9E37),
fbm(vec2(u * 23.0, 9.0), s ^ 0x85EB),
);
p += n * (0.05 + 0.6 * self.b.high + 0.5 * self.b.high_on);
*slot = p;
}
normalize(&mut v);
v
};
let attr = {
let mut v = self.trail;
normalize(&mut v);
v
};
let pick = |k: Kind| -> &[Vec3; NP] {
match k {
Kind::Attractor => &attr,
Kind::Knot => &knot,
}
};
let e = smoothstep(self.morph);
let from = pick(self.from);
let to = pick(self.to);
let scale = self.sp_scale.x.clamp(0.4, 2.4);
let mut out = [[0.0f32; 4]; NP];
for i in 0..NP {
let p = (from[i] + (to[i] - from[i]) * e) * scale;
// Radius: tube spring + per-point energy bump from the spectrum.
let band = self.b.spec[(i * crate::audio::SPEC_N) / NP];
let r = (self.sp_tube.x * (0.5 + 0.8 * band)).clamp(0.003, 0.022);
out[i] = [p.x, p.y, p.z, r];
}
out
}
/// Render this frame's raymarch into the target and return it. Mirrors the
/// other modes' tunables: `scale`/`warp` come from the live gain keys,
/// `fade` is the phosphor persistence, `ca_px` the aberration amount.
#[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,
) -> &wgpu::Texture {
let pts = self.build_points();
let base = pal.bone(0.0);
let acc = pal.stroke(1.0, 0.85, 0.0);
let mut u = [0.0f32; UBO_LEN];
// row0 cam
u[0] = self.yaw;
u[1] = self.pitch;
u[2] = self.roll;
u[3] = self.sp_dist.x.clamp(2.0, 6.0) * (1.0 + 0.05 * (1.0 - scale));
// row1 scale,tube,glow,ca
u[4] = scale;
u[5] = self.sp_tube.x;
u[6] = self.sp_glow.x.clamp(0.35, 1.4); // closest-approach glow ≤1.4
u[7] = ca_px;
// row2 base.rgb, fade
u[8] = base[0];
u[9] = base[1];
u[10] = base[2];
u[11] = fade.clamp(0.0, 1.0);
// row3 accent.rgb, flash
u[12] = acc[0];
u[13] = acc[1];
u[14] = acc[2];
u[15] = pal.flash;
// row4 res, frame, n_pts, time
u[16] = Gpu::RES 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);
u[21] = (0.015 + 0.03 * self.b.loud + 0.02 * self.b.flux).clamp(0.01, 0.05);
// first frame has no valid history; gate it like Post::primed
u[22] = if feedback && self.gpu.primed { 1.0 } else { 0.0 };
// bounding-sphere radius: normalized curve (0.92·scale) + max tube.
u[23] = 0.92 * self.sp_scale.x.clamp(0.4, 2.4) + 0.14;
// points
for (i, p) in pts.iter().enumerate() {
let o = 24 + 4 * i;
u[o] = p[0];
u[o + 1] = p[1];
u[o + 2] = p[2];
u[o + 3] = p[3];
}
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())
}
}
// ---------------------------------------------------------------------------
// Isolated wgpu raymarch pipeline. The deliberate exception to the codebase's
// "no hand-written wgpu pipelines" rule; everything risky is contained here.
// ---------------------------------------------------------------------------
struct Gpu {
pipeline: wgpu::RenderPipeline,
ubo: wgpu::Buffer,
tex: [wgpu::Texture; 2],
view: [wgpu::TextureViewHandle; 2],
bind: [wgpu::BindGroup; 2], // bind[w]: writes view[w], samples view[1-w]
cur: usize, // index last written / presented
primed: bool,
}
fn as_bytes(v: &[f32]) -> &[u8] {
// A flat f32 slice has no padding; reinterpret as bytes for write_buffer.
unsafe { std::slice::from_raw_parts(v.as_ptr() as *const u8, std::mem::size_of_val(v)) }
}
impl Gpu {
const RES: u32 = crate::viz::post::RES;
fn new(device: &wgpu::Device) -> Self {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("breakcore-shader"),
source: wgpu::ShaderSource::Wgsl(include_str!("breakcore.wgsl").into()),
});
let mk = || {
wgpu::TextureBuilder::new()
.size([Self::RES, Self::RES])
.format(FMT)
.usage(
wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_SRC,
)
.build(device)
};
let tex = [mk(), mk()];
let view = [
tex[0].create_view(&wgpu::TextureViewDescriptor::default()),
tex[1].create_view(&wgpu::TextureViewDescriptor::default()),
];
// Bindings (order = WGSL @binding 0/1/2): uniform, prev texture, sampler.
let bgl = wgpu::BindGroupLayoutBuilder::new()
.uniform_buffer(wgpu::ShaderStages::FRAGMENT, false)
.texture_from(wgpu::ShaderStages::FRAGMENT, &tex[0])
.sampler(wgpu::ShaderStages::FRAGMENT, true)
.build(device);
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("breakcore-pl"),
bind_group_layouts: &[&bgl],
push_constant_ranges: &[],
});
// Fullscreen triangle: no vertex buffers; default triangle-list.
let pipeline = wgpu::RenderPipelineBuilder::from_layout(&layout, &shader)
.vertex_entry_point("vs_main")
.fragment_shader(&shader)
.fragment_entry_point("fs_main")
.color_format(FMT)
.build(device);
let ubo = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("breakcore-ubo"),
size: (UBO_LEN * 4) as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let sampler = wgpu::SamplerBuilder::new()
.address_mode(wgpu::AddressMode::ClampToEdge)
.mag_filter(wgpu::FilterMode::Linear)
.min_filter(wgpu::FilterMode::Linear)
.mipmap_filter(wgpu::FilterMode::Nearest)
.build(device);
let mk_bind = |w: usize| {
wgpu::BindGroupBuilder::new()
.binding(ubo.as_entire_binding())
.texture_view(&view[1 - w])
.sampler(&sampler)
.build(device, &bgl)
};
let bind = [mk_bind(0), mk_bind(1)];
Gpu {
pipeline,
ubo,
tex,
view,
bind,
cur: 0,
primed: false,
}
}
fn render(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
ubo: &[f32; UBO_LEN],
) -> &wgpu::Texture {
let w = 1 - self.cur; // write target; sample the last-written (self.cur)
queue.write_buffer(&self.ubo, 0, as_bytes(ubo));
let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("breakcore-enc"),
});
{
let mut rp = enc.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("breakcore-pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &self.view[w],
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: true,
},
})],
depth_stencil_attachment: None,
});
rp.set_pipeline(&self.pipeline);
rp.set_bind_group(0, &self.bind[w], &[]);
rp.draw(0..3, 0..1);
}
queue.submit(Some(enc.finish()));
self.cur = w;
self.primed = true;
&self.tex[self.cur]
}
fn current(&self) -> &wgpu::Texture {
&self.tex[self.cur]
}
}
+168
View File
@@ -0,0 +1,168 @@
// breakcore raymarch — dark volumetric cybersigil.
//
// A capsule chain through `pts` (a CPU-integrated strange-attractor /
// distorted-torus-knot curve) unioned with a polynomial smin so folds melt.
// Cost is bounded hard: a ray/bounding-sphere test discards background pixels
// in ~one op, the march is sphere-traced with a low step cap, and brightness
// is a *closest-approach* falloff (not unbounded accumulation) so the field
// stays black with a crisp neon tube + soft halo — no white-out, no GPU hang.
//
// 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).
struct U {
cam: vec4<f32>, // yaw, pitch, roll, dist
p0: vec4<f32>, // scale, tube, glow_gain, ca_px
col0: vec4<f32>, // base.rgb, fade
col1: vec4<f32>, // accent.rgb, flash
p1: vec4<f32>, // res, frame, n_pts, time
p2: vec4<f32>, // march_steps, melt_k, feedback_on, world_r
pts: array<vec4<f32>, 64>, // xyz = point, w = capsule radius
};
@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>,
};
// Fullscreen triangle (no vertex buffer): 3 verts covering clip space.
@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);
}
// Polynomial smooth-min (premise §4): melts intersecting curve folds.
fn smin(a: f32, b: f32, k: f32) -> f32 {
let h = max(k - abs(a - b), 0.0) / max(k, 1e-4);
return min(a, b) - h * h * k * 0.25;
}
fn sd_capsule(p: vec3<f32>, a: vec3<f32>, b: vec3<f32>, r: f32) -> f32 {
let pa = p - a;
let ba = b - a;
let t = clamp(dot(pa, ba) / max(dot(ba, ba), 1e-6), 0.0, 1.0);
return length(pa - ba * t) - r;
}
// yaw(Y) -> pitch(X) -> roll(Z), matching the scope mode's convention.
// Rigid, so it never changes distance-to-origin (bounding sphere stays valid).
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);
}
// Scene SDF: smin-union of the capsule chain (already in rotated space).
fn map(p: vec3<f32>) -> f32 {
let n = i32(u.p1.z);
let k = u.p2.y;
var d = 1e9;
for (var i = 0; i < n - 1; i = i + 1) {
let a = u.pts[i];
let c = u.pts[i + 1];
let r = max(0.5 * (a.w + c.w), 0.004);
d = smin(d, sd_capsule(p, a.xyz, c.xyz, r), k);
}
return d;
}
@fragment
fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
let res = u.p1.x;
let frame = u.p1.y;
let gain = u.p0.z;
let ca_px = u.p0.w;
let base = u.col0.xyz;
let accent = u.col1.xyz;
let flash = u.col1.w;
let rb = u.p2.w; // bounding-sphere radius (curve extent + tube)
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, 1.6));
// Ray vs bounding sphere — discards every background pixel in ~one op,
// which is what keeps this from melting the GPU.
let b = dot(ro, rd);
let c = dot(ro, ro) - rb * rb;
let disc = b * b - c;
var glow = 0.0;
var depth = 0.0;
if (disc > 0.0) {
let sq = sqrt(disc);
var t = max(-b - sq, 0.0);
let t_end = -b + sq;
let span = max(t_end - t, 1e-3);
let min_step = span / 40.0; // guarantees the march finishes
let steps = min(i32(u.p2.x), 40);
var dmin = 1e9;
for (var s = 0; s < steps; s = s + 1) {
let d = map(rot(ro + rd * t));
dmin = min(dmin, d);
if (d < 0.004) {
dmin = 0.0;
depth = clamp((t + b) / max(2.0 * sq, 1e-3), 0.0, 1.0);
break;
}
t = t + max(d * 0.85, min_step);
if (t > t_end) { break; }
}
// Closest-approach falloff. A near-Gaussian core gives a *thin*
// filament; a small, fast-decaying halo is the only volumetric
// spill. Everything more than ~0.1 from the curve is pure black —
// that's what kills the wash. Bounded in [0, ~1.1].
let core = exp(-dmin * dmin * 900.0);
let halo = 0.22 * exp(-dmin * 24.0);
glow = clamp((core + halo) * gain, 0.0, 1.2);
}
// Colour: a saturated, fairly dark hue carries the line; luminance is the
// glow alone, so off-line pixels are black, not grey haze.
var col = mix(base, accent, depth) * (0.45 + 0.55 * depth) * glow;
col = col + accent * flash * glow * glow * 0.4;
// Faint grain so the black field is alive (very low amplitude).
col = max(col + (hash21(in.uv * res + vec2<f32>(frame, frame * 1.7)) - 0.5) * 0.006,
vec3<f32>(0.0));
// Phosphor persistence: a *decaying* trail via max() — can never brighten
// past the fresh frame, so no additive runaway to white. Cheap radial
// chromatic aberration on the trail term only.
if (u.p2.z > 0.5) {
// Shorter than Post's trail: a long phosphor tail on a fat glow reads
// as smear/wash. fade 0.11 -> ~0.67 decay (~a dozen frames).
let decay = clamp(1.0 - 3.0 * u.col0.w, 0.30, 0.90);
let off = (in.uv - vec2<f32>(0.5)) * (ca_px / max(res, 1.0));
let pr = textureSample(prev_tex, prev_smp, in.uv + off).r;
let pg = textureSample(prev_tex, prev_smp, in.uv).g;
let pb = textureSample(prev_tex, prev_smp, in.uv - off).b;
col = max(col, vec3<f32>(pr, pg, pb) * decay);
}
return vec4<f32>(min(col, vec3<f32>(1.0)), 1.0);
}
+132
View File
@@ -0,0 +1,132 @@
//! Geometry helpers: Catmull-Rom smoothing + hand-rolled gradient noise.
//!
//! The sigil stores sparse *control* points; everything is rendered as a
//! Catmull-Rom spline so straight skeleton walks read as flowing curves. A
//! small fBm gradient-noise field domain-warps the sampled points so the whole
//! figure breathes and morphs instead of rigidly transforming.
use nannou::prelude::*;
/// xorshift64* — deterministic, no rng-crate global state. Shared by the
/// visual modules so live and offline renders are bit-identical per seed.
pub struct Rng(u64);
impl Rng {
pub fn new(seed: u64) -> Self {
Rng(seed | 1)
}
fn next_u64(&mut self) -> u64 {
let mut x = self.0;
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
self.0 = x;
x.wrapping_mul(0x2545_F491_4F6C_DD1D)
}
pub fn unit(&mut self) -> f32 {
(self.next_u64() >> 40) as f32 / (1u64 << 24) as f32
}
pub fn range(&mut self, a: f32, b: f32) -> f32 {
a + (b - a) * self.unit()
}
pub fn idx(&mut self, n: usize) -> usize {
(self.next_u64() as usize) % n.max(1)
}
pub fn chance(&mut self, p: f32) -> bool {
self.unit() < p
}
}
pub fn smoothstep(e0: f32, e1: f32, x: f32) -> f32 {
let t = ((x - e0) / (e1 - e0)).clamp(0.0, 1.0);
t * t * (3.0 - 2.0 * t)
}
/// Centripetal-ish Catmull-Rom: sample `seg` points between each control pair.
/// Endpoints are duplicated so the curve passes through the first/last point.
pub fn catmull_rom(ctrl: &[Vec2], seg: usize) -> Vec<Vec2> {
if ctrl.len() < 3 {
return ctrl.to_vec();
}
let n = ctrl.len();
let pt = |i: i32| ctrl[i.clamp(0, n as i32 - 1) as usize];
let mut out = Vec::with_capacity(n * seg);
for i in 0..n - 1 {
let p0 = pt(i as i32 - 1);
let p1 = pt(i as i32);
let p2 = pt(i as i32 + 1);
let p3 = pt(i as i32 + 2);
for s in 0..seg {
let t = s as f32 / seg as f32;
let t2 = t * t;
let t3 = t2 * t;
// Standard Catmull-Rom basis (tension 0.5).
let a = p1 * 2.0;
let b = (p2 - p0) * t;
let c = (p0 * 2.0 - p1 * 5.0 + p2 * 4.0 - p3) * t2;
let d = (-p0 + p1 * 3.0 - p2 * 3.0 + p3) * t3;
out.push((a + b + c + d) * 0.5);
}
}
out.push(ctrl[n - 1]);
out
}
// --- gradient (Perlin-style) noise, 2D, hash-based, no external crate -------
fn hash2(mut x: u32) -> f32 {
x ^= x >> 16;
x = x.wrapping_mul(0x7feb_352d);
x ^= x >> 15;
x = x.wrapping_mul(0x846c_a68b);
x ^= x >> 16;
x as f32 / u32::MAX as f32
}
fn grad(ix: i32, iy: i32, seed: u32) -> Vec2 {
let h = (ix as u32)
.wrapping_mul(0x9E37_79B1)
^ (iy as u32).wrapping_mul(0x85EB_CA77)
^ seed.wrapping_mul(0xC2B2_AE3D);
let a = hash2(h) * std::f32::consts::TAU;
vec2(a.cos(), a.sin())
}
/// Perlin gradient noise in roughly -1..1.
pub fn noise2(p: Vec2, seed: u32) -> f32 {
let xi = p.x.floor() as i32;
let yi = p.y.floor() as i32;
let fx = p.x - xi as f32;
let fy = p.y - yi as f32;
let u = fx * fx * (3.0 - 2.0 * fx);
let v = fy * fy * (3.0 - 2.0 * fy);
let n = |cx: i32, cy: i32| {
let g = grad(cx, cy, seed);
g.dot(vec2(p.x - cx as f32, p.y - cy as f32))
};
let x1 = n(xi, yi) * (1.0 - u) + n(xi + 1, yi) * u;
let x2 = n(xi, yi + 1) * (1.0 - u) + n(xi + 1, yi + 1) * u;
(x1 * (1.0 - v) + x2 * v) * 1.4
}
/// Fractal sum of [`noise2`] — 3 octaves, ~ -1..1.
pub fn fbm(p: Vec2, seed: u32) -> f32 {
let mut a = 0.5;
let mut f = 1.0;
let mut sum = 0.0;
for o in 0..3 {
sum += a * noise2(p * f, seed.wrapping_add(o * 1013));
f *= 2.03;
a *= 0.5;
}
sum
}
/// Curl-ish 2D warp vector from the fBm field (divergence-free-ish flow).
pub fn flow(p: Vec2, t: f32, seed: u32) -> Vec2 {
let e = 0.15;
let q = p * 0.004 + vec2(t * 0.05, -t * 0.04);
let n1 = fbm(q, seed);
let n2 = fbm(q + vec2(e, 0.0), seed);
let n3 = fbm(q + vec2(0.0, e), seed);
vec2(n3 - n1, -(n2 - n1)) / e
}
+12
View File
@@ -0,0 +1,12 @@
//! Visual layer: organic curve geometry, audio-driven OKLCH colour, the
//! living hybrid cyber-organic sigil, and the feedback/bloom post stack.
//!
//! `breakcore` is the one module that owns a hand-written wgpu raymarch
//! pipeline; `post` and everything else stay on nannou's validated renderer.
pub mod breakcore;
pub mod curve;
pub mod palette;
pub mod post;
pub mod scope;
pub mod sigil;
+95
View File
@@ -0,0 +1,95 @@
//! Audio-driven colour. Perceptual OKLCH so hue sweeps stay even in
//! brightness/chroma; converted to gamma sRGB for nannou's `srgba`.
//!
//! centroid -> base hue (brightness of the mix picks the colour)
//! dominant chroma pitch class -> accent hue rotation (harmony tints the edges)
//! loudness -> lightness · broadband flux -> a chroma/lightness flash
use crate::audio::Bands;
fn srgb_encode(c: f32) -> f32 {
let c = c.clamp(0.0, 1.0);
if c <= 0.0031308 {
12.92 * c
} else {
1.055 * c.powf(1.0 / 2.4) - 0.055
}
}
/// OKLCH (L 0..1, C ~0..0.4, H radians) -> gamma sRGB `[r,g,b]`.
pub fn oklch(l: f32, c: f32, h: f32) -> [f32; 3] {
let a = c * h.cos();
let b = c * h.sin();
let l_ = l + 0.3963377774 * a + 0.2158037573 * b;
let m_ = l - 0.1055613458 * a - 0.0638541728 * b;
let s_ = l - 0.0894841775 * a - 1.2914855480 * b;
let (l3, m3, s3) = (l_ * l_ * l_, m_ * m_ * m_, s_ * s_ * s_);
let r = 4.0767416621 * l3 - 3.3077115913 * m3 + 0.2309699292 * s3;
let g = -1.2684380046 * l3 + 2.6097574011 * m3 - 0.3413193965 * s3;
let bl = -0.0041960863 * l3 - 0.7034186147 * m3 + 1.7076147010 * s3;
[srgb_encode(r), srgb_encode(g), srgb_encode(bl)]
}
/// A momentary colour state derived from one analysis frame.
#[derive(Clone, Copy)]
pub struct Palette {
base_h: f32, // radians
accent_h: f32, // radians
light: f32,
chroma: f32,
pub flash: f32, // 0..1 broadband onset, used by post too
}
const TAU: f32 = std::f32::consts::TAU;
impl Palette {
pub fn from_audio(b: &Bands) -> Self {
// Centroid sweeps a deep-violet -> cyan -> warm-gold arc (~250°..30°).
let base_h = (4.4 - b.centroid * 3.1) % TAU;
// Dominant pitch class rotates an accent around the wheel.
let (mut dom, mut dv) = (0usize, 0.0f32);
for (i, &c) in b.chroma.iter().enumerate() {
if c > dv {
dv = c;
dom = i;
}
}
let accent_h = base_h + 1.7 + dom as f32 / 12.0 * TAU * 0.5;
let light = 0.42 + b.loud * 0.34;
let chroma = 0.10 + (b.loud * 0.5 + b.mid * 0.4).min(1.0) * 0.13;
Palette {
base_h,
accent_h,
light,
chroma,
flash: b.flux,
}
}
/// Colour along a strand. `t` 0..1 root->tip blends base->accent hue and
/// fades lightness toward the tip; `vigor` 0..1 scales presence.
/// Returns gamma-sRGB `[r,g,b,a]`.
pub fn stroke(&self, t: f32, vigor: f32, hue_off: f32) -> [f32; 4] {
let h = self.base_h + (self.accent_h - self.base_h) * t + hue_off;
let l = (self.light + 0.18 * (1.0 - t) + self.flash * 0.25).min(0.97);
let c = self.chroma * (0.55 + 0.45 * vigor) + self.flash * 0.04;
let [r, g, bl] = oklch(l, c, h);
let a = (0.30 + 0.70 * vigor) * (0.55 + 0.45 * (1.0 - t * 0.6));
[r, g, bl, a.clamp(0.0, 1.0)]
}
/// Bright structural colour for the cyber skeleton (less hue travel,
/// higher lightness so the bones stay legible under the overgrowth).
pub fn bone(&self, t: f32) -> [f32; 4] {
let h = self.base_h + 0.25 * t;
let l = (self.light + 0.30 + self.flash * 0.2).min(0.99);
let [r, g, bl] = oklch(l, self.chroma * 0.7, h);
[r, g, bl, 0.92]
}
/// Dim background field tint (very low lightness, base hue).
pub fn bg(&self) -> [f32; 3] {
let [r, g, bl] = oklch(0.06 + self.flash * 0.02, 0.03, self.base_h);
[r, g, bl]
}
}
+251
View File
@@ -0,0 +1,251 @@
//! Frame-feedback + bloom post stack, built only from nannou's own validated
//! Draw + offscreen renderer (no hand-written render pipelines).
//!
//! 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.
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
/// 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.
pub fn read_texture_rgba(
device: &wgpu::Device,
queue: &wgpu::Queue,
tex: &wgpu::Texture,
) -> anyhow::Result<Vec<u8>> {
let (w, h) = (RES, RES);
let unpadded = w * 4; // Rgba8
let align = 256u32;
let padded = unpadded.div_ceil(align) * align;
let buf = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("sigil-readback"),
size: (padded as u64) * (h as u64),
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("sigil-readback"),
});
enc.copy_texture_to_buffer(
wgpu::ImageCopyTexture {
texture: &**tex, // nannou Texture -> raw wgpu::Texture
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::ImageCopyBuffer {
buffer: &buf,
layout: wgpu::ImageDataLayout {
offset: 0,
bytes_per_row: Some(padded),
rows_per_image: Some(h),
},
},
wgpu::Extent3d {
width: w,
height: h,
depth_or_array_layers: 1,
},
);
queue.submit(Some(enc.finish()));
let slice = buf.slice(..);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
device.poll(wgpu::Maintain::Wait);
rx.recv()
.map_err(|_| anyhow::anyhow!("map channel closed"))?
.map_err(|e| anyhow::anyhow!("buffer map failed: {e:?}"))?;
let data = slice.get_mapped_range();
let mut pixels = Vec::with_capacity((unpadded * h) as usize);
for row in 0..h {
let s = (row * padded) as usize;
pixels.extend_from_slice(&data[s..s + unpadded as usize]);
}
drop(data);
buf.unmap();
Ok(pixels)
}
+390
View File
@@ -0,0 +1,390 @@
//! 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::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 KINDS: u32 = 5;
const PARAMS: usize = 7;
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
}
fn smoothstep(t: f32) -> f32 {
let t = t.clamp(0.0, 1.0);
t * t * (3.0 - 2.0 * t)
}
/// One figure: a kind tag + its numeric parameters. Sampled into a Vec3 path.
#[derive(Clone, Copy)]
struct Figure {
kind: u32,
p: [f32; PARAMS],
}
impl Figure {
/// Seed a fresh figure. Ratios/petals are small integers so the curves
/// close cleanly (the oscilloscope-art look); free exponents add variety.
fn random(rng: &mut Rng) -> Self {
let kind = (rng.idx(KINDS as usize)) as u32;
let mut p = [0.0f32; PARAMS];
match kind {
// torus knot (p,q): coprime-ish small ints, tube radius
0 => {
p[0] = (2 + rng.idx(6)) as f32;
p[1] = (1 + rng.idx(7)) as f32;
p[2] = rng.range(0.25, 0.6);
p[3] = rng.range(2.0, 4.0); // winds (path loops)
}
// 3D supershape (Gielis): two superformulas, spherical product
1 => {
p[0] = (rng.idx(12) as f32) + 1.0; // m
p[1] = rng.range(0.3, 3.0); // n1
p[2] = rng.range(0.3, 4.0); // n2
p[3] = rng.range(0.3, 4.0); // n3
p[4] = (1 + rng.idx(8)) as f32; // surface-spiral turns
}
// 3D Lissajous: integer freqs + phase offsets
2 => {
p[0] = (1 + rng.idx(7)) as f32;
p[1] = (1 + rng.idx(7)) as f32;
p[2] = (1 + rng.idx(7)) as f32;
p[3] = rng.range(0.0, std::f32::consts::TAU);
p[4] = rng.range(0.0, std::f32::consts::TAU);
}
// harmonograph: damped sum of sinusoids
3 => {
for s in p.iter_mut().take(4) {
*s = (1 + rng.idx(5)) as f32;
}
p[4] = rng.range(0.0, std::f32::consts::TAU);
p[5] = rng.range(0.0, std::f32::consts::TAU);
p[6] = rng.range(0.6, 2.4); // decay
}
// rose-helix: k-petal rose climbing in z
_ => {
p[0] = (2 + rng.idx(9)) as f32; // petals
p[1] = rng.range(3.0, 9.0); // turns
p[2] = rng.range(0.4, 1.1); // height
}
}
Figure { kind, p }
}
/// Sample at `u` in 0..1, returned roughly within a unit-ish box.
fn at(&self, u: f32) -> Vec3 {
let tau = std::f32::consts::TAU;
let p = &self.p;
match self.kind {
0 => {
let t = tau * p[3].max(1.0) * u;
let (pn, qn) = (p[0], p[1]);
let r = 1.0 + p[2] * (qn * t).cos();
vec3(r * (pn * t).cos(), r * (pn * t).sin(), p[2] * (qn * t).sin()) * 0.85
}
1 => {
let sf = |ang: f32, m: f32, n1: f32, n2: f32, n3: f32| -> f32 {
let a = ((m * ang / 4.0).cos().abs()).powf(n2);
let b = ((m * ang / 4.0).sin().abs()).powf(n3);
(a + b).powf(-1.0 / n1.max(0.05)).min(3.0)
};
// wind a spiral over the supershape surface
let lon = (u * p[4].max(1.0) * tau).rem_euclid(tau) - std::f32::consts::PI;
let lat = (u - 0.5) * std::f32::consts::PI;
let r1 = sf(lon, p[0], p[1], p[2], p[3]);
let r2 = sf(lat, p[0], p[1], p[2], p[3]);
vec3(
r1 * lon.cos() * r2 * lat.cos(),
r1 * lon.sin() * r2 * lat.cos(),
r2 * lat.sin(),
) * 0.7
}
2 => {
let t = tau * u;
vec3(
(p[0] * t + p[3]).sin(),
(p[1] * t + p[4]).sin(),
(p[2] * t).sin(),
)
}
3 => {
let t = u * tau * 4.0;
let d = (-p[6] * u).exp();
vec3(
d * ((p[0] * t).sin() + 0.6 * (p[2] * t + p[4]).sin()),
d * ((p[1] * t + p[5]).sin() + 0.6 * (p[3] * t).sin()),
d * (0.5 * ((p[0] + p[1]) * 0.5 * t).sin()),
)
}
_ => {
let th = u * tau * p[1].max(1.0);
let r = (p[0] * th).cos();
vec3(r * th.cos(), r * th.sin(), p[2] * (u - 0.5) * 2.0)
}
}
}
}
pub struct Scope {
pub seed: u64,
rng: Rng,
cur: Figure,
tgt: Figure,
morph: f32, // 0..1 cur->tgt (1 = settled)
yaw: f32,
pitch: f32,
roll: f32,
breathe: f32,
restruct_cd: f32,
prev_flux: f32,
idle: f32, // seconds since last change (quiet-track fallback)
wave: [f32; WAVE_N],
loud: f32,
flux: f32,
centroid: f32,
t: f32,
}
impl Scope {
pub fn new(seed: u64) -> Self {
let mut rng = Rng::new(seed ^ 0x05C0_BE11);
let cur = Figure::random(&mut rng);
Scope {
seed,
rng,
cur,
tgt: cur,
morph: 1.0,
yaw: 0.0,
pitch: 0.0,
roll: 0.0,
breathe: 0.0,
restruct_cd: 0.0,
prev_flux: 0.0,
idle: 0.0,
wave: [0.0; WAVE_N],
loud: 0.0,
flux: 0.0,
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) {
self.tgt = Figure::random(&mut self.rng);
self.morph = 0.0;
self.idle = 0.0;
}
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; settle onto the target.
if self.morph < 1.0 {
self.morph = (self.morph + dt / MORPH_SECS).min(1.0);
if self.morph >= 1.0 {
self.cur = self.tgt;
}
}
// Change figure on a rising broadband transient (cooldown-gated), or
// on a long idle so quiet passages still evolve.
self.restruct_cd = (self.restruct_cd - dt).max(0.0);
self.idle += dt;
let rising = b.flux > 0.6 && self.prev_flux <= 0.6;
if self.morph >= 1.0
&& self.restruct_cd <= 0.0
&& (rising || self.idle > 12.0)
{
self.restruct_cd = 1.2;
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 = smoothstep(self.morph);
// 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.cur.at(u);
let mut q = if e < 1.0 {
let bpt = self.tgt.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,
));
}
}
}
+531
View File
@@ -0,0 +1,531 @@
//! 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));
}
}