mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-30 05:16:56 -06:00
Shadows and layer blurs dominated PNG/JPG/WebP export time (gdb stack sampling of the example files: about 45% in fx::box_blur_h, 13% in fx::morph). Both now produce the same bytes, faster: - fx::blur runs its three box passes per row while the row is in cache, works on whole pixels instead of bounds-checked bytes, skips fully transparent rows, transposes in cache-friendly column blocks, and splits rows and transposes across rayon on desktop for pixmaps of 64K pixels and more, only when not already on a rayon worker (a band of render_view stays on its thread: no nested parallelism). - fx::morph (shadow spread on layers that aren't one outline) uses the van Herk / Gil-Werman running max/min: constant work per pixel instead of 2r + 1. Integer arithmetic and pass order are unchanged; new tests compare both against the previous implementations byte for byte, including sizes on the parallel path. An ignored timing test exports every top-level frame of examples/ as PNG, JPG and WebP at 2x through export_file: cargo test --release -p figcraft-engine --lib export_timings -- --ignored --nocapture Ryzen, 16 threads, Linux; median of 7 (min): PNG 2195 ms (2182) -> 992 ms (989) -55% JPG 2880 ms (2560) -> 1550 ms (1535) -46% WebP 1964 ms (1901) -> 1022 ms (1018) -48% total 7035 ms (6677) -> 3567 ms (3555) -49% All 80 exported files (PNG, JPG, WebP, PDF, SVG) are byte-identical. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
523 lines
20 KiB
Rust
523 lines
20 KiB
Rust
//! Raster effects on premultiplied RGBA8 pixmaps: blur (3× box ≈ Gaussian), dilate/erode (spread),
|
||
//! alpha extraction and tinting.
|
||
|
||
use tiny_skia::Pixmap;
|
||
|
||
/// Box radii for three passes approximating a Gaussian of `sigma` (Kovesi).
|
||
fn box_sizes(sigma: f64) -> [usize; 3] {
|
||
let n = 3.0;
|
||
let w_ideal = (12.0 * sigma * sigma / n + 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 * (wl * wl) as f64 - 4.0 * n * wl as f64 - 3.0 * n) / (-4.0 * wl as f64 - 4.0);
|
||
let m = m_ideal.round() as i64;
|
||
let r = |i: i64| -> usize {
|
||
let w = if i < m { wl } else { wu };
|
||
((w.max(1) - 1) / 2) as usize
|
||
};
|
||
[r(0), r(1), r(2)]
|
||
}
|
||
|
||
/// Pixmaps from this many pixels blur and transpose in parallel (desktop).
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
const PARALLEL_PIXELS: usize = 1 << 16;
|
||
|
||
/// Whether `pixels` go across rayon: large pixmaps, and only from outside its pool. A blur on a
|
||
/// pool thread (a band of `render_view`) is already one of several running in parallel.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
fn parallel(pixels: usize) -> bool {
|
||
pixels >= PARALLEL_PIXELS && rayon::current_thread_index().is_none()
|
||
}
|
||
|
||
/// Rows (or transposed columns) per parallel job.
|
||
const ROWS_PER_JOB: usize = 16;
|
||
|
||
/// Run `job` on matching chunks of ROWS_PER_JOB rows of `src` and `dst` (`w` pixels each), in
|
||
/// parallel for large pixmaps on desktop (see [`parallel`]).
|
||
fn row_jobs(src: &[[u8; 4]], dst: &mut [[u8; 4]], w: usize, job: impl Fn(&[[u8; 4]], &mut [[u8; 4]]) + Sync + Send) {
|
||
let n = w.saturating_mul(ROWS_PER_JOB).max(1);
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
if parallel(dst.len()) {
|
||
use rayon::prelude::*;
|
||
src.par_chunks(n).zip(dst.par_chunks_mut(n)).for_each(|(s, d)| job(s, d));
|
||
return;
|
||
}
|
||
src.chunks(n).zip(dst.chunks_mut(n)).for_each(|(s, d)| job(s, d));
|
||
}
|
||
|
||
/// One row box-blurred by radius `r` (transparent past its ends) into `dst`, the same length.
|
||
fn box_blur_row(src: &[[u8; 4]], dst: &mut [[u8; 4]], r: usize) {
|
||
if r == 0 {
|
||
dst.copy_from_slice(src);
|
||
return;
|
||
}
|
||
let div = (2 * r + 1) as u32;
|
||
let mut acc = [0u32; 4];
|
||
for p in src.iter().take(r + 1) {
|
||
for (a, c) in acc.iter_mut().zip(p) {
|
||
*a += u32::from(*c);
|
||
}
|
||
}
|
||
for (x, d) in dst.iter_mut().enumerate() {
|
||
*d = acc.map(|a| (a / div) as u8);
|
||
if let Some(p) = src.get(x + r + 1) {
|
||
for (a, c) in acc.iter_mut().zip(p) {
|
||
*a += u32::from(*c);
|
||
}
|
||
}
|
||
if let Some(p) = x.checked_sub(r).and_then(|i| src.get(i)) {
|
||
for (a, c) in acc.iter_mut().zip(p) {
|
||
*a = a.saturating_sub(u32::from(*c));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Box-blur each `w`-pixel row of `src` by each of `radii` in turn into `dst`. A row is blurred
|
||
/// through all passes while it is in cache; a transparent row stays transparent.
|
||
fn box_blur_rows(src: &[[u8; 4]], dst: &mut [[u8; 4]], w: usize, radii: [usize; 3]) {
|
||
if w == 0 {
|
||
return;
|
||
}
|
||
row_jobs(src, dst, w, |s, d| {
|
||
let mut t = vec![[0u8; 4]; w];
|
||
for (s, d) in s.chunks(w).zip(d.chunks_mut(w)) {
|
||
if s.iter().all(|p| *p == [0; 4]) {
|
||
d.fill([0; 4]);
|
||
continue;
|
||
}
|
||
let t = t.get_mut(..s.len()).unwrap_or_default();
|
||
box_blur_row(s, d, radii[0]);
|
||
box_blur_row(d, t, radii[1]);
|
||
box_blur_row(t, d, radii[2]);
|
||
}
|
||
});
|
||
}
|
||
|
||
/// `src` (`w` × `h` pixels) transposed into `dst` (`h` × `w`), a block of columns at a time.
|
||
fn transpose(src: &[[u8; 4]], dst: &mut [[u8; 4]], w: usize, h: usize) {
|
||
if w == 0 || h == 0 {
|
||
return;
|
||
}
|
||
// Job `i` writes rows `x0..` of `dst` (ROWS_PER_JOB of them): columns `x0..` of `src`.
|
||
let job = |(i, d): (usize, &mut [[u8; 4]])| {
|
||
let (x0, cols) = (i * ROWS_PER_JOB, d.len() / h);
|
||
for (y, row) in src.chunks(w).enumerate() {
|
||
let Some(part) = row.get(x0..x0 + cols) else { continue };
|
||
for (k, p) in part.iter().enumerate() {
|
||
if let Some(o) = d.get_mut(k * h + y) {
|
||
*o = *p;
|
||
}
|
||
}
|
||
}
|
||
};
|
||
let n = h * ROWS_PER_JOB;
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
if parallel(dst.len()) {
|
||
use rayon::prelude::*;
|
||
dst.par_chunks_mut(n).enumerate().for_each(job);
|
||
return;
|
||
}
|
||
dst.chunks_mut(n).enumerate().for_each(job);
|
||
}
|
||
|
||
/// The largest blur standard deviation drawn (pixels): far wider than any layer (at most 16,384
|
||
/// pixels a side), so wider blurs look the same (everything averages out to nothing). The box
|
||
/// passes cost the same at any radius, so the documented blur radii (up to 10,000 units) draw
|
||
/// in full at any zoom.
|
||
pub const MAX_SIGMA: f64 = 1_000_000.0;
|
||
|
||
/// Gaussian-like blur of the whole pixmap with standard deviation `sigma` (pixels): three box
|
||
/// passes along the rows, then three down the columns (as rows of the transposed pixmap).
|
||
pub fn blur(p: &mut Pixmap, sigma: f64) {
|
||
if !(sigma.is_finite() && sigma > 0.3) {
|
||
return;
|
||
}
|
||
let sigma = sigma.min(MAX_SIGMA);
|
||
let (w, h) = (p.width() as usize, p.height() as usize);
|
||
let radii = box_sizes(sigma);
|
||
let n = p.data().len() / 4;
|
||
let mut a = vec![[0u8; 4]; n];
|
||
let mut b = vec![[0u8; 4]; n];
|
||
box_blur_rows(p.data().as_chunks::<4>().0, &mut a, w, radii);
|
||
transpose(&a, &mut b, w, h);
|
||
box_blur_rows(&b, &mut a, h, radii);
|
||
transpose(&a, p.data_mut().as_chunks_mut::<4>().0, h, w);
|
||
}
|
||
|
||
/// For each `x`, the largest (`grow`) or smallest value of `src` over `x - r ..= x + r` (clipped
|
||
/// to its ends) into `out`, in constant time per value whatever `r` (van Herk / Gil-Werman: the
|
||
/// window spans at most two blocks of `2r + 1`, from the running extremes within each block).
|
||
/// `scratch` holds the working lines.
|
||
fn window_extremes(src: &[u8], out: &mut [u8], r: usize, grow: bool, scratch: &mut [Vec<u8>; 3]) {
|
||
let op = |a: u8, b: u8| if grow { a.max(b) } else { a.min(b) };
|
||
// Past the ends: the value that changes nothing (the window is clipped there).
|
||
let id = if grow { 0 } else { 255 };
|
||
let [pad, fwd, back] = scratch;
|
||
pad.clear();
|
||
pad.resize(r, id);
|
||
pad.extend_from_slice(src);
|
||
pad.resize(src.len() + 2 * r, id);
|
||
let k = 2 * r + 1;
|
||
fwd.clear();
|
||
for block in pad.chunks(k) {
|
||
let mut m = id;
|
||
fwd.extend(block.iter().map(|v| {
|
||
m = op(m, *v);
|
||
m
|
||
}));
|
||
}
|
||
back.clear();
|
||
back.resize(pad.len(), id);
|
||
for (block, b) in pad.chunks(k).zip(back.chunks_mut(k)) {
|
||
let mut m = id;
|
||
for (v, o) in block.iter().zip(b.iter_mut()).rev() {
|
||
m = op(m, *v);
|
||
*o = m;
|
||
}
|
||
}
|
||
for (x, o) in out.iter_mut().enumerate() {
|
||
*o = op(back.get(x).copied().unwrap_or(id), fwd.get(x + 2 * r).copied().unwrap_or(id));
|
||
}
|
||
}
|
||
|
||
/// Grow (`r > 0`) or shrink (`r < 0`) the alpha of a pixmap by `r` pixels (square structuring
|
||
/// element, separable max/min). Colour channels follow alpha (tinted afterwards anyway).
|
||
pub fn morph(p: &mut Pixmap, r: i32) {
|
||
if r == 0 {
|
||
return;
|
||
}
|
||
let grow = r > 0;
|
||
let r = r.unsigned_abs().min(400) as usize;
|
||
let (w, h) = (p.width() as usize, p.height() as usize);
|
||
if w == 0 || h == 0 {
|
||
return;
|
||
}
|
||
let alpha: Vec<u8> = p.data().as_chunks::<4>().0.iter().map(|c| c[3]).collect();
|
||
let mut scratch: [Vec<u8>; 3] = Default::default();
|
||
// Shrinking treats outside as empty: the first and last `r` of each line go.
|
||
let edges = |line: &mut [u8]| {
|
||
if !grow {
|
||
let n = line.len();
|
||
for (i, v) in line.iter_mut().enumerate() {
|
||
if i < r || i + r >= n {
|
||
*v = 0;
|
||
}
|
||
}
|
||
}
|
||
};
|
||
let mut a1 = vec![0u8; alpha.len()];
|
||
for (s, o) in alpha.chunks(w).zip(a1.chunks_mut(w)) {
|
||
window_extremes(s, o, r, grow, &mut scratch);
|
||
edges(o);
|
||
}
|
||
// Down the columns.
|
||
let (mut col, mut out) = (vec![0u8; h], vec![0u8; h]);
|
||
let mut a2 = vec![0u8; a1.len()];
|
||
for x in 0..w {
|
||
for (c, v) in col.iter_mut().zip(a1.iter().skip(x).step_by(w)) {
|
||
*c = *v;
|
||
}
|
||
window_extremes(&col, &mut out, r, grow, &mut scratch);
|
||
edges(&mut out);
|
||
for (d, v) in a2.iter_mut().skip(x).step_by(w).zip(&out) {
|
||
*d = *v;
|
||
}
|
||
}
|
||
for (px, a) in p.data_mut().as_chunks_mut::<4>().0.iter_mut().zip(a2) {
|
||
px.copy_from_slice(&[a, a, a, a]);
|
||
}
|
||
}
|
||
|
||
/// Replace every pixel with `color` (non-premultiplied RGBA 0..1) at the pixel's alpha × color alpha.
|
||
pub fn tint(p: &mut Pixmap, color: [f32; 4]) {
|
||
let [r, g, b, a] = color;
|
||
for px in p.data_mut().as_chunks_mut::<4>().0 {
|
||
let alpha = f32::from(px.get(3).copied().unwrap_or(0)) / 255.0 * a;
|
||
let q = |c: f32| (c * alpha * 255.0).round().clamp(0.0, 255.0) as u8;
|
||
px.copy_from_slice(&[q(r), q(g), q(b), (alpha * 255.0).round().clamp(0.0, 255.0) as u8]);
|
||
}
|
||
}
|
||
|
||
/// Invert the alpha (premultiplied white with alpha 1-a): used for inner shadows.
|
||
pub fn invert_alpha(p: &mut Pixmap) {
|
||
for px in p.data_mut().as_chunks_mut::<4>().0 {
|
||
let a = 255 - px.get(3).copied().unwrap_or(0);
|
||
px.copy_from_slice(&[a, a, a, a]);
|
||
}
|
||
}
|
||
|
||
/// Multiply every pixel of `p` by the alpha of `m` (same size); `invert` uses 1-alpha.
|
||
pub fn mask_by(p: &mut Pixmap, m: &Pixmap, invert: bool) {
|
||
for (px, mp) in p.data_mut().as_chunks_mut::<4>().0.iter_mut().zip(m.data().chunks_exact(4)) {
|
||
let ma = u32::from(mp.get(3).copied().unwrap_or(0));
|
||
let ma = if invert { 255 - ma } else { ma };
|
||
for c in px.iter_mut() {
|
||
*c = ((u32::from(*c) * ma + 127) / 255) as u8;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Call `f(dst_row, src_row, row, first_col)` for each row where `src`, with its top-left at `at`
|
||
/// on `dst`, overlaps it (premultiplied RGBA bytes of the overlapping columns; `row` and
|
||
/// `first_col` in `src`'s pixels). Nothing past either edge is visited.
|
||
pub(crate) fn overlap_rows(dst: &mut Pixmap, at: (i32, i32), src: &Pixmap, mut f: impl FnMut(&mut [u8], &[u8], i64, i64)) {
|
||
let (dw, dh) = (i64::from(dst.width()), i64::from(dst.height()));
|
||
let (sw, sh) = (i64::from(src.width()), i64::from(src.height()));
|
||
let (ax, ay) = (i64::from(at.0), i64::from(at.1));
|
||
let (c0, c1) = ((-ax).max(0), sw.min(dw - ax));
|
||
let (r0, r1) = ((-ay).max(0), sh.min(dh - ay));
|
||
if c1 <= c0 || r1 <= r0 {
|
||
return;
|
||
}
|
||
let len = (c1 - c0) as usize * 4;
|
||
let s = src.data();
|
||
let d = dst.data_mut();
|
||
for r in r0..r1 {
|
||
let (si, di) = ((r * sw + c0) as usize * 4, ((r + ay) * dw + ax + c0) as usize * 4);
|
||
if let (Some(sp), Some(dp)) = (s.get(si..si + len), d.get_mut(di..di + len)) {
|
||
f(dp, sp, r, c0);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Composite `src` at `opacity` onto `dst` (top-left at `at`) with plus-darker (linear burn:
|
||
/// `max(0, backdrop + source - 1)`, through the W3C general compositing formula), which
|
||
/// tiny-skia lacks.
|
||
pub fn plus_darker(dst: &mut Pixmap, at: (i32, i32), src: &Pixmap, opacity: f32) {
|
||
let o = if opacity.is_finite() { opacity.clamp(0.0, 1.0) } else { 0.0 };
|
||
overlap_rows(dst, at, src, |dp, sp, _, _| {
|
||
for (d, s) in dp.chunks_exact_mut(4).zip(sp.chunks_exact(4)) {
|
||
let f = |v: u8| f32::from(v) / 255.0;
|
||
let sa = f(s[3]) * o;
|
||
if sa <= 0.0 {
|
||
continue;
|
||
}
|
||
let da = f(d[3]);
|
||
for c in 0..3 {
|
||
let (sc, dc) = (f(s[c]) * o, f(d[c]));
|
||
let v = sc * (1.0 - da) + dc * (1.0 - sa) + (sa * dc + da * sc - sa * da).max(0.0);
|
||
d[c] = (v.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||
}
|
||
d[3] = ((sa + da - sa * da).clamp(0.0, 1.0) * 255.0).round() as u8;
|
||
}
|
||
});
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
/// The blur as first written (one box pass at a time over the whole pixmap, bounds-checked
|
||
/// byte by byte): the faster one must give the same bytes.
|
||
fn reference_blur(p: &mut Pixmap, sigma: f64) {
|
||
fn box_blur_h(src: &[u8], dst: &mut [u8], w: usize, h: usize, r: usize) {
|
||
if r == 0 {
|
||
dst.copy_from_slice(src);
|
||
return;
|
||
}
|
||
let div = (2 * r + 1) as u32;
|
||
for y in 0..h {
|
||
let row = y * w * 4;
|
||
let mut acc = [0u32; 4];
|
||
for x in 0..=r.min(w.saturating_sub(1)) {
|
||
for c in 0..4 {
|
||
acc[c] += u32::from(src.get(row + x * 4 + c).copied().unwrap_or(0));
|
||
}
|
||
}
|
||
for x in 0..w {
|
||
for c in 0..4 {
|
||
if let Some(d) = dst.get_mut(row + x * 4 + c) {
|
||
*d = (acc[c] / div) as u8;
|
||
}
|
||
}
|
||
let add = x + r + 1;
|
||
if add < w {
|
||
for c in 0..4 {
|
||
acc[c] += u32::from(src.get(row + add * 4 + c).copied().unwrap_or(0));
|
||
}
|
||
}
|
||
if x >= r {
|
||
let sub = x - r;
|
||
for c in 0..4 {
|
||
acc[c] = acc[c].saturating_sub(u32::from(src.get(row + sub * 4 + c).copied().unwrap_or(0)));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
fn transpose(src: &[u8], dst: &mut [u8], w: usize, h: usize) {
|
||
for y in 0..h {
|
||
for x in 0..w {
|
||
let (s, d) = ((y * w + x) * 4, (x * h + y) * 4);
|
||
dst[d..d + 4].copy_from_slice(&src[s..s + 4]);
|
||
}
|
||
}
|
||
}
|
||
if !(sigma.is_finite() && sigma > 0.3) {
|
||
return;
|
||
}
|
||
let (w, h) = (p.width() as usize, p.height() as usize);
|
||
let radii = box_sizes(sigma.min(MAX_SIGMA));
|
||
let mut a = p.data().to_vec();
|
||
let mut b = vec![0u8; a.len()];
|
||
for r in radii {
|
||
box_blur_h(&a, &mut b, w, h, r);
|
||
std::mem::swap(&mut a, &mut b);
|
||
}
|
||
transpose(&a, &mut b, w, h);
|
||
std::mem::swap(&mut a, &mut b);
|
||
for r in radii {
|
||
box_blur_h(&a, &mut b, h, w, r);
|
||
std::mem::swap(&mut a, &mut b);
|
||
}
|
||
transpose(&a, &mut b, h, w);
|
||
p.data_mut().copy_from_slice(&b);
|
||
}
|
||
|
||
/// The spread as first written (every pixel looks at its whole window).
|
||
fn reference_morph(p: &mut Pixmap, r: i32) {
|
||
if r == 0 {
|
||
return;
|
||
}
|
||
let grow = r > 0;
|
||
let r = r.unsigned_abs().min(400) as usize;
|
||
let (w, h) = (p.width() as usize, p.height() as usize);
|
||
let alpha: Vec<u8> = p.data().chunks(4).map(|c| c[3]).collect();
|
||
let pick = |v: u8, s: u8| if grow { v.max(s) } else { v.min(s) };
|
||
let mut a1 = vec![0u8; alpha.len()];
|
||
for y in 0..h {
|
||
for x in 0..w {
|
||
let mut v: u8 = if grow { 0 } else { 255 };
|
||
for k in x.saturating_sub(r)..=(x + r).min(w - 1) {
|
||
v = pick(v, alpha[y * w + k]);
|
||
}
|
||
a1[y * w + x] = if !grow && (x < r || x + r >= w) { 0 } else { v };
|
||
}
|
||
}
|
||
let mut a2 = vec![0u8; a1.len()];
|
||
for x in 0..w {
|
||
for y in 0..h {
|
||
let mut v: u8 = if grow { 0 } else { 255 };
|
||
for k in y.saturating_sub(r)..=(y + r).min(h - 1) {
|
||
v = pick(v, a1[k * w + x]);
|
||
}
|
||
a2[y * w + x] = if !grow && (y < r || y + r >= h) { 0 } else { v };
|
||
}
|
||
}
|
||
for (px, a) in p.data_mut().chunks_mut(4).zip(a2) {
|
||
px.copy_from_slice(&[a, a, a, a]);
|
||
}
|
||
}
|
||
|
||
/// A `w` × `h` pixmap of premultiplied noise in a few rectangles, transparent elsewhere (whole
|
||
/// transparent rows and columns, as layers have around their content).
|
||
fn noise(w: u32, h: u32, seed: u32) -> Pixmap {
|
||
let mut p = Pixmap::new(w, h).unwrap();
|
||
let mut s = seed.wrapping_mul(2_654_435_761).max(1);
|
||
let mut next = || {
|
||
s ^= s << 13;
|
||
s ^= s >> 17;
|
||
s ^= s << 5;
|
||
s
|
||
};
|
||
let (wu, hu) = (w as usize, h as usize);
|
||
for (x0, y0, x1, y1) in [(wu / 8, hu / 6, wu / 2, hu / 2), (wu / 3, hu / 2, wu.saturating_sub(3), hu * 5 / 6), (0, 0, 2, hu)] {
|
||
for y in y0..y1.min(hu) {
|
||
for x in x0..x1.min(wu) {
|
||
let a = (next() % 256) as u8;
|
||
let c = |v: u32| ((v % 256) * u32::from(a) / 255) as u8;
|
||
let i = (y * wu + x) * 4;
|
||
p.data_mut()[i..i + 4].copy_from_slice(&[c(next()), c(next()), c(next()), a]);
|
||
}
|
||
}
|
||
}
|
||
p
|
||
}
|
||
|
||
#[test]
|
||
fn blur_matches_the_reference_bytes() {
|
||
// Small (one thread), wide, tall and large enough to blur in parallel.
|
||
for (i, (w, h)) in [(1, 1), (7, 3), (40, 31), (301, 17), (13, 290), (300, 230)].into_iter().enumerate() {
|
||
for sigma in [0.2, 0.5, 1.0, 2.5, 7.3, 24.0, 160.0, 5000.0] {
|
||
let mut fast = noise(w, h, i as u32 + 1);
|
||
let mut slow = fast.clone();
|
||
blur(&mut fast, sigma);
|
||
reference_blur(&mut slow, sigma);
|
||
assert!(fast.data() == slow.data(), "{w}×{h} sigma {sigma}");
|
||
}
|
||
}
|
||
}
|
||
|
||
/// On a rayon pool thread (a band of a parallel render) the blur stays on that thread and
|
||
/// gives the same bytes.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
#[test]
|
||
fn blur_on_a_pool_thread_matches_the_reference_bytes() {
|
||
let (fast, slow) = rayon::join(
|
||
|| {
|
||
assert!(rayon::current_thread_index().is_some());
|
||
let mut p = noise(300, 230, 3);
|
||
blur(&mut p, 9.5);
|
||
p
|
||
},
|
||
|| {
|
||
let mut p = noise(300, 230, 3);
|
||
reference_blur(&mut p, 9.5);
|
||
p
|
||
},
|
||
);
|
||
assert!(fast.data() == slow.data());
|
||
}
|
||
|
||
#[test]
|
||
fn morph_matches_the_reference_bytes() {
|
||
for (i, (w, h)) in [(1, 1), (5, 9), (40, 31), (301, 17), (13, 290), (300, 230)].into_iter().enumerate() {
|
||
for r in [-500, -40, -6, -2, -1, 1, 2, 5, 33, 400, 1000] {
|
||
let mut fast = noise(w, h, i as u32 + 7);
|
||
let mut slow = fast.clone();
|
||
morph(&mut fast, r);
|
||
reference_morph(&mut slow, r);
|
||
assert!(fast.data() == slow.data(), "{w}×{h} r {r}");
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn blur_spreads_and_conserves() {
|
||
let mut p = Pixmap::new(64, 64).unwrap();
|
||
let i = (32 * 64 + 32) * 4;
|
||
p.data_mut()[i..i + 4].copy_from_slice(&[255, 255, 255, 255]);
|
||
let before: u32 = p.data().iter().map(|v| u32::from(*v)).sum();
|
||
blur(&mut p, 3.0);
|
||
let after: u32 = p.data().iter().map(|v| u32::from(*v)).sum();
|
||
assert!(p.data()[i + 3] < 255);
|
||
// Integer rounding loses a little.
|
||
assert!(after <= before && after > 0, "{before} {after}");
|
||
}
|
||
|
||
#[test]
|
||
fn morph_grows() {
|
||
let mut p = Pixmap::new(20, 20).unwrap();
|
||
let i = (10 * 20 + 10) * 4;
|
||
p.data_mut()[i..i + 4].copy_from_slice(&[255, 255, 255, 255]);
|
||
morph(&mut p, 2);
|
||
let count = p.data().as_chunks::<4>().0.iter().filter(|c| c[3] == 255).count();
|
||
assert_eq!(count, 25);
|
||
morph(&mut p, -2);
|
||
let count = p.data().as_chunks::<4>().0.iter().filter(|c| c[3] == 255).count();
|
||
assert_eq!(count, 1);
|
||
}
|
||
|
||
#[test]
|
||
fn box_sizes_sane() {
|
||
for s in [0.5, 1.0, 4.0, 50.0] {
|
||
let b = box_sizes(s);
|
||
assert!(b.iter().all(|r| *r < 1000));
|
||
}
|
||
}
|
||
}
|