From db41fa7980a4a2eaf7a1b4e13fea862adf5b78fb Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:40:43 +0100 Subject: [PATCH] Skip transparent pixels and quantise while compositing Much of a layer buffer is often transparent (a stroked ring, a glow-padded text layer, a precomp canvas), yet every destination pixel sampled it bilinearly, and 8/16 bpc comps then re-quantised the layer's whole rotated bounding box. - Bilinear warps skip 16x16 source tiles whose pixels (and a one-pixel apron) all have zero alpha: their samples are transparent and were never drawn. - The fast affine warp reports the rectangle it changed and, given the comp's levels, quantises each pixel as it blends it, so draw_comp has nothing left to re-quantise for that layer. quantize_px moves to effectcraft-raster for this. - quantize_rect skips runs of four all-zero (+0.0) pixels, which quantise to themselves. - to_rgba8 / to_rgba8_over convert row by row instead of per 4-byte item. Every pixel stays bit-identical: tests compare the warp against the reference path, fused quantising against quantising after blending, and the zero-run skip against per-pixel quantising. With the previous commit, demo comp to H.264 (`cargo xtask bench --scenario demo --format h264 --frames 300`, 11 interleaved runs on a 16-thread Ryzen): wall 9.26 s -> 6.97 s median (-24.8%), 8.55 s -> 6.55 s min (-23.3%); CPU time -23%. Output byte-identical. Co-Authored-By: Claude Opus 5.5 --- crates/raster/src/lib.rs | 43 ++++- crates/raster/src/warp.rs | 372 +++++++++++++++++++++++++++++------- crates/render/src/color.rs | 68 ++++--- crates/render/src/layers.rs | 56 +++++- crates/render/src/lib.rs | 2 +- 5 files changed, 430 insertions(+), 111 deletions(-) diff --git a/crates/raster/src/lib.rs b/crates/raster/src/lib.rs index dce8dda81..9dfd06822 100644 --- a/crates/raster/src/lib.rs +++ b/crates/raster/src/lib.rs @@ -18,10 +18,32 @@ use rayon::prelude::*; pub use blur::{box_blur, directional_blur, gaussian_blur, gaussian_blur_exact, gaussian_is_exact, radial_blur}; pub use channels3d::AuxChannels; -pub use warp::{Sampling, WarpOpts, accumulate_warp, composite_warp, composite_warp_reference, resample}; +pub use warp::{Sampling, WarpOpts, accumulate_warp, composite_warp, composite_warp_changed, composite_warp_reference, resample}; pub type Px = [f32; 4]; +/// Clamp (colour ≤ alpha ≤ 1, NaN → 0) and round half up to `levels` steps (`inv` = 1 / levels): +/// one pixel of an 8/16 bpc buffer. Values are non-negative after the clamp, so the integer +/// conversion rounds like `round()` and vectorises. +#[allow(clippy::manual_clamp)] // max/min map NaN to 0; clamp would keep NaN +#[inline(always)] +pub fn quantize_px(p: &mut Px, levels: f32, inv: f32) { + let a = round_half_up(p[3].max(0.0).min(1.0) * levels) * inv; + let hi = [a, a, a, a]; + for c in 0..3 { + p[c] = round_half_up(p[c].max(0.0).min(hi[c]) * levels) * inv; + } + p[3] = a; +} + +/// `v` (0..=65 535) rounded half up. The same either way; the fastest on each target: x86-64's +/// baseline converts to integers in one instruction but has no `floor` (SSE4.1), while in wasm +/// `floor` is one instruction and converting back from `u32` several (about 1.5× faster there). +#[inline(always)] +fn round_half_up(v: f32) -> f32 { + if cfg!(target_arch = "wasm32") { (v + 0.5).floor() } else { (v + 0.5) as u32 as f32 } +} + #[derive(Clone, Debug, Default, PartialEq)] pub struct Image { pub width: u32, @@ -199,26 +221,33 @@ impl Image { } /// Straight-alpha 8-bit RGBA (sRGB-encoded values as stored). pub fn to_rgba8(&self) -> Vec { - let mut out = vec![0u8; self.data.len() * 4]; - out.par_chunks_mut(4).zip(self.data.par_iter()).for_each(|(o, p)| { + self.map_rgba8(|o, p| { let a = p[3].clamp(0.0, 1.0); let inv = if a > 0.0 { 1.0 / p[3] } else { 0.0 }; for c in 0..3 { o[c] = ((p[c] * inv).clamp(0.0, 1.0) * 255.0 + 0.5) as u8; } o[3] = (a * 255.0 + 0.5) as u8; - }); - out + }) } /// Composite over an opaque background colour and return opaque 8-bit RGBA. pub fn to_rgba8_over(&self, bg: [f32; 3]) -> Vec { - let mut out = vec![0u8; self.data.len() * 4]; - out.par_chunks_mut(4).zip(self.data.par_iter()).for_each(|(o, p)| { + self.map_rgba8(|o, p| { let k = 1.0 - p[3].clamp(0.0, 1.0); for c in 0..3 { o[c] = ((p[c] + bg[c] * k).clamp(0.0, 1.0) * 255.0 + 0.5) as u8; } o[3] = 255; + }) + } + /// 8-bit RGBA with each pixel written by `f`, row-parallel. + fn map_rgba8(&self, f: impl Fn(&mut [u8; 4], &Px) + Sync) -> Vec { + let mut out = vec![0u8; self.data.len() * 4]; + let w = self.width.max(1) as usize; + out.par_chunks_mut(w * 4).zip(self.data.par_chunks(w)).for_each(|(o, row)| { + for (o, p) in o.as_chunks_mut::<4>().0.iter_mut().zip(row) { + f(o, p); + } }); out } diff --git a/crates/raster/src/warp.rs b/crates/raster/src/warp.rs index 8b45761e4..c16480865 100644 --- a/crates/raster/src/warp.rs +++ b/crates/raster/src/warp.rs @@ -57,7 +57,15 @@ fn half(src: &Image) -> Image { /// bilinear without bounds checks, integer-translation copies, inlined Normal blending); it /// matches [`composite_warp_reference`] to float rounding. pub fn composite_warp(dst: &mut Image, src: &Image, m: &Mat3, opts: &WarpOpts) { - composite_warp_impl(dst, src, m, opts, true, None); + composite_warp_impl(dst, src, m, opts, true, None, None); +} + +/// [`composite_warp`], also returning the destination pixels it changed when it knows them: +/// `Some([x0, y0, x1, y1])` (half-open, empty when nothing was drawn) on the fast affine path, +/// `None` otherwise. With `quantize` (an 8/16 bpc destination's levels), the fast path also +/// quantises each pixel it blends ([`crate::quantize_px`]): exactly when this returns `Some`. +pub fn composite_warp_changed(dst: &mut Image, src: &Image, m: &Mat3, opts: &WarpOpts, quantize: Option) -> Option<[u32; 4]> { + composite_warp_impl(dst, src, m, opts, true, None, quantize) } /// `dst += warp(src) · weight` (plain sum, no blending): motion-blur sub-samples accumulate @@ -65,7 +73,7 @@ pub fn composite_warp(dst: &mut Image, src: &Image, m: &Mat3, opts: &WarpOpts) { pub fn accumulate_warp(dst: &mut Image, src: &Image, m: &Mat3, sampling: Sampling, weight: f32) { let opts = WarpOpts { sampling, ..Default::default() }; if m.is_affine() && m.inverse().is_some_and(|i| i.is_affine()) { - composite_warp_impl(dst, src, m, &opts, true, Some(weight)); + composite_warp_impl(dst, src, m, &opts, true, Some(weight), None); return; } let mut tmp = Image::new(dst.width, dst.height); @@ -79,7 +87,7 @@ pub fn accumulate_warp(dst: &mut Image, src: &Image, m: &Mat3, sampling: Samplin /// The straightforward per-pixel implementation (reference for the fast path's tests). pub fn composite_warp_reference(dst: &mut Image, src: &Image, m: &Mat3, opts: &WarpOpts) { - composite_warp_impl(dst, src, m, opts, false, None); + composite_warp_impl(dst, src, m, opts, false, None, None); } /// Bilinear blend of four taps (same operation order as [`Image::sample_bilinear`]). @@ -94,9 +102,49 @@ fn bilerp(a: Px, b: Px, c: Px, d: Px, tx: f32, ty: f32) -> Px { o } -/// Bilinear sample with an unchecked interior fast path. +/// Side of the square source tiles of [`ClearTiles`]. +const TILE: usize = 16; + +/// Which `TILE`×`TILE` source tiles are fully transparent, the one-pixel apron to their right +/// and below included: the four taps of a bilinear sample whose top-left tap lies in such a +/// tile all have zero alpha, so the sample is transparent and draws nothing. +struct ClearTiles { + cols: usize, + clear: Vec, +} + +impl ClearTiles { + fn new(img: &Image) -> ClearTiles { + let (w, h) = (img.width as usize, img.height as usize); + let (cols, rows) = (w.div_ceil(TILE), h.div_ceil(TILE)); + let clear = (0..rows) + .into_par_iter() + .flat_map_iter(|ty| { + let mut row_clear = vec![true; cols]; + for y in ty * TILE..(ty * TILE + TILE + 1).min(h) { + let row = &img.data[y * w..(y + 1) * w]; + for (tx, c) in row_clear.iter_mut().enumerate() { + if *c { + *c = row[tx * TILE..(tx * TILE + TILE + 1).min(w)].iter().all(|p| p[3] == 0.0); + } + } + } + row_clear + }) + .collect(); + ClearTiles { cols, clear } + } + + #[inline(always)] + fn is_clear(&self, x: usize, y: usize) -> bool { + self.clear.get((y / TILE) * self.cols + x / TILE).copied().unwrap_or(false) + } +} + +/// Bilinear sample with an unchecked interior fast path; `None` when every interior tap is in a +/// [`ClearTiles`] tile (the sample would have zero alpha). #[inline(always)] -fn sample_bilinear_fast(img: &Image, x: f64, y: f64) -> Px { +fn sample_bilinear_fast(img: &Image, clear: &ClearTiles, x: f64, y: f64) -> Option { let fx = x - 0.5; let fy = y - 0.5; let x0 = fx.floor(); @@ -104,14 +152,17 @@ fn sample_bilinear_fast(img: &Image, x: f64, y: f64) -> Px { let (xi, yi) = (x0 as i64, y0 as i64); let (w, h) = (img.width as i64, img.height as i64); if xi >= 0 && yi >= 0 && xi + 1 < w && yi + 1 < h { + if clear.is_clear(xi as usize, yi as usize) { + return None; + } let tx = (fx - x0) as f32; let ty = (fy - y0) as f32; let i = yi as usize * w as usize + xi as usize; let row0 = &img.data[i..i + 2]; let row1 = &img.data[i + w as usize..i + w as usize + 2]; - return bilerp(row0[0], row0[1], row1[0], row1[1], tx, ty); + return Some(bilerp(row0[0], row0[1], row1[0], row1[1], tx, ty)); } - img.sample_bilinear(x, y) + Some(img.sample_bilinear(x, y)) } /// Index range `[lo, hi)` of `i` in `0..n` for which `p0 + i·s` may lie in `[min, max]` @@ -127,7 +178,33 @@ fn span(p0: f64, s: f64, min: f64, max: f64, n: usize) -> (usize, usize) { if hi <= lo { (0, 0) } else { (lo as usize, hi as usize) } } -/// Fast affine rows: `blend(dst, src, x, y)` is only called for visible source pixels. +/// Columns `[a, b)` of one destination row that a warp changed (`a == b`: none). +#[derive(Clone, Copy)] +struct RowSpan { + a: usize, + b: usize, +} + +impl RowSpan { + const NONE: RowSpan = RowSpan { a: usize::MAX, b: 0 }; + #[inline(always)] + fn add(&mut self, x: usize) { + self.a = self.a.min(x); + self.b = x + 1; + } +} + +/// Union of changed pixels as `[x0, y0, x1, y1]` (half-open; `x0 >= x1` when empty). +fn union(r: [usize; 4], q: [usize; 4]) -> [usize; 4] { + match (r[0] < r[2], q[0] < q[2]) { + (false, _) => q, + (_, false) => r, + _ => [r[0].min(q[0]), r[1].min(q[1]), r[2].max(q[2]), r[3].max(q[3])], + } +} + +/// Fast affine rows: `blend(dst, src, x, y)` is only called for visible source pixels. Returns +/// the rectangle of the pixels it blended. #[allow(clippy::too_many_arguments)] fn affine_rows( dst: &mut Image, @@ -135,12 +212,14 @@ fn affine_rows( inv: &Mat3, sampling: Sampling, op: f32, + quantize: Option, x0: usize, x1: usize, y0: usize, y1: usize, blend: impl Fn(Px, Px, u32, u32) -> Px + Sync, -) { +) -> [usize; 4] { + let levels = quantize.map(|l| (l, 1.0 / l)); let w = dst.width as usize; let m = inv.0; let (iw, ih) = (img.width as f64, img.height as f64); @@ -148,71 +227,104 @@ fn affine_rows( // Pure integer translation: every sample lands on a pixel centre, so all filters return the // source pixel itself (bicubic still clamps negatives, as in the reference). let int_tx = m[0][0] == 1.0 && m[1][1] == 1.0 && m[0][1] == 0.0 && m[1][0] == 0.0 && m[0][2].fract() == 0.0 && m[1][2].fract() == 0.0; - dst.data.par_chunks_mut(w).enumerate().skip(y0).take(y1 - y0).for_each(|(y, row)| { - let fy = y as f64 + 0.5; - let p0 = vec2(m[0][0] * (x0 as f64 + 0.5) + m[0][1] * fy + m[0][2], m[1][0] * (x0 as f64 + 0.5) + m[1][1] * fy + m[1][2]); - let n = x1 - x0; - let (ax, bx) = span(p0.x, step.x, -1.0, iw + 1.0, n); - let (ay, by) = span(p0.y, step.y, -1.0, ih + 1.0, n); - let (lo, hi) = (ax.max(ay), bx.min(by)); - if lo >= hi { - return; - } - if int_tx { - let sy = y as i64 + m[1][2] as i64; - if sy < 0 || sy >= img.height as i64 { - return; + // Bilinear samples skip the transparent parts of the source (most of a thin shape's or a + // glow-padded layer's buffer) without reading them. + let clear = (sampling == Sampling::Bilinear && !int_tx).then(|| ClearTiles::new(img)); + let empty = [usize::MAX, usize::MAX, 0, 0]; + dst.data + .par_chunks_mut(w) + .enumerate() + .skip(y0) + .take(y1 - y0) + .map(|(y, row)| { + let mut changed = RowSpan::NONE; + let fy = y as f64 + 0.5; + let p0 = vec2(m[0][0] * (x0 as f64 + 0.5) + m[0][1] * fy + m[0][2], m[1][0] * (x0 as f64 + 0.5) + m[1][1] * fy + m[1][2]); + let n = x1 - x0; + let (ax, bx) = span(p0.x, step.x, -1.0, iw + 1.0, n); + let (ay, by) = span(p0.y, step.y, -1.0, ih + 1.0, n); + let (lo, hi) = (ax.max(ay), bx.min(by)); + if lo >= hi { + return empty; } - let dx = m[0][2] as i64; - let srow = &img.data[sy as usize * img.width as usize..(sy as usize + 1) * img.width as usize]; - let xa = (x0 as i64 + lo as i64).max(-dx).max(0) as usize; - let xb = ((x0 + hi) as i64).min(img.width as i64 - dx).max(0) as usize; - for x in xa..xb.max(xa) { - let mut s = srow[(x as i64 + dx) as usize]; - if sampling == Sampling::Bicubic { - s = [s[0].max(0.0), s[1].max(0.0), s[2].max(0.0), s[3].max(0.0)]; + if int_tx { + let sy = y as i64 + m[1][2] as i64; + if sy < 0 || sy >= img.height as i64 { + return empty; } - if s[3] <= 0.0 { - continue; - } - if op < 1.0 { - for c in s.iter_mut() { - *c *= op; + let dx = m[0][2] as i64; + let srow = &img.data[sy as usize * img.width as usize..(sy as usize + 1) * img.width as usize]; + let xa = (x0 as i64 + lo as i64).max(-dx).max(0) as usize; + let xb = ((x0 + hi) as i64).min(img.width as i64 - dx).max(0) as usize; + for x in xa..xb.max(xa) { + let mut s = srow[(x as i64 + dx) as usize]; + if sampling == Sampling::Bicubic { + s = [s[0].max(0.0), s[1].max(0.0), s[2].max(0.0), s[3].max(0.0)]; } + if s[3] <= 0.0 { + continue; + } + if op < 1.0 { + for c in s.iter_mut() { + *c *= op; + } + } + row[x] = blend(row[x], s, x as u32, y as u32); + if let Some((l, inv)) = levels { + crate::quantize_px(&mut row[x], l, inv); + } + changed.add(x); } - row[x] = blend(row[x], s, x as u32, y as u32); - } - return; - } - for i in lo..hi { - let x = x0 + i; - let p = vec2(p0.x + step.x * i as f64, p0.y + step.y * i as f64); - if p.x < -1.0 || p.y < -1.0 || p.x > iw + 1.0 || p.y > ih + 1.0 { - continue; - } - let mut s = match sampling { - Sampling::Nearest => img.get(p.x.floor() as i64, p.y.floor() as i64), - Sampling::Bilinear => sample_bilinear_fast(img, p.x, p.y), - Sampling::Bicubic => img.sample_bicubic(p.x, p.y), - }; - if s[3] <= 0.0 { - continue; - } - if op < 1.0 { - for c in s.iter_mut() { - *c *= op; + } else { + for i in lo..hi { + let x = x0 + i; + let p = vec2(p0.x + step.x * i as f64, p0.y + step.y * i as f64); + if p.x < -1.0 || p.y < -1.0 || p.x > iw + 1.0 || p.y > ih + 1.0 { + continue; + } + let mut s = match (sampling, &clear) { + (Sampling::Nearest, _) => img.get(p.x.floor() as i64, p.y.floor() as i64), + (Sampling::Bilinear, Some(clear)) => match sample_bilinear_fast(img, clear, p.x, p.y) { + Some(s) => s, + None => continue, + }, + (Sampling::Bilinear, None) => img.sample_bilinear(p.x, p.y), + (Sampling::Bicubic, _) => img.sample_bicubic(p.x, p.y), + }; + if s[3] <= 0.0 { + continue; + } + if op < 1.0 { + for c in s.iter_mut() { + *c *= op; + } + } + row[x] = blend(row[x], s, x as u32, y as u32); + if let Some((l, inv)) = levels { + crate::quantize_px(&mut row[x], l, inv); + } + changed.add(x); } } - row[x] = blend(row[x], s, x as u32, y as u32); - } - }); + if changed.a < changed.b { [changed.a, y, changed.b, y + 1] } else { empty } + }) + .reduce(|| empty, union) } -fn composite_warp_impl(dst: &mut Image, src: &Image, m: &Mat3, opts: &WarpOpts, allow_fast: bool, accumulate: Option) { +fn composite_warp_impl( + dst: &mut Image, + src: &Image, + m: &Mat3, + opts: &WarpOpts, + allow_fast: bool, + accumulate: Option, + quantize: Option, +) -> Option<[u32; 4]> { + const NOTHING: Option<[u32; 4]> = Some([0; 4]); if src.is_empty() || dst.is_empty() || opts.opacity <= 0.0 { - return; + return NOTHING; } - let Some(inv) = m.inverse() else { return }; + let Some(inv) = m.inverse() else { return NOTHING }; // Minification: pick a pre-filtered level so sampling doesn't alias. let scale = m.mean_scale(); let mut level_img; @@ -239,7 +351,7 @@ fn composite_warp_impl(dst: &mut Image, src: &Image, m: &Mat3, opts: &WarpOpts, area = area.intersect(&c); } if area.is_empty() { - return; + return NOTHING; } let (x0, x1) = (area.x0.max(0.0) as usize, area.x1.min(dst.width as f64) as usize); let (y0, y1) = (area.y0.max(0.0) as usize, area.y1.min(dst.height as f64) as usize); @@ -247,25 +359,28 @@ fn composite_warp_impl(dst: &mut Image, src: &Image, m: &Mat3, opts: &WarpOpts, let affine = inv.is_affine(); let mode = opts.mode; let op = opts.opacity; + let changed = |r: [usize; 4]| Some(r.map(|v| v.min(u32::MAX as usize) as u32)); if let Some(k) = accumulate && affine { - affine_rows(dst, img, &inv, opts.sampling, 1.0, x0, x1, y0, y1, |d, s, _, _| [d[0] + s[0] * k, d[1] + s[1] * k, d[2] + s[2] * k, d[3] + s[3] * k]); - return; + let r = affine_rows(dst, img, &inv, opts.sampling, 1.0, None, x0, x1, y0, y1, |d, s, _, _| { + [d[0] + s[0] * k, d[1] + s[1] * k, d[2] + s[2] * k, d[3] + s[3] * k] + }); + return changed(r); } if allow_fast && affine && !mode.is_stencil() { - match mode { - BlendMode::Normal => affine_rows(dst, img, &inv, opts.sampling, op, x0, x1, y0, y1, |d, s, _, _| { + let r = match mode { + BlendMode::Normal => affine_rows(dst, img, &inv, opts.sampling, op, quantize, x0, x1, y0, y1, |d, s, _, _| { let k = 1.0 - s[3]; [s[0] + d[0] * k, s[1] + d[1] * k, s[2] + d[2] * k, s[3] + d[3] * k] }), BlendMode::Dissolve | BlendMode::DancingDissolve => { let seed = opts.seed; - affine_rows(dst, img, &inv, opts.sampling, op, x0, x1, y0, y1, |d, s, x, y| blend_pixel(mode, d, s, hash_noise(x, y, seed))) + affine_rows(dst, img, &inv, opts.sampling, op, quantize, x0, x1, y0, y1, |d, s, x, y| blend_pixel(mode, d, s, hash_noise(x, y, seed))) } - _ => affine_rows(dst, img, &inv, opts.sampling, op, x0, x1, y0, y1, |d, s, _, _| blend_pixel(mode, d, s, 0.5)), - } - return; + _ => affine_rows(dst, img, &inv, opts.sampling, op, quantize, x0, x1, y0, y1, |d, s, _, _| blend_pixel(mode, d, s, 0.5)), + }; + return changed(r); } dst.data.par_chunks_mut(w).enumerate().skip(y0).take(y1 - y0).for_each(|(y, row)| { let fy = y as f64 + 0.5; @@ -312,6 +427,7 @@ fn composite_warp_impl(dst: &mut Image, src: &Image, m: &Mat3, opts: &WarpOpts, } } } + None } /// Resize with bilinear filtering (box pre-filter when shrinking). @@ -394,6 +510,116 @@ mod tests { } } + /// A mostly transparent source: a thin ring with a few stray pixels (one with negative zero alpha). + fn sparse_image(w: u32, h: u32) -> Image { + let mut img = Image::new(w, h); + let (cx, cy, r) = (w as f32 / 2.0, h as f32 / 2.0, w.min(h) as f32 * 0.4); + for y in 0..h { + for x in 0..w { + let d = ((x as f32 + 0.5 - cx).hypot(y as f32 + 0.5 - cy) - r).abs(); + if d < 1.5 { + let a = 1.0 - d / 1.5; + img.set(x, y, [0.2 * a, 0.6 * a, a, a]); + } + } + } + img.set(3, 2, [0.5, 0.5, 0.5, 0.5]); + img.set(w - 1, h - 1, [1.0; 4]); + img.set(w / 2, 1, [0.0, 0.0, 0.0, -0.0]); + img + } + + #[test] + fn clear_tiles_hold_only_transparent_taps() { + let img = sparse_image(150, 97); + let clear = ClearTiles::new(&img); + let mut skipped = 0; + for y in 0..img.height as usize - 1 { + for x in 0..img.width as usize - 1 { + if clear.is_clear(x, y) { + skipped += 1; + for (dx, dy) in [(0, 0), (1, 0), (0, 1), (1, 1)] { + assert_eq!(img.get(x as i64 + dx, y as i64 + dy)[3], 0.0, "tap ({}, {}) of a clear tile", x as i64 + dx, y as i64 + dy); + } + } + } + } + assert!(skipped > 100 * 70, "the ring's empty middle is skipped: {skipped}"); + } + + /// The changed rectangle holds every pixel a warp changed, and the skipped transparent + /// samples leave the result as the reference path draws it. + #[test] + fn changed_rect_covers_the_changed_pixels() { + let src = sparse_image(90, 70); + let mats = [ + Mat3::translate(vec2(7.0, 3.0)), + Mat3::translate(vec2(10.25, 4.5)), + Mat3::translate(vec2(60.0, 50.0)) * Mat3::rotate_deg(33.0) * Mat3::scale(vec2(0.9, 1.2)) * Mat3::translate(vec2(-45.0, -35.0)), + Mat3::translate(vec2(-30.0, -20.0)), + Mat3::scale(vec2(0.3, 0.3)), + ]; + for (mi, m) in mats.iter().enumerate() { + for mode in [BlendMode::Normal, BlendMode::Add, BlendMode::Dissolve] { + let opts = WarpOpts { mode, seed: 3, ..Default::default() }; + let before = test_image(120, 100, 4); + let mut a = before.clone(); + let r = composite_warp_changed(&mut a, &src, m, &opts, None); + let Some([x0, y0, x1, y1]) = r else { panic!("mat {mi}: the fast path tracks what it changed") }; + for (i, (p, q)) in a.data.iter().zip(&before.data).enumerate() { + let (x, y) = (i as u32 % a.width, i as u32 / a.width); + if p != q { + assert!(x >= x0 && x < x1 && y >= y0 && y < y1, "mat {mi} {mode:?}: ({x}, {y}) changed outside {r:?}"); + } + } + let mut b = before.clone(); + composite_warp_reference(&mut b, &src, m, &opts); + for (i, (p, q)) in a.data.iter().zip(&b.data).enumerate() { + for c in 0..4 { + assert!((p[c] - q[c]).abs() < 1e-4, "mat {mi} {mode:?} px {i}: {p:?} vs {q:?}"); + } + } + } + } + let mut dst = Image::new(10, 10); + assert_eq!(composite_warp_changed(&mut dst, &Image::new(4, 4), &Mat3::IDENTITY, &WarpOpts::default(), None), Some([u32::MAX, u32::MAX, 0, 0])); + let stencil = WarpOpts { mode: BlendMode::StencilAlpha, ..Default::default() }; + assert_eq!(composite_warp_changed(&mut dst, &src, &Mat3::IDENTITY, &stencil, Some(255.0)), None, "stencils change the whole destination"); + } + + /// Quantising while blending equals blending, then quantising the changed rectangle of an + /// already quantised destination (what an 8/16 bpc comp did after every layer), bit for bit. + #[test] + fn quantize_while_blending_matches_quantizing_after() { + let src = sparse_image(90, 70); + let mats = [Mat3::translate(vec2(7.0, 3.0)), Mat3::translate(vec2(60.0, 50.0)) * Mat3::rotate_deg(33.0) * Mat3::translate(vec2(-45.0, -35.0))]; + for m in &mats { + for mode in [BlendMode::Normal, BlendMode::Screen, BlendMode::Dissolve] { + for (levels, opacity) in [(255.0f32, 1.0), (32_768.0, 0.7)] { + let mut before = test_image(120, 100, 8); + for p in &mut before.data { + crate::quantize_px(p, levels, 1.0 / levels); + } + let opts = WarpOpts { mode, opacity, seed: 5, ..Default::default() }; + let mut after = before.clone(); + let Some([x0, y0, x1, y1]) = composite_warp_changed(&mut after, &src, m, &opts, None) else { panic!("fast path") }; + for y in y0..y1 { + for x in x0..x1 { + let i = after.idx(x, y); + crate::quantize_px(&mut after.data[i], levels, 1.0 / levels); + } + } + let mut fused = before.clone(); + let r = composite_warp_changed(&mut fused, &src, m, &opts, Some(levels)); + assert_eq!(r, Some([x0, y0, x1, y1])); + for (i, (p, q)) in fused.data.iter().zip(&after.data).enumerate() { + assert_eq!(p.map(f32::to_bits), q.map(f32::to_bits), "{mode:?} {levels} px {i}"); + } + } + } + } + } + /// Accumulating sub-samples equals warping each into its own image and summing. #[test] fn accumulate_matches_per_sample_images() { diff --git a/crates/render/src/color.rs b/crates/render/src/color.rs index abfa791e8..d27526553 100644 --- a/crates/render/src/color.rs +++ b/crates/render/src/color.rs @@ -21,7 +21,7 @@ use effectcraft_color::{ColorSpace, Conversion}; use effectcraft_project::{Footage, HdrMode, ProjectSettings}; -use effectcraft_raster::Image; +use effectcraft_raster::{Image, quantize_px}; use rayon::prelude::*; /// The colour pipeline of a project (see the module docs). @@ -218,27 +218,6 @@ pub fn quantize(img: &mut Image, levels: f32) { quantize_rect(img, PxRect { x0: 0, y0: 0, x1: img.width, y1: img.height }, levels); } -/// Clamp (colour ≤ alpha ≤ 1, NaN → 0) and round half up; values are non-negative after the -/// clamp, so the integer conversion rounds like `round()` and vectorises. -#[allow(clippy::manual_clamp)] // max/min map NaN to 0; clamp would keep NaN -#[inline(always)] -fn quantize_px(p: &mut [f32; 4], levels: f32, inv: f32) { - let a = round_half_up(p[3].max(0.0).min(1.0) * levels) * inv; - let hi = [a, a, a, a]; - for c in 0..3 { - p[c] = round_half_up(p[c].max(0.0).min(hi[c]) * levels) * inv; - } - p[3] = a; -} - -/// `v` (0..=65 535) rounded half up. The same either way; the fastest on each target: x86-64's -/// baseline converts to integers in one instruction but has no `floor` (SSE4.1), while in wasm -/// `floor` is one instruction and converting back from `u32` several (about 1.5× faster there). -#[inline(always)] -fn round_half_up(v: f32) -> f32 { - if cfg!(target_arch = "wasm32") { (v + 0.5).floor() } else { (v + 0.5) as u32 as f32 } -} - /// [`quantize`] restricted to a rectangle (already clamped to the image), row-parallel. fn quantize_rect(img: &mut Image, r: PxRect, levels: f32) { if r.x0 >= r.x1 || r.y0 >= r.y1 { @@ -248,7 +227,17 @@ fn quantize_rect(img: &mut Image, r: PxRect, levels: f32) { let w = img.width as usize; let (x0, x1) = (r.x0 as usize, r.x1 as usize); img.data[r.y0 as usize * w..r.y1 as usize * w].par_chunks_mut(w).for_each(|row| { - for p in &mut row[x0..x1] { + // Runs of all-zero pixels (+0.0 in every channel) quantise to themselves: skip them a + // cache line at a time (sparse shape, precomp and glow buffers are mostly empty). + let (blocks, rest) = row[x0..x1].as_chunks_mut::<4>(); + for b in blocks { + if b.as_flattened().iter().fold(0, |a, v| a | v.to_bits()) != 0 { + for p in b { + quantize_px(p, levels, inv); + } + } + } + for p in rest { quantize_px(p, levels, inv); } }); @@ -307,6 +296,39 @@ pub fn output_hdr(img: &mut Image, to_linear: Option, mode: HdrMode, #[cfg(test)] mod tests { + use super::*; + + /// Skipping all-zero runs leaves every pixel as quantising it one by one does, bit for bit + /// (negative zeros and lone pixels in empty runs included). + #[test] + fn quantize_skips_only_zero_runs() { + let mut img = Image::new(23, 7); + for (i, p) in img.data.iter_mut().enumerate() { + *p = match i % 11 { + 0 => [0.3, -0.2, 0.71, 0.9], + 3 => [-0.0, 0.0, 0.0, 0.0], + 5 => [0.0, 0.0, 0.0, -0.0], + 7 => [1.7, 0.4, f32::NAN, 0.5], + _ => [0.0; 4], + }; + } + for levels in [255.0f32, 32_768.0] { + let mut want = img.clone(); + for p in &mut want.data { + quantize_px(p, levels, 1.0 / levels); + } + for r in [PxRect { x0: 0, y0: 0, x1: 23, y1: 7 }, PxRect { x0: 3, y0: 1, x1: 20, y1: 6 }] { + let mut got = img.clone(); + quantize_rect(&mut got, r, levels); + for (i, (g, p)) in got.data.iter().zip(&img.data).enumerate() { + let (x, y) = (i as u32 % 23, i as u32 / 23); + let w = if x >= r.x0 && x < r.x1 && y >= r.y0 && y < r.y1 { want.data[i] } else { *p }; + assert_eq!(g.map(f32::to_bits), w.map(f32::to_bits), "{levels} {r:?} px ({x}, {y})"); + } + } + } + } + /// Both forms `round_half_up` picks between agree on everything quantising feeds it: /// 0..=levels, exact halves included. #[test] diff --git a/crates/render/src/layers.rs b/crates/render/src/layers.rs index 15476a66f..c24c37ff8 100644 --- a/crates/render/src/layers.rs +++ b/crates/render/src/layers.rs @@ -342,11 +342,26 @@ impl<'a> Renderer<'a> { /// Draw a processed layer into `target` (transform, motion blur) with `mode`/`opacity`. pub(crate) fn place(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf, target: &mut Image, mode: BlendMode, opacity: f32) { + self.place_changed(ctx, layer, buf, target, mode, opacity, None); + } + + /// [`Self::place`], returning the target pixels it changed when the warp tracked them (then + /// also quantised to `quantize` levels; see [`composite_warp_changed`]). + #[allow(clippy::too_many_arguments)] + fn place_changed( + &self, + ctx: &EvalCtx, + layer: &Layer, + buf: &Buf, + target: &mut Image, + mode: BlendMode, + opacity: f32, + quantize: Option, + ) -> Option<[u32; 4]> { let pl = self.placement(ctx, layer, buf); let opts = WarpOpts { sampling: pl.sampling, opacity, mode, seed: pl.seed, clip: None }; if let [m] = pl.matrices.as_slice() { - composite_warp(target, &buf.img, m, &opts); - return; + return composite_warp_changed(target, &buf.img, m, &opts, quantize); } // Sub-samples sum straight into one buffer (each warp is row-parallel). let mut acc = Image::new(target.width, target.height); @@ -355,6 +370,7 @@ impl<'a> Renderer<'a> { effectcraft_raster::accumulate_warp(&mut acc, &buf.img, m, opts.sampling, k); } target.blend_from(&acc, mode, opacity, pl.seed); + None } /// Draw a 2D layer into `canvas`; returns the region it may have changed. @@ -377,8 +393,14 @@ impl<'a> Renderer<'a> { let st = self.styled_to_blend(st); let t1 = web_time::Instant::now(); let changed = if st.plain { - self.composite_layer(ctx, layer, &st.body, canvas, opacity); - self.footprint(ctx, layer, &st.body, opacity, blank) + // Pixels the warp tracks are quantised as it blends them (nothing left to do); the + // footprint is still needed when it doesn't, or when no pixel needs quantising. + let footprint = self.footprint(ctx, layer, &st.body, opacity, blank); + let quantize = if footprint == Region::Empty { None } else { self.pipe.levels }; + match self.composite_layer(ctx, layer, &st.body, canvas, opacity, quantize) { + Some(_) if quantize.is_some() => Region::Empty, + drawn => narrow(footprint, drawn), + } } else { self.composite_styled(ctx, layer, &st, canvas, opacity); Region::Full @@ -432,16 +454,18 @@ impl<'a> Renderer<'a> { }); } - fn composite_layer(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf, canvas: &mut Image, opacity: f32) { + /// Composite a plain layer; returns the canvas pixels it changed when they are known (then + /// also quantised to `quantize` levels). + fn composite_layer(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf, canvas: &mut Image, opacity: f32, quantize: Option) -> Option<[u32; 4]> { let matte = layer.track_matte.is_some_and(|tm| tm.layer != layer.id && ctx.comp.layer(tm.layer).is_some()); if !matte && !layer.preserve_transparency { - self.place(ctx, layer, buf, canvas, layer.blend_mode, opacity); - return; + return self.place_changed(ctx, layer, buf, canvas, layer.blend_mode, opacity, quantize); } // Render in isolation, then matte / preserve transparency, then blend. let mut iso = Image::new(canvas.width, canvas.height); self.place(ctx, layer, buf, &mut iso, BlendMode::Normal, 1.0); self.composite_iso(ctx, layer, iso, canvas, opacity); + None } /// Apply the track matte / preserve transparency to an isolated layer render, then blend. @@ -556,3 +580,21 @@ pub struct Placement { /// Dissolve noise seed. pub seed: u32, } + +/// The part of `footprint` a warp actually changed (`drawn`, see [`composite_warp_changed`]): +/// pixels it left alone are still quantised. +fn narrow(footprint: Region, drawn: Option<[u32; 4]>) -> Region { + let Some([x0, y0, x1, y1]) = drawn else { return footprint }; + if x0 >= x1 || y0 >= y1 { + return Region::Empty; + } + let r = color::PxRect { x0, y0, x1, y1 }; + match footprint { + Region::Empty => Region::Empty, + Region::Full => Region::Rect(r), + Region::Rect(f) => { + let q = color::PxRect { x0: r.x0.max(f.x0), y0: r.y0.max(f.y0), x1: r.x1.min(f.x1), y1: r.y1.min(f.y1) }; + if q.x0 >= q.x1 || q.y0 >= q.y1 { Region::Empty } else { Region::Rect(q) } + } + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 439f92cb5..0552a44d4 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -32,7 +32,7 @@ use effectcraft_effects::{Buf, EffectCtx, EffectEnv, EffectHost, LayerPixels, Pa use effectcraft_geom::{Mat3, Mat4, vec2}; use effectcraft_project::{Comp, Footage, FootageKind, FrameBlend, GroupKind, ItemId, ItemKind, Layer, LayerSource, MatteKind, Project, Quality, Sampling}; pub use effectcraft_raster::Image; -use effectcraft_raster::{WarpOpts, composite_warp}; +use effectcraft_raster::{WarpOpts, composite_warp_changed}; use effectcraft_time::{FrameRate, TICKS_PER_SECOND, Tick}; pub use eval::{EvalCtx, ExprHost, effect_bounds, source_size}; use rayon::prelude::*;