perf(paint): sampled, textured and wet-edge dabs rasterize on all cores (#11369)

* perf(paint): sampled, textured and wet-edge dabs rasterize on all cores (#11350)

The general dab loop runs its rows on the thread pool, with the same pixels as serially.
It reaches the pool from about 16,000 pixels, a quarter of the round path's threshold.

* test(paint): the parallel-vs-serial depth test checks colour, not alpha, for painted pixels

The opaque background's alpha (1.0) is 0.5 away from 0.5, so the check
passed even if nothing was painted (CodeRabbit on #11369).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(paint): read pixels with as_chunks (clippy chunks_exact_to_as_chunks)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-11-05 22:40:33 +01:00
1 parent 6326117c53
commit c441fd350c
4 files changed
+371 -80

No files matched your search

+4 -1
View File
@@ -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
);
+38
View File
@@ -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<f32> {
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).
+182 -79
View File
@@ -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;
+147
View File
@@ -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<u32> {
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);