GPU: build the warp coverage mask per block with interval bounds
The coverage mask from 94fe300 evaluated the f64 warp for every output
pixel. coverage_mask now asks Warp::block_coverage for each 32 x 16 block
(the shared warp formulas evaluated with outward-rounded intervals, no
edge margin) and evaluates only undecided blocks near the edge per pixel
with Warp::covers; the bits are unchanged
(coverage_mask_matches_per_pixel_decision, incl. partial words and
padding bits; coverage_mask_with_the_horizon_in_view, a perspective
denominator exactly 0 inside the image, so the clamp and
undecided-division paths go through block_coverage and the mask).
Measured (release, synthetic source, embedded lens warp + CA +
perspective as in embedded_lens_perspective_edges_match):
6000x4000 ~30 ms -> 2.7 ms (32 threads, 11x), ~580 ms -> 24.5 ms
(1 thread, 24x); 2048x1365 3.6 ms -> 1.0 ms (3.5x), 67.5 ms -> ~5 ms
(12-14x). Rotate270 the same.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
2b2ebfc7ca
commit
1237e198d5
|
|
@ -410,6 +410,42 @@ fn warp_params(
|
|||
p
|
||||
}
|
||||
|
||||
/// Output rows per block of [`coverage_mask`]; a block is one mask word (32 px) wide. 16 rows measured
|
||||
/// ~20 % faster than 8 at 6000 × 4000 (fewer interval evaluations, few more edge pixels).
|
||||
const COVER_ROWS: usize = 16;
|
||||
|
||||
/// The reference framing decision ([`Warp::covers`](lightcraft_pipeline::optics::Warp::covers)) for every
|
||||
/// output pixel, one bit per pixel, rows padded to 32-bit words. Blocks of 32 × [`COVER_ROWS`] pixels whose
|
||||
/// interval bounds place them clearly inside or outside the image are filled at once
|
||||
/// ([`Warp::block_coverage`](lightcraft_pipeline::optics::Warp::block_coverage), the same formulas evaluated
|
||||
/// with outward-rounded intervals); only blocks near the image edge evaluate each pixel. Same bits as
|
||||
/// evaluating every pixel.
|
||||
fn coverage_mask(wp: &lightcraft_pipeline::optics::Warp, o2t: &lightcraft_geom::Affine, w: usize, h: usize) -> Vec<u32> {
|
||||
let words = w.div_ceil(32);
|
||||
let mut coverage = vec![0u32; words * h];
|
||||
if words == 0 {
|
||||
return coverage;
|
||||
}
|
||||
lightcraft_raster::par_rows(&mut coverage, words * COVER_ROWS, |band, rows| {
|
||||
let y0 = band * COVER_ROWS;
|
||||
let y1 = y0 + rows.len() / words;
|
||||
for word in 0..words {
|
||||
let (x0, x1) = (word * 32, (word * 32 + 32).min(w));
|
||||
let full = if x1 - x0 == 32 { u32::MAX } else { (1u32 << (x1 - x0)) - 1 };
|
||||
let block = wp.block_coverage(o2t, x0, x1, y0, y1);
|
||||
for (y, row) in (y0..y1).zip(rows.chunks_exact_mut(words)) {
|
||||
let Some(bits) = row.get_mut(word) else { continue };
|
||||
*bits = match block {
|
||||
Some(true) => full,
|
||||
Some(false) => 0,
|
||||
None => (x0..x1).filter(|&x| wp.covers(o2t, x, y)).fold(0, |b, x| b | 1 << (x - x0)),
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
coverage
|
||||
}
|
||||
|
||||
/// Resample the source into the output frame (`Frame::sample`). May return the source buffer
|
||||
/// itself when the frame is the identity.
|
||||
fn sample(cx: &mut Cx<'_>, src: &Arc<Rgb32f>, src_buf: Arc<Buf>, plan: &Plan<'_>) -> Arc<Buf> {
|
||||
|
|
@ -446,23 +482,7 @@ fn sample(cx: &mut Cx<'_>, src: &Arc<Rgb32f>, src_buf: Arc<Buf>, plan: &Plan<'_>
|
|||
let p = warp_params(wp, (bw, bh), (w, h), o2t, sp.sx, sp.sy);
|
||||
// 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);
|
||||
let coverage = cx.gpu.upload(&coverage_mask(wp, o2t, w, h));
|
||||
cx.run("sample_warp", &p, &[Some(&base), Some(&out), Some(&coverage)], groups2(w, h, [16, 16]));
|
||||
Arc::new(out)
|
||||
}
|
||||
|
|
@ -969,6 +989,73 @@ fn masks(cx: &mut Cx<'_>, lin: &Buf, prep: &Prep, plan: &Plan<'_>, host: &mut Ho
|
|||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn coverage_mask_matches_per_pixel_decision() {
|
||||
use lightcraft_develop::{EmbeddedLens, EmbeddedWarp};
|
||||
let lens = EmbeddedLens {
|
||||
warp: Some(EmbeddedWarp {
|
||||
planes: [[1.0, -0.03, 0.01, 0.0, 0.001, -0.002]; 3],
|
||||
center: lightcraft_geom::Point::new(0.52, 0.48),
|
||||
radius: 0.6,
|
||||
}),
|
||||
vignette: None,
|
||||
};
|
||||
let mut s = DevelopSettings::default();
|
||||
s.optics.lens_profile = true;
|
||||
s.optics.distortion = -40.0;
|
||||
s.geometry.horizontal = -15.0;
|
||||
s.geometry.rotate = 3.0;
|
||||
for orientation in [Orientation::Normal, Orientation::Rotate270] {
|
||||
s.orientation = orientation;
|
||||
let f = lightcraft_pipeline::geometry::Frame::with_lens(900, 600, &s, true, Some(&lens));
|
||||
let wp = f.warp.as_ref().expect("warp");
|
||||
// widths that do and don't fill the last word; heights that do and don't fill the last band
|
||||
for (w, h) in [f.fit(700, 700), (333, 221), (64, 16), (1, 1)] {
|
||||
let o2t = f.out_to_oriented(w, h);
|
||||
let mask = coverage_mask(wp, &o2t, w, h);
|
||||
let words = w.div_ceil(32);
|
||||
assert_eq!(mask.len(), words * h);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let bit = mask[y * words + x / 32] >> (x % 32) & 1 == 1;
|
||||
assert_eq!(bit, wp.covers(&o2t, x, y), "{orientation:?} {w}x{h}: pixel {x},{y}");
|
||||
}
|
||||
let pad = mask[y * words + words - 1] >> (w % 32);
|
||||
assert!(w.is_multiple_of(32) || pad == 0, "padding bits set");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The perspective horizon in view: its denominator is exactly 0 on output row 75 (clamped to 1e-300) and
|
||||
/// changes sign there, so the blocks around it are undecided (clamp, and division by a range that may hold
|
||||
/// zero) and are decided per pixel, while blocks clear of it are filled at once.
|
||||
#[test]
|
||||
fn coverage_mask_with_the_horizon_in_view() {
|
||||
use lightcraft_geom::{Affine, Homography};
|
||||
let (w, h) = (900, 600);
|
||||
let mut wp = lightcraft_pipeline::optics::Warp::identity(w as f64, h as f64);
|
||||
// centred coordinates: v = (y − 300) / 450, denominator 2·v + m8, exactly 0 at y = 75.5
|
||||
let m8 = -((75.5 - 300.0) / 450.0 * 2.0);
|
||||
wp.persp_inv = Homography([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 2.0, m8]);
|
||||
wp.persp = wp.persp_inv.inverse().expect("invertible");
|
||||
let o2t = Affine::IDENTITY;
|
||||
assert_eq!(wp.block_coverage(&o2t, 0, 32, 64, 80), None, "the block holding the horizon");
|
||||
assert_eq!(wp.block_coverage(&o2t, 0, 32, 0, 16), Some(false), "beyond the horizon");
|
||||
assert_eq!(wp.block_coverage(&o2t, 448, 480, 288, 304), Some(true), "the centre");
|
||||
let mask = coverage_mask(&wp, &o2t, w, h);
|
||||
let words = w.div_ceil(32);
|
||||
let mut covered = 0;
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let bit = mask[y * words + x / 32] >> (x % 32) & 1 == 1;
|
||||
assert_eq!(bit, wp.covers(&o2t, x, y), "pixel {x},{y}");
|
||||
covered += bit as usize;
|
||||
}
|
||||
}
|
||||
assert!(covered > 0 && covered < w * h, "{covered} px covered");
|
||||
}
|
||||
|
||||
/// Kernel timings on a 24 MP plane: `cargo test --release -p lightcraft-gpu -- --ignored --nocapture`.
|
||||
#[test]
|
||||
#[ignore]
|
||||
|
|
|
|||
|
|
@ -9,7 +9,17 @@ 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.
|
||||
result. The mask is built per 32 × 16 block (`Warp::block_coverage`): the warp
|
||||
formulas are written once, generic over `lightcraft_geom::Real`, and evaluated
|
||||
with outward-rounded `Interval`s over the block. Those bounds enclose the f64
|
||||
result of every pixel in the block, so a block whose bounds lie inside the
|
||||
image edges (the same f64 comparisons, no margin), or beyond one of them, is
|
||||
filled at once; only blocks across the edge, or whose bounds are undecided (a
|
||||
perspective denominator that may vanish), evaluate each pixel (`Warp::covers`,
|
||||
the framing chain `Warp::frame` the CPU resample uses too). The bits equal the
|
||||
per-pixel decision. At 6000 × 4000 with lens warp + perspective the mask takes
|
||||
~2.7 ms (32 threads; ~24.5 ms on one) instead of ~30 ms (~580 ms), and 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
|
||||
|
|
|
|||
Loading…
Reference in New Issue