mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
Render: a knockout group drawn over its backdrop counts an element's partial coverage once
A knockout group that is not isolated, and whose elements blend, is drawn over a copy of the art below it when the renderer has no layer open around it. Each element was drawn over the whole copy in a layer, and the layer was cut to the element's knockout shape with DestIn. Where the shape covers part of a pixel, the cut counted the element's coverage a second time. With Page Knockout Group on and Page Isolated Blending off, the page is drawn this way as soon as an object on it outside an isolated group has a blend mode other than Normal, on itself or on a fill or stroke. On a test page, adding a Multiply square changed the half-covered edge of a blue square over orange from [115, 115, 141] to [172, 159, 147] on white. Each element is now drawn over the copy offscreen, over its own bounds. With the DestOut pass before it, each pixel gets (1 - shape) times what was below plus shape times the element composited with the copy at a shape of 1, as ISO 32000-1 section 11.4.6 describes. Pixels that the shape covers fully or not at all are drawn as before.
This commit is contained in:
+50
-22
@@ -137,17 +137,7 @@ impl Renderer {
|
||||
fn flatten(&mut self, ctx: &mut RenderContext, r: Region) -> Arc<Pixmap> {
|
||||
let all = self.finish(ctx, self.raster);
|
||||
ctx.reset();
|
||||
let crop = if (r.x, r.y, r.w, r.h) == (0, 0, all.width(), all.height()) {
|
||||
None
|
||||
} else {
|
||||
let mut out = Pixmap::new(r.w, r.h);
|
||||
let (src_w, w) = (all.width() as usize, r.w as usize);
|
||||
for row in 0..r.h as usize {
|
||||
let from = (r.y as usize + row) * src_w + r.x as usize;
|
||||
out.data_mut()[row * w..(row + 1) * w].copy_from_slice(&all.data()[from..from + w]);
|
||||
}
|
||||
Some(Arc::new(out))
|
||||
};
|
||||
let crop = if (r.x, r.y, r.w, r.h) == (0, 0, all.width(), all.height()) { None } else { Some(Arc::new(cropped(&all, r))) };
|
||||
let all = Arc::new(all);
|
||||
// With 8-bit compositing, rendering over the picture gives the pixels that drawing it back
|
||||
// as an image gives: the context starts again empty, and `finish` renders what it draws
|
||||
@@ -241,27 +231,41 @@ impl Renderer {
|
||||
}
|
||||
|
||||
/// Draw knockout element `c` in a layer composited with `compose`: as its knockout shape
|
||||
/// (`DestOut` erases what the elements below drew there, `DestIn` keeps only that part), or
|
||||
/// as itself (`Plus` adds it). In a non-isolated knockout group (`backdrop`), the element is
|
||||
/// laid over the group's backdrop and cut to its shape, so it composites against the art
|
||||
/// below the group instead of the elements below it.
|
||||
pub(crate) fn knockout_pass(&mut self, ctx: &mut RenderContext, f: &Frame, c: &Arc<Node>, compose: Compose, backdrop: Option<&Arc<Pixmap>>) {
|
||||
/// (`DestOut` erases what the elements below drew there, `SrcOver` draws the shape), or as
|
||||
/// itself (`Plus` adds it).
|
||||
pub(crate) fn knockout_pass(&mut self, ctx: &mut RenderContext, f: &Frame, c: &Arc<Node>, compose: Compose) {
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
ctx.push_layer(None, Some(BlendMode::new(Mix::Normal, compose)), None, None, None);
|
||||
self.nested += 1;
|
||||
if let Some(b) = backdrop {
|
||||
draw_pixmap(ctx, b.clone(), (0.0, 0.0));
|
||||
}
|
||||
let shape = compose != Compose::Plus && !c.knockout_shape;
|
||||
self.shape_of = if shape { Arc::as_ptr(c) as usize } else { 0 };
|
||||
self.draw_arc(ctx, f, c);
|
||||
self.shape_of = 0;
|
||||
if backdrop.is_some() {
|
||||
self.knockout_pass(ctx, f, c, Compose::DestIn, None);
|
||||
}
|
||||
self.nested -= 1;
|
||||
ctx.pop_layer();
|
||||
}
|
||||
|
||||
/// Add knockout element `c` of a non-isolated knockout group drawn over a copy of its backdrop
|
||||
/// (`backdrop`, in `ctx`'s pixels), once its `DestOut` pass has erased what the elements below
|
||||
/// it drew: `c` composited with the backdrop at a shape of 1, times its knockout shape
|
||||
/// (ISO 32000-1 §11.4.6). That is `c` drawn over the backdrop, less (1 − shape) × backdrop.
|
||||
/// `c` over the backdrop and its shape are drawn offscreen over `c`'s bounds, and `c` is drawn
|
||||
/// as in the other knockout passes (groups in it can't copy their backdrop).
|
||||
pub(crate) fn knockout_over(&mut self, ctx: &mut RenderContext, f: &Frame, c: &Arc<Node>, backdrop: &Arc<Pixmap>) {
|
||||
let bounds = self.bounds_of(f.symbols, 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 whole = (r.x, r.y, r.w, r.h) == (0, 0, backdrop.width(), backdrop.height());
|
||||
let below = if whole { backdrop.clone() } else { Arc::new(cropped(backdrop, r)) };
|
||||
let over = self.offscreen(r, &sub, Some(below.clone()), &mut |s, cx, fr| s.inside_layer(|s| s.draw_arc(cx, fr, c)));
|
||||
let shape = self.offscreen(r, &sub, None, &mut |s, cx, fr| s.knockout_pass(cx, fr, c, Compose::SrcOver));
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
ctx.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::Plus)), None, None, None);
|
||||
draw_pixmap(ctx, Arc::new(inside_shape(over, &shape, &below)), (r.x as f64, r.y as f64));
|
||||
ctx.pop_layer();
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw `pm` with its top-left corner at pixel `at` (whole pixels: copied exactly).
|
||||
@@ -293,6 +297,17 @@ 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 pixels of region `r` of `pm`.
|
||||
fn cropped(pm: &Pixmap, r: Region) -> Pixmap {
|
||||
let mut out = Pixmap::new(r.w, r.h);
|
||||
let (src_w, w) = (pm.width() as usize, r.w as usize);
|
||||
for row in 0..r.h as usize {
|
||||
let from = (r.y as usize + row) * src_w + r.x as usize;
|
||||
out.data_mut()[row * w..(row + 1) * w].copy_from_slice(&pm.data()[from..from + w]);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The group's own colour from `content` (the group drawn over `backdrop`) and `alone` (drawn
|
||||
/// over transparency, for its coverage): content = group + (1 − group alpha) × backdrop, all
|
||||
/// premultiplied.
|
||||
@@ -304,3 +319,16 @@ fn remove_backdrop(mut content: Pixmap, alone: &Pixmap, backdrop: &Pixmap) -> Pi
|
||||
}
|
||||
content
|
||||
}
|
||||
|
||||
/// What a knockout element adds once its knockout shape has erased what was below it: `over` (the
|
||||
/// element drawn over `backdrop`) less (1 − shape alpha) × `backdrop`, with `shape` the knockout
|
||||
/// shape, all premultiplied. Each color is kept within the alpha left.
|
||||
fn inside_shape(mut over: Pixmap, shape: &Pixmap, backdrop: &Pixmap) -> Pixmap {
|
||||
for ((o, s), b) in over.data_mut().iter_mut().zip(shape.data()).zip(backdrop.data()) {
|
||||
let outside = 255 - s.a as u32;
|
||||
let sub = |v: u8, bv: u8| (v as u32).saturating_sub((outside * bv as u32 + 127) / 255);
|
||||
let a = sub(o.a, b.a);
|
||||
(o.r, o.g, o.b, o.a) = (sub(o.r, b.r).min(a) as u8, sub(o.g, b.g).min(a) as u8, sub(o.b, b.b).min(a) as u8, a as u8);
|
||||
}
|
||||
over
|
||||
}
|
||||
|
||||
@@ -1359,15 +1359,18 @@ impl Renderer {
|
||||
/// object opacity without its opacity mask, or as drawn when its opacity and mask define the
|
||||
/// knockout shape), then adds itself: it composites against the group's backdrop instead of
|
||||
/// over the elements below it (the art below the group when the group is non-isolated and
|
||||
/// drawn over a copy of it, see [`Self::group`]).
|
||||
/// drawn over a copy of it, see [`Self::group`] and [`Self::knockout_over`]).
|
||||
fn draw_knockout(&mut self, ctx: &mut RenderContext, f: &Frame, children: &[Arc<Node>]) {
|
||||
let backdrop = self.backdrop.take();
|
||||
for c in Node::knockout_elements(children) {
|
||||
if self.skipped(f, c) {
|
||||
continue;
|
||||
}
|
||||
self.knockout_pass(ctx, f, c, Compose::DestOut, None);
|
||||
self.knockout_pass(ctx, f, c, Compose::Plus, backdrop.as_ref());
|
||||
self.knockout_pass(ctx, f, c, Compose::DestOut);
|
||||
match &backdrop {
|
||||
Some(b) => self.knockout_over(ctx, f, c, b),
|
||||
None => self.knockout_pass(ctx, f, c, Compose::Plus),
|
||||
}
|
||||
}
|
||||
self.backdrop = backdrop;
|
||||
}
|
||||
|
||||
@@ -186,6 +186,34 @@ fn a_non_isolated_knockout_group_composites_its_children_against_the_backdrop()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_knockout_group_weights_a_partly_covered_pixel_by_the_coverage_once() {
|
||||
// The upper Multiply square's left edge covers half of pixel column 40. There the upper square
|
||||
// takes half of the pixel with its color composited against the backdrop, and the lower one
|
||||
// keeps the other half (ISO 32000-1 §11.4.6). Right of x = 70 the backdrop is a stripe of
|
||||
// another color, which the upper square multiplies with.
|
||||
const T: [f32; 3] = [1.0, 0.4, 0.6];
|
||||
const STRIPE: [f32; 3] = [0.3, 0.7, 0.9];
|
||||
for isolate in [false, true] {
|
||||
let mut d = doc(|d| {
|
||||
let mut lower = rect(d, Rect::new(10.0, 10.0, 60.0, 90.0), S);
|
||||
let mut upper = rect(d, Rect::new(40.5, 10.0, 90.0, 90.0), T);
|
||||
(lower.blend, upper.blend) = (Blend::Multiply, Blend::Multiply);
|
||||
let mut g = group(d, vec![lower, upper], 1.0, Blend::Normal, isolate);
|
||||
g.knockout = Knockout::On;
|
||||
g
|
||||
});
|
||||
let stripe = rect(&mut d, Rect::new(70.0, 0.0, 100.0, 100.0), STRIPE);
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 1, stripe).unwrap();
|
||||
let r = render(&d);
|
||||
// Isolated, each square multiplies with nothing and shows its own color.
|
||||
let multiplied = |b: [f32; 3], c: [f32; 3]| if isolate { c } else { blend_rgb(Blend::Multiply, b, c) };
|
||||
assert_near(r.pixel(40, 50), lerp(multiplied(B, S), multiplied(B, T), 0.5), &format!("half-covered edge, isolate {isolate}"));
|
||||
assert_near(r.pixel(80, 50), multiplied(STRIPE, T), &format!("over the stripe, isolate {isolate}"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_view_page_group_and_threads_keep_the_non_isolated_look() {
|
||||
let d = doc(|d| {
|
||||
|
||||
@@ -126,3 +126,61 @@ fn page_knockout_and_isolated_blending() {
|
||||
d.page_isolate = true;
|
||||
assert!(close(render(&d).pixel(30, 50), [255, 0, 0, 255]), "isolated: {:?}", render(&d).pixel(30, 50));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_knockout_looks_the_same_when_another_object_blends() {
|
||||
// With Page Knockout Group on, a Multiply square on the page makes the renderer draw the page
|
||||
// over a copy of what is below it (white, or nothing). Four objects knock out part of the
|
||||
// orange page: a blue square whose left edge covers half of pixel column 10, a 50% blue square
|
||||
// whose opacity defines its knockout shape, a 50% blue square, and an isolated group of a green
|
||||
// square and a 50% group holding a Multiply square, which draws its middle half transparent.
|
||||
// Each looks the same with and without the Multiply square.
|
||||
let rect = |d: &mut Document, r: Rect, (red, green, blue): (f32, f32, f32)| {
|
||||
Node::path(d.alloc_id(), shapes::rectangle(r), Appearance::basic(Paint::solid(Color::rgb(red, green, blue)), Paint::None, 0.0))
|
||||
};
|
||||
let page = |blending: bool| {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
d.page_knockout = true;
|
||||
let orange = rect(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), (0.8, 0.6, 0.2));
|
||||
let edge = rect(&mut d, Rect::new(10.5, 10.0, 30.5, 30.0), (0.1, 0.3, 0.9));
|
||||
let mut shaped = rect(&mut d, Rect::new(40.0, 10.0, 60.0, 30.0), (0.1, 0.3, 0.9));
|
||||
(shaped.opacity, shaped.knockout_shape) = (0.5, true);
|
||||
let mut translucent = rect(&mut d, Rect::new(70.0, 10.0, 90.0, 30.0), (0.1, 0.3, 0.9));
|
||||
translucent.opacity = 0.5;
|
||||
let green = rect(&mut d, Rect::new(10.0, 40.0, 60.0, 90.0), (0.2, 0.9, 0.4));
|
||||
let mut multiply = rect(&mut d, Rect::new(20.0, 50.0, 50.0, 80.0), (0.5, 0.5, 1.0));
|
||||
multiply.blend = Blend::Multiply;
|
||||
let mut inner = Node::group(d.alloc_id(), vec![Arc::new(multiply)]);
|
||||
inner.opacity = 0.5;
|
||||
let mut isolated = Node::group(d.alloc_id(), vec![Arc::new(green), Arc::new(inner)]);
|
||||
isolated.isolate = true;
|
||||
let mut objects = vec![orange, edge, shaped, translucent, isolated];
|
||||
if blending {
|
||||
let mut other = rect(&mut d, Rect::new(70.0, 70.0, 90.0, 90.0), (0.5, 0.5, 1.0));
|
||||
other.blend = Blend::Multiply;
|
||||
objects.push(other);
|
||||
}
|
||||
let l = d.layers[0].id;
|
||||
for n in objects {
|
||||
d.insert(Some(l), usize::MAX, n).unwrap();
|
||||
}
|
||||
d
|
||||
};
|
||||
// Half of the orange stays and blue takes the other half: 0.5 · orange + 0.5 · blue. The 50%
|
||||
// square knocks out all of the orange and shows over what is below the page: 0.5 · white +
|
||||
// 0.5 · blue, or blue at 50% over nothing.
|
||||
let half_blue = [115, 115, 140, 255];
|
||||
for (below, half_square) in [(Some([255, 255, 255, 255]), [140, 166, 242, 255]), (None, [26, 77, 230, 128])] {
|
||||
let opts = RenderOptions { background: below, ..Default::default() };
|
||||
let draw = |d: &Document| Renderer::new().render(d, 100, 100, Affine::IDENTITY, &opts);
|
||||
let (plain, blending) = (draw(&page(false)), draw(&page(true)));
|
||||
let cases = [("half-covered edge", 10, 20, half_blue), ("knockout shape", 50, 20, half_blue), ("50%", 80, 20, half_square)];
|
||||
for (what, x, y, want) in cases {
|
||||
for r in [&plain, &blending] {
|
||||
assert!(close(r.pixel(x, y), want), "{what} over {below:?}: {:?}", r.pixel(x, y));
|
||||
}
|
||||
}
|
||||
let (a, b) = (plain.pixel(35, 65), blending.pixel(35, 65));
|
||||
assert!(close(a, b), "the group's middle over {below:?}: {a:?} alone, {b:?} with the Multiply square");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user