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)
}
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.chunks_exact(4).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.chunks_exact_mut(4).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;
}
}
}
}
#[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");
}
}