From 9c4c3730ab5425eed3177e900cb4cb871d65a776 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 19:42:30 -0800 Subject: [PATCH 1/2] CMYK view: convert the blended inks to screen colours on the render threads In the CMYK blend space view (Proof Colors off) every render converted each pixel of the ink passes' area from the blended inks to a screen colour on the calling thread, through a 16-entry lookup per pixel. With render threads it now converts bands of rows on that many threads at once, at least 32 rows a band. The colours are the same; the Wasm build and single-threaded renders convert on one thread as before. --- crates/render/src/blendspace.rs | 93 +++++++++++++++++++++++++++++---- crates/render/src/driver.rs | 2 + crates/render/src/lib.rs | 3 ++ 3 files changed, 88 insertions(+), 10 deletions(-) diff --git a/crates/render/src/blendspace.rs b/crates/render/src/blendspace.rs index 449410c59..1d992c6fd 100644 --- a/crates/render/src/blendspace.rs +++ b/crates/render/src/blendspace.rs @@ -12,6 +12,7 @@ use crate::{Frame, Placed, RenderOptions, Rendered, Renderer}; use designcraft_color::BlendMode as DcBlend; use designcraft_doc::{Document, Item, SpreadRef}; use designcraft_geom::{Affine, Rect, Shape}; +use vello_cpu::color::PremulRgba8; use vello_cpu::{RenderContext, peniko}; /// At most one viewport, capped at 64 MiB. The snapshot shares the document's large Arcs; @@ -284,6 +285,58 @@ fn srgb_in(table: &[[f32; 3]], cmyk: [f32; 4]) -> [f32; 3] { out } +/// Rows of the ink passes' area one conversion thread takes at least. +const MIN_BAND_ROWS: usize = 32; + +/// The blended inks, the C/M/Y complements in `cmy` and the K complement in `black` (`width` +/// pixels a row), converted to screen colours in `cmy`. With render threads, bands of rows are +/// converted on that many threads at once. Returns the number of bands. +fn inks_to_screen(cmy: &mut [PremulRgba8], black: &[PremulRgba8], width: usize, rich_black: bool, threads: u16) -> usize { + let table = srgb_table(); + let convert = |cmy: &mut [PremulRgba8], black: &[PremulRgba8]| { + for (c, k) in cmy.iter_mut().zip(black) { + if c.a == 0 { + continue; + } + let a = c.a as f32; + let inks = [1.0 - c.r as f32 / a, 1.0 - c.g as f32 / a, 1.0 - c.b as f32 / a, 1.0 - k.r as f32 / a]; + let rgb = if rich_black && inks[0..3].iter().all(|v| *v <= 1e-3) && inks[3] >= 0.999 { [0.0; 3] } else { srgb_in(&table, inks) }; + let [r, g, b] = rgb.map(|v| (v.clamp(0.0, 1.0) * a).round() as u8); + (c.r, c.g, c.b) = (r, g, b); + } + }; + let rows = cmy.len().checked_div(width).unwrap_or(0); + let bands = usize::from(threads).min(rows / MIN_BAND_ROWS).max(1); + if cfg!(target_arch = "wasm32") || bands == 1 { + convert(cmy, black); + return 1; + } + let chunk = rows.div_ceil(bands) * width; + let mut parts: Vec<(&mut [PremulRgba8], &[PremulRgba8])> = cmy.chunks_mut(chunk).zip(black.chunks(chunk)).collect(); + let convert = &convert; + let mut failed = Vec::new(); + std::thread::scope(|s| { + let mut each = parts.iter_mut().enumerate(); + let first = each.next(); + for (i, (c, k)) in each { + if let Err(e) = std::thread::Builder::new().spawn_scoped(s, move || convert(c, k)) { + log::warn!("CMYK view: a conversion thread did not start ({e})"); + failed.push(i); + } + } + if let Some((_, (c, k))) = first { + convert(c, k); + } + }); + // A band whose thread did not start is converted here. + for i in failed { + if let Some((c, k)) = parts.get_mut(i) { + convert(c, k); + } + } + bands +} + impl Renderer { /// The view pixels the spread's transparency can change: the union of its transparent /// objects (opacity, blend, effects, knockout, also nested or anchored) with their effects' @@ -413,16 +466,10 @@ impl Renderer { crate::PLATE.with(|p| p.set(f.opts.plate)); self.stats.items = (self.stats.items - stats.items) / 2 + stats.items; self.stats.glyphs = (self.stats.glyphs - stats.glyphs) / 2 + stats.glyphs; - let table = srgb_table(); - for (c, k) in cmy.data_mut().iter_mut().zip(black.data()) { - if c.a == 0 { - continue; - } - let a = c.a as f32; - let inks = [1.0 - c.r as f32 / a, 1.0 - c.g as f32 / a, 1.0 - c.b as f32 / a, 1.0 - k.r as f32 / a]; - let rgb = if f.opts.rich_black && inks[0..3].iter().all(|v| *v <= 1e-3) && inks[3] >= 0.999 { [0.0; 3] } else { srgb_in(&table, inks) }; - let [r, g, b] = rgb.map(|v| (v.clamp(0.0, 1.0) * a).round() as u8); - (c.r, c.g, c.b) = (r, g, b); + let _bands = inks_to_screen(cmy.data_mut(), black.data(), usize::from(aw), f.opts.rich_black, threads); + #[cfg(test)] + { + self.cmyk_convert_bands = _bands; } ctx.set_transform(Affine::translate((area.x0, area.y0))); ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(std::sync::Arc::new(cmy)), sampler: peniko::ImageSampler::default() }); @@ -669,6 +716,32 @@ mod tests { } } + /// With render threads, the ink passes' colours are converted in that many bands of rows at + /// once, each band by its own thread, and the result is the same as converting on one thread. + #[test] + fn ink_colours_convert_in_a_band_per_render_thread() { + let mut doc = document(); + rectangle(&mut doc, SpreadRef::Doc(0), Rect::new(-20.0, -5.0, 180.0, 125.0), "C=100 M=0 Y=0 K=0", DcBlend::Multiply); + let render = |threads: u16| { + let mut renderer = Renderer::new(); + renderer.threads = threads; + let image = renderer.render( + &doc, + &Cache::new(), + &[Placed { spread: SpreadRef::Doc(0), xf: Affine::IDENTITY }], + (400, 300), + Affine::scale(2.0) * Affine::translate((30.0, 10.0)), + &RenderOptions { blend_space_view: true, background: Some([180, 180, 180, 255]), ..Default::default() }, + ); + (image, renderer.cmyk_convert_bands) + }; + let (one, bands) = render(0); + assert_eq!(bands, 1); + let (four, bands) = render(4); + assert_eq!(bands, 4, "a band per render thread"); + assert_eq!(four.pixels, one.pixels); + } + #[test] fn cmyk_nonseparable_modes_use_the_existing_rgb_approximation() { let mut doc = document(); diff --git a/crates/render/src/driver.rs b/crates/render/src/driver.rs index a5dd2b86b..7230644a6 100644 --- a/crates/render/src/driver.rs +++ b/crates/render/src/driver.rs @@ -21,6 +21,8 @@ impl Renderer { cmyk_pass_pixels: 0, #[cfg(test)] cmyk_pass_items: 0, + #[cfg(test)] + cmyk_convert_bands: 0, threads: default_threads(), stats: FrameStats::default(), } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 25207b248..9f790c9f4 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -163,6 +163,9 @@ pub struct Renderer { /// Items the last ink pass drew (tests). #[cfg(test)] cmyk_pass_items: usize, + /// Bands of rows the last ink passes' colours were converted in (tests). + #[cfg(test)] + cmyk_convert_bands: usize, pub threads: u16, pub stats: FrameStats, } From c9cc76e6ad2b568981652e96fb4344065f2633e8 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 22:45:44 -0800 Subject: [PATCH 2/2] CMYK view: the conversion returns the number of bands it converted With more render threads than bands of 32 rows fit evenly, the rows split into fewer bands than requested, and the returned count was the requested one. It is now the number of bands converted. --- crates/render/src/blendspace.rs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/crates/render/src/blendspace.rs b/crates/render/src/blendspace.rs index 1d992c6fd..4eafc1d89 100644 --- a/crates/render/src/blendspace.rs +++ b/crates/render/src/blendspace.rs @@ -285,7 +285,7 @@ fn srgb_in(table: &[[f32; 3]], cmyk: [f32; 4]) -> [f32; 3] { out } -/// Rows of the ink passes' area one conversion thread takes at least. +/// Rows of the ink passes' area one conversion thread takes at least; the last band can hold fewer. const MIN_BAND_ROWS: usize = 32; /// The blended inks, the C/M/Y complements in `cmy` and the K complement in `black` (`width` @@ -334,7 +334,7 @@ fn inks_to_screen(cmy: &mut [PremulRgba8], black: &[PremulRgba8], width: usize, convert(c, k); } } - bands + parts.len() } impl Renderer { @@ -742,6 +742,18 @@ mod tests { assert_eq!(four.pixels, one.pixels); } + /// The returned count is the number of bands converted: 1,281 rows on 40 threads split into + /// bands of 33 rows, which makes 39 bands. + #[test] + fn ink_conversion_returns_the_bands_it_converted() { + let rows = 1281; + let mut cmy = vec![PremulRgba8 { r: 0, g: 255, b: 255, a: 255 }; rows]; + let black = vec![PremulRgba8 { r: 255, g: 255, b: 255, a: 255 }; rows]; + assert_eq!(inks_to_screen(&mut cmy, &black, 1, false, 40), 39); + assert!(cmy.iter().all(|c| *c == cmy[0]), "every band is converted"); + assert_ne!(cmy[0], PremulRgba8 { r: 0, g: 255, b: 255, a: 255 }); + } + #[test] fn cmyk_nonseparable_modes_use_the_existing_rgb_approximation() { let mut doc = document();