init
This commit is contained in:
@@ -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]
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user