use super::Frame;
use super::prim::{CircleInstance, LineInstance, QuadInstance, RingInstance, shape};
const TAU: f32 = std::f32::consts::TAU;
#[inline]
pub fn lerp_rgba(a: [f32; 4], b: [f32; 4], t: f32) -> [f32; 4] {
let t = t.clamp(0.0, 1.0);
[
a[0] + (b[0] - a[0]) * t,
a[1] + (b[1] - a[1]) * t,
a[2] + (b[2] - a[2]) * t,
a[3] + (b[3] - a[3]) * t,
]
}
pub fn gradient_by_scalar(stops: &[(f32, [f32; 4])], t: f32) -> [f32; 4] {
debug_assert!(!stops.is_empty(), "gradient needs at least one stop");
let t = t.clamp(0.0, 1.0);
if t <= stops[0].0 {
return stops[0].1;
}
for w in stops.windows(2) {
let (p0, c0) = w[0];
let (p1, c1) = w[1];
if t <= p1 {
let span = (p1 - p0).max(1e-6);
return lerp_rgba(c0, c1, (t - p0) / span);
}
}
stops[stops.len() - 1].1
}
#[inline]
pub fn pulse(phase: f32) -> f32 {
0.5 - 0.5 * (phase * TAU).cos()
}
#[inline]
pub fn strobe(phase: f32, duty: f32) -> f32 {
if phase.rem_euclid(1.0) < duty.clamp(0.0, 1.0) { 1.0 } else { 0.0 }
}
#[inline]
pub fn glow_halo(center: [f32; 2], r: f32, color: [f32; 3], intensity: f32, spread: f32) -> RingInstance {
let spread = spread.max(1.05);
RingInstance {
center,
radius: r * spread,
inner: r * 1.05,
color: [color[0], color[1], color[2], intensity.clamp(0.0, 1.0)],
aa: r * 0.5 + 1.5, }
}
#[inline]
pub fn ao_shadow(center: [f32; 2], r: f32, depth: f32) -> CircleInstance {
CircleInstance {
center: [center[0] + r * 0.22, center[1] + r * 0.30],
radius: r * 1.18,
color: [0.0, 0.0, 0.0, depth.clamp(0.0, 1.0)],
aa: r * 0.6 + 1.5,
}
}
pub fn node_decor(
quads: &[QuadInstance],
intensity: f32,
ao: f32,
) -> (Vec<QuadInstance>, Vec<QuadInstance>) {
let mut shadows = Vec::with_capacity(quads.len());
let mut glows = Vec::with_capacity(quads.len());
for q in quads {
if q.shape == shape::SQUARE || q.shape == shape::CIRCLE || q.shape == shape::DIAMOND {
let r = q.radius;
shadows.push(ao_shadow(q.center, r, ao).lower());
glows.push(glow_halo(q.center, r, [q.color[0], q.color[1], q.color[2]], intensity, 2.1).lower());
}
}
(shadows, glows)
}
#[allow(clippy::too_many_arguments)] pub fn dash_line(
a: [f32; 2],
b: [f32; 2],
half_width: f32,
aa: f32,
color: [f32; 4],
dash: f32,
gap: f32,
phase: f32,
) -> Vec<LineInstance> {
let dash = dash.max(0.5);
let gap = gap.max(0.0);
let period = dash + gap;
let dx = b[0] - a[0];
let dy = b[1] - a[1];
let len = (dx * dx + dy * dy).sqrt();
if len < 1e-3 {
return Vec::new();
}
let (ux, uy) = (dx / len, dy / len);
let mut t = -(phase.rem_euclid(1.0) * period);
let mut out = Vec::new();
while t < len {
let s = t.max(0.0);
let e = (t + dash).min(len);
if e > s {
out.push(LineInstance::round(
[a[0] + ux * s, a[1] + uy * s],
[a[0] + ux * e, a[1] + uy * e],
half_width,
aa,
color,
));
}
t += period;
}
out
}
pub fn particles_on_edge(
a: [f32; 2],
b: [f32; 2],
n: usize,
radius: f32,
color: [f32; 4],
phase: f32,
) -> Vec<CircleInstance> {
if n == 0 {
return Vec::new();
}
(0..n)
.map(|i| {
let f = ((i as f32 / n as f32) + phase).rem_euclid(1.0);
let head = 0.55 + 0.45 * f; let c = [color[0], color[1], color[2], (color[3] * head).clamp(0.0, 1.0)];
CircleInstance {
center: [a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f],
radius: radius * (0.6 + 0.6 * f),
color: c,
aa: 1.0,
}
})
.collect()
}
#[inline]
pub fn shockwave_ring(center: [f32; 2], r0: f32, reach: f32, color: [f32; 3], t: f32) -> RingInstance {
let t = t.clamp(0.0, 1.0);
let radius = r0 + reach * t;
let thickness = (reach * 0.10).max(2.0);
RingInstance {
center,
radius,
inner: (radius - thickness).max(0.0),
color: [color[0], color[1], color[2], (1.0 - t).powf(1.4)],
aa: 2.0,
}
}
pub fn bloom_composite(frame: &mut Frame, threshold: f32, radius: u32, intensity: f32) {
if intensity <= 0.0 || radius == 0 || frame.width == 0 || frame.height == 0 {
return;
}
let (w, h) = (frame.width as usize, frame.height as usize);
let radius = radius.min(8) as i32; let n = w * h;
let mut bright = vec![0f32; n * 3];
for (i, px) in frame.rgba.as_chunks::<4>().0.iter().enumerate() {
let r = px[0] as f32 / 255.0;
let g = px[1] as f32 / 255.0;
let b = px[2] as f32 / 255.0;
let luma = 0.2126 * r + 0.7152 * g + 0.0722 * b;
if luma > threshold {
let k = ((luma - threshold) / (1.0 - threshold).max(1e-3)).clamp(0.0, 1.0);
bright[i * 3] = r * k;
bright[i * 3 + 1] = g * k;
bright[i * 3 + 2] = b * k;
}
}
let win = (2 * radius + 1) as f32;
let mut tmp = vec![0f32; n * 3];
for y in 0..h {
for x in 0..w {
let mut acc = [0f32; 3];
for d in -radius..=radius {
let sx = (x as i32 + d).clamp(0, w as i32 - 1) as usize;
let si = (y * w + sx) * 3;
acc[0] += bright[si];
acc[1] += bright[si + 1];
acc[2] += bright[si + 2];
}
let di = (y * w + x) * 3;
tmp[di] = acc[0] / win;
tmp[di + 1] = acc[1] / win;
tmp[di + 2] = acc[2] / win;
}
}
for y in 0..h {
for x in 0..w {
let mut acc = [0f32; 3];
for d in -radius..=radius {
let sy = (y as i32 + d).clamp(0, h as i32 - 1) as usize;
let si = (sy * w + x) * 3;
acc[0] += tmp[si];
acc[1] += tmp[si + 1];
acc[2] += tmp[si + 2];
}
let di = (y * w + x) * 3;
bright[di] = acc[0] / win;
bright[di + 1] = acc[1] / win;
bright[di + 2] = acc[2] / win;
}
}
for (i, px) in frame.rgba.as_chunks_mut::<4>().0.iter_mut().enumerate() {
for k in 0..3 {
let base = px[k] as f32 / 255.0;
let add = bright[i * 3 + k] * intensity;
px[k] = ((base + add) * 255.0).round().clamp(0.0, 255.0) as u8;
}
if px[3] == 0 {
let lit = bright[i * 3] + bright[i * 3 + 1] + bright[i * 3 + 2];
if lit * intensity > 0.02 {
px[3] = (lit * intensity * 255.0).round().clamp(0.0, 255.0) as u8;
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct GlowPass {
pub width_mul: f32,
pub alpha: f32,
pub white: f32,
}
pub const GLOW_LADDER: [GlowPass; 5] = [
GlowPass { width_mul: 14.0, alpha: 0.09, white: 0.0 },
GlowPass { width_mul: 8.0, alpha: 0.16, white: 0.0 },
GlowPass { width_mul: 4.2, alpha: 0.30, white: 0.10 },
GlowPass { width_mul: 2.0, alpha: 0.62, white: 0.55 },
GlowPass { width_mul: 1.0, alpha: 1.0, white: 0.90 },
];
#[must_use]
pub fn glow_stroke_passes(core_width: f32, ink: [f32; 3]) -> Vec<(f32, [f32; 4])> {
glow_stroke_passes_on(core_width, ink, GlowGround::Dark)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GlowGround {
Dark,
Light,
}
const GLOW_LIGHT_DEEPEN_BASE: f32 = 0.30;
const GLOW_LIGHT_DEEPEN_CORE: f32 = 0.30;
const GLOW_LIGHT_ALPHA_GAIN: f32 = 2.2;
#[must_use]
pub fn glow_stroke_passes_on(
core_width: f32,
ink: [f32; 3],
ground: GlowGround,
) -> Vec<(f32, [f32; 4])> {
let core = core_width.max(0.1);
GLOW_LADDER
.iter()
.map(|p| {
let t = p.white.clamp(0.0, 1.0);
let (rgb, alpha) = match ground {
GlowGround::Dark => (
[
ink[0] + (1.0 - ink[0]) * t,
ink[1] + (1.0 - ink[1]) * t,
ink[2] + (1.0 - ink[2]) * t,
],
p.alpha,
),
GlowGround::Light => {
let k = 1.0 - GLOW_LIGHT_DEEPEN_BASE - t * GLOW_LIGHT_DEEPEN_CORE;
(
[ink[0] * k, ink[1] * k, ink[2] * k],
p.alpha * GLOW_LIGHT_ALPHA_GAIN,
)
}
};
(core * p.width_mul, [rgb[0], rgb[1], rgb[2], alpha.clamp(0.0, 1.0)])
})
.collect()
}
#[must_use]
pub fn glow_cross_section(core_width: f32, ink: [f32; 3], ground: GlowGround) -> Vec<(f32, [f32; 4])> {
let passes = glow_stroke_passes_on(core_width, ink, ground);
let mut out: Vec<(f32, [f32; 4])> = Vec::with_capacity(passes.len() + 1);
if let Some((w, c)) = passes.first() {
out.push((w * 0.5, [c[0], c[1], c[2], 0.0]));
}
let mut acc: [f32; 4] = [0.0, 0.0, 0.0, 0.0];
for (w, c) in &passes {
acc = over(*c, acc);
out.push((w * 0.5, acc));
}
out.push((0.0, acc));
out
}
fn over(src: [f32; 4], dst: [f32; 4]) -> [f32; 4] {
let (sa, da) = (src[3].clamp(0.0, 1.0), dst[3].clamp(0.0, 1.0));
let out_a = sa + da * (1.0 - sa);
if out_a <= f32::EPSILON {
return [src[0], src[1], src[2], 0.0];
}
let mix = |s: f32, d: f32| (s * sa + d * da * (1.0 - sa)) / out_a;
[
mix(src[0], dst[0]),
mix(src[1], dst[1]),
mix(src[2], dst[2]),
out_a,
]
}
#[must_use]
pub fn glow_core_coverage() -> f32 {
GLOW_LADDER.iter().fold(0.0, |acc, p| acc + p.alpha.clamp(0.0, 1.0) * (1.0 - acc))
}
#[must_use]
pub fn glow_reach_mul() -> f32 {
GLOW_LADDER.iter().fold(0.0f32, |m, p| m.max(p.width_mul))
}
#[must_use]
pub fn glow_flow_phase(t: f32, period_s: f32) -> f32 {
(t / period_s.max(1e-3)).rem_euclid(1.0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn gradient_by_scalar_ramps_and_clamps() {
let stops = [(0.0, [0.0, 0.0, 1.0, 1.0]), (1.0, [1.0, 0.0, 0.0, 1.0])];
assert_eq!(gradient_by_scalar(&stops, -1.0), stops[0].1, "clamps below");
assert_eq!(gradient_by_scalar(&stops, 2.0), stops[1].1, "clamps above");
let mid = gradient_by_scalar(&stops, 0.5);
assert!((mid[0] - 0.5).abs() < 1e-5 && (mid[2] - 0.5).abs() < 1e-5, "mid blends: {mid:?}");
let three = [(0.0, [0.0; 4]), (0.5, [1.0, 1.0, 1.0, 1.0]), (1.0, [0.0; 4])];
assert!(gradient_by_scalar(&three, 0.25)[0] > 0.4, "rises to the mid stop");
assert!(gradient_by_scalar(&three, 0.75)[0] < 0.6, "falls past the mid stop");
}
#[test]
fn pulse_and_strobe_are_deterministic_envelopes() {
assert!(pulse(0.0).abs() < 1e-5, "pulse troughs at phase 0");
assert!((pulse(0.5) - 1.0).abs() < 1e-5, "pulse peaks at half-cycle");
assert_eq!(pulse(0.25), pulse(0.25), "deterministic");
assert_eq!(strobe(0.05, 0.5), 1.0, "strobe lit in the duty window");
assert_eq!(strobe(0.75, 0.5), 0.0, "strobe dark past the duty window");
assert_eq!(strobe(1.05, 0.5), 1.0, "strobe is periodic (phase wraps)");
}
#[test]
fn dash_line_marches_with_phase() {
let a = [0.0, 0.0];
let b = [100.0, 0.0];
let d0 = dash_line(a, b, 2.0, 1.0, [1.0; 4], 8.0, 6.0, 0.0);
let d1 = dash_line(a, b, 2.0, 1.0, [1.0; 4], 8.0, 6.0, 0.5);
assert!(!d0.is_empty() && !d1.is_empty(), "the dash produced segments");
assert_ne!(d0[0].b[0], d1[0].b[0], "dashes moved along the edge with phase");
let d0b = dash_line(a, b, 2.0, 1.0, [1.0; 4], 8.0, 6.0, 0.0);
assert_eq!(d0[0].a, d0b[0].a, "same phase → same dash (start)");
assert_eq!(d0[0].b, d0b[0].b, "same phase → same dash (end)");
}
#[test]
fn particles_flow_along_the_edge() {
let a = [0.0, 0.0];
let b = [200.0, 0.0];
let p0 = particles_on_edge(a, b, 4, 3.0, [1.0; 4], 0.0);
let p1 = particles_on_edge(a, b, 4, 3.0, [1.0; 4], 0.25);
assert_eq!(p0.len(), 4);
assert!((p0[0].center[0] - 0.0).abs() < 1e-3, "particle 0 at the tail at phase 0");
assert!((p1[0].center[0] - 50.0).abs() < 1e-3, "particle 0 advanced to x=50 at phase 0.25");
}
#[test]
fn shockwave_expands_and_fades() {
let early = shockwave_ring([50.0, 50.0], 10.0, 100.0, [1.0, 0.4, 0.9], 0.1);
let late = shockwave_ring([50.0, 50.0], 10.0, 100.0, [1.0, 0.4, 0.9], 0.9);
assert!(late.radius > early.radius, "the ring expands with t");
assert!(late.color[3] < early.color[3], "and fades out");
assert!(early.inner < early.radius, "valid annulus");
}
#[test]
fn node_decor_emits_shadow_and_glow_per_node() {
let q = QuadInstance {
center: [40.0, 40.0],
radius: 8.0,
inner: 2.0,
color: [0.2, 0.8, 1.0, 1.0],
aa: 1.0,
shape: shape::SQUARE,
_pad: [0.0, 0.0],
};
let (shadows, glows) = node_decor(&[q], 0.5, 0.4);
assert_eq!(shadows.len(), 1, "one AO shadow under the node");
assert_eq!(glows.len(), 1, "one glow halo behind the node");
assert!((glows[0].color[2] - 1.0).abs() < 1e-5, "glow carries the node blue");
assert!((glows[0].color[3] - 0.5).abs() < 1e-5, "glow at the requested intensity");
assert!(shadows[0].color[0] < 0.05, "shadow is black");
assert!(glows[0].radius > q.radius, "glow is larger than the marker (a bleed)");
}
#[test]
fn bloom_spreads_light_to_neighbours() {
let (w, h) = (9u32, 9u32);
let mut rgba = vec![0u8; (w * h * 4) as usize];
let c = ((4 * w + 4) * 4) as usize;
rgba[c..c + 4].copy_from_slice(&[255, 255, 255, 255]);
let mut frame = Frame { width: w, height: h, rgba };
let neighbour = ((4 * w + 5) * 4) as usize; assert_eq!(frame.rgba[neighbour], 0, "neighbour starts dark");
bloom_composite(&mut frame, 0.5, 2, 1.0);
assert!(frame.rgba[neighbour] > 0, "bloom bled light onto the neighbour");
assert!(frame.rgba[neighbour + 3] > 0, "and lifted its alpha so the glow shows");
assert!(frame.rgba[c] > 200, "the source pixel stays bright");
}
#[test]
fn bloom_disabled_is_a_noop() {
let (w, h) = (5u32, 5u32);
let mut rgba = vec![0u8; (w * h * 4) as usize];
rgba[((2 * w + 2) * 4) as usize..((2 * w + 2) * 4) as usize + 4]
.copy_from_slice(&[255, 255, 255, 255]);
let before = rgba.clone();
let mut frame = Frame { width: w, height: h, rgba };
bloom_composite(&mut frame, 0.5, 0, 1.0);
assert_eq!(frame.rgba, before, "radius 0 → no-op");
bloom_composite(&mut frame, 0.5, 2, 0.0);
assert_eq!(frame.rgba, before, "intensity 0 → no-op");
}
fn the_old_three_pass_ladder() -> Vec<(f32, [f32; 4])> {
let ink = [0.0, 0.95, 1.0];
[(9.0f32, 46.0f32), (4.5, 120.0), (1.6, 255.0)]
.iter()
.map(|&(w, a)| (w, [ink[0], ink[1], ink[2], a / 255.0]))
.collect()
}
#[test]
fn the_old_three_pass_ladder_is_not_a_glow() {
let old = the_old_three_pass_ladder();
let core_w = 1.6f32;
let reach = old.iter().fold(0.0f32, |m, &(w, _)| m.max(w)) / core_w;
assert!(reach < 6.0, "the old halo reached only {reach:.1}x the core — a thick line");
let core = old.last().expect("three passes").1;
assert!(core[0] < 0.2, "the old core is saturated cyan (r={}), not hot white", core[0]);
let alphas: Vec<f32> = old.iter().map(|p| p.1[3]).collect();
assert!(alphas[0] > 0.15, "the old outer pass was too opaque to read as bleed: {}", alphas[0]);
}
#[test]
fn the_glow_ladder_reaches_wide_and_burns_white_at_the_core() {
let ink = [0.0, 0.95, 1.0];
let passes = glow_stroke_passes(1.6, ink);
assert_eq!(passes.len(), GLOW_LADDER.len());
let widths: Vec<f32> = passes.iter().map(|p| p.0).collect();
for w in widths.windows(2) {
assert!(w[0] > w[1], "the ladder is drawn widest-first: {widths:?}");
}
assert!((widths[widths.len() - 1] - 1.6).abs() < 1e-4, "the last pass IS the core width");
assert!(glow_reach_mul() >= 10.0, "the halo reaches {}x the core", glow_reach_mul());
let alphas: Vec<f32> = passes.iter().map(|p| p.1[3]).collect();
for a in alphas.windows(2) {
assert!(a[1] > a[0], "alpha rises inward: {alphas:?}");
}
assert!(alphas[0] <= 0.10, "the outer bleed is nearly transparent: {}", alphas[0]);
assert!(alphas[alphas.len() - 1] >= 0.99, "the core is opaque: {:?}", alphas.last());
let steps: Vec<f32> = alphas.windows(2).map(|w| w[1] - w[0]).collect();
for s in steps.windows(2) {
assert!(s[1] > s[0], "the alpha ramp accelerates inward: {steps:?}");
}
let outer = passes[0].1;
assert!((outer[0] - ink[0]).abs() < 1e-6, "the bleed is the pure hue");
let core = passes[passes.len() - 1].1;
assert!(core[0] > 0.85, "the core is white-hot: r={}", core[0]);
assert!(core[2] > 0.95, "…and still carries the hue's blue: b={}", core[2]);
let cov = glow_core_coverage();
assert!(cov > 0.99, "the ladder covers {cov} at the centre — a solid core");
}
#[test]
fn the_flow_phase_is_pure_and_wraps() {
assert!((glow_flow_phase(0.0, 2.0) - 0.0).abs() < 1e-6);
assert!((glow_flow_phase(1.0, 2.0) - 0.5).abs() < 1e-6);
assert!((glow_flow_phase(2.0, 2.0) - 0.0).abs() < 1e-6, "one period wraps to 0");
assert!(
(glow_flow_phase(3.7, 2.0) - glow_flow_phase(5.7, 2.0)).abs() < 1e-5,
"pure and periodic",
);
assert!(glow_flow_phase(1.0, 0.0).is_finite());
}
}
#[cfg(test)]
mod glow_cross_section_tests {
use super::*;
#[test]
fn the_cross_section_peaks_at_the_centre_and_fades_to_nothing() {
for ground in [GlowGround::Dark, GlowGround::Light] {
let xs = glow_cross_section(1.6, [0.2, 0.8, 1.0], ground);
assert!(xs.len() >= 3, "a profile needs an edge, some rings and a centre");
let (edge_off, edge_c) = xs[0];
assert_eq!(edge_c[3], 0.0, "{ground:?}: the ribbon fades OUT, it does not end on an edge");
assert!(edge_off > 0.0, "{ground:?}: the edge is off the centreline");
let (centre_off, centre_c) = *xs.last().expect("a centreline");
assert_eq!(centre_off, 0.0, "{ground:?}: the profile ends ON the centreline");
let mut prev_off = f32::INFINITY;
let mut prev_a = -1.0f32;
for (off, c) in &xs {
assert!(*off <= prev_off, "{ground:?}: offsets go outer → inner, got {off} after {prev_off}");
assert!(
c[3] >= prev_a - 1e-6,
"{ground:?}: alpha DIPS on the way in ({} after {prev_a}) — a dip is a ring, \
and rings are what made it read as a band",
c[3],
);
prev_off = *off;
prev_a = c[3];
}
assert!(
centre_c[3] > edge_c[3],
"{ground:?}: the centreline must be brighter than the edge",
);
let alphas: Vec<f32> = xs.iter().map(|(_, c)| c[3]).collect();
let distinct = {
let mut v = alphas.clone();
v.sort_by(f32::total_cmp);
v.dedup_by(|a, b| (*a - *b).abs() < 1e-6);
v.len()
};
assert!(distinct >= 4, "{ground:?}: a real falloff has steps, got {alphas:?}");
}
}
#[test]
fn the_cross_section_carries_the_stroke_ladders_own_rings() {
let ink = [0.2, 0.8, 1.0];
let passes = glow_stroke_passes_on(1.6, ink, GlowGround::Dark);
let xs = glow_cross_section(1.6, ink, GlowGround::Dark);
let mut want: [f32; 4] = [0.0, 0.0, 0.0, 0.0];
for (i, (w, c)) in passes.iter().enumerate() {
let (sa, da) = (c[3], want[3]);
let oa = sa + da * (1.0 - sa);
want = [
(c[0] * sa + want[0] * da * (1.0 - sa)) / oa,
(c[1] * sa + want[1] * da * (1.0 - sa)) / oa,
(c[2] * sa + want[2] * da * (1.0 - sa)) / oa,
oa,
];
let (off, xc) = xs[i + 1];
assert!(
(off - w * 0.5).abs() < 1e-6,
"ring {i}: the offset is the PASS's half-width — the widths come from \
the ladder even though the colours accumulate",
);
for k in 0..4 {
assert!(
(xc[k] - want[k]).abs() < 1e-5,
"ring {i} channel {k}: {} vs the composite {}",
xc[k],
want[k],
);
}
assert!(
xc[3] >= c[3] - 1e-6,
"ring {i}: accumulating can only make the ribbon MORE opaque, not less",
);
}
let core = xs.last().expect("a centreline");
assert!((core.0 - 0.0).abs() < 1e-6, "the centreline sits at offset 0");
assert!(
(core.1[3] - glow_core_coverage()).abs() < 1e-5,
"the centre's alpha is the ladder's own coverage: {} vs {}",
core.1[3],
glow_core_coverage(),
);
}
}