From 94fe30015c50e9fd685049fd9c01cc0986b9ef8c Mon Sep 17 00:00:00 2001 From: radialmonster Date: Wed, 7 Oct 2026 08:34:05 -0400 Subject: [PATCH] GPU: decide warped-geometry edges with a CPU coverage mask The lens + perspective warp classified source-edge pixels in f32 on the GPU. A position 0.000035 px outside the edge rounded inside, so the GPU sampled photo pixels where the CPU reference draws blank canvas (geometry_variants, "embedded lens + perspective", max 91 LSB). The CPU now evaluates the f64 framing decision into a one-bit-per-pixel mask; sampling and colour stay on the GPU. The embedded-lens cases move to their own test, embedded_lens_perspective_edges_match, with the same tolerances. Co-Authored-By: Claude Opus 5.5 --- crates/gpu/src/ctx.rs | 2 +- crates/gpu/src/render.rs | 25 ++++++++++++++++++++++--- crates/gpu/src/wgsl/geom.wgsl | 4 +++- crates/gpu/tests/equivalence.rs | 17 ++++++++++++++--- docs/gpu-pipeline.md | 7 +++++++ 5 files changed, 47 insertions(+), 8 deletions(-) diff --git a/crates/gpu/src/ctx.rs b/crates/gpu/src/ctx.rs index 1003298a0..0f826bf44 100644 --- a/crates/gpu/src/ctx.rs +++ b/crates/gpu/src/ctx.rs @@ -62,7 +62,7 @@ const MODULES: &[Module] = &[ }, Module { src: include_str!("wgsl/geom.wgsl"), - bindings: &[("src", false, "f32"), ("dst", true, "f32")], + bindings: &[("src", false, "f32"), ("dst", true, "f32"), ("coverage", false, "u32")], entries: &["orient", "sample_affine", "sample_warp"], }, ]; diff --git a/crates/gpu/src/render.rs b/crates/gpu/src/render.rs index 0e18785c7..02d3c8433 100644 --- a/crates/gpu/src/render.rs +++ b/crates/gpu/src/render.rs @@ -422,7 +422,7 @@ fn sample(cx: &mut Cx<'_>, src: &Arc, src_buf: Arc, plan: &Plan<'_> let out = cx.gpu.buffer(sp.ow * sp.oh * 3); let mut p = vec![src.width as u32, src.height as u32, sp.ow as u32, sp.oh as u32]; p.extend(orient_map(fr.orient, src.width, src.height).map(|v| v as u32)); - cx.run("orient", &p, &[Some(&src_buf), Some(&out)], groups2(sp.ow, sp.oh, [16, 16])); + cx.run("orient", &p, &[Some(&src_buf), Some(&out), None], groups2(sp.ow, sp.oh, [16, 16])); Arc::new(out) }; let (base, (bw, bh)) = match sp.prefilter { @@ -433,7 +433,7 @@ fn sample(cx: &mut Cx<'_>, src: &Arc, src_buf: Arc, plan: &Plan<'_> let out = cx.gpu.buffer(w * h * 3); let mut p = vec![bw as u32, bh as u32, w as u32, h as u32]; p.extend(affine_bits(xf)); - cx.run("sample_affine", &p, &[Some(&base), Some(&out)], groups2(w, h, [16, 16])); + cx.run("sample_affine", &p, &[Some(&base), Some(&out), None], groups2(w, h, [16, 16])); Arc::new(out) }; match (&sp.mode, fr.warp.as_ref()) { @@ -444,7 +444,26 @@ fn sample(cx: &mut Cx<'_>, src: &Arc, src_buf: Arc, plan: &Plan<'_> (SampleMode::Warp(o2t), Some(wp)) => { let out = cx.gpu.buffer(w * h * 3); let p = warp_params(wp, (bw, bh), (w, h), o2t, sp.sx, sp.sy); - cx.run("sample_warp", &p, &[Some(&base), Some(&out)], groups2(w, h, [16, 16])); + // f32 rounding can cross a source edge. Keep the reference f64 + // framing decision in a bit mask; color sampling stays on the GPU. + let words = w.div_ceil(32); + let mut coverage = vec![0u32; words * h]; + lightcraft_raster::par_rows(&mut coverage, words, |y, row| { + for (word, bits) in row.iter_mut().enumerate() { + for bit in 0..32 { + let x = word * 32 + bit; + if x >= w { + break; + } + let t = o2t.apply(lightcraft_geom::Point::new(x as f64 + 0.5, y as f64 + 0.5)); + if wp.inside(wp.corrected_to_source(wp.to_corrected(t), 1)) { + *bits |= 1 << bit; + } + } + } + }); + let coverage = cx.gpu.upload(&coverage); + cx.run("sample_warp", &p, &[Some(&base), Some(&out), Some(&coverage)], groups2(w, h, [16, 16])); Arc::new(out) } } diff --git a/crates/gpu/src/wgsl/geom.wgsl b/crates/gpu/src/wgsl/geom.wgsl index 973f386d1..e80cfb87e 100644 --- a/crates/gpu/src/wgsl/geom.wgsl +++ b/crates/gpu/src/wgsl/geom.wgsl @@ -177,7 +177,9 @@ fn sample_warp(@builtin(global_invocation_id) g: vec3) { let s = corrected_to_source(c, 1u); let sx = pf(10u); let sy = pf(11u); - if (!(s.x >= -0.5 && s.y >= -0.5 && s.x <= pf(12u) + 0.5 && s.y <= pf(13u) + 0.5)) { + // CPU reference coverage avoids an f32 rounding discontinuity at the edge. + let word = g.y * ((w + 31u) / 32u) + g.x / 32u; + if ((coverage[word] & (1u << (g.x % 32u))) == 0u) { put(g.y * w + g.x, vec3(BLANK_R, BLANK_G, BLANK_B)); return; } diff --git a/crates/gpu/tests/equivalence.rs b/crates/gpu/tests/equivalence.rs index a806f13b9..c16b46cb4 100644 --- a/crates/gpu/tests/equivalence.rs +++ b/crates/gpu/tests/equivalence.rs @@ -430,7 +430,6 @@ fn geometry_variants() { if !gpu() { return; } - use lightcraft_develop::{EmbeddedLens, EmbeddedVignette, EmbeddedWarp}; let src = scene(2, 900, 600); let raw = SourceInfo { raw: true, ..Default::default() }; use Orientation::*; @@ -445,7 +444,19 @@ fn geometry_variants() { check(&format!("{o:?} crop={crop}"), &src, &raw, &s, &req); } } - // embedded DNG lens corrections (per-plane warp + vignette) with manual CA +} + +#[test] +fn embedded_lens_perspective_edges_match() { + if !gpu() { + return; + } + use lightcraft_develop::{EmbeddedLens, EmbeddedVignette, EmbeddedWarp}; + let src = scene(2, 900, 600); + let raw = SourceInfo { raw: true, ..Default::default() }; + // The unrotated case includes a position only 0.000035 px outside the edge. + // f32 classification previously sampled the photo instead of blank canvas. + // Embedded DNG lens corrections (per-plane warp + vignette) with manual CA. let lens = EmbeddedLens { warp: Some(EmbeddedWarp { planes: [[1.0, -0.03, 0.01, 0.0, 0.001, -0.002], [1.0, -0.028, 0.01, 0.0, 0.001, -0.002], [1.0, -0.026, 0.01, 0.0, 0.001, -0.002]], @@ -460,7 +471,7 @@ fn geometry_variants() { s.optics.ca_red = 30.0; s.geometry.horizontal = -15.0; check("embedded lens + perspective", &src, &info, &s, &RenderRequest::fit(700, 700)); - s.orientation = Rotate270; + s.orientation = Orientation::Rotate270; check("embedded lens rotated", &src, &info, &s, &RenderRequest::fit(700, 700)); } diff --git a/docs/gpu-pipeline.md b/docs/gpu-pipeline.md index 7ea22d06a..7086dc343 100644 --- a/docs/gpu-pipeline.md +++ b/docs/gpu-pipeline.md @@ -4,6 +4,13 @@ The CPU pipeline (`crates/pipeline`) is the reference ("oracle"). `lightcraft-gp evaluates the same stages with wgpu compute shaders (WGSL) on Metal / Vulkan / DX12. Everything is pure Rust (wgpu, naga); the drivers are the system's. No GL backend is compiled in. +Warped geometry uses a CPU-computed coverage bit mask for the source boundary. +The CPU reference evaluates that boundary in double precision; GPU float +rounding otherwise can turn a blank edge pixel into a photo pixel. The mask +uses one bit per output pixel (rows padded to 32 bits). Sampling and color +corrections remain on the GPU, and existing geometry stage caching reuses the +result. This adds CPU coordinate evaluation only when warped geometry rebuilds. + ## Backends, environment variables and troubleshooting (issue #136) wgpu loads the driver of **every** backend in an instance's set while it enumerates adapters — even when it then picks another one. A Vulkan driver that crashes there (issue #136: an access violation