Files
effectcraft/crates/raster/src/blur.rs
T
Brandon ThomasandClaude Opus 5.5 a2ff724ac2 M12.1: render cache + parallel compositing fast paths
- render: LayerCache (content-keyed processed-layer cache: source -> masks ->
  effects, keyed by a hash of the layer's non-transform props evaluated at the
  frame time, solid item, switches, scale; time folded in for clock-driven
  effects / wiggle paths). Static and transform-only layers render once;
  edits only re-render the edited layer. Session + UI frame jobs share it.
- effects: TIME_DEPENDENT list + test that every clock-reading effect is listed.
- raster: affine composite fast path (per-row span clipping, unchecked interior
  bilinear, integer-translation copies, inlined Normal blend), checked against
  composite_warp_reference; box/Gaussian blur without transposes, two
  ping-pong buffers; accumulate_warp so motion blur sums into one buffer
  instead of one full-frame image per sub-sample.
- ui: viewer frame is urgent and jumps the prefetch queue; stale-revision jobs
  are dropped; paused prefetch waits for the viewer frame; "Frame Render Time"
  reports viewer frames only; parallel f32 -> Color32 conversion.
- cli: `frame --bench N` (per-layer/per-effect timings, wall + CPU time) and
  `frame --bench-play N` (consecutive frames with/without the layer cache).
- tests: cache transparency (scrub, edits of values/keys/effects/masks/
  solids/text/timing), hit/miss accounting, time-dependent effects, demo
  cached == uncached, fast warp/blur/accumulate == reference.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 21:34:33 -04:00

293 lines
11 KiB
Rust

//! Blurs: separable box passes (exact running sums) approximating Gaussians, plus directional
//! and radial (spin/zoom) blurs.
//!
//! Box passes run straight on row-major pixels: horizontal passes per row, vertical passes over
//! blocks of rows with a per-column running sum (no transposes), ping-ponging between two
//! buffers so a Gaussian allocates two images regardless of the number of passes.
use rayon::prelude::*;
use crate::{Image, Px};
/// One horizontal box pass of radius `r` (window 2r+1) over each row of `src` (width `w`) into
/// `out`; `repeat` = edge pixels extend, otherwise transparent.
fn box_h_into(src: &[Px], out: &mut [Px], w: usize, r: usize, repeat: bool) {
if r == 0 {
out.copy_from_slice(src);
return;
}
let norm = 1.0 / (2 * r + 1) as f32;
out.par_chunks_mut(w).zip(src.par_chunks(w)).for_each(|(o, row)| {
let get = |i: isize| -> Px {
if i < 0 {
if repeat { row[0] } else { [0.0; 4] }
} else if i as usize >= w {
if repeat { row[w - 1] } else { [0.0; 4] }
} else {
row[i as usize]
}
};
let mut acc = [0.0f32; 4];
for i in -(r as isize)..=(r as isize) {
let p = get(i);
for c in 0..4 {
acc[c] += p[c];
}
}
for x in 0..w {
o[x] = acc.map(|v| v * norm);
let add = get(x as isize + r as isize + 1);
let sub = get(x as isize - r as isize);
for c in 0..4 {
acc[c] += add[c] - sub[c];
}
}
});
}
/// One vertical box pass (same semantics as [`box_h_into`] along columns).
fn box_v_into(src: &[Px], out: &mut [Px], w: usize, h: usize, r: usize, repeat: bool) {
if r == 0 {
out.copy_from_slice(src);
return;
}
let norm = 1.0 / (2 * r + 1) as f32;
let block = (4 * r).clamp(32, 256);
let row = |i: isize| -> Option<&[Px]> {
if i < 0 {
repeat.then(|| &src[0..w])
} else if i as usize >= h {
repeat.then(|| &src[(h - 1) * w..h * w])
} else {
Some(&src[i as usize * w..(i as usize + 1) * w])
}
};
out.par_chunks_mut(w * block).enumerate().for_each(|(bi, chunk)| {
let ya = (bi * block) as isize;
let mut acc = vec![[0.0f32; 4]; w];
for i in ya - r as isize..=ya + r as isize {
if let Some(rw) = row(i) {
for (a, p) in acc.iter_mut().zip(rw) {
for c in 0..4 {
a[c] += p[c];
}
}
}
}
for (yy, o) in chunk.chunks_mut(w).enumerate() {
let y = ya + yy as isize;
for (o, a) in o.iter_mut().zip(&acc) {
*o = a.map(|v| v * norm);
}
match (row(y + r as isize + 1), row(y - r as isize)) {
(Some(add), Some(sub)) => {
for ((a, p), q) in acc.iter_mut().zip(add).zip(sub) {
for c in 0..4 {
a[c] += p[c] - q[c];
}
}
}
(Some(add), None) => {
for (a, p) in acc.iter_mut().zip(add) {
for c in 0..4 {
a[c] += p[c];
}
}
}
(None, Some(sub)) => {
for (a, q) in acc.iter_mut().zip(sub) {
for c in 0..4 {
a[c] -= q[c];
}
}
}
(None, None) => {}
}
}
});
}
/// Run horizontal passes with radii `rx` then vertical passes with radii `ry`.
fn box_passes(img: &Image, rx: &[usize], ry: &[usize], repeat: bool) -> Image {
let (w, h) = (img.width as usize, img.height as usize);
let passes: Vec<(bool, usize)> = rx.iter().map(|&r| (false, r)).chain(ry.iter().map(|&r| (true, r))).filter(|p| p.1 > 0).collect();
if passes.is_empty() || w == 0 || h == 0 {
return img.clone();
}
let mut a = Image::new(img.width, img.height);
let mut b = Image::new(img.width, img.height);
for (i, &(vertical, r)) in passes.iter().enumerate() {
let src: &[Px] = if i == 0 { &img.data } else { &a.data };
if vertical {
box_v_into(src, &mut b.data, w, h, r, repeat);
} else {
box_h_into(src, &mut b.data, w, r, repeat);
}
std::mem::swap(&mut a, &mut b);
}
a
}
/// Box radii for `n` passes approximating a Gaussian of `sigma` (Kovesi / "boxes for Gauss").
fn box_radii(sigma: f64, n: usize) -> Vec<usize> {
if sigma <= 0.0 {
return vec![0; n];
}
let w_ideal = (12.0 * sigma * sigma / n as f64 + 1.0).sqrt();
let mut wl = w_ideal.floor() as i64;
if wl % 2 == 0 {
wl -= 1;
}
let wu = wl + 2;
let m_ideal = (12.0 * sigma * sigma - (n as i64 * wl * wl) as f64 - 4.0 * n as f64 * wl as f64 - 3.0 * n as f64) / (-4.0 * wl as f64 - 4.0);
let m = m_ideal.round() as i64;
(0..n as i64).map(|i| (((if i < m { wl } else { wu }) - 1) / 2).max(0) as usize).collect()
}
/// Separable box blur with given radii, `iterations` passes.
pub fn box_blur(img: &Image, rx: usize, ry: usize, iterations: usize, repeat: bool) -> Image {
let n = iterations.max(1);
box_passes(img, &vec![rx; n], &vec![ry; n], repeat)
}
/// Gaussian blur with standard deviations in pixels (3 box passes per axis).
pub fn gaussian_blur(img: &Image, sigma_x: f64, sigma_y: f64, repeat: bool) -> Image {
let rx = if sigma_x > 0.05 { box_radii(sigma_x, 3) } else { vec![] };
let ry = if sigma_y > 0.05 { box_radii(sigma_y, 3) } else { vec![] };
box_passes(img, &rx, &ry, repeat)
}
/// Motion blur along `angle_deg` (0 = vertical in AE's Directional Blur, measured clockwise from
/// up) over `length` pixels.
pub fn directional_blur(img: &Image, angle_deg: f64, length: f64) -> Image {
if length < 0.5 {
return img.clone();
}
let a = angle_deg.to_radians();
let (dx, dy) = (a.sin(), -a.cos());
let n = (length.ceil() as usize * 2 + 1).clamp(3, 257);
let mut out = Image::new(img.width, img.height);
out.rows_mut().for_each(|(y, row)| {
for (x, px) in row.iter_mut().enumerate() {
let mut acc = [0.0f32; 4];
for i in 0..n {
let t = (i as f64 / (n - 1) as f64 - 0.5) * length;
let p = img.sample_bilinear(x as f64 + 0.5 + dx * t, y as f64 + 0.5 + dy * t);
for c in 0..4 {
acc[c] += p[c];
}
}
*px = acc.map(|v| v / n as f32);
}
});
out
}
/// Radial blur around `center`: `spin` (degrees) or zoom (`amount` as a fraction of distance).
pub fn radial_blur(img: &Image, center: (f64, f64), amount: f64, zoom: bool) -> Image {
if amount.abs() < 1e-6 {
return img.clone();
}
let n = 32usize;
let mut out = Image::new(img.width, img.height);
out.rows_mut().for_each(|(y, row)| {
for (x, px) in row.iter_mut().enumerate() {
let (vx, vy) = (x as f64 + 0.5 - center.0, y as f64 + 0.5 - center.1);
let mut acc = [0.0f32; 4];
for i in 0..n {
let t = i as f64 / (n - 1) as f64 - 0.5;
let (sx, sy) = if zoom {
let k = 1.0 + t * amount;
(center.0 + vx * k, center.1 + vy * k)
} else {
let (s, c) = (t * amount).to_radians().sin_cos();
(center.0 + vx * c - vy * s, center.1 + vx * s + vy * c)
};
let p = img.sample_bilinear(sx, sy);
for ch in 0..4 {
acc[ch] += p[ch];
}
}
*px = acc.map(|v| v / n as f32);
}
});
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn gaussian_preserves_mass() {
let mut img = Image::new(64, 64);
img.set(32, 32, [1.0; 4]);
let b = gaussian_blur(&img, 4.0, 4.0, false);
let sum: f32 = b.data.iter().map(|p| p[3]).sum();
assert!((sum - 1.0).abs() < 1e-3, "{sum}");
assert!(b.get(32, 32)[3] < 0.05);
}
#[test]
fn flat_image_stays_flat_with_repeat() {
let img = Image::filled(20, 10, [0.5, 0.5, 0.5, 1.0]);
let b = gaussian_blur(&img, 3.0, 3.0, true);
assert!(b.data.iter().all(|p| (p[0] - 0.5).abs() < 1e-5));
}
/// Direct (windowed sum) box filter along x or y: the definition the passes must match.
fn box_ref(img: &Image, r: usize, vertical: bool, repeat: bool) -> Image {
let mut out = Image::new(img.width, img.height);
let (w, h) = (img.width as i64, img.height as i64);
for y in 0..h {
for x in 0..w {
let mut acc = [0.0f64; 4];
for k in -(r as i64)..=(r as i64) {
let (sx, sy) = if vertical { (x, y + k) } else { (x + k, y) };
let p = if repeat { img.get_clamped(sx, sy) } else { img.get(sx, sy) };
for c in 0..4 {
acc[c] += p[c] as f64;
}
}
out.set(x as u32, y as u32, acc.map(|v| (v / (2 * r + 1) as f64) as f32));
}
}
out
}
#[test]
fn passes_match_direct_box_filter() {
let mut img = Image::new(53, 301);
for y in 0..301 {
for x in 0..53 {
let v = crate::hash_noise(x, y, 3);
img.set(x, y, [v * 0.5, v * 0.25, v, v]);
}
}
for repeat in [false, true] {
for (rx, ry) in [(0, 3), (2, 0), (5, 9), (40, 70), (1, 200)] {
let fast = box_passes(&img, &[rx, rx + 1], &[ry, ry / 2], repeat);
let mut slow = img.clone();
for r in [rx, rx + 1] {
slow = box_ref(&slow, r, false, repeat);
}
for r in [ry, ry / 2] {
slow = box_ref(&slow, r, true, repeat);
}
for (i, (p, q)) in fast.data.iter().zip(&slow.data).enumerate() {
for c in 0..4 {
assert!((p[c] - q[c]).abs() < 1e-4, "r {rx},{ry} repeat {repeat} px {i}: {p:?} vs {q:?}");
}
}
}
}
}
#[test]
fn radii_sane() {
let r = box_radii(10.0, 3);
assert_eq!(r.len(), 3);
assert!(r.iter().all(|&v| (7..=10).contains(&v)), "{r:?}");
}
}