diff --git a/crates/engine/src/export_bench_tests.rs b/crates/engine/src/export_bench_tests.rs new file mode 100644 index 00000000..56c6c40f --- /dev/null +++ b/crates/engine/src/export_bench_tests.rs @@ -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)> { + 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 = 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)], format: ExportFormat) -> (f64, Vec<(String, Vec)>) { + 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 = 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(", ")); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 60f240cb..ea135ca1 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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; diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 5d65667c..fd5f3764 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -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; 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 = 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 { - 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 = p.data().as_chunks::<4>().0.iter().map(|c| c[3]).collect(); + let mut scratch: [Vec; 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 = 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();