M5: raster effects render on the multithreaded pipeline (each filter layer offscreen, composited with its blend/opacity; tested equal to single-threaded); fix inner glow blend isolated by its clip layer

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-01 00:41:55 -04:00
parent abe2ed7080
commit f855da4fb8
4 changed files with 196 additions and 74 deletions

View File

@ -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 `<mask>` 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 |

View File

@ -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}");
}
}
}

View File

@ -131,7 +131,7 @@ struct GeomEntry {
/// Reusable renderer (keeps the render context, decoded images and glyph caches between frames).
pub struct Renderer {
texts: HashMap<usize, (Arc<Node>, Arc<TextGeom>)>,
/// 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<RenderContext>,
/// 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

View File

@ -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);
}