diff --git a/crates/gpu/src/effects.rs b/crates/gpu/src/effects.rs index b33c15e99..fd5a97c38 100644 --- a/crates/gpu/src/effects.rs +++ b/crates/gpu/src/effects.rs @@ -274,7 +274,9 @@ pub(crate) fn box_passes(e: &mut Enc, img: &GpuImage, rx: &[usize], ry: &[usize] let mut cur = img.clone(); let passes = rx.iter().map(|&r| (false, r)).chain(ry.iter().map(|&r| (true, r))).filter(|p| p.1 > 0); for (vertical, r) in passes { - let block = if vertical { (4 * r).clamp(32, 256) } else { 256 } as u32; + // Vertical sums restart every `block` rows as the CPU's do; a row is one running sum, as + // on the CPU (restarting it rounds differently). + let block = if vertical { (4 * r).clamp(32, 256) as u32 } else { cur.width.max(1) }; let (lines, n) = if vertical { (cur.width, cur.height) } else { (cur.height, cur.width) }; let mut p = Params::default(); p.u[0] = [r as u32, repeat as u32, block, 0]; diff --git a/crates/gpu/src/shaders/kernels.wgsl b/crates/gpu/src/shaders/kernels.wgsl index bb4a5e63a..a123b70ca 100644 --- a/crates/gpu/src/shaders/kernels.wgsl +++ b/crates/gpu/src/shaders/kernels.wgsl @@ -270,7 +270,7 @@ fn line_get(horizontal: bool, line: i32, i: i32, n: i32, repeat: bool) -> vec4, horizontal: bool) { let dims = out_dims(); let r = i32(P.u[0].x); diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index 7b85bfc85..b0587b2e2 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -731,6 +731,40 @@ fn small_blurs_fall_back_to_the_cpu() { } } +/// Horizontal GPU box passes used to restart the running sum every 256 px; the CPU keeps one sum +/// per row, so images wider than 256 disagreed in f32 rounding. Width 320 forces that path. +/// Exact on llvmpipe (CI lavapipe), which fails without the fix; other adapters (Metal measured +/// ~1e-6 either way) only get a 2e-6 sanity bound and do not catch the regression. Metal still +/// differs by ~1e-6 with matching blocks. +#[test] +fn box_blur_horizontal_rows_match_cpu_beyond_256() { + let Some(g) = gpu() else { return }; + let img = pattern(11, 320, 48); + let up = g.ctx.upload_image(&img).expect("upload"); + let tol = if on_llvmpipe() { 0.0 } else { 2e-6 }; + let check_case = |name: &str, want: Image, got: Image| { + assert_eq!((got.width, got.height), (want.width, want.height), "{name}: size"); + let mut max_abs = 0.0f32; + for (a, b) in want.data.iter().zip(&got.data) { + for c in 0..4 { + max_abs = max_abs.max((a[c] - b[c]).abs()); + } + } + assert!(max_abs <= tol, "{name}: max abs channel diff {max_abs} exceeds {tol} on {}", g.ctx.name); + }; + for (name, r) in [("box r5×3", 5usize), ("box r25×3", 25)] { + let mut e = crate::context::Enc::new(&g.ctx); + let rx = [r, r, r]; + let out = crate::effects::box_passes(&mut e, &up, &rx, &rx, false); + check_case(name, effectcraft_raster::box_blur(&img, r, r, 3, false), e.download(&out).expect("download")); + } + for (name, sigma) in [("gauss σ10", 10.0f64), ("gauss σ40", 40.0)] { + let mut e = crate::context::Enc::new(&g.ctx); + let out = crate::effects::gaussian_blur(&mut e, &up, sigma, sigma, false); + check_case(name, effectcraft_raster::gaussian_blur(&img, sigma, sigma, false), e.download(&out).expect("download")); + } +} + #[test] fn gpu_effects_match_cpu() { // Sigma 3 px and up and even radii, so the half-scale pass stays on the GPU too (#1939).