Render opacity masks over the masked object's bounds

Fixes #3580

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
NetterSilver
2026-10-24 01:44:00 +07:00
committed by bflatastic
co-authored by Claude Opus 5.5
parent 22057c973e
commit 4331eba12e
3 changed files with 96 additions and 32 deletions
+55 -24
View File
@@ -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<vello_cpu::Mask>,
/// 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<Vec<u8>>,
/// 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<vello_cpu::Mask>| {
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<Rect>) -> Option<Region
(x1 > 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<Rect>) -> 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);
+13 -8
View File
@@ -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<u8> {
@@ -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<u64> = const { std::cell::Cell::new(0) };
}
#[cfg(test)]
mod tests;
#[cfg(test)]
+28
View File
@@ -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);