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 <[email protected]>
This commit is contained in:
radialmonsterandClaude Opus 5.5 committed 2026-10-07 18:49:59 -04:00
1 parent e99ce5a578
commit 94fe30015c
5 files changed
+47 -8

No files matched your search

+1 -1
View File
@@ -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"],
},
];
+22 -3
View File
@@ -422,7 +422,7 @@ fn sample(cx: &mut Cx<'_>, src: &Arc<Rgb32f>, src_buf: Arc<Buf>, 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<Rgb32f>, src_buf: Arc<Buf>, 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<Rgb32f>, src_buf: Arc<Buf>, 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)
}
}
+3 -1
View File
@@ -177,7 +177,9 @@ fn sample_warp(@builtin(global_invocation_id) g: vec3<u32>) {
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<f32>(BLANK_R, BLANK_G, BLANK_B));
return;
}
+14 -3
View File
@@ -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));
}
+7
View File
@@ -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