diff --git a/crates/render/src/group.rs b/crates/render/src/group.rs index 95e1c379c..4383d515a 100644 --- a/crates/render/src/group.rs +++ b/crates/render/src/group.rs @@ -74,11 +74,17 @@ impl Renderer { return; } if c.blends && (c.isolated || self.nested == 0) { - return self.group_offscreen(ctx, f, c, draw); + let Some(r) = region(ctx, f, c.bounds) else { return }; + // Past the budget for offscreen pixels, the group is drawn as a layer (isolated). + // A masked group is drawn offscreen whatever the budget: its mask scales its pixels. + if c.mask.is_some() || self.offscreen_fits(ctx, r, c.isolated) { + return self.group_offscreen(ctx, f, c, r, 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); + let Some(r) = region(ctx, f, c.bounds) else { return }; + return self.group_offscreen(ctx, f, Composite { isolated: true, ..c }, r, draw); } ctx.set_transform(f.view); if let Some((_, rule)) = c.clip { @@ -94,9 +100,22 @@ impl Renderer { ctx.pop_layer(); } - 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, sub)) = area(ctx, f, bounds) else { return }; + /// Whether a group drawn offscreen over region `r` keeps the offscreen pixels alive within + /// [`OFFSCREEN_FRAMES`] times the frame `ctx` (the frame's own context when none is alive). + fn offscreen_fits(&self, ctx: &RenderContext, r: Region, isolated: bool) -> bool { + let budget = + if self.offscreen_live == 0 { OFFSCREEN_FRAMES * u64::from(ctx.width()) * u64::from(ctx.height()) } else { self.offscreen_budget }; + self.offscreen_live.saturating_add(offscreen_cost(r, isolated)) <= budget + } + + fn group_offscreen(&mut self, ctx: &mut RenderContext, f: &Frame, c: Composite, r: Region, draw: &mut Content) { + let Composite { clip, blend, opacity, mask, isolated, .. } = c; + if self.offscreen_live == 0 { + self.offscreen_budget = OFFSCREEN_FRAMES * u64::from(ctx.width()) * u64::from(ctx.height()); + } + let cost = offscreen_cost(r, isolated); + self.offscreen_live = self.offscreen_live.saturating_add(cost); + let sub = frame_over(f, r); 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); @@ -143,6 +162,7 @@ impl Renderer { } } ctx.pop_layer(); + self.offscreen_live = self.offscreen_live.saturating_sub(cost); } /// Render what `ctx` holds so far, and start it again from that picture (so later drawing sees @@ -301,6 +321,16 @@ thread_local! { pub(crate) static PIXELS_DRAWN: std::cell::Cell = const { std::cell::Cell::new(0) }; } +/// The offscreen pixels that groups drawn offscreen may keep alive at once, in frames: deeper +/// nested blending groups are drawn as layers instead (#3581). +const OFFSCREEN_FRAMES: u64 = 4; + +/// The pixels a group drawn offscreen over `r` keeps alive: its content, and when it isn't +/// isolated the backdrop copy and the content drawn alone. +fn offscreen_cost(r: Region, isolated: bool) -> u64 { + u64::from(r.w) * u64::from(r.h) * if isolated { 1 } else { 3 } +} + /// The pixel region of `ctx` that `bounds` (document space) covers, with a margin for /// antialiasing; `None` when it is off the context. fn region(ctx: &RenderContext, f: &Frame, bounds: Option) -> Option { @@ -317,9 +347,14 @@ fn region(ctx: &RenderContext, f: &Frame, bounds: Option) -> Option(ctx: &RenderContext, f: &Frame<'a>, bounds: Option) -> Option<(Region, Frame<'a>)> { let r = region(ctx, f, bounds)?; + Some((r, frame_over(f, r))) +} + +/// Frame `f` drawing into a context of region `r`'s size (on the calling thread). +fn frame_over<'a>(f: &Frame<'a>, r: Region) -> Frame<'a> { 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 })) + Frame { mt: false, view, visible, ..*f } } /// `pm` (premultiplied) with each pixel scaled by its coverage in `mask` (row-major, `pm`'s size). diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 82a44aa58..a4eb30fcd 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -528,6 +528,11 @@ pub struct Renderer { /// The picture [`Self::flatten`] started the current context again from, with 8-bit /// compositing: [`Self::finish`] renders the context over it. picture: Option>, + /// Pixels of the groups being drawn offscreen (contexts and backdrop copies), and the most + /// they may hold (set from the frame when the first opens, see [`Self::group`]); both reset + /// at the start of every frame. + offscreen_live: u64, + offscreen_budget: u64, /// What clipping paths paint, per clipping path (see [`Self::clip_paint_of`]). clip_paints: FrameCache, /// Gradients along or across strokes (see [`Self::fill_path_gradient`]), keyed by @@ -653,6 +658,8 @@ impl Renderer { blends: PtrMap::default(), clip_paths: vec![], picture: None, + offscreen_live: 0, + offscreen_budget: 0, clip_paints: FrameCache::new(lru::CLIP_BUDGET, 256), stroke_slices: PtrMap::default(), adjusts: HashMap::new(), @@ -737,6 +744,8 @@ impl Renderer { fn begin_frame(&mut self) { self.stamp += 1; self.inline_depth = 0; + // Nothing is drawn offscreen yet (even if an earlier frame stopped part way). + (self.offscreen_live, self.offscreen_budget) = (0, 0); } /// End a frame: drop cache entries not used for a few frames (every render, thumbnails too, diff --git a/crates/render/src/tests_isolation.rs b/crates/render/src/tests_isolation.rs index 7b2296920..d4d05b48a 100644 --- a/crates/render/src/tests_isolation.rs +++ b/crates/render/src/tests_isolation.rs @@ -416,3 +416,27 @@ fn isolation_mode_dims_the_art_around_the_isolated_group() { // An object that isn't in the document dims nothing. assert_eq!(isolated(NodeId(u64::MAX)).pixels, plain.pixels); } + +/// Deeply nested isolated blending groups (#3581): each level is drawn offscreen only while the +/// offscreen pixels alive stay within a few frames' worth; deeper levels are drawn as layers. +#[test] +fn nested_isolated_blending_groups_stay_within_the_offscreen_budget() { + let d = doc(|d| { + let mut n = multiply(d); + for _ in 0..30 { + n = group(d, vec![n], 1.0, Blend::Multiply, true); + } + n + }); + let drawn = || crate::group::PIXELS_DRAWN.with(std::cell::Cell::get); + let before = drawn(); + let frame = render(&d); + let images = drawn() - before; + // Each level's offscreen region is the content's bounds (20..80) plus `region`'s 2 px + // antialiasing margin on each side: 64 × 64. A budget of 4 frames fits whole levels only. + let level = 64 * 64; + let budget = 4 * 100 * 100 / level * level; + assert!(images <= budget, "{images} pixels drawn from offscreen groups, budget {budget}"); + assert_near(frame.pixel(50, 50), blend_rgb(Blend::Multiply, B, S), "inside the groups"); + assert_near(frame.pixel(10, 10), B, "outside the groups"); +}