mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-31 05:46:46 -06:00
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:
co-authored by
Claude Opus 5.5
parent
43af145a12
commit
adb1b43033
@@ -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(", "));
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user