From d387f52f1f2f5681b2c7d1c517aa5b5992c7bc88 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:32:39 +0200 Subject: [PATCH] Box-filter minified patterns in the CPU and GPU samplers (#8831) --- crates/compose/src/effects.rs | 6 +-- crates/compose/src/pattern.rs | 84 ++++++++++++++++++++++++++++++++--- crates/gpu/src/compose.wgsl | 35 +++++++++++---- crates/gpu/tests/it/parity.rs | 2 +- 4 files changed, 106 insertions(+), 21 deletions(-) diff --git a/crates/compose/src/effects.rs b/crates/compose/src/effects.rs index 264dc3529b..a55550094e 100644 --- a/crates/compose/src/effects.rs +++ b/crates/compose/src/effects.rs @@ -1257,8 +1257,7 @@ fn paint_pattern( m, |i| { let (x, y) = (f64::from(big.x0 + (i % w) as i32) + 0.5, f64::from(big.y0 + (i / w) as i32) + 0.5); - let (u, v) = place.map(x, y); - tile.sample(u, v) + tile.sample_area(&place, x, y) }, blend, opacity, @@ -1609,8 +1608,7 @@ fn bevel_height(shape: &Map, b: &Bevel, g: &BevelGeom, tex: &TextureCtx, pattern || (), |_, y, row, _| { for (x, o) in row.iter_mut().enumerate() { - let (u, v) = place.map(f64::from(x0 + x as i32) + 0.5, f64::from(y0 + y as i32) + 0.5); - let p = tile.sample(u, v); + let p = tile.sample_area(&place, f64::from(x0 + x as i32) + 0.5, f64::from(y0 + y as i32) + 0.5); let l = (0.299 * p[0] + 0.587 * p[1] + 0.114 * p[2]) * p[3]; *o += k * if t.invert { 1.0 - l } else { l }; } diff --git a/crates/compose/src/pattern.rs b/crates/compose/src/pattern.rs index b66ab0ad1e..258ef5664a 100644 --- a/crates/compose/src/pattern.rs +++ b/crates/compose/src/pattern.rs @@ -3,7 +3,9 @@ //! A pattern tiles the plane from an origin (the layer's frame when linked, else the canvas //! origin, plus the phase), scaled and rotated about that origin. Samples are bilinear with //! wrap-around on premultiplied colour, so integer placements at 100 % reproduce the tile -//! exactly and scaled ones stay seamless. +//! exactly and scaled ones stay seamless. Below 100 % each document pixel averages an +//! `n × n` grid of bilinear taps over its footprint in the tile (see [`pixel_taps`]), so a +//! minified tile is box-filtered instead of aliased; at 100 % and above it is a single tap. use std::collections::HashMap; use std::sync::{Arc, Mutex, OnceLock}; @@ -157,6 +159,33 @@ impl Tile { /// Bilinear, wrapping sample at continuous tile coordinates (pixel centres at `i + 0.5`). /// Returns straight alpha. pub fn sample(&self, u: f64, v: f64) -> [f32; 4] { + unpremultiply(self.sample_premul(u, v)) + } + + /// The pattern at the centre `(x, y)` of a document pixel, filtered over the pixel's + /// footprint in the tile when the placement shrinks it. Returns straight alpha. + pub fn sample_area(&self, place: &Placement, x: f64, y: f64) -> [f32; 4] { + let n = pixel_taps(place.inv_scale); + if n == 1 { + let (u, v) = place.map(x, y); + return self.sample(u, v); + } + let mut acc = [0.0f32; 4]; + for j in 0..n { + for i in 0..n { + let (u, v) = place.map(x + tap_offset(i, n), y + tap_offset(j, n)); + let q = self.sample_premul(u, v); + for c in 0..4 { + acc[c] += q[c]; + } + } + } + let k = 1.0 / (n * n) as f32; + unpremultiply([acc[0] * k, acc[1] * k, acc[2] * k, acc[3] * k]) + } + + /// Bilinear, wrapping sample, premultiplied. + fn sample_premul(&self, u: f64, v: f64) -> [f32; 4] { let (u, v) = (u - 0.5, v - 0.5); let (x0, y0) = (u.floor(), v.floor()); let (fx, fy) = ((u - x0) as f32, (v - y0) as f32); @@ -177,13 +206,32 @@ impl Tile { } } } - if acc[3] <= 0.0 { - return [0.0; 4]; - } - [acc[0] / acc[3], acc[1] / acc[3], acc[2] / acc[3], acc[3].min(1.0)] + acc } } +/// Most taps per axis of the minification filter (the WGSL mirror uses the same cap). +const MAX_TAPS: usize = 8; + +/// Taps per axis for a placement whose inverse scale is `inv_scale`: one pixel covers +/// `inv_scale` tile pixels, so that many taps (capped) box-filter it. 1 at 100 % and above. +fn pixel_taps(inv_scale: f64) -> usize { + let s = inv_scale as f32; + if s.is_finite() && s > 1.0 { (s.ceil() as usize).min(MAX_TAPS) } else { 1 } +} + +/// Offset in document pixels of tap `i` of `n`, centred on the pixel. +fn tap_offset(i: usize, n: usize) -> f64 { + (i as f64 + 0.5) / n as f64 - 0.5 +} + +fn unpremultiply(acc: [f32; 4]) -> [f32; 4] { + if acc[3] <= 0.0 { + return [0.0; 4]; + } + [acc[0] / acc[3], acc[1] / acc[3], acc[2] / acc[3], acc[3].min(1.0)] +} + /// Where a pattern sits: document point → tile coordinates. #[derive(Clone, Copy, Debug)] pub struct Placement { @@ -234,8 +282,7 @@ pub fn render(tile: &Tile, place: &Placement, rect: Rect) -> Vec<[f32; 4]> { let mut out = Vec::with_capacity(rect.width() as usize * rect.height() as usize); for y in rect.y0..rect.y1 { for x in rect.x0..rect.x1 { - let (u, v) = place.map(f64::from(x) + 0.5, f64::from(y) + 0.5); - out.push(tile.sample(u, v)); + out.push(tile.sample_area(place, f64::from(x) + 0.5, f64::from(y) + 0.5)); } } out @@ -299,6 +346,29 @@ mod tests { assert!((u - -3.0).abs() < 1e-9 && (v - 1.0).abs() < 1e-9, "{u},{v}"); } + #[test] + fn minified_pattern_is_filtered_not_aliased() { + // 1-px dark / light stripes (mean 0.5), shown at 25 %: every pixel covers 4 tile pixels. + // The 0.125 px phase puts each pixel centre on a light tile pixel's centre, so a single + // bilinear tap would return pure white. + let mut s = Surface::new(PixelFormat::RGBA8); + s.fill_rect(Rect::new(0, 0, 8, 8), &[1.0, 1.0, 1.0, 1.0]); + for x in (0..8).step_by(2) { + s.fill_rect(Rect::new(x, 0, x + 1, 8), &[0.0, 0.0, 0.0, 1.0]); + } + let t = Tile::new(&Pattern::new("stripes", s, 8, 8)).unwrap(); + let place = Placement::new(Rect::EMPTY, false, (0.125, 0.0), 0.25, 0.0); + let (u, _) = place.map(0.5, 0.5); + assert_eq!(t.sample(u, 0.5)[0], 1.0, "the single tap lands on a light stripe"); + for p in render(&t, &place, Rect::new(0, 0, 8, 1)) { + assert!((p[0] - 0.5).abs() < 0.05, "{p:?}"); + } + // 100 % stays exact. + let one = Placement::new(Rect::EMPTY, false, (0.0, 0.0), 1.0, 0.0); + let row = render(&t, &one, Rect::new(0, 0, 2, 1)); + assert_eq!((row[0][0], row[1][0]), (0.0, 1.0)); + } + #[test] fn sampling_at_hostile_phases_does_not_overflow() { let patterns = [checker()]; diff --git a/crates/gpu/src/compose.wgsl b/crates/gpu/src/compose.wgsl index a6c0c63579..dc18241b46 100644 --- a/crates/gpu/src/compose.wgsl +++ b/crates/gpu/src/compose.wgsl @@ -1097,15 +1097,10 @@ fn wrap(i: i32, n: i32) -> i32 { return ((i % n) + n) % n; } -// compose::pattern::Tile::sample through Placement::map. p3 = (origin, cos, sin), -// p4 = (1 / scale, tile w, tile h); `pat_tex` holds premultiplied RGBA. -fn pattern_sample(d: vec2) -> vec4 { - let dx = f32(d.x) + 0.5 - op.p3.x; - let dy = f32(d.y) + 0.5 - op.p3.y; - let c = op.p3.z; - let s = op.p3.w; - let u = (dx * c - dy * s) * op.p4.x - 0.5; - let v = (dx * s + dy * c) * op.p4.x - 0.5; +// One bilinear, wrapping, premultiplied tap at tile coordinates (u, v) (Tile::sample_premul). +fn pattern_tap(u0: f32, v0: f32) -> vec4 { + let u = u0 - 0.5; + let v = v0 - 0.5; let x0 = floor(u); let y0 = floor(v); let fx = u - x0; @@ -1124,6 +1119,28 @@ fn pattern_sample(d: vec2) -> vec4 { acc += textureLoad(pat_tex, vec2(wrap(ix + i, tw), wrap(iy + j, th)), 0) * (wx[i] * wy[j]); } } + return acc; +} + +// compose::pattern::Tile::sample_area through Placement::map. p3 = (origin, cos, sin), +// p4 = (1 / scale, tile w, tile h); `pat_tex` holds premultiplied RGBA. Below 100 % a pixel +// averages n x n taps over its footprint (n = ceil(1 / scale), at most 8), like the CPU. +fn pattern_sample(d: vec2) -> vec4 { + var n = 1; + if (op.p4.x > 1.0) { n = i32(min(ceil(op.p4.x), 8.0)); } + let c = op.p3.z; + let s = op.p3.w; + var acc = vec4(0.0); + for (var j = 0; j < n; j++) { + for (var i = 0; i < n; i++) { + let ox = (f32(i) + 0.5) / f32(n) - 0.5; + let oy = (f32(j) + 0.5) / f32(n) - 0.5; + let dx = f32(d.x) + 0.5 + ox - op.p3.x; + let dy = f32(d.y) + 0.5 + oy - op.p3.y; + acc += pattern_tap((dx * c - dy * s) * op.p4.x, (dx * s + dy * c) * op.p4.x); + } + } + acc = acc / f32(n * n); if (acc.a <= 0.0) { return vec4(0.0); } return vec4(acc.rgb / acc.a, min(acc.a, 1.0)); } diff --git a/crates/gpu/tests/it/parity.rs b/crates/gpu/tests/it/parity.rs index 464ad1f1a2..476474f4ac 100644 --- a/crates/gpu/tests/it/parity.rs +++ b/crates/gpu/tests/it/parity.rs @@ -2630,7 +2630,7 @@ fn pattern_cache_tracks_source_format_and_cow_tiles() { fn pattern_fill_layers() { let Some(mut g) = gpu() else { return }; let pat = checker_pattern(); - for (link, scale, angle, masked) in [(true, 1.0, 0.0, false), (false, 0.7, 25.0, true), (true, 1.6, -40.0, false)] { + for (link, scale, angle, masked) in [(true, 1.0, 0.0, false), (false, 0.7, 25.0, true), (true, 1.6, -40.0, false), (true, 0.3, 10.0, false)] { let mut d = base_doc(70, 50); d.patterns.push(pat.clone()); let fill = Fill::Pattern { name: pat.name.clone(), id: pat.id.clone(), scale, angle, link, phase: (3.0, -2.0) };