shader fix

This commit is contained in:
2026-05-20 17:03:22 +02:00
parent 2c3418f608
commit d54621e0b4
13 changed files with 456 additions and 149 deletions
+69 -68
View File
@@ -38,6 +38,7 @@ use crate::viz::math::{Spring, angle_to};
use crate::viz::palette::{Palette, oklch};
use crate::viz::post::read_texture_rgba;
use crate::viz::shader::ShaderPipeline;
use crate::viz::structure::{Arch, Structure};
use nannou::wgpu;
/// UBO length in f32: **10** std140 rows (40). Header layout is duplicated
@@ -98,6 +99,8 @@ pub struct Monolith {
fp_committed: bool,
accent_class: Accent,
structure: Structure,
// springs
sp_scale: Spring, // sub-bass breath (bulb world scale)
sp_glow: Spring,
@@ -152,6 +155,7 @@ impl Monolith {
fp: Fingerprint::default(),
fp_committed: false,
accent_class: Accent::Cyan,
structure: Structure::new(),
sp_scale: Spring { x: 1.0, v: 0.0 },
sp_glow: Spring { x: 0.55, v: 0.0 },
sp_power: Spring { x: 8.0, v: 0.0 },
@@ -217,6 +221,10 @@ impl Monolith {
self.frame = self.frame.wrapping_add(1);
self.b = *b;
self.structure.update(b, dt);
let arch = self.structure.arch();
let release = self.structure.release();
// Dynamic-range scaled motion (premise: quiet songs move slowly).
let dyn_m = (0.40 + 0.85 * self.fp.dyn_range).clamp(0.40, 1.20);
@@ -230,15 +238,17 @@ impl Monolith {
let flux_q = b.flux * b.flux;
// --- sub-bass breath: sp_scale grows on low²; the kick still reads,
// a mid-range hum doesn't. Range now ≈ 1.0..1.20 instead of ≈1.0..1.55.
let scale_t = 1.0 + 0.20 * low_q + 0.12 * b.low_on;
self.sp_scale.step(scale_t, 4.0 * dyn_m, dt);
let dist_t = 2.8 + 0.25 * low_q + 0.10 * b.low_on;
// a mid-range hum doesn't.
let scale_t = 1.0 + 0.40 * low_q + 0.22 * b.low_on;
self.sp_scale.step(scale_t, 5.0 * dyn_m, dt);
// --- distance: kick pulls back into void; release kick-back shove.
let dist_t = 2.8 + 0.35 * low_q + 0.15 * b.low_on;
self.sp_dist.step(dist_t, 4.0 * dyn_m, dt);
self.sp_dist.x += 1.2 * release * release; // Immediate shove on drop
// --- bulb power: small drift around 8 — mid² nudges it gently and
// the fingerprint's tonality biases the resting point (tonal music
// keeps a cleaner low-power bulb, noisy/atonal goes higher).
// the fingerprint's tonality biases the resting point.
let power_t = 7.8 + 0.5 * self.fp.tonality + 0.2 * (mid_q - 0.25);
self.sp_power.step(power_t.clamp(6.5, 9.5), 2.0, dt);
@@ -250,31 +260,32 @@ impl Monolith {
dt,
);
// --- glitch envelope: smoothed hi-band onset / flux with a deadband
// (subtract `min_trigger` so a steady noise floor produces nothing).
// Slow attack so a single hi-hat tap doesn't ping the grid; slow
// decay so a snare roll holds the effect through the burst.
// --- glitch envelope: smoothed hi-band onset / flux with a deadband.
let raw = 0.7 * b.high_on * b.high_on + 0.35 * flux_q;
let glitch_target = (raw - 0.10).max(0.0).min(1.2);
let a_g = if glitch_target > self.glitch_env {
0.18
let glitch_target = (raw - 0.10).clamp(0.0, 1.2);
let g_tau = if glitch_target > self.glitch_env {
0.08
} else {
0.04
0.40
};
let a_g = 1.0 - (-dt / g_tau).exp();
self.glitch_env += (glitch_target - self.glitch_env) * a_g;
// --- stutter FSM (the "drops to 12 fps" simulation). Triggered only
// by real snare-flux bursts — both bands strong, or one very strong.
// Gate is wide (0.45 s) so a busy fill can't latch repeatedly; the
// hold itself is short (~80 ms) so the freeze reads as a tic, not a
// dropped section.
// by real snare-flux bursts.
self.stutter_gate = (self.stutter_gate - dt).max(0.0);
self.stutter_w = (self.stutter_w - dt / 0.08).max(0.0);
let snare_burst =
(b.high_on > 0.72 && b.flux > 0.65) || b.flux > 0.90 || b.high_on > 0.85;
let snare_burst = (b.high_on > 0.75 && b.flux > 0.70) || b.flux > 0.94 || b.high_on > 0.88;
// Gate stutter more aggressively during drops/builds so it doesn't "stuck" the energy.
let stutter_cooldown = match arch {
Arch::Drop | Arch::Build => 0.65,
_ => 0.45,
};
if snare_burst && self.stutter_gate <= 0.0 {
self.stutter_w = 1.0;
self.stutter_gate = 0.45;
self.stutter_gate = stutter_cooldown;
self.held_yaw = self.yaw;
self.held_pitch = self.pitch;
self.held_roll = self.roll;
@@ -283,24 +294,24 @@ impl Monolith {
self.held_glow = self.sp_glow.x;
}
// --- camera (BPM-paced + small audio jitter). No constant baseline
// beyond tempo — true silence keeps it nearly still. All terms
// gated quadratically so a quiet passage holds a steady frame.
let base_rate = 0.05 + 0.10 * self.fp.tempo_class;
let rate = base_rate * dyn_m;
self.yaw += rate * dt + 0.10 * b.beat * b.beat * dt;
self.pitch += (0.06 * low_q - 0.02) * rate * dt;
self.roll += 0.02 * high_q * dt;
// --- camera: Section-aware rotation rate.
let energy_mult = match arch {
Arch::Drop => 2.4,
Arch::Build => 1.4 + self.structure.tension(),
Arch::Ambient | Arch::Breakdown => 0.45,
_ => 1.0,
};
// --- swirl: mid² accumulates the feedback rotation. Sustained pad
// = a slow drift; sparse mids = nearly stationary trail.
let base_rate = 0.05 + 0.10 * self.fp.tempo_class;
let rate = base_rate * dyn_m * energy_mult;
self.yaw += rate * dt + 0.15 * b.beat * b.beat * dt;
self.pitch += (0.12 * low_q - 0.04) * rate * dt;
self.roll += 0.04 * high_q * dt;
// --- swirl: mid² accumulates the feedback rotation.
self.swirl_phase += (0.15 + 0.6 * mid_q) * 0.30 * dt;
// --- colour inertia. Base hue drifts blue↔purple with centroid; the
// two accents ease toward the neon class + its partner so contrast
// stays alive. Each accent picks up a small audio nudge (mid drifts
// the primary, high drifts the secondary) so the hues breathe with
// the music instead of being statically committed.
// --- colour inertia.
let base_t = (5.0 - b.centroid * 1.4).rem_euclid(TAU);
let acc_t = self.accent_class.hue()
+ (self.fp.chroma_dom as f32) / 12.0 * 0.3
@@ -312,10 +323,7 @@ impl Monolith {
self.hue_a2 = angle_to(self.hue_a2, acc2_t, ha);
}
/// Render this frame into the target and return it. Bin tunables match
/// the other modes' contract so cfg keys stay shared (`fade`/`ca_px`/
/// `drive`/`march_cap`); `warp` is unused — monolith has its own
/// audio-driven swirl so the bin's noise-warp slot is a no-op here.
/// Render this frame into the target and return it.
#[allow(clippy::too_many_arguments)]
pub fn render(
&mut self,
@@ -332,10 +340,9 @@ impl Monolith {
) -> &wgpu::Texture {
let dr = drive.clamp(0.0, 3.0);
let dyn_m = (0.40 + 0.85 * self.fp.dyn_range).clamp(0.40, 1.20);
let arch = self.structure.arch();
// Held-vs-live blending: while stutter is high, upload the held
// values so the picture freezes; the shader also pins the feedback
// floor near 1.0 from `stutter_w` so the trail survives unchanged.
// Held-vs-live blending
let s = self.stutter_w.clamp(0.0, 1.0);
let yaw = self.yaw * (1.0 - s) + self.held_yaw * s;
let pitch = self.pitch * (1.0 - s) + self.held_pitch * s;
@@ -344,10 +351,7 @@ impl Monolith {
let scl = self.sp_scale.x * (1.0 - s) + self.held_scale * s;
let glow = self.sp_glow.x * (1.0 - s) + self.held_glow * s;
// Palette — base lands deep dark silver/blue; two accents on
// contrasting neon classes so a frame never reads as one hue.
// Lightness rides loud² so quiet stays dark; saturation rides
// tonality. Capped low so neither accent can wash the frame.
// Palette
let lo = self.b.loud;
let base = oklch(
(0.14 + 0.14 * lo * lo).min(0.42),
@@ -356,8 +360,6 @@ impl Monolith {
);
let acc_sat = (0.18 + 0.05 * lo) * (0.65 + 0.40 * self.fp.tonality);
let acc = oklch((0.68 + 0.20 * lo).min(0.92), acc_sat, self.hue_a);
// Secondary accent slightly less saturated so the body's primary tint
// still reads — the partner is a *contrast*, not a competing colour.
let acc2 = oklch(
(0.66 + 0.20 * lo).min(0.90),
(acc_sat * 0.85).min(0.30),
@@ -366,29 +368,22 @@ impl Monolith {
let mut u = [0.0f32; UBO_LEN];
// row0 cam — held during stutter (see above)
// row0 cam
u[0] = yaw;
u[1] = pitch;
u[2] = roll;
u[3] = dist.clamp(1.8, 4.5);
// row1 p0 = scale, glow_gain, ca_px, edge_softness
// `scale` (caller's expressive multiplier) rides on top of breath.
u[4] = (scale * scl).clamp(0.4, 1.8);
u[4] = (scale * scl).clamp(0.4, 2.2);
u[5] = glow.clamp(0.18, 0.85);
// CA: small base, stutter lifts it modestly (the smear during freeze
// reads as signal corruption — but not screen-wide prism shimmer).
u[6] = ca_px * (0.6 + 0.4 * dr) * (1.0 + 0.6 * s);
// Tonal music keeps a crisp particle edge; noisy/atonal softens.
u[7] = (0.50 + 0.45 * (1.0 - self.fp.tonality) + 0.15 * self.b.flatness)
.clamp(0.30, 1.10);
u[7] = (0.50 + 0.45 * (1.0 - self.fp.tonality) + 0.15 * self.b.flatness).clamp(0.30, 1.10);
// row2 col0 = base.rgb, fade
u[8] = base[0];
u[9] = base[1];
u[10] = base[2];
// Wider fade (longer trail) when dyn_motion is low (calm tracks leave
// more wake). Drive doesn't pull it shorter — the stutter does.
u[11] = (fade * (1.0 - 0.25 * (1.0 - dyn_m))).clamp(0.02, 1.0);
// row3 col1 = accent.rgb, flash
@@ -403,17 +398,23 @@ impl Monolith {
u[18] = (self.frame & 0xffff) as f32;
u[19] = self.t;
// row5 p2 = march_steps, power_n, feedback_on, world_r (bounding)
// Mandelbulb takes ~8 iters/step — heavier than the capsule field, so
// hold the request slightly lower than breakcore's.
// row5 p2 = march_steps, power_n, feedback_on, world_r
u[20] = (20.0 + 7.0 * dr).clamp(16.0, march_cap.min(96) as f32);
u[21] = self.sp_power.x.clamp(6.5, 9.5);
u[22] = if feedback && self.gpu.primed() { 1.0 } else { 0.0 };
// Bulb fits in r ≈ 1.25; pad for breath + sub-bass extension.
u[23] = (1.30 * scl + 0.20).clamp(1.0, 3.0);
u[22] = if feedback && self.gpu.primed() {
1.0
} else {
0.0
};
u[23] = (1.30 * scl + 0.20).clamp(1.0, 3.5);
// row6 p3 = grain, glitch_a, fog, beat
u[24] = (0.006 + 0.014 * self.b.flux * dr).clamp(0.0, 0.022);
// Gated grain: ambient/breakdown reduces grain base to near zero.
let g_base = match arch {
Arch::Ambient | Arch::Breakdown => 0.001,
_ => 0.005,
};
u[24] = (g_base + 0.012 * self.b.flux * dr).clamp(0.0, 0.022);
u[25] = (self.glitch_env * dr).clamp(0.0, 1.2);
u[26] = (0.30 + 0.35 * self.b.loud).clamp(0.20, 0.75);
u[27] = self.b.beat;
@@ -430,11 +431,11 @@ impl Monolith {
u[34] = self.rw as f32 / self.rh.max(1) as f32;
u[35] = self.fp.tonality.clamp(0.0, 1.0);
// row9 col2 = secondary accent.rgb, _ — paints surface contrast
// row9 col2 = secondary accent.rgb, release
u[36] = acc2[0];
u[37] = acc2[1];
u[38] = acc2[2];
u[39] = 0.0;
u[39] = self.structure.release();
self.gpu.render(device, queue, &u)
}