From 21f14d7f79a30a581fc03068fad446982d91f18e Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 15 Oct 2026 06:15:21 +0000 Subject: [PATCH] fix(render): wide feathers and inner effects stay sharp at high zoom --- ROADMAP.md | 1 + crates/render/src/fx.rs | 75 ++++++++++++++++++++++------------ crates/render/tests/effects.rs | 61 +++++++++++++++++++++++++++ 3 files changed, 110 insertions(+), 27 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d60a6bf49..d4403a13b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -347,6 +347,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-15** · Very wide effects stay sharp at high zoom: a wide Basic Feather draws the object itself at full resolution, with only the soft mask at a reduced one, and wide inner shadows, inner glows, satin and bevels are clipped to the shape at full resolution, so they no longer blur the object or bleed past its edge. No readiness percentage change. - **2026-10-14** · The Type tool draws a new text frame over a locked empty frame, as InDesign does, instead of turning the locked frame into a text frame. No readiness percentage change. - **2026-10-14** · 100% black shows as pure black on screen by default, as InDesign's Appearance of Black does out of the box (Display All Blacks as Rich Black); Preferences › Appearance of Black › Display All Blacks Accurately still shows it as the dark grey it prints, and a saved choice is kept. No readiness percentage change. - **2026-10-14** · Pathfinder keeps the frontmost object, as InDesign does: a photo placed over shapes and intersected with them shows through the shapes (it was dropped, and the shapes kept their black fill); Add, Exclude Overlap and Minus Back take the front object's fill too, Subtract still keeps the back one. No readiness percentage change. diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 264d641db..527b09c00 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -4,7 +4,8 @@ //! vello_cpu only supports filter layers in single-threaded contexts, so on the multithreaded //! pipeline each effect is rendered into an offscreen single-threaded context cropped to its //! reach and composited back as an image ([`Renderer::with_filters`]); single-threaded contexts -//! draw directly. A very wide blur goes offscreen in both, at a lower resolution. +//! draw directly. A very wide blur goes offscreen in both, at a lower resolution; the object +//! itself and the clip to its shape stay at full resolution. //! //! - Drop shadow / outer glow: the object's silhouette (everything it paints: fill, image, text, //! stroke) in a `DropShadowOnly` layer under the object. `size` sets the blur (σ = size / 2), @@ -113,7 +114,7 @@ impl Renderer { // The offset moves the geometry, not the filter: vello drops layer content that lies // entirely outside the viewport before filtering. let r = (reach + off).inflate(grow, grow); - self.with_filters(ctx, f, r, sigma, |me, c, fr| { + self.with_filters(ctx, f, r, sigma, None, |me, c, fr| { let moved = Frame { view: fr.view * Affine::translate(off), ..*fr }; c.set_transform(moved.view); c.push_layer(None, None, None, None, Some(filter)); @@ -138,13 +139,15 @@ impl Renderer { // The object, feathered or not. if e.feather > 0.0 && it.path.is_closed() { let w = e.feather; - self.with_filters(ctx, f, reach, w / 4.0, |me, c, fr| { - c.set_transform(fr.view * xf); - c.push_clip_layer(bp); - me.draw_body(c, fr, it, bp, xf, page); - // Keep the body where the blurred inset silhouette is. - c.set_transform(Affine::IDENTITY); - c.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::DestIn)), None, None, None); + // The body and its clip stay at device resolution; only the blurred mask may be drawn + // offscreen at a lower one. + ctx.set_transform(f.view * xf); + ctx.push_clip_layer(bp); + self.draw_body(ctx, f, it, bp, xf, page); + // Keep the body where the blurred inset silhouette is. + ctx.set_transform(Affine::IDENTITY); + ctx.push_layer(None, Some(BlendMode::new(Mix::Normal, Compose::DestIn)), None, None, None); + self.with_filters(ctx, f, reach, w / 4.0, None, |_, c, fr| { c.set_transform(fr.view); c.push_layer(None, None, None, None, Some(blur(w / 4.0))); c.set_transform(fr.view * xf); @@ -157,9 +160,9 @@ impl Renderer { c.stroke_path(bp); c.pop_layer(); c.pop_layer(); - c.pop_layer(); - c.pop_layer(); }); + ctx.pop_layer(); + ctx.pop_layer(); } else { self.draw_body(ctx, f, it, bp, xf, page); } @@ -217,9 +220,7 @@ impl Renderer { let (sigma, choke) = split(g.size, g.choke); let paint = color_of(&c, g.opacity.clamp(0.0, 1.0)); if g.center { - self.with_blended_filters(ctx, f, reach, sigma, Mix::Screen, |_, c, fr| { - c.set_transform(fr.view * xf); - c.push_clip_layer(bp); + self.with_blended_filters(ctx, f, reach, sigma, Mix::Screen, (bp, xf), |_, c, fr| { c.set_transform(fr.view); c.push_layer(None, None, None, None, Some(blur(sigma))); c.set_transform(fr.view * xf); @@ -234,7 +235,6 @@ impl Renderer { c.stroke_path(bp); c.pop_layer(); c.pop_layer(); - c.pop_layer(); }); } else { self.inner_edge(ctx, f, reach, bp, xf, Vec2::ZERO, sigma, choke, paint, Mix::Screen); @@ -249,9 +249,7 @@ impl Renderer { let sigma = st.size.max(0.0) / 2.0; let paint = color_of(&c, st.opacity.clamp(0.0, 1.0)); let invert = st.invert; - self.with_blended_filters(ctx, f, reach, sigma, Mix::Multiply, |_, c, fr| { - c.set_transform(fr.view * xf); - c.push_clip_layer(bp); + self.with_blended_filters(ctx, f, reach, sigma, Mix::Multiply, (bp, xf), |_, c, fr| { c.set_transform(fr.view); c.push_layer(None, None, None, None, Some(blur(sigma))); if invert { @@ -271,7 +269,6 @@ impl Renderer { c.pop_layer(); c.pop_layer(); c.pop_layer(); - c.pop_layer(); }); } // Bevel and Emboss (inner bevel): highlight from the lit side, shadow from the other. @@ -305,9 +302,7 @@ impl Renderer { paint: peniko::Color, mix: Mix, ) { - self.with_blended_filters(ctx, f, reach, sigma, mix, |_, c, fr| { - c.set_transform(fr.view * xf); - c.push_clip_layer(bp); + self.with_blended_filters(ctx, f, reach, sigma, mix, (bp, xf), |_, c, fr| { c.set_transform(fr.view); c.push_layer(None, None, None, None, Some(blur(sigma))); // Everything outside the offset shape, so the colour bleeds in from the edges. @@ -326,12 +321,13 @@ impl Renderer { c.stroke_path(&shape); } c.pop_layer(); - c.pop_layer(); }); } - /// The effect's blend must see the object's backdrop, outside its clipping and offscreen + /// [`Renderer::with_filters`] clipped to `clip` (a path and its spread transform), blended with + /// `mix`. The effect's blend must see the object's backdrop, outside its clipping and offscreen /// filter layers. Putting it inside the clip blends with transparent pixels instead. + #[allow(clippy::too_many_arguments)] fn with_blended_filters( &mut self, ctx: &mut RenderContext, @@ -339,13 +335,14 @@ impl Renderer { reach: Rect, sigma: f64, mix: Mix, + clip: (&BezPath, Affine), draw: impl FnOnce(&mut Self, &mut RenderContext, &Frame), ) { if mix != Mix::Normal { ctx.set_transform(Affine::IDENTITY); ctx.push_layer(None, Some(BlendMode::new(mix, Compose::SrcOver)), None, None, None); } - self.with_filters(ctx, f, reach, sigma, draw); + self.with_filters(ctx, f, reach, sigma, Some(clip), draw); if mix != Mix::Normal { ctx.pop_layer(); } @@ -354,35 +351,59 @@ impl Renderer { /// Run `draw`, which pushes filter layers. Single-threaded contexts draw directly. On a /// multithreaded context, or for a blur wider than [`MAX_BLUR_SPREAD`], the layer is drawn into /// an offscreen single-threaded context covering `reach` (spread space) plus the blur's spread, - /// then composited back. + /// then composited back. `clip` (a path and its spread transform) clips what `draw` paints; + /// at a lower resolution it clips the composited image on `ctx`, so its edge stays sharp. pub(crate) fn with_filters( &mut self, ctx: &mut RenderContext, f: &Frame, reach: Rect, sigma: f64, + clip: Option<(&BezPath, Affine)>, draw: impl FnOnce(&mut Self, &mut RenderContext, &Frame), ) { // 3σ of the Gaussian in pixels, plus a pixel of antialiasing. let spread = sigma.max(0.0) * 3.0 / f.px + 2.0; if !f.mt && spread <= MAX_BLUR_SPREAD { - return draw(self, ctx, f); + push_clip(ctx, f, clip); + draw(self, ctx, f); + pop_clip(ctx, clip); + return; } let Some(o) = offscreen(f.view.transform_rect_bbox(reach), spread, ctx.width(), ctx.height()) else { return }; let mut off = RenderContext::new_with(o.width, o.height, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() }); let view = Affine::scale(1.0 / o.scale) * Affine::translate((-o.x0, -o.y0)) * f.view; let shifted = Frame { mt: false, view, px: f.px * o.scale, ..*f }; + // Clip where the result is at full resolution: offscreen unless that is reduced. + let (inner, outer) = if o.scale > 1.0 { (None, clip) } else { (clip, None) }; + push_clip(&mut off, &shifted, inner); draw(self, &mut off, &shifted); + pop_clip(&mut off, inner); off.flush(); let mut pm = vello_cpu::Pixmap::new(o.width, o.height); off.render(&mut pm, &mut self.resources); + push_clip(ctx, f, outer); ctx.set_transform(Affine::translate((o.x0, o.y0)) * Affine::scale(o.scale)); 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, f64::from(o.width), f64::from(o.height))); + pop_clip(ctx, outer); ctx.set_transform(Affine::IDENTITY); } } +fn push_clip(ctx: &mut RenderContext, f: &Frame, clip: Option<(&BezPath, Affine)>) { + if let Some((bp, xf)) = clip { + ctx.set_transform(f.view * xf); + ctx.push_clip_layer(bp); + } +} + +fn pop_clip(ctx: &mut RenderContext, clip: Option<(&BezPath, Affine)>) { + if clip.is_some() { + ctx.pop_layer(); + } +} + /// The widest blur (3σ, device pixels) drawn at full resolution. vello pads a filter layer by the /// blur's width on every side, so a wide blur needs gigabytes; it is drawn at a lower resolution /// instead, where it is this wide, and has no detail to lose there. diff --git a/crates/render/tests/effects.rs b/crates/render/tests/effects.rs index bd0160ee1..65fb2bbe5 100644 --- a/crates/render/tests/effects.rs +++ b/crates/render/tests/effects.rs @@ -199,6 +199,67 @@ fn shadow_of_an_object_outside_the_view_still_reaches_it() { assert!(lum(img.pixel(8, 80)) < 200, "{:?}", img.pixel(8, 80)); } +/// Points 70..90 of the page at 8 px a point (400% on a 2x screen), where a wide blur is drawn +/// offscreen at a lower resolution. +fn render_zoomed(d: &Document) -> Rendered { + let mut r = Renderer::new(); + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let view = Affine::scale(8.0) * Affine::translate(Vec2::new(-70.0, -70.0)); + r.render(d, &Cache::new(), &[Placed { spread: SpreadRef::Doc(0), xf: Affine::translate(Vec2::ZERO) }], SIZE, SIZE, view, &opts) +} + +#[test] +fn a_wide_feather_keeps_the_object_sharp_at_high_zoom() { + let mut d = doc(); + // A huge yellow frame holding a small black square (pt 78..82, px 64..96 when zoomed). + let id = rect_item(&mut d, Rect::new(-5000.0, -5000.0, 5160.0, 5160.0), "C=0 M=0 Y=100 K=0"); + let kid = Item { + fill: Fill::swatch("[Black]"), + ..Item::new(ItemId(d.alloc()), d.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(78.0, 78.0, 82.0, 82.0))) + }; + d.item_mut(id).unwrap().content = designcraft_doc::Content::Group { items: vec![std::sync::Arc::new(kid)] }; + let plain = render_zoomed(&d); + // 3σ of a 1,000 pt feather is 6,000 px here, so its mask is drawn at about 1/6 resolution. + d.item_mut(id).unwrap().effects.feather = 1000.0; + let img = render_zoomed(&d); + let worst = plain.pixels.iter().zip(&img.pixels).map(|(a, b)| a.abs_diff(*b)).max().unwrap(); + assert!(worst <= 4, "the inside of a feathered object matches the object: max channel difference {worst}"); +} + +#[test] +fn a_wide_inner_effect_stays_inside_the_shape_at_high_zoom() { + let mut d = doc(); + // A black backdrop shows a screened glow; white paper shows a shadow. + let backdrop = rect_item(&mut d, Rect::new(0.0, 0.0, 160.0, 160.0), "[Black]"); + // The shape's right edge is at pt 80, px 80 when zoomed. + let id = rect_item(&mut d, Rect::new(-2000.0, -2000.0, 80.0, 2160.0), "C=100 M=0 Y=0 K=0"); + for glow in [false, true] { + d.item_mut(backdrop).unwrap().fill = Fill::swatch(if glow { "[Black]" } else { "[None]" }); + let plain = render_zoomed(&d); + let e = &mut d.item_mut(id).unwrap().effects; + // 3σ is 24,000 px, so the effect is drawn at about 1/23 resolution. + if glow { + e.inner_glow.on = true; + e.inner_glow.size = 2000.0; + e.inner_glow.opacity = 1.0; + } else { + e.inner_shadow.on = true; + e.inner_shadow.color = "[Black]".into(); + e.inner_shadow.distance = 0.0; + e.inner_shadow.size = 2000.0; + e.inner_shadow.opacity = 1.0; + } + let img = render_zoomed(&d); + assert_ne!(lum(img.pixel(78, 80)), lum(plain.pixel(78, 80)), "glow {glow}: the effect shows inside the edge"); + for x in 81..110 { + assert_eq!(lum(img.pixel(x, 80)), lum(plain.pixel(x, 80)), "glow {glow}: nothing outside the edge at x {x}"); + } + let e = &mut d.item_mut(id).unwrap().effects; + e.inner_glow.on = false; + e.inner_shadow.on = false; + } +} + /// Multithreaded contexts draw effects offscreen; the result must match single-threaded. #[test] fn multithreaded_effects_match_single_threaded() {