From 1237e198d523af7440353297ecfecb2db7647ab8 Mon Sep 17 00:00:00 2001 From: radialmonster Date: Wed, 7 Oct 2026 09:56:16 -0400 Subject: [PATCH] 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 --- crates/gpu/src/render.rs | 121 +++++++++++++++++++++++++++++++++------ docs/gpu-pipeline.md | 12 +++- 2 files changed, 115 insertions(+), 18 deletions(-) diff --git a/crates/gpu/src/render.rs b/crates/gpu/src/render.rs index 02d3c843..d6a838b0 100644 --- a/crates/gpu/src/render.rs +++ b/crates/gpu/src/render.rs @@ -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 { + 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, src_buf: Arc, plan: &Plan<'_>) -> Arc { @@ -446,23 +482,7 @@ fn sample(cx: &mut Cx<'_>, src: &Arc, src_buf: Arc, 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] diff --git a/docs/gpu-pipeline.md b/docs/gpu-pipeline.md index 7086dc34..794b3aa8 100644 --- a/docs/gpu-pipeline.md +++ b/docs/gpu-pipeline.md @@ -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