Speed up raster export: faster blur and spread passes

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>
This commit is contained in:
NetterSilver
2026-10-27 17:43:23 +01:00
co-authored by Claude Opus 5.5
parent 43af145a12
commit adb1b43033
3 changed files with 428 additions and 80 deletions
+96
View File
@@ -0,0 +1,96 @@
//! Export timings over the example files: every top-level frame of each `examples/*.figcraft`
//! exported as PNG, JPG and WebP at 2x through [`export_file`], as Export and `figcraft-cli export`
//! do. Run in release:
//! `cargo test --release -p figcraft-engine --lib export_timings -- --ignored --nocapture`.
//!
//! `FIGCRAFT_EXPORT_BENCH_RUNS` sets the timed runs (default 7; one warm-up run first),
//! `FIGCRAFT_EXPORT_BENCH_FORMATS` the formats (default `png,jpg,webp`) and
//! `FIGCRAFT_EXPORT_BENCH_OUT` a folder to write the files of the last run to (to compare outputs).
use std::time::Instant;
use figcraft_doc::{Document, ExportFormat, ExportSetting, NodeId};
use crate::cmd::file::{export_file, load_path};
/// The example documents and their top-level frames.
fn examples() -> Vec<(String, Document, Vec<NodeId>)> {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples");
let mut paths: Vec<_> =
std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().path()).filter(|p| p.extension().is_some_and(|e| e == "figcraft")).collect();
paths.sort();
paths
.into_iter()
.map(|p| {
let (loaded, _) = load_path(&p.to_string_lossy(), None).unwrap();
let doc = loaded.doc;
let frames: Vec<NodeId> = doc.pages.iter().flat_map(|pg| doc.top_frames(*pg)).collect();
(p.file_stem().unwrap().to_string_lossy().into_owned(), doc, frames)
})
.collect()
}
fn format(name: &str) -> ExportFormat {
match name {
"png" => ExportFormat::Png,
"jpg" => ExportFormat::Jpg,
"webp" => ExportFormat::Webp,
"svg" => ExportFormat::Svg,
"pdf" => ExportFormat::Pdf,
other => panic!("unknown format {other}"),
}
}
/// Export every frame in `format` at 2x: (milliseconds, files).
fn run(docs: &[(String, Document, Vec<NodeId>)], format: ExportFormat) -> (f64, Vec<(String, Vec<u8>)>) {
let setting = ExportSetting { format, ..Default::default() };
let mut files = Vec::new();
let t = Instant::now();
for (name, doc, frames) in docs {
for f in frames {
let (bytes, _) = export_file(doc, &[*f], &setting, 2.0).unwrap();
files.push((format!("{name}-{}.{}", f.0, format.ext()), bytes));
}
}
(t.elapsed().as_secs_f64() * 1000.0, files)
}
#[test]
#[ignore = "timing: run in release with --ignored --nocapture"]
fn export_timings() {
let runs: usize = std::env::var("FIGCRAFT_EXPORT_BENCH_RUNS").ok().and_then(|v| v.parse().ok()).unwrap_or(7).max(1);
let formats = std::env::var("FIGCRAFT_EXPORT_BENCH_FORMATS").unwrap_or_else(|_| "png,jpg,webp".into());
let out = std::env::var("FIGCRAFT_EXPORT_BENCH_OUT").ok();
let docs = examples();
let frames: usize = docs.iter().map(|d| d.2.len()).sum();
println!("{} documents, {frames} frames, {runs} runs", docs.len());
let mut total = vec![0.0; runs];
for name in formats.split(',').map(str::trim).filter(|f| !f.is_empty()) {
let fmt = format(name);
let _ = run(&docs, fmt);
let mut times = Vec::with_capacity(runs);
let mut last = Vec::new();
for t in &mut total {
let (ms, files) = run(&docs, fmt);
*t += ms;
times.push(ms);
last = files;
}
if let Some(dir) = &out {
std::fs::create_dir_all(dir).unwrap();
for (file, bytes) in &last {
std::fs::write(std::path::Path::new(dir).join(file), bytes).unwrap();
}
}
report(name, &mut times);
}
report("total", &mut total);
}
/// Print the median and minimum of `times` (and every run).
fn report(name: &str, times: &mut [f64]) {
let runs: Vec<String> = times.iter().map(|t| format!("{t:.1}")).collect();
times.sort_by(f64::total_cmp);
let median = times[times.len() / 2];
println!("{name:>6}: median {median:8.1} ms min {:8.1} ms runs [{}]", times[0], runs.join(", "));
}
+2
View File
@@ -256,6 +256,8 @@ mod effects_tests;
#[cfg(test)]
mod export_behaviour_tests;
#[cfg(test)]
mod export_bench_tests;
#[cfg(test)]
mod export_depth_tests;
#[cfg(test)]
mod extended_chain_tests;
+330 -80
View File
@@ -21,53 +21,107 @@ fn box_sizes(sigma: f64) -> [usize; 3] {
[r(0), r(1), r(2)]
}
fn box_blur_h(src: &[u8], dst: &mut [u8], w: usize, h: usize, r: usize) {
/// 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;
for y in 0..h {
let row = y * w * 4;
let mut acc = [0u32; 4];
// Edge: transparent outside.
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));
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);
}
}
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)));
}
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));
}
}
}
}
fn transpose(src: &[u8], dst: &mut [u8], w: usize, h: usize) {
for y in 0..h {
for x in 0..w {
let s = (y * w + x) * 4;
let d = (x * h + y) * 4;
if let (Some(sp), Some(dp)) = (src.get(s..s + 4), dst.get_mut(d..d + 4)) {
dp.copy_from_slice(sp);
/// 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
@@ -76,7 +130,8 @@ fn transpose(src: &[u8], dst: &mut [u8], w: usize, h: usize) {
/// 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).
/// 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;
@@ -84,20 +139,49 @@ pub fn blur(p: &mut Pixmap, sigma: f64) {
let sigma = sigma.min(MAX_SIGMA);
let (w, h) = (p.width() as usize, p.height() as usize);
let radii = box_sizes(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);
}
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);
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);
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));
}
transpose(&a, &mut b, h, w);
p.data_mut().copy_from_slice(&b);
}
/// Grow (`r > 0`) or shrink (`r < 0`) the alpha of a pixmap by `r` pixels (square structuring
@@ -109,47 +193,38 @@ pub fn morph(p: &mut Pixmap, r: i32) {
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().as_chunks::<4>().0.iter().map(|c| c.get(3).copied().unwrap_or(0)).collect();
let pass = |src: &[u8], w: usize, h: usize, horizontal: bool| -> Vec<u8> {
let mut out = vec![0u8; src.len()];
for y in 0..h {
for x in 0..w {
let mut v: u8 = if grow { 0 } else { 255 };
let (lo, hi) = (x.saturating_sub(r), (x + r).min(w.saturating_sub(1)));
for k in lo..=hi {
let idx = if horizontal { y * w + k } else { k * h + y };
let s = src.get(idx).copied().unwrap_or(0);
v = if grow { v.max(s) } else { v.min(s) };
}
// Shrinking treats outside as empty.
if !grow && (x < r || x + r >= w) {
v = 0;
}
let o = if horizontal { y * w + x } else { x * h + y };
if let Some(d) = out.get_mut(o) {
*d = v;
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;
}
}
}
out
};
let a1 = pass(&alpha, w, h, true);
// Vertical pass: treat columns as rows by swapping roles.
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 y in 0..h {
let mut v: u8 = if grow { 0 } else { 255 };
let (lo, hi) = (y.saturating_sub(r), (y + r).min(h.saturating_sub(1)));
for k in lo..=hi {
let s = a1.get(k * w + x).copied().unwrap_or(0);
v = if grow { v.max(s) } else { v.min(s) };
}
if !grow && (y < r || y + r >= h) {
v = 0;
}
if let Some(d) = a2.get_mut(y * w + x) {
*d = v;
}
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) {
@@ -236,6 +311,181 @@ pub fn plus_darker(dst: &mut Pixmap, at: (i32, i32), src: &Pixmap, opacity: f32)
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();