diff --git a/ROADMAP.md b/ROADMAP.md index 61635b00..c288fac2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -58,7 +58,7 @@ _Last updated: 2026-10-01._ | M2 | Drawing tools + smart guides | βœ… mostly done (Shaper, Pen modifier nuances) | 5–10 | | M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | 🟑 panels done; opacity masks done (make/release, clip, invert, disable, link; render + SVG `` in/out + PDF soft mask); mask-editing mode, freeform gradients pending | 6–10 | | M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | 🟑 Export for Screens (PNG/JPG/WebP/SVG/PDF Γ— scales) done; headless CLI/MCP export every format; system clipboard SVG/PNG interop, EPS/DXF pending | 8–12 | -| M5 | Performance | 🟑 background render + caches + MT done; `drawcraft-cli bench`; raster-effect docs fall back to single-threaded (neon-drive demo: 214 ms/frame) β€” MT filters, dirty-region rendering, GPU backend spike pending | 10–20 | +| M5 | Performance | 🟑 background render + caches + MT done; `drawcraft-cli bench`; raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 10–20 | | M6 | Path operations (Pathfinder, Shape Builder, offset…) | βœ… mostly done (Shape Builder edge erase, large-offset bug open) | 3–6 | | M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟑 in progress | 20–30 | | M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | 🟑 live Blends, Envelopes (warp/mesh/top object), Width tool, Liquify tools, Puppet Warp and Perspective Grid landed; fidelity pass pending | 8–12 | diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 8aebaabe..040be86e 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -1,8 +1,10 @@ //! Live effects in the renderer. //! //! Geometry effects (object-level, then per fill/stroke) rewrite the path before painting via -//! `drawcraft-effects`. Raster effects use vello_cpu filter layers (single-threaded contexts only, -//! which is what the renderer creates): +//! `drawcraft-effects`. Raster effects use vello_cpu filter layers. Those only work in +//! single-threaded contexts, so on the multithreaded pipeline each filter layer is rendered +//! offscreen on the calling thread (cropped to its reach) and composited back with the same blend +//! mode and opacity ([`Renderer::with_filters`]): //! //! - Drop Shadow / Outer Glow: the object's silhouette in a `DropShadowOnly` filter layer, painted //! below the object with the effect's blend mode and opacity; @@ -140,67 +142,146 @@ impl Renderer { } let gctx = geom_ctx(n); let rfx = effects::raster_effects(&n.appearance.effects); + // Document-space reach of the painted geometry (strokes, arrowheads…). + let outset = n.appearance.outset(); + let reach = g.bounding_box().inflate(outset, outset); // Below the object: shadows and outer glows. for fx in rfx.iter().filter(|x| x.is_below()) { // The offset is applied to the geometry rather than in the filter: vello_cpu drops // layer content that lies entirely outside the viewport before filtering. - let (mode, opacity, dx, dy, filter) = match fx { + let (mode, opacity, dx, dy, blur, filter) = match fx { RasterFx::DropShadow { mode, opacity, dx, dy, blur, color } => { - (*mode, *opacity, *dx, *dy, shadow_filter(0.0, 0.0, *blur, pcolor(color))) + (*mode, *opacity, *dx, *dy, *blur, shadow_filter(0.0, 0.0, *blur, pcolor(color))) + } + RasterFx::OuterGlow { mode, opacity, blur, color } => { + (*mode, *opacity, 0.0, 0.0, *blur, shadow_filter(0.0, 0.0, *blur, pcolor(color))) } - RasterFx::OuterGlow { mode, opacity, blur, color } => (*mode, *opacity, 0.0, 0.0, shadow_filter(0.0, 0.0, *blur, pcolor(color))), _ => continue, }; let mut gs = g.clone(); gs.apply_affine(Affine::translate((dx, dy))); - ctx.set_transform(f.view); - ctx.push_layer(None, Some(blend_mode(mode)), Some(opacity), None, Some(filter)); - self.paint_items(ctx, f, n, &gs, rule, &gctx); - ctx.pop_layer(); + let reach = reach + drawcraft_geom::Vec2::new(dx, dy); + self.with_filters(ctx, f, reach, blur, Some((mode, opacity)), |r, c, fr, comp| { + c.set_transform(fr.view); + c.push_layer(None, comp.map(|m| blend_mode(m.0)), comp.map(|m| m.1), None, Some(filter)); + r.paint_items(c, fr, n, &gs, rule, &gctx); + c.pop_layer(); + }); } // The object itself (blurred / feathered). - let mut layers = 0; - for fx in &rfx { - match fx { - RasterFx::Feather { radius } if *radius > 0.0 => { - ctx.set_transform(f.view); - ctx.set_fill_rule(fill_rule(rule)); - ctx.push_clip_layer(&g); - ctx.push_layer(None, None, None, None, Some(blur_filter(radius / 2.0))); - layers += 2; + let blur: f64 = rfx + .iter() + .map(|fx| match fx { + RasterFx::Feather { radius } | RasterFx::GaussianBlur { radius } => radius.max(0.0), + _ => 0.0, + }) + .sum(); + if blur > 0.0 { + self.with_filters(ctx, f, reach, blur, None, |r, c, fr, _| { + let mut layers = 0; + for fx in &rfx { + match fx { + RasterFx::Feather { radius } if *radius > 0.0 => { + c.set_transform(fr.view); + c.set_fill_rule(fill_rule(rule)); + c.push_clip_layer(&g); + c.push_layer(None, None, None, None, Some(blur_filter(radius / 2.0))); + layers += 2; + } + RasterFx::GaussianBlur { radius } if *radius > 0.0 => { + c.set_transform(fr.view); + c.push_layer(None, None, None, None, Some(blur_filter(radius / 2.0))); + layers += 1; + } + _ => {} + } } - RasterFx::GaussianBlur { radius } if *radius > 0.0 => { - ctx.set_transform(f.view); - ctx.push_layer(None, None, None, None, Some(blur_filter(radius / 2.0))); - layers += 1; + r.paint_items(c, fr, n, &g, rule, &gctx); + for _ in 0..layers { + c.pop_layer(); } - _ => {} - } - } - self.paint_items(ctx, f, n, &g, rule, &gctx); - for _ in 0..layers { - ctx.pop_layer(); + }); + } else { + self.paint_items(ctx, f, n, &g, rule, &gctx); } // Above the object: inner glows, clipped to the shape. for fx in &rfx { let RasterFx::InnerGlow { mode, opacity, blur, color, center } = fx else { continue }; - ctx.set_transform(f.view); - ctx.set_fill_rule(fill_rule(rule)); - ctx.push_clip_layer(&g); - ctx.push_layer(None, Some(blend_mode(*mode)), Some(*opacity), None, Some(shadow_filter(0.0, 0.0, *blur, pcolor(color)))); - ctx.set_paint(peniko::Color::BLACK); - if *center { - ctx.set_fill_rule(fill_rule(rule)); - ctx.fill_path(&g); - } else { - // Everything outside the shape, so the glow bleeds in from the edges. - let pad = blur * 2.0 + 4.0 * f.px; - let mut inv = g.bounding_box().inflate(pad, pad).to_path(0.1); - inv.extend(g.iter()); - ctx.set_fill_rule(peniko::Fill::EvenOdd); - ctx.fill_path(&inv); - } - ctx.pop_layer(); + let filter = shadow_filter(0.0, 0.0, *blur, pcolor(color)); + self.with_filters(ctx, f, g.bounding_box(), *blur, Some((*mode, *opacity)), |_, c, fr, comp| { + // The blend layer goes outside the clip: a clip layer is isolated, so a blend + // inside it would mix with nothing instead of the object below. + if let Some((m, o)) = comp { + c.set_transform(Affine::IDENTITY); + c.push_layer(None, Some(blend_mode(m)), Some(o), None, None); + } + c.set_transform(fr.view); + c.set_fill_rule(fill_rule(rule)); + c.push_clip_layer(&g); + c.push_layer(None, None, None, None, Some(filter)); + c.set_paint(peniko::Color::BLACK); + if *center { + c.set_fill_rule(fill_rule(rule)); + c.fill_path(&g); + } else { + // Everything outside the shape, so the glow bleeds in from the edges. + let pad = blur * 2.0 + 4.0 * fr.px; + let mut inv = g.bounding_box().inflate(pad, pad).to_path(0.1); + inv.extend(g.iter()); + c.set_fill_rule(peniko::Fill::EvenOdd); + c.fill_path(&inv); + } + c.pop_layer(); + c.pop_layer(); + if comp.is_some() { + c.pop_layer(); + } + }); + } + ctx.set_transform(Affine::IDENTITY); + } + + /// Run `draw`, which pushes filter layers. Single-threaded contexts draw directly and `draw` + /// applies `composite` (blend mode, opacity) on its filter layer. On a multithreaded context + /// the layer is drawn into an offscreen single-threaded context covering `reach` (document + /// space) plus the blur's spread, then composited back with `composite`. + fn with_filters( + &mut self, + ctx: &mut RenderContext, + f: &Frame, + reach: Rect, + blur: f64, + composite: Option<(drawcraft_doc::color::BlendMode, f32)>, + draw: impl FnOnce(&mut Self, &mut RenderContext, &Frame, Option<(drawcraft_doc::color::BlendMode, f32)>), + ) { + if !f.mt { + return draw(self, ctx, f, composite); + } + // 3Οƒ of the Gaussian (Οƒ = blur / 2) in pixels, plus a pixel of antialiasing. + let spread = blur.max(0.0) * 1.5 / f.px + 2.0; + let screen = Rect::new(0.0, 0.0, ctx.width() as f64, ctx.height() as f64).inflate(spread, spread); + let r = f.view.transform_rect_bbox(reach).inflate(spread, spread).intersect(screen); + let (x0, y0) = (r.x0.floor(), r.y0.floor()); + let (w, h) = ((r.x1.ceil() - x0).min(u16::MAX as f64), (r.y1.ceil() - y0).min(u16::MAX as f64)); + if w < 1.0 || h < 1.0 { + return; + } + let (w, h) = (w as u16, h as u16); + let mut off = crate::single_threaded_context(w, h); + let shifted = Frame { mt: false, view: Affine::translate((-x0, -y0)) * f.view, ..*f }; + draw(self, &mut off, &shifted, None); + off.flush(); + let mut pm = vello_cpu::Pixmap::new(w, h); + off.render(&mut pm, &mut self.resources); + let layered = composite.is_some_and(|(m, o)| m != drawcraft_doc::color::BlendMode::Normal || o < 1.0); + if let Some((m, o)) = composite.filter(|_| layered) { + ctx.set_transform(Affine::IDENTITY); + ctx.push_layer(None, Some(blend_mode(m)), Some(o), None, None); + } + ctx.set_transform(Affine::translate((x0, y0))); + ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(std::sync::Arc::new(pm)), sampler: peniko::ImageSampler::default() }); + ctx.fill_rect(&Rect::new(0.0, 0.0, w as f64, h as f64)); + if layered { ctx.pop_layer(); } ctx.set_transform(Affine::IDENTITY); @@ -396,4 +477,51 @@ mod tests { let b = super::visual_bounds(n).unwrap(); assert!(b.x1 >= 10.0 + 7.0 + 7.5 - 1e-9); } + + /// Multithreaded rendering filters each effect offscreen; it must match the single-threaded + /// reference (blend modes against the real backdrop, objects partly off-screen, all effects). + #[test] + fn multithreaded_filters_match_single_threaded() { + let cases: Vec<(Rect, Vec<(&str, serde_json::Value)>)> = vec![ + (Rect::new(20.0, 20.0, 60.0, 60.0), vec![("stylize.dropShadow", json!({"x": 6, "y": 4, "blur": 5, "opacity": 75}))]), + (Rect::new(20.0, 20.0, 60.0, 60.0), vec![("stylize.outerGlow", json!({"color": "#ff00aa", "blur": 8, "opacity": 90}))]), + ( + Rect::new(20.0, 20.0, 60.0, 60.0), + vec![ + ("stylize.innerGlow", json!({"blur": 6})), + ("stylize.innerGlow", json!({"blur": 4, "source": "center", "mode": "multiply", "color": "#0044ff"})), + ], + ), + (Rect::new(20.0, 20.0, 60.0, 60.0), vec![("stylize.feather", json!({"radius": 6}))]), + (Rect::new(20.0, 20.0, 60.0, 60.0), vec![("blur.gaussian", json!({"radius": 4})), ("stylize.dropShadow", json!({"blur": 3}))]), + // Partly outside the frame: the blur still spreads in from off-screen content. + ( + Rect::new(-30.0, 70.0, 30.0, 130.0), + vec![("stylize.outerGlow", json!({"blur": 12, "mode": "normal"})), ("blur.gaussian", json!({"radius": 3}))], + ), + ]; + for (r, fx) in cases { + let name = format!("{fx:?}"); + let mut d = doc_with(r, Color::rgb(0.2, 0.6, 0.3), fx); + // A coloured backdrop so blend modes have something to blend with. + let l = d.layers[0].id; + let bid = d.alloc_id(); + let bg = Node::path( + bid, + shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), + Appearance::basic(Paint::solid(Color::rgb(0.9, 0.5, 0.1)), Paint::None, 0.0), + ); + d.insert(Some(l), 0, bg).unwrap(); + let opts = RenderOptions::default(); + let mut st = Renderer::new(); + st.threads = 0; + let mut mt = Renderer::new(); + mt.threads = 3; + let a = st.render(&d, 100, 100, Affine::IDENTITY, &opts); + let b = mt.render(&d, 100, 100, Affine::IDENTITY, &opts); + let worst = a.pixels.iter().zip(&b.pixels).map(|(x, y)| x.abs_diff(*y)).max().unwrap(); + // The offscreen pass adds one 8-bit premultiplied round trip: a few levels of rounding. + assert!(worst <= 4, "{name}: max channel difference {worst}"); + } + } } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 249c9ab8..ac326518 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -131,7 +131,7 @@ struct GeomEntry { /// Reusable renderer (keeps the render context, decoded images and glyph caches between frames). pub struct Renderer { texts: HashMap, Arc)>, - /// Single-threaded context used when the document has raster filters (vello's filters require it). + /// Context reused when rendering single-threaded (`threads == 0`). ctx_st: Option, /// Worker threads for the multithreaded rasterizer (0 = single-threaded). pub threads: u16, @@ -163,7 +163,10 @@ impl Default for Renderer { } } +#[derive(Clone, Copy)] struct Frame<'a> { + /// Drawing into a multithreaded context: vello filter layers must be rendered offscreen. + mt: bool, doc: &'a Document, view: Affine, /// Visible region in document coordinates (for culling). @@ -198,14 +201,9 @@ impl Renderer { let doc: &Document = &prepared; let w = width.clamp(1, u16::MAX as u32) as u16; let h = height.clamp(1, u16::MAX as u32) as u16; - // Raster filters (drop shadow, glows, blur) need the single-threaded pipeline. - let mut has_filters = false; - doc.walk(|n| { - if !has_filters && node_has_raster_fx(n) { - has_filters = true; - } - }); - let threads = if has_filters { 0 } else { self.threads }; + // Raster filters (drop shadow, glows, blur) are rendered offscreen on this thread when the + // main context is multithreaded (see `fx::with_filters`). + let threads = self.threads; let slot = if threads == 0 { self.ctx_st.take() } else { self.ctx.take() }; let mut ctx = match slot { Some(mut c) if c.width() == w && c.height() == h => { @@ -218,7 +216,7 @@ impl Renderer { let inv = view.inverse(); let visible = inv.transform_rect_bbox(Rect::new(0.0, 0.0, w as f64, h as f64)); let px = 1.0 / view.determinant().abs().sqrt().max(1e-12); - let frame = Frame { doc, view, visible, px, opts }; + let frame = Frame { mt: threads > 0, doc, view, visible, px, opts }; if let Some(bg) = opts.background { ctx.set_transform(Affine::IDENTITY); @@ -283,7 +281,7 @@ impl Renderer { ctx.set_paint(peniko::Color::from_rgba8(bg[0], bg[1], bg[2], bg[3])); ctx.fill_rect(&kurbo::Rect::new(0.0, 0.0, w as f64, w as f64)); } - let frame = Frame { doc, view, visible: b.inflate(1.0, 1.0), px: 1.0 / s, opts: &RenderOptions::default() }; + let frame = Frame { mt: false, doc, view, visible: b.inflate(1.0, 1.0), px: 1.0 / s, opts: &RenderOptions::default() }; self.draw_node(&mut ctx, &frame, n, true); ctx.flush(); let mut pm = Pixmap::new(w, w); @@ -368,7 +366,7 @@ impl Renderer { /// Render an opacity mask's art offscreen and turn its luminance into a coverage mask. fn opacity_mask(&mut self, f: &Frame, m: &drawcraft_doc::OpacityMask, w: u16, h: u16) -> vello_cpu::Mask { let mut mctx = single_threaded_context(w, h); - self.draw_node(&mut mctx, f, &m.art, true); + self.draw_node(&mut mctx, &Frame { mt: false, ..*f }, &m.art, true); mctx.flush(); let mut pm = Pixmap::new(w, h); mctx.render(&mut pm, &mut self.resources); @@ -729,9 +727,6 @@ impl Renderer { } } -/// Number of visible painted fill/stroke items (opacity folding is exact only for one). -/// A render context on the calling thread. vello_cpu's `RenderContext::new` defaults to a -/// multithreaded dispatcher, which panics on filter effects (glows, shadows, blur). /// Opacity-mask coverage of one premultiplied pixel: luminance, with the area outside the mask /// art black (clip) or white (no clip), optionally inverted. fn mask_value(r: u8, g: u8, b: u8, a: u8, clip: bool, invert: bool) -> u8 { @@ -745,20 +740,13 @@ fn mask_value(r: u8, g: u8, b: u8, a: u8, clip: bool, invert: bool) -> u8 { (l.clamp(0.0, 1.0) * 255.0 + 0.5) as u8 } +/// A render context on the calling thread. vello_cpu's `RenderContext::new` defaults to a +/// multithreaded dispatcher, which panics on filter effects (glows, shadows, blur). pub(crate) fn single_threaded_context(w: u16, h: u16) -> RenderContext { RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() }) } -/// Whether drawing `n` itself may push a raster filter (node-level or per fill/stroke effects). -fn node_has_raster_fx(n: &Node) -> bool { - let raster = |e: &[_]| !e.is_empty() && !drawcraft_effects::raster_effects(e).is_empty(); - raster(&n.appearance.effects) - || n.appearance.items.iter().any(|i| match i { - AppearanceItem::Fill(f) => raster(&f.effects), - AppearanceItem::Stroke(s) => raster(&s.effects), - }) -} - +/// Number of visible painted fill/stroke items (opacity folding is exact only for one). fn painted_items(n: &Node) -> usize { n.appearance .items diff --git a/crates/render/src/pattern.rs b/crates/render/src/pattern.rs index 28427f24..427a0099 100644 --- a/crates/render/src/pattern.rs +++ b/crates/render/src/pattern.rs @@ -69,8 +69,14 @@ fn rasterize(r: &mut Renderer, doc: &Document, def: &PatternDef, region: Rect, w let px_rect = Rect::new(0.0, 0.0, w as f64, h as f64); for o in def.offsets_covering(region) { let v = view * def.instance_xf(o); - let frame = - Frame { doc, view: v, visible: v.inverse().transform_rect_bbox(px_rect), px: 1.0 / v.determinant().abs().sqrt().max(1e-12), opts: &opts }; + let frame = Frame { + mt: false, + doc, + view: v, + visible: v.inverse().transform_rect_bbox(px_rect), + px: 1.0 / v.determinant().abs().sqrt().max(1e-12), + opts: &opts, + }; for a in &def.art { r.draw_arc(&mut ctx, &frame, a); } @@ -182,7 +188,7 @@ impl Renderer { for o in def.preview_offsets() { let view = f.view * Affine::translate(o); let visible = f.visible - o; - let frame = Frame { doc: f.doc, view, visible, px: f.px, opts: f.opts }; + let frame = Frame { view, visible, ..*f }; for c in &children { self.draw_arc(ctx, &frame, c); }