From 4331eba12e8fcade18297fcea21271acc3272d0e Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 23 Oct 2026 08:52:02 +0200 Subject: [PATCH] Render opacity masks over the masked object's bounds Fixes #3580 Co-Authored-By: Claude Opus 5.5 --- crates/render/src/group.rs | 79 ++++++++++++++++++++++++++------------ crates/render/src/lib.rs | 21 ++++++---- crates/render/src/tests.rs | 28 ++++++++++++++ 3 files changed, 96 insertions(+), 32 deletions(-) diff --git a/crates/render/src/group.rs b/crates/render/src/group.rs index c7f7b5acd..95e1c379c 100644 --- a/crates/render/src/group.rs +++ b/crates/render/src/group.rs @@ -19,8 +19,9 @@ pub(crate) struct Composite<'a> { pub clip: Option<(&'a BezPath, FillRule)>, pub blend: Blend, pub opacity: f32, - /// Opacity mask (the context's size). - pub mask: Option, + /// Opacity mask: the coverage of each pixel (row-major) of the region `bounds` covers + /// ([`area`]). A masked group is drawn offscreen over that region only. + pub mask: Option>, /// The content starts from transparency instead of from the art below. pub isolated: bool, /// Blending inside reaches the group's backdrop ([`Node::blends_through`]). @@ -41,11 +42,11 @@ pub(crate) type Content<'d> = dyn FnMut(&mut Renderer, &mut RenderContext, &Fram /// A pixel region of a context: origin and size. #[derive(Clone, Copy)] -struct Region { - x: u16, - y: u16, - w: u16, - h: u16, +pub(crate) struct Region { + pub x: u16, + pub y: u16, + pub w: u16, + pub h: u16, } impl Renderer { @@ -75,11 +76,15 @@ impl Renderer { if c.blends && (c.isolated || self.nested == 0) { return self.group_offscreen(ctx, f, c, draw); } + if c.mask.is_some() { + // Its backdrop out of reach (or not needed), a masked group starts from transparency. + return self.group_offscreen(ctx, f, Composite { isolated: true, ..c }, draw); + } ctx.set_transform(f.view); if let Some((_, rule)) = c.clip { ctx.set_fill_rule(fill_rule(rule)); } - ctx.push_layer(c.clip.map(|(p, _)| p), Some(blend_mode(c.blend)), Some(c.opacity), c.mask, None); + ctx.push_layer(c.clip.map(|(p, _)| p), Some(blend_mode(c.blend)), Some(c.opacity), None, None); self.nested += 1; // The layer starts from transparency: no backdrop for the content. let backdrop = self.backdrop.take(); @@ -91,42 +96,50 @@ impl Renderer { fn group_offscreen(&mut self, ctx: &mut RenderContext, f: &Frame, c: Composite, draw: &mut Content) { let Composite { clip, blend, opacity, mask, isolated, bounds, .. } = c; - let Some(r) = region(ctx, f, bounds) else { return }; - let view = Affine::translate((-(r.x as f64), -(r.y as f64))) * f.view; - let visible = view.inverse().transform_rect_bbox(Rect::new(0.0, 0.0, r.w as f64, r.h as f64)); - let sub = Frame { mt: false, view, visible, ..*f }; + let Some((r, sub)) = area(ctx, f, bounds) else { return }; let backdrop = (!isolated).then(|| self.flatten(ctx, r)); let content = self.offscreen(r, &sub, backdrop.clone(), draw); let at = (r.x as f64, r.y as f64); - let push = |ctx: &mut RenderContext, mode: BlendMode, mask: Option| { + let push = |ctx: &mut RenderContext, mode: BlendMode| { ctx.set_transform(f.view); if let Some((_, rule)) = clip { ctx.set_fill_rule(fill_rule(rule)); } - ctx.push_layer(clip.map(|(p, _)| p), Some(mode), Some(opacity), mask, None); + ctx.push_layer(clip.map(|(p, _)| p), Some(mode), Some(opacity), None, None); + }; + // The mask scales what the group draws, as a layer mask does. + let masked = |pm: Pixmap| match &mask { + Some(m) => apply_mask(pm, m), + None => pm, }; match backdrop { // The backdrop under the content, mixed with it by opacity, mask and clip: take that // share of the backdrop out, then add the same share of the content. Some(_) if blend == Blend::Normal => { - push(ctx, BlendMode::new(Mix::Normal, Compose::DestOut), mask.clone()); - ctx.set_transform(Affine::IDENTITY); - ctx.set_paint(peniko::Color::BLACK); - ctx.fill_rect(&Rect::new(at.0, at.1, at.0 + r.w as f64, at.1 + r.h as f64)); + push(ctx, BlendMode::new(Mix::Normal, Compose::DestOut)); + if mask.is_some() { + let mut black = Pixmap::new(r.w, r.h); + black.data_mut().iter_mut().for_each(|p| p.a = 255); + draw_pixmap(ctx, Arc::new(masked(black)), at); + } else { + ctx.set_transform(Affine::IDENTITY); + ctx.set_paint(peniko::Color::BLACK); + ctx.fill_rect(&Rect::new(at.0, at.1, at.0 + r.w as f64, at.1 + r.h as f64)); + } ctx.pop_layer(); - push(ctx, BlendMode::new(Mix::Normal, Compose::Plus), mask); - draw_pixmap(ctx, Arc::new(content), at); + push(ctx, BlendMode::new(Mix::Normal, Compose::Plus)); + draw_pixmap(ctx, Arc::new(masked(content)), at); } // A blend mode applies to the group as an object of its own: take the backdrop's share // out of the content (it needs the content's own coverage), then blend the rest. Some(b) => { let alone = self.offscreen(r, &sub, None, draw); - push(ctx, blend_mode(blend), mask); - draw_pixmap(ctx, Arc::new(remove_backdrop(content, &alone, &b)), at); + push(ctx, blend_mode(blend)); + draw_pixmap(ctx, Arc::new(masked(remove_backdrop(content, &alone, &b))), at); } None => { - push(ctx, blend_mode(blend), mask); - draw_pixmap(ctx, Arc::new(content), at); + push(ctx, blend_mode(blend)); + draw_pixmap(ctx, Arc::new(masked(content)), at); } } ctx.pop_layer(); @@ -300,6 +313,24 @@ fn region(ctx: &RenderContext, f: &Frame, bounds: Option) -> Option x0 && y1 > y0).then_some(Region { x: x0 as u16, y: y0 as u16, w: (x1 - x0) as u16, h: (y1 - y0) as u16 }) } +/// The pixel region of `ctx` that `bounds` covers ([`region`]), and the frame that draws into a +/// context of that region's size. +pub(crate) fn area<'a>(ctx: &RenderContext, f: &Frame<'a>, bounds: Option) -> Option<(Region, Frame<'a>)> { + let r = region(ctx, f, bounds)?; + let view = Affine::translate((-(r.x as f64), -(r.y as f64))) * f.view; + let visible = view.inverse().transform_rect_bbox(Rect::new(0.0, 0.0, r.w as f64, r.h as f64)); + Some((r, Frame { mt: false, view, visible, ..*f })) +} + +/// `pm` (premultiplied) with each pixel scaled by its coverage in `mask` (row-major, `pm`'s size). +fn apply_mask(mut pm: Pixmap, mask: &[u8]) -> Pixmap { + let scale = |v: u8, m: u8| ((v as u32 * m as u32 + 127) / 255) as u8; + for (p, &m) in pm.data_mut().iter_mut().zip(mask) { + (p.r, p.g, p.b, p.a) = (scale(p.r, m), scale(p.g, m), scale(p.b, m), scale(p.a, m)); + } + pm +} + /// The pixels of region `r` of `pm`. fn cropped(pm: &Pixmap, r: Region) -> Pixmap { let mut out = Pixmap::new(r.w, r.h); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index fcb73fff5..82a44aa58 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -610,6 +610,8 @@ impl Frame<'_> { /// A render context for drawing this frame's art offscreen (on the calling thread), with its /// edge smoothing: hard edges must not depend on where the art is drawn. fn offscreen_context(&self, w: u16, h: u16) -> RenderContext { + #[cfg(test)] + OFFSCREEN_PIXELS.with(|n| n.set(n.get() + u64::from(w) * u64::from(h))); let mut ctx = single_threaded_context(w, h); ctx.set_aliasing_threshold(self.opts.anti_alias.threshold()); ctx @@ -1075,21 +1077,18 @@ impl Renderer { && !f.opts.outline && !self.is_shape(a) { - // A masked object is a non-isolated group of its own. - let mask = Some(self.opacity_mask(f, m, ctx.width(), ctx.height())); + // A masked object is a non-isolated group of its own, drawn over its own pixels: the + // mask is rendered over those only. + let bounds = self.bounds_of(f.symbols, a); + let Some((r, sub)) = group::area(ctx, f, bounds) else { return }; + let mask = Some(self.mask_values(&sub, m, r.w, r.h)); let blends = a.blend != vectorcraft_color::BlendMode::Normal || (!a.isolate && self.blends_through(a)); - let bounds = if blends { self.bounds_of(f.symbols, a) } else { None }; self.group(ctx, f, Composite { mask, blends, bounds, ..Default::default() }, &mut |r, c, fr| r.draw_arc_body(c, fr, a)); return; } self.draw_arc_body(ctx, f, a); } - /// Render an opacity mask's art offscreen and turn its luminance into a coverage mask. - fn opacity_mask(&mut self, f: &Frame, m: &vectorcraft_doc::OpacityMask, w: u16, h: u16) -> vello_cpu::Mask { - vello_cpu::Mask::from_parts(self.mask_values(f, m, w, h), w, h) - } - /// The coverage of opacity mask `m` per output pixel (row-major): its art rendered offscreen, /// as luminance ([`mask_value`]). fn mask_values(&mut self, f: &Frame, m: &vectorcraft_doc::OpacityMask, w: u16, h: u16) -> Vec { @@ -2624,6 +2623,12 @@ fn now() -> u64 { 0 } +#[cfg(test)] +thread_local! { + /// The pixels of the offscreen contexts [`Frame::offscreen_context`] has made on this thread. + static OFFSCREEN_PIXELS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + #[cfg(test)] mod tests; #[cfg(test)] diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 2f907c5cd..ff97f8510 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -664,6 +664,34 @@ fn opacity_mask_without_clip_and_inverted() { assert_eq!(img.pixel(80, 50), [255, 0, 0, 255], "inverted clip background → visible"); } +/// #3580: each opacity mask is rendered over its object's pixels, not over the whole frame. 300 +/// masked 4 × 4 pt squares on a 1000 × 1000 page draw offscreen in proportion to their bounds, and +/// still draw where their masks show them. +#[test] +fn small_masks_render_over_their_objects_only() { + let mut d = Document::new(1000.0, 1000.0); + let l = d.layers[0].id; + for i in 0..300 { + let (x, y) = (10.0 + (i % 30) as f64 * 30.0, 10.0 + (i / 30) as f64 * 30.0); + let square = Rect::new(x, y, x + 4.0, y + 4.0); + let mut n = rect_node(&mut d, square, Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); + let white = rect_node(&mut d, square, Paint::solid(Color::WHITE), Paint::None, 0.0); + n.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(white, true))); + d.insert(Some(l), usize::MAX, n).unwrap(); + } + let mut r = Renderer::new(); + r.threads = 0; + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + OFFSCREEN_PIXELS.with(|n| n.set(0)); + let img = r.render(&d, 1000, 1000, Affine::IDENTITY, &opts); + let px = OFFSCREEN_PIXELS.with(|n| n.get()); + // Each square's region is at most 4 + 2 × 2 margin + 1 rounding pixels a side, mask and content. + assert!(px <= 300 * 2 * 9 * 9, "{px} offscreen pixels for 300 masked 4 × 4 squares"); + assert_eq!(img.pixel(12, 12), [255, 0, 0, 255]); + assert_eq!(img.pixel(12 + 29 * 30, 12 + 9 * 30), [255, 0, 0, 255]); + assert_eq!(img.pixel(20, 20), [255, 255, 255, 255]); +} + #[test] fn disabled_or_outline_mask_draws_unmasked() { let mut d = masked_doc(true, false);