diff --git a/crates/paint/examples/bench_brush.rs b/crates/paint/examples/bench_brush.rs index efd9c010f1..d6d5567188 100644 --- a/crates/paint/examples/bench_brush.rs +++ b/crates/paint/examples/bench_brush.rs @@ -29,6 +29,7 @@ fn run(fmt: PixelFormat, label: &str, brush: &BrushSettings) { let start = Instant::now(); let mut r = StrokeRenderer::new(brush, Some(fmt), 1.0); let mut worst = 0.0f64; + let (mut worst_push, mut worst_comp) = (0.0f64, 0.0f64); let mut chunks = 0; let (mut t_push, mut t_comp) = (0.0f64, 0.0f64); for ch in pts.chunks(16) { @@ -40,13 +41,15 @@ fn run(fmt: PixelFormat, label: &str, brush: &BrushSettings) { t_push += tp; t_comp += tt - tp; worst = worst.max(tt); + worst_push = worst_push.max(tp); + worst_comp = worst_comp.max(tt - tp); chunks += 1; } r.finish(); r.composite(&pre, &mut s, None, false, false); let total = start.elapsed().as_secs_f64() * 1000.0; println!( - "{label:<34} dabs {:>5} total {total:>7.1} ms (dabs {t_push:>6.1}, composite {t_comp:>6.1}) mean/chunk {:>6.2} ms worst/chunk {worst:>6.2} ms", + "{label:<34} dabs {:>5} total {total:>7.1} ms (dabs {t_push:>6.1}, composite {t_comp:>6.1}) mean/chunk {:>6.2} ms worst/chunk {worst:>6.2} ms worst push/chunk {worst_push:>6.2} ms worst composite/chunk {worst_comp:>6.2} ms", r.dab_count(), total / chunks as f64 ); diff --git a/crates/paint/src/brush_tests.rs b/crates/paint/src/brush_tests.rs index 8b024de5a6..268ca52b95 100644 --- a/crates/paint/src/brush_tests.rs +++ b/crates/paint/src/brush_tests.rs @@ -1874,6 +1874,44 @@ fn large_tips_render_the_same_in_parallel_and_serially() { } } +/// The general loop (sampled tips, per-tip textures) paints the same pixels on the thread pool +/// and serially, at 8-bit, 16-bit and float depth. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn general_loop_strokes_render_the_same_in_parallel_and_serially_at_every_depth() { + let tip = GrayTile::from_fn(16, 16, |x, y| if (x + y) % 3 == 0 { 1.0 } else { 0.4 }); + let mut textured = BrushSettings { size: 120.0, hardness: 0.3, ..brush() }; + textured.texture = Texture { enabled: true, pattern: Pattern::Tile(tip.clone()), each_tip: true, ..Default::default() }; + let strokes = [BrushSettings { size: 120.0, tip: TipShape::Sampled(tip), color: [0.9, 0.2, 0.1, 1.0], ..brush() }, textured]; + let full = Rect::new(0, 0, 400, 200); + let formats = [PixelFormat::RGBA8, PixelFormat::RGBA8.with_sample(SampleType::U16), PixelFormat::RGBA32F]; + struct Restore; + impl Drop for Restore { + fn drop(&mut self) { + crate::render::set_parallel_min_pixels(crate::render::DEFAULT_PARALLEL_MIN_PIXELS); + } + } + let _restore = Restore; + for fmt in formats { + for (i, b) in strokes.iter().enumerate() { + let render = |parallel_from: usize| -> Vec { + crate::render::set_parallel_min_pixels(parallel_from); + let mut s = Surface::new(fmt); + s.fill_rect(full, &[0.5, 0.5, 0.5, 1.0]); + render_stroke(&mut s, b, &line(80.0, 320.0, 100.0), None, false, 1.0); + s.read_region(full) + }; + let parallel = render(0); + let serial = render(usize::MAX); + assert_eq!(parallel.len(), serial.len()); + let diff = parallel.iter().zip(&serial).filter(|(a, b)| a.to_bits() != b.to_bits()).count(); + assert_eq!(diff, 0, "stroke {i} at {fmt:?}: {diff} pixels differ between the parallel and the serial render"); + // Colour channels only: the opaque background's alpha (1.0) is always 0.5 away from 0.5. + assert!(parallel.as_chunks::<4>().0.iter().any(|px| px[..3].iter().any(|v| (*v - 0.5).abs() > 0.1)), "stroke {i} at {fmt:?} painted something"); + } + } +} + /// A CMYK document with its own profile: blend modes other than Normal convert each pixel through /// that profile, which is active per thread, so a stroke composited in parallel gives the pixels the /// serial path gives (the workers enter the caller's CMYK space too). diff --git a/crates/paint/src/render.rs b/crates/paint/src/render.rs index 6a679decf6..66cd382876 100644 --- a/crates/paint/src/render.rs +++ b/crates/paint/src/render.rs @@ -48,6 +48,11 @@ pub const COV_TILE: i32 = 64; #[cfg(not(target_arch = "wasm32"))] pub(crate) const DEFAULT_PARALLEL_MIN_PIXELS: usize = 256 * 256; +/// The general loop (sampled tips, noise, textures, wet edges) reaches the thread pool at a quarter +/// of the pixels: at about 9-12 ns a pixel, threads already halve a dab of 16,000 pixels or more. +#[cfg(not(target_arch = "wasm32"))] +const GENERAL_LOOP_COST: usize = 4; + /// The threshold in use: [`DEFAULT_PARALLEL_MIN_PIXELS`] (tests move it). #[cfg(not(target_arch = "wasm32"))] static PARALLEL_MIN_PIXELS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(DEFAULT_PARALLEL_MIN_PIXELS); @@ -325,16 +330,6 @@ impl BrushContext { } None => (0, 1.0, 1.0, 1.0), }; - // A pixel offset from the dab centre in tip space: to y-up, project, rotate by -angle, flip. - let to_tip = |dx: f32, dy: f32| { - let (mut ux, mut uy) = (dx, -dy); - if let Some((ax, ay, k)) = proj { - let t = (ux * ax + uy * ay) * k; - ux += t * ax; - uy += t * ay; - } - ((ux * cs + uy * sn) * fx, (-ux * sn + uy * cs) * fy) - }; // A tip a few pixels across takes each pixel's covered area (a 4×4 grid inside it): // sampling only the pixel centre made a tiny dab's total coverage swing with its sub-pixel // position, and thin lines beaded. @@ -360,75 +355,59 @@ impl BrushContext { rasterize_round(d, rect, out, profile, aliased); return; } - for yy in 0..h { - let y = rect.y0 + yy as i32; - let dy = y as f32 + 0.5 - cy; - for xx in 0..w { - let x = rect.x0 + xx as i32; - let dx = x as f32 + 0.5 - cx; - let (u, v) = to_tip(dx, dy); - let (mut val, rn) = match mips { - // Block: the whole-pixel square, hard (the Eraser's Block mode). - _ if aliased && b.square => { - if dx.abs() >= r || dy.abs() >= r { - continue; - } - (1.0, 0.0) - } - None if small => { - let d2 = (u * ro).powi(2) + v * v; - if d2 >= small_reach2 { - continue; - } - let mut sum = 0.0; - for sy in SUBPIXEL { - for sx in SUBPIXEL { - let (su, sv) = to_tip(dx + sx, dy + sy); - let sd = ((su * ro).powi(2) + sv * sv).sqrt(); - // Each sample takes the tip's own edge, so the covered area - // changes smoothly with the dab's position. - sum += profile.falloff(sd); - } - } - (sum / (SUBPIXEL.len() * SUBPIXEL.len()) as f32, d2.sqrt() / rm) - } - None => { - let d2 = (u * ro).powi(2) + v * v; - if d2 >= reach2 { - continue; - } - let dd = d2.sqrt(); - let val = if aliased { if dd <= rm { 1.0 } else { 0.0 } } else { profile.coverage(dd) }; - (val, dd / rm) - } - Some(m) => { - let (px, py) = (u * inv_scale + tw / 2.0, -(v / ro) * inv_scale + th / 2.0); - if px < -1.0 || py < -1.0 || px > tw + 1.0 || py > th + 1.0 { - continue; - } - let s = m.sample_clamped(level, px / tw, py / th); - let val = if aliased { if s >= 0.5 { 1.0 } else { 0.0 } } else { s }; - (val, 1.0 - s) - } - }; - if val <= 0.0 { - continue; - } - if noise && val < 1.0 { - let n = hash2(x, y, NOISE_SALT); - let hard = if n < val { 1.0 } else { 0.0 }; - val += (hard - val) * 0.7; - } - if tex_tip { - val = texture_combine(b.texture.mode, val, self.texture_at(x, y), d.depth); - } - if wet { - val *= wet_factor(rn); - } - if let Some(slot) = depth.as_deref_mut().and_then(|dp| dp.get_mut(yy * w + xx)) { - *slot = (1.0 - rn).clamp(0.0, 1.0); - } - out[yy * w + xx] = val * d.alpha; + if w == 0 { + return; + } + let tip = GeneralTip { + ctx: self, + d, + mips, + x0: rect.x0, + y0: rect.y0, + aliased, + small, + noise, + tex_tip, + wet, + cx, + cy, + sn, + cs, + fx, + fy, + proj, + r, + ro, + rm, + profile, + reach2, + small_reach2, + level, + inv_scale, + tw, + th, + }; + // Rows are independent (noise is a pure hash of position, everything else is read-only), + // so the pixels are the same whether they run in parallel or not. The serial path stays a + // plain loop over an inlined row (a closure called from several sites cost a small dab a + // tenth of its time). + #[cfg(not(target_arch = "wasm32"))] + if parallel(out.len().saturating_mul(GENERAL_LOOP_COST)) { + use rayon::prelude::*; + if let Some(dp) = depth.as_deref_mut() { + out.par_chunks_exact_mut(w).zip(dp.par_chunks_exact_mut(w)).enumerate().for_each(|(yy, (row, drow))| tip.row(yy, row, Some(drow))); + } else { + out.par_chunks_exact_mut(w).enumerate().for_each(|(yy, row)| tip.row(yy, row, None)); + } + return; + } + if let Some(dp) = depth { + for (yy, (row, drow)) in out.chunks_exact_mut(w).zip(dp.chunks_exact_mut(w)).enumerate() { + tip.row(yy, row, Some(drow)); + } + } else { + for (yy, row) in out.chunks_exact_mut(w).enumerate() { + tip.row(yy, row, None); } } } @@ -1361,6 +1340,130 @@ impl RoundTip { } } +/// The general rasterizer's per-dab state, one row at a time (see [`BrushContext::rasterize_into`]). +#[derive(Clone, Copy)] +struct GeneralTip<'a> { + ctx: &'a BrushContext, + d: &'a Dab, + mips: Option<&'a Mips>, + x0: i32, + y0: i32, + aliased: bool, + small: bool, + noise: bool, + tex_tip: bool, + wet: bool, + cx: f32, + cy: f32, + sn: f32, + cs: f32, + fx: f32, + fy: f32, + proj: Option<(f32, f32, f32)>, + r: f32, + ro: f32, + rm: f32, + profile: TipProfile, + reach2: f32, + small_reach2: f32, + level: usize, + inv_scale: f32, + tw: f32, + th: f32, +} + +impl GeneralTip<'_> { + /// A pixel offset from the dab centre in tip space: to y-up, project, rotate by -angle, flip. + #[inline(always)] + fn to_tip(self, dx: f32, dy: f32) -> (f32, f32) { + let (mut ux, mut uy) = (dx, -dy); + if let Some((ax, ay, k)) = self.proj { + let t = (ux * ax + uy * ay) * k; + ux += t * ax; + uy += t * ay; + } + ((ux * self.cs + uy * self.sn) * self.fx, (-ux * self.sn + uy * self.cs) * self.fy) + } + + /// Row `yy` of the dab's rectangle into `row` (and its depth into `depth_row`). + #[inline(always)] + fn row(self, yy: usize, row: &mut [f32], mut depth_row: Option<&mut [f32]>) { + let Self { + ctx, d, mips, x0, y0, aliased, small, noise, tex_tip, wet, cx, cy, r, ro, rm, profile, reach2, small_reach2, level, inv_scale, tw, th, .. + } = self; + let b = &ctx.brush; + let y = y0 + yy as i32; + let dy = y as f32 + 0.5 - cy; + for (xx, dst) in row.iter_mut().enumerate() { + let x = x0 + xx as i32; + let dx = x as f32 + 0.5 - cx; + let (u, v) = self.to_tip(dx, dy); + let (mut val, rn) = match mips { + // Block: the whole-pixel square, hard (the Eraser's Block mode). + _ if aliased && b.square => { + if dx.abs() >= r || dy.abs() >= r { + continue; + } + (1.0, 0.0) + } + None if small => { + let d2 = (u * ro).powi(2) + v * v; + if d2 >= small_reach2 { + continue; + } + let mut sum = 0.0; + for sy in SUBPIXEL { + for sx in SUBPIXEL { + let (su, sv) = self.to_tip(dx + sx, dy + sy); + let sd = ((su * ro).powi(2) + sv * sv).sqrt(); + // Each sample takes the tip's own edge, so the covered area + // changes smoothly with the dab's position. + sum += profile.falloff(sd); + } + } + (sum / (SUBPIXEL.len() * SUBPIXEL.len()) as f32, d2.sqrt() / rm) + } + None => { + let d2 = (u * ro).powi(2) + v * v; + if d2 >= reach2 { + continue; + } + let dd = d2.sqrt(); + let val = if aliased { if dd <= rm { 1.0 } else { 0.0 } } else { profile.coverage(dd) }; + (val, dd / rm) + } + Some(m) => { + let (px, py) = (u * inv_scale + tw / 2.0, -(v / ro) * inv_scale + th / 2.0); + if px < -1.0 || py < -1.0 || px > tw + 1.0 || py > th + 1.0 { + continue; + } + let s = m.sample_clamped(level, px / tw, py / th); + let val = if aliased { if s >= 0.5 { 1.0 } else { 0.0 } } else { s }; + (val, 1.0 - s) + } + }; + if val <= 0.0 { + continue; + } + if noise && val < 1.0 { + let n = hash2(x, y, NOISE_SALT); + let hard = if n < val { 1.0 } else { 0.0 }; + val += (hard - val) * 0.7; + } + if tex_tip { + val = texture_combine(b.texture.mode, val, ctx.texture_at(x, y), d.depth); + } + if wet { + val *= wet_factor(rn); + } + if let Some(slot) = depth_row.as_deref_mut().and_then(|dp| dp.get_mut(xx)) { + *slot = (1.0 - rn).clamp(0.0, 1.0); + } + *dst = val * d.alpha; + } + } +} + #[cfg(test)] mod reference; diff --git a/crates/paint/src/render/reference.rs b/crates/paint/src/render/reference.rs index e10fdd099f..c7dc366bb2 100644 --- a/crates/paint/src/render/reference.rs +++ b/crates/paint/src/render/reference.rs @@ -322,6 +322,153 @@ fn cached_profiles_match_scalar_for_small_and_transformed_dabs() { } } +/// Dabs that take the general loop, each with the context and flags that select a feature. +#[cfg(not(target_arch = "wasm32"))] +fn general_loop_cases() -> Vec<(&'static str, BrushContext, Dab, bool, bool)> { + let tip = GrayTile::from_fn(16, 12, |x, y| ((x + 2 * y) % 7) as f32 / 6.0 + 0.1); + let base = || BrushSettings { hardness: 0.25, ..Default::default() }; + let dab = |angle: f32, roundness: f32, proj_scale: f32| { + let mut d = Dab::round(Point::new(50.3, 60.7), 40.0, 0.8); + d.angle = angle; + d.roundness = roundness; + d.proj_scale = proj_scale; + d.proj_angle = 0.3; + d + }; + let ctx = |b: BrushSettings| BrushContext::new(&b); + vec![ + ("sampled", ctx(BrushSettings { tip: TipShape::Sampled(tip.clone()), ..base() }), dab(0.4, 1.0, 1.0), false, false), + ( + "per-tip texture", + ctx({ + let mut b = base(); + b.texture.enabled = true; + b.texture.each_tip = true; + b.texture.pattern = Pattern::Tile(tip.clone()); + b + }), + dab(0.0, 1.0, 1.0), + false, + false, + ), + ("noise", ctx(BrushSettings { noise: true, ..base() }), dab(0.0, 1.0, 1.0), false, false), + ("wet edges", ctx(BrushSettings { wet_edges: true, ..base() }), dab(0.0, 1.0, 1.0), false, false), + ("wet edges with depth", ctx(BrushSettings { wet_edges: true, ..base() }), dab(0.0, 1.0, 1.0), false, true), + ("rotated ellipse", ctx(base()), dab(0.7, 0.4, 1.0), false, false), + ("projection", ctx(base()), dab(0.0, 1.0, 0.6), false, false), + ( + "dual tip", + ctx({ + let mut b = base(); + b.dual_brush.enabled = true; + b.dual_brush.tip = TipShape::Sampled(tip.clone()); + b + }), + dab(0.2, 1.0, 1.0), + true, + false, + ), + ] +} + +#[cfg(not(target_arch = "wasm32"))] +struct RestoreThreshold; + +#[cfg(not(target_arch = "wasm32"))] +impl Drop for RestoreThreshold { + fn drop(&mut self) { + set_parallel_min_pixels(DEFAULT_PARALLEL_MIN_PIXELS); + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn bits(v: &[f32]) -> Vec { + v.iter().map(|x| x.to_bits()).collect() +} + +/// Rows of the general loop run on the thread pool from a quarter of the pixels the round path +/// needs, and give the bits the serial loop gives. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn general_loop_rows_match_serially_and_in_parallel() { + let _restore = RestoreThreshold; + for (label, ctx, d, dual, with_depth) in general_loop_cases() { + let rect = ctx.dab_rect(&d, dual); + assert!(rect.height() > 8, "{label}: many rows"); + let run = |threshold: usize| { + set_parallel_min_pixels(threshold); + let (mut out, mut depth) = (vec![0.75; 7], vec![0.25; 5]); + ctx.rasterize_into(&d, dual, rect, &mut out, with_depth.then_some(&mut depth)); + if !with_depth { + depth.clear(); + } + (out, depth) + }; + let (par, par_depth) = run(0); + let (ser, ser_depth) = run(usize::MAX); + assert_eq!(bits(&par), bits(&ser), "{label}: coverage differs between parallel and serial"); + assert_eq!(bits(&par_depth), bits(&ser_depth), "{label}: depth differs between parallel and serial"); + assert!(par.iter().any(|v| *v != 0.0), "{label}: painted something"); + if with_depth { + assert!(par_depth.iter().any(|v| *v != 0.0), "{label}: depth written"); + } + } +} + +/// The parallel general loop gives the bits of the frozen serial loop. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn parallel_general_loop_matches_the_frozen_reference() { + let _restore = RestoreThreshold; + set_parallel_min_pixels(0); + for (label, ctx, d, dual, with_depth) in general_loop_cases() { + let rect = ctx.dab_rect(&d, dual); + let (mut actual, mut expected) = (Vec::new(), Vec::new()); + let (mut actual_depth, mut expected_depth) = (Vec::new(), Vec::new()); + ctx.rasterize_into(&d, dual, rect, &mut actual, with_depth.then_some(&mut actual_depth)); + ctx.rasterize_reference(&d, dual, rect, &mut expected, with_depth.then_some(&mut expected_depth)); + assert_eq!(bits(&actual), bits(&expected), "{label}: coverage differs from the reference"); + assert_eq!(bits(&actual_depth), bits(&expected_depth), "{label}: depth differs from the reference"); + assert!(actual.iter().any(|v| *v != 0.0), "{label}: painted something"); + } +} + +/// Empty rects and non-finite dabs return normally from the general loop at either threshold. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn general_loop_survives_empty_rects_and_nan_dabs() { + let _restore = RestoreThreshold; + let tip = GrayTile::from_fn(8, 5, |x, y| ((x + y) % 7) as f32 / 6.0); + let sampled = BrushContext::new(&BrushSettings { tip: TipShape::Sampled(tip), ..Default::default() }); + let plain = BrushContext::new(&BrushSettings::default()); + for threshold in [0, usize::MAX] { + set_parallel_min_pixels(threshold); + for ctx in [&sampled, &plain] { + for rect in [Rect::new(0, 0, 0, 5), Rect::new(0, 0, 5, 0), Rect::new(0, 0, 0, 0)] { + for with_depth in [false, true] { + let (mut out, mut depth) = (vec![0.5; 9], vec![0.5; 9]); + ctx.rasterize_into(&Dab::round(Point::new(2.0, 2.0), 6.0, 1.0), false, rect, &mut out, with_depth.then_some(&mut depth)); + assert!(out.is_empty()); + if with_depth { + assert!(depth.is_empty()); + } + } + } + } + for field in 0..2 { + let mut d = Dab::round(Point::new(0.0, 0.0), 6.0, 1.0); + if field == 0 { + d.radius = f32::NAN; + } else { + d.angle = f32::NAN; + } + let mut out = Vec::new(); + sampled.rasterize_into(&d, false, Rect::new(-4, -4, 4, 4), &mut out, None); + assert_eq!(out.len(), 64); + } + } +} + // Keep the pre-cache scalar coverage equations independent of TipProfile. fn tip_falloff(d: f32, radius: f32, hardness: f32) -> f32 { let (r0, s) = tip_shape(radius, hardness);