From 40f83a9a08ec7d601495bd2513e0305a989ced75 Mon Sep 17 00:00:00 2001 From: samhyun Date: Fri, 30 Oct 2026 15:10:48 +0900 Subject: [PATCH] M8: a patch reads no backdrop past the view's edge Background blurs and glass read the pixels drawn under them, and a full render's backdrops end where the view does. The retained renderer differed in two ways. render_into drew each damage rect with a margin all round, past the edge too, so a backdrop there read content a full render never drew: with a background blur of 60 across the left edge, moving a square beside it patched 10 levels off a full render, and moving one entirely off the view 89. The margin now stops at the edge. A pan re-rendered only the strips it uncovered. At the opposite edges, which moved into the old frame, the shifted pixels kept what their backdrop had read past the old edge: a background blur of 10 across the view was 89 levels off after a pan of 30. Those edges are now redrawn as far as a backdrop reads. Co-Authored-By: Claude Opus 5.5 --- crates/render/src/retained.rs | 27 ++++++++-- crates/render/src/retained_tests.rs | 83 +++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 5 deletions(-) diff --git a/crates/render/src/retained.rs b/crates/render/src/retained.rs index 046ed8427..f345e21d6 100644 --- a/crates/render/src/retained.rs +++ b/crates/render/src/retained.rs @@ -413,6 +413,19 @@ fn patch(last: Frame, doc: &Arc, page: NodeId, view: &View, px: (i64, } else if dy < 0 { damage.push(Px { x0: 0, y0: 0, x1: w, y1: reach - dy }); } + // And at the edges that moved in: a backdrop there read what is now past the edge. + if reach > 0 { + if dx > 0 { + damage.push(Px { x0: 0, y0: 0, x1: reach, y1: h }); + } else if dx < 0 { + damage.push(Px { x0: w - reach, y0: 0, x1: w, y1: h }); + } + if dy > 0 { + damage.push(Px { x0: 0, y0: 0, x1: w, y1: reach }); + } else if dy < 0 { + damage.push(Px { x0: 0, y0: h - reach, x1: w, y1: h }); + } + } // Layers that changed, where they were and where they are now. if !Arc::ptr_eq(&last.doc, doc) { let mut areas = Vec::new(); @@ -528,18 +541,22 @@ struct FrameRender<'a> { opts: &'a Options, } -/// Render the frame pixels `d` (with `margin` more around, discarded) into `pix`. +/// Render the frame pixels `d` (with `margin` more around, discarded) into `pix`. The margin stops +/// at the frame's edge: background blurs and glass read what is drawn under them, and a full +/// render's backdrops end where the frame does. fn render_into(frame: FrameRender<'_>, d: &Px, margin: i64, pix: &mut Pixmap) -> Option<()> { let FrameRender { doc, page, view, px, opts } = frame; let s = view.scale; - let (ox, oy) = (d.x0 - margin, d.y0 - margin); - let (rw, rh) = (u32::try_from(d.x1 - d.x0 + 2 * margin).ok()?, u32::try_from(d.y1 - d.y0 + 2 * margin).ok()?); + let (w, h) = (i64::from(pix.width()), i64::from(pix.height())); + let (ox, oy) = ((d.x0 - margin).max(0), (d.y0 - margin).max(0)); + let (ex, ey) = ((d.x1 + margin).min(w), (d.y1 + margin).min(h)); + let (rw, rh) = (u32::try_from(ex - ox).ok()?, u32::try_from(ey - oy).ok()?); // The same pixel grid as the frame: origin (px + offset) / scale. let sub = View { origin: ((px.0 + ox) as f64 / s, (px.1 + oy) as f64 / s), scale: s, width: rw, height: rh }; let part = render_view(doc, page, &sub, opts)?; let inner = IntRect::from_xywh( - i32::try_from(margin).ok()?, - i32::try_from(margin).ok()?, + i32::try_from(d.x0 - ox).ok()?, + i32::try_from(d.y0 - oy).ok()?, u32::try_from(d.x1 - d.x0).ok()?, u32::try_from(d.y1 - d.y0).ok()?, )?; diff --git a/crates/render/src/retained_tests.rs b/crates/render/src/retained_tests.rs index 1b1d94457..5060d1cab 100644 --- a/crates/render/src/retained_tests.rs +++ b/crates/render/src/retained_tests.rs @@ -456,3 +456,86 @@ fn assert_within(a: &Pixmap, b: &Pixmap, rows_down: u32, tol: u8, what: &str) { let worst = a.data().iter().zip(below).map(|(x, y)| x.abs_diff(*y)).max().unwrap_or(0); assert!(worst <= tol, "{what}: differs by {worst} levels"); } + +#[test] +fn backdrops_at_the_views_edge_patch_as_they_render() { + fonts(); + let blur = |r: f64| { + let mut e = Effect::background_blur(); + if let EffectKind::BackgroundBlur(b) = &mut e.kind { + b.radius = r; + } + e + }; + let mut glass = Effect::glass(); + if let EffectKind::Glass(g) = &mut glass.kind { + (g.depth, g.refraction) = (30.0, 1.0); + } + // A layer reading its backdrop across the view's left edge, over stripes, and a square moved + // beside it in the view and off it: what the patch reads past the edge a full render doesn't. + for (why, fx) in [("a background blur of 60", blur(60.0)), ("glass", glass)] { + for (from, by) in [((180.0, 100.0), (15.0, 10.0)), ((-150.0, 100.0), (20.0, 0.0))] { + let mut d = Document::new("edge"); + let p = d.first_page().unwrap(); + for k in 0..20 { + let s = d.create(p, NodeKind::Rectangle, "S", -200.0 + f64::from(k) * 20.0, 0.0, 10.0, 400.0).unwrap(); + solid(d.node_mut(s).unwrap(), if k % 2 == 0 { "FF0000" } else { "0000FF" }); + } + let e = d.create(p, NodeKind::Rectangle, "E", -60.0, 50.0, 160.0, 200.0).unwrap(); + let n = d.node_mut(e).unwrap(); + n.fills = vec![Paint { opacity: 0.2, ..Paint::solid(Color::WHITE) }]; + n.effects = vec![fx.clone()]; + let m = d.create(p, NodeKind::Rectangle, "M", from.0, from.1, 40.0, 40.0).unwrap(); + solid(d.node_mut(m).unwrap(), "00AA00"); + let before = Arc::new(d); + let after = edit(&before, |d| d.node_mut(m).unwrap().transform = Affine::translate((from.0 + by.0, from.1 + by.1))); + let v = View { origin: (0.0, 0.0), scale: 1.0, width: 400, height: 300 }; + let mut r = Retained::default(); + r.render(&before, p, &v, &Options::default()).unwrap(); + let (shown, pix, work) = r.render(&after, p, &v, &Options::default()).unwrap(); + let pix = pix.clone(); + let full = render_view(&after, p, &shown, &Options::default()).unwrap(); + let worst = pix.data().iter().zip(full.data()).map(|(a, b)| a.abs_diff(*b)).max().unwrap_or(0); + assert!(worst <= 2, "{why}, a square moved from {from:?}: {work:?} is {worst} levels off a full render"); + } + } +} + +#[test] +fn pans_patch_backdrops_at_the_edges_that_moved_in() { + fonts(); + let mut blur = Effect::background_blur(); + if let EffectKind::BackgroundBlur(b) = &mut blur.kind { + b.radius = 10.0; + } + let mut glass = Effect::glass(); + if let EffectKind::Glass(g) = &mut glass.kind { + (g.depth, g.refraction) = (30.0, 1.0); + } + // A layer reading its backdrop across all four edges of the view, over stripes: after a pan, + // what it read past an edge that moved in is past the edge no more. + for (why, fx) in [("a background blur of 10", blur), ("glass", glass)] { + let mut d = Document::new("pan"); + let p = d.first_page().unwrap(); + for k in 0..40 { + let s = d.create(p, NodeKind::Rectangle, "S", -200.0 + f64::from(k) * 20.0, -100.0, 10.0, 600.0).unwrap(); + solid(d.node_mut(s).unwrap(), if k % 2 == 0 { "FF0000" } else { "0000FF" }); + } + let e = d.create(p, NodeKind::Rectangle, "E", -60.0, -60.0, 500.0, 420.0).unwrap(); + let n = d.node_mut(e).unwrap(); + n.fills = vec![Paint { opacity: 0.2, ..Paint::solid(Color::WHITE) }]; + n.effects = vec![fx]; + let doc = Arc::new(d); + for (dx, dy) in [(30.0, 0.0), (-30.0, 0.0), (0.0, 30.0), (0.0, -30.0)] { + let mut r = Retained::default(); + r.render(&doc, p, &View { origin: (0.0, 0.0), scale: 1.0, width: 400, height: 300 }, &Options::default()).unwrap(); + let v = View { origin: (dx, dy), scale: 1.0, width: 400, height: 300 }; + let (shown, pix, work) = r.render(&doc, p, &v, &Options::default()).unwrap(); + let pix = pix.clone(); + let full = render_view(&doc, p, &shown, &Options::default()).unwrap(); + let worst = pix.data().iter().zip(full.data()).map(|(a, b)| a.abs_diff(*b)).max().unwrap_or(0); + assert!(partial(work), "{why}, a pan by ({dx}, {dy}): {work:?}"); + assert!(worst <= 2, "{why}, a pan by ({dx}, {dy}): {work:?} is {worst} levels off a full render"); + } + } +}