From 4b06bb219c04bd75ffd8931473e9553de622a1e4 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 19:14:37 -0800 Subject: [PATCH 1/4] Render: keep drop shadow and outer glow images while an object moves The canvas drew and blurred every drop shadow and outer glow on every render, also when the object or the view had only moved. Each image is now kept per object, effect, page and ink pass, with the whole effect drawn once. While the object (apart from its position), the effect, the view's scale and rotation, the render options and the document's colours, styles, layers and images stay the same, a move by whole device pixels places the kept image again. Any other change draws it afresh. Only multithreaded renders (the canvas) keep images, and only for objects without text or nested items. Kept images are limited to 4 Mpx each and 128 MiB in all; the images used longest ago go first, and an image no render placed in 256 renders is dropped. --- crates/render/src/driver.rs | 5 +- crates/render/src/fx.rs | 417 ++++++++++++++++++++++++++++++++++-- crates/render/src/lib.rs | 2 + 3 files changed, 403 insertions(+), 21 deletions(-) diff --git a/crates/render/src/driver.rs b/crates/render/src/driver.rs index a5dd2b86b..b9568a595 100644 --- a/crates/render/src/driver.rs +++ b/crates/render/src/driver.rs @@ -15,6 +15,7 @@ impl Renderer { drawing_items: Vec::new(), cmyk_view: None, cmyk_ran: false, + kept: Default::default(), #[cfg(test)] cmyk_passes: 0, #[cfg(test)] @@ -26,11 +27,12 @@ impl Renderer { } } - /// Drop cached glyph paths and the CMYK viewport (rebuilt on demand). + /// Drop cached glyph paths, effect images and the CMYK viewport (rebuilt on demand). pub fn clear_caches(&mut self) { self.cmyk_view = None; self.glyphs.clear(); self.stories.clear(); + self.kept.clear(); } /// Number of text frames whose glyph paths are cached. @@ -88,6 +90,7 @@ impl Renderer { self.stats = FrameStats::default(); self.stories.clear(); self.glyphs.tick(); + self.kept.begin(doc); if let Some(bg) = opts.background { ctx.set_transform(Affine::IDENTITY); ctx.set_paint(peniko::Color::from_rgba8(bg[0], bg[1], bg[2], bg[3])); diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 7c7c91fe1..44513c3ac 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -17,13 +17,16 @@ //! - Feather: the object is masked by its shape inset by half the width and blurred, so the //! edge fades from ~0 at the path to full strength `width` inside. -use designcraft_doc::Item; +use std::collections::HashMap; +use std::sync::Arc; + +use designcraft_doc::{Asset, AssetId, DocSettings, Document, InkManager, Item, ItemId, Layer, StrokeStyleDef}; use designcraft_geom::{Affine, BezPath, Rect, Shape, Vec2}; use vello_common::filter_effects::{EdgeMode, Filter, FilterPrimitive}; use vello_cpu::peniko::{self, BlendMode, Compose, Mix}; -use vello_cpu::{RenderContext, kurbo}; +use vello_cpu::{Pixmap, RenderContext, kurbo}; -use crate::{Frame, Renderer, color_of}; +use crate::{Frame, RenderOptions, Renderer, color_of}; use designcraft_compose::ShownOn; /// Shadow offset (spread units) for an InDesign angle (degrees, light direction) and distance. @@ -113,16 +116,25 @@ impl Renderer { let mut below = vec![]; if e.outer_glow.on { let g = &e.outer_glow; - below.push((Vec2::ZERO, g.size, g.spread, g.color.clone(), g.opacity, g.mode)); + below.push((Slot::OuterGlow, Vec2::ZERO, g.size, g.spread, g.color.clone(), g.opacity, g.mode)); } if e.drop_shadow.on { let d = &e.drop_shadow; - below.push((offset(doc.light_angle(d.angle, d.global_light), d.distance), d.size, d.spread, d.color.clone(), d.opacity, d.mode)); + below.push(( + Slot::DropShadow, + offset(doc.light_angle(d.angle, d.global_light), d.distance), + d.size, + d.spread, + d.color.clone(), + d.opacity, + d.mode, + )); } - for (off, size, pct, color, opacity, mode) in below { + for (slot, off, size, pct, color, opacity, mode) in below { let Some(c) = doc.resolve_color(&color, 1.0) else { continue }; let (sigma, grow) = split(size, pct); - let filter = shadow(sigma, color_of(&c, opacity.clamp(0.0, 1.0))); + let paint = color_of(&c, opacity.clamp(0.0, 1.0)); + let filter = shadow(sigma, paint); // 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); @@ -132,7 +144,8 @@ impl Renderer { ctx.set_transform(Affine::IDENTITY); ctx.push_layer(None, Some(BlendMode::new(mix, Compose::SrcOver)), None, None, None); } - self.with_filters(ctx, f, r, sigma, None, |me, c, fr| { + let look = Look { slot, off, sigma, grow, paint: paint.components }; + self.with_kept_filters(ctx, f, it, xf, page, look, r, |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)); @@ -448,23 +461,251 @@ impl Renderer { return; } let Some(o) = offscreen(f.view.transform_rect_bbox(reach), spread, ctx.width(), ctx.height()) else { return }; + // 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) }; + let pm = self.draw_offscreen(f, &o, inner, draw); + push_clip(ctx, f, outer); + place(ctx, o.x0, o.y0, o.scale, Arc::new(pm)); + pop_clip(ctx, outer); + } + + /// `draw` into the single-threaded offscreen image `o`. + fn draw_offscreen( + &mut self, + f: &Frame, + o: &Offscreen, + clip: Option<(&BezPath, Affine)>, + draw: impl FnOnce(&mut Self, &mut RenderContext, &Frame), + ) -> Pixmap { 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); + push_clip(&mut off, &shifted, clip); draw(self, &mut off, &shifted); - pop_clip(&mut off, inner); + pop_clip(&mut off, clip); off.flush(); - let mut pm = vello_cpu::Pixmap::new(o.width, o.height); + let mut pm = 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); + pm + } + + /// [`Renderer::with_filters`] for a drop shadow or an outer glow of `it`, keeping its image + /// between renders. While the object, its effect, the view's scale and rotation, the render + /// options and the document's colours, styles and images stay the same, a move of the object + /// or of the view by whole device pixels places the kept image again instead of drawing and + /// blurring it. Only multithreaded contexts (the canvas) keep images, and only of objects + /// without text or nested items. + #[allow(clippy::too_many_arguments)] + fn with_kept_filters( + &mut self, + ctx: &mut RenderContext, + f: &Frame, + it: &Item, + xf: Affine, + page: ShownOn, + look: Look, + reach: Rect, + draw: impl FnOnce(&mut Self, &mut RenderContext, &Frame), + ) { + let keepable = f.mt && it.text_frame().is_none() && it.children().is_empty() && it.media.is_none(); + if !keepable { + self.with_filters(ctx, f, reach, look.sigma, None, draw); + return; + } + let [a, b, c, d, e, g] = (f.view * xf).as_coeffs(); + let (linear, anchor) = ([a, b, c, d], Vec2::new(e, g)); + let plate = crate::PLATE.with(std::cell::Cell::get); + let rich_black = crate::color::RICH_BLACK.with(std::cell::Cell::get); + let key = (it.id, page, look.slot, plate); + let tick = self.kept.tick; + if let Some(k) = self.kept.entries.get_mut(&key) + && k.look == look + && k.linear == linear + && k.px == f.px + && k.rich_black == rich_black + && k.opts == *f.opts + && (Item { xf: k.item.xf, ..it.clone() }) == k.item + { + let shift = anchor - k.anchor; + let (dx, dy) = (shift.x.round(), shift.y.round()); + if (shift.x - dx).abs() < 1e-3 && (shift.y - dy).abs() < 1e-3 { + k.used = tick; + let (x0, y0, scale, image) = (k.x0 + dx, k.y0 + dy, k.scale, k.image.clone()); + place(ctx, x0, y0, scale, image); + return; + } + } + // The whole effect, not only the part in view, so that it can be placed anywhere later. + let spread = bound(look.sigma).max(0.0) * 3.0 / f.px + 2.0; + let whole = f.view.transform_rect_bbox(reach).inflate(spread, spread); + let covers = |o: &Offscreen| f64::from(o.width) * o.scale >= whole.x1 - o.x0 && f64::from(o.height) * o.scale >= whole.y1 - o.y0; + let Some(o) = fit(whole, spread).filter(|o| usize::from(o.width) * usize::from(o.height) <= KEEP_MAX_PIXELS && covers(o)) else { + self.with_filters(ctx, f, reach, look.sigma, None, draw); + return; + }; + let image = Arc::new(self.draw_offscreen(f, &o, None, draw)); + #[cfg(test)] + { + self.kept.drawn += 1; + } + place(ctx, o.x0, o.y0, o.scale, image.clone()); + let kept = Kept { + item: it.clone(), + linear, + anchor, + look, + opts: f.opts.clone(), + px: f.px, + rich_black, + x0: o.x0, + y0: o.y0, + scale: o.scale, + image, + used: tick, + }; + self.kept.insert(f.doc, key, kept); + } +} + +/// Paint `image` with its top-left corner at device pixel (`x0`, `y0`), each of its pixels +/// covering `scale` device pixels. +fn place(ctx: &mut RenderContext, x0: f64, y0: f64, scale: f64, image: Arc) { + let (w, h) = (f64::from(image.width()), f64::from(image.height())); + ctx.set_transform(Affine::translate((x0, y0)) * Affine::scale(scale)); + ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(image), sampler: peniko::ImageSampler::default() }); + ctx.fill_rect(&Rect::new(0.0, 0.0, w, h)); + ctx.set_transform(Affine::IDENTITY); +} + +/// Pixels one kept effect image may have; a larger one is drawn each time. +const KEEP_MAX_PIXELS: usize = 4 << 20; +/// Pixels all kept effect images may have together (128 MiB). +const KEEP_BUDGET: usize = 32 << 20; +/// Renders an image no render has placed is kept for. +const KEEP_RENDERS: u64 = 256; + +/// The effect below an object a kept image is of. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub(crate) enum Slot { + OuterGlow, + DropShadow, +} + +/// What an effect image looks like apart from the object: its offset (spread space), blur, +/// growth and colour. +#[derive(Clone, Copy, PartialEq)] +struct Look { + slot: Slot, + off: Vec2, + sigma: f64, + grow: f64, + paint: [f32; 4], +} + +/// One kept effect image and what it was drawn from. +struct Kept { + /// The object, with the transform it had. + item: Item, + /// The linear part of the object's spread → device transform. + linear: [f64; 4], + /// The device position of the object's origin. + anchor: Vec2, + look: Look, + opts: RenderOptions, + px: f64, + rich_black: bool, + x0: f64, + y0: f64, + scale: f64, + image: Arc, + used: u64, +} + +/// What every kept image depends on besides its object. +struct Deps { + colors: designcraft_color::cms::ColorSettings, + swatches: Vec, + settings: DocSettings, + inks: InkManager, + stroke_styles: Vec, + layers: Vec, + /// Held, so that an asset changed in place or a new one at the same address is a new `Arc`. + assets: Vec<(AssetId, Arc)>, +} + +impl Deps { + fn of(doc: &Document) -> Self { + Deps { + colors: designcraft_color::cms::active_settings(), + swatches: doc.swatches.clone(), + settings: doc.settings.clone(), + inks: doc.inks.clone(), + stroke_styles: doc.stroke_styles.clone(), + layers: doc.layers.clone(), + assets: doc.assets.iter().map(|(id, a)| (*id, a.clone())).collect(), + } + } + fn same(&self, doc: &Document) -> bool { + self.colors == designcraft_color::cms::active_settings() + && self.swatches == doc.swatches + && self.settings == doc.settings + && self.inks == doc.inks + && self.stroke_styles == doc.stroke_styles + && self.layers == doc.layers + && self.assets.len() == doc.assets.len() + && self.assets.iter().zip(&doc.assets).all(|((a, p), (b, x))| a == b && Arc::ptr_eq(p, x)) + } +} + +/// Drop shadows and outer glows kept between renders ([`Renderer::with_kept_filters`]), at most +/// [`KEEP_BUDGET`] pixels; the images used longest ago go first, and an image no render placed +/// in [`KEEP_RENDERS`] renders goes too. +#[derive(Default)] +pub(crate) struct KeptEffects { + deps: Option, + entries: HashMap<(ItemId, ShownOn, Slot, Option), Kept>, + pixels: usize, + tick: u64, + /// Effect images drawn for keeping (tests). + #[cfg(test)] + pub(crate) drawn: usize, +} + +impl KeptEffects { + /// Start a render of `doc`: drop every image when what they depend on changed. + pub(crate) fn begin(&mut self, doc: &Document) { + self.tick += 1; + if !self.deps.as_ref().is_some_and(|d| d.same(doc)) { + self.clear(); + return; + } + let tick = self.tick; + let before = self.entries.len(); + self.entries.retain(|_, k| k.used + KEEP_RENDERS >= tick); + if self.entries.len() != before { + self.pixels = self.entries.values().map(|k| k.image.width() as usize * k.image.height() as usize).sum(); + } + } + pub(crate) fn clear(&mut self) { + self.entries.clear(); + self.pixels = 0; + self.deps = None; + } + fn insert(&mut self, doc: &Document, key: (ItemId, ShownOn, Slot, Option), kept: Kept) { + if self.deps.is_none() { + self.deps = Some(Deps::of(doc)); + } + let size = |k: &Kept| k.image.width() as usize * k.image.height() as usize; + self.pixels += size(&kept); + if let Some(old) = self.entries.insert(key, kept) { + self.pixels -= size(&old); + } + while self.pixels > KEEP_BUDGET { + let Some(oldest) = self.entries.iter().min_by_key(|(_, k)| k.used).map(|(key, _)| *key) else { break }; + if let Some(old) = self.entries.remove(&oldest) { + self.pixels -= size(&old); + } + } } } @@ -502,7 +743,15 @@ struct Offscreen { /// [`MAX_BLUR_SPREAD`], at most `MAX_SIDE` a side. `None` when none of it shows. fn offscreen(reach: Rect, spread: f64, width: u16, height: u16) -> Option { let screen = Rect::new(0.0, 0.0, f64::from(width), f64::from(height)).inflate(spread, spread); - let r = reach.inflate(spread, spread).intersect(screen); + fit(reach.inflate(spread, spread).intersect(screen), spread) +} + +/// The offscreen image covering `r` (device pixels) for a blur of `spread`, or `None` when it is +/// empty, not finite or more than `MAX_SIDE` a side. +fn fit(r: Rect, spread: f64) -> Option { + if !(r.x0.is_finite() && r.y0.is_finite() && r.x1.is_finite() && r.y1.is_finite()) { + return None; + } let (x0, y0) = (r.x0.floor(), r.y0.floor()); let scale = (spread / MAX_BLUR_SPREAD).max(1.0); let side = |len: f64| (len / scale).ceil().min(crate::MAX_SIDE as f64); @@ -607,4 +856,132 @@ mod tests { // The transparent half shows the page: no shadow painted there. assert!(lum(150) > 700, "nothing over the transparent part: {}", lum(150)); } + + /// A document with one image frame casting a drop shadow, and the frame's id. + fn shadowed_image() -> (designcraft_doc::Document, designcraft_doc::ItemId) { + use designcraft_doc::build::NewDocument; + use designcraft_doc::{Document, SpreadRef}; + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let img = image::RgbaImage::from_fn(64, 48, |x, y| image::Rgba([(x * 4) as u8, (y * 5) as u8, 128, 255])); + let mut png = Vec::new(); + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let aid = designcraft_doc::AssetId(d.alloc()); + d.assets.insert( + aid, + Arc::new(designcraft_doc::Asset { + id: aid, + name: "photo.png".into(), + mime: "image/png".into(), + data: Arc::new(png), + pixels: Some((64, 48)), + ..Default::default() + }), + ); + let id = designcraft_doc::ItemId(d.alloc()); + let mut it = Item::new(id, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 160.0, 120.0))); + it.xf = Affine::translate((100.0, 100.0)); + it.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic { + layer_options: None, + asset: aid, + size: (160.0, 120.0), + xf: Affine::IDENTITY, + clip: None, + auto_fit: Default::default(), + fit_align: 4, + crop: [0.0; 4], + wrap: Default::default(), + fill: Default::default(), + }); + let s = &mut it.effects.drop_shadow; + (s.on, s.color, s.opacity, s.size, s.distance) = (true, "[Black]".into(), 0.75, 12.0, 9.0); + d.insert_item(SpreadRef::Doc(0), it, None).unwrap(); + (d, id) + } + + /// Moving a shadowed frame, or the view, by whole pixels places the kept shadow again: the + /// shadow is drawn and blurred once, and every frame matches a render that draws it afresh. + /// A new effect setting, zoom, colour or a move by part of a pixel draws it again. + #[test] + fn a_moved_shadow_is_drawn_once_and_placed_again() { + use designcraft_doc::SpreadRef; + let (mut d, id) = shadowed_image(); + let cache = crate::Cache::new(); + let placed = [crate::Placed { spread: SpreadRef::Doc(0), xf: Affine::IDENTITY }]; + let opts = crate::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let mut kept = Renderer::new(); + kept.threads = 2; + let render = + |r: &mut Renderer, d: &designcraft_doc::Document, size: (u32, u32), view: Affine| r.render(d, &cache, &placed, size, view, &opts); + let fresh = |d: &designcraft_doc::Document, size: (u32, u32), view: Affine| { + let mut r = Renderer::new(); + r.threads = 2; + r.render(d, &crate::Cache::new(), &placed, size, view, &opts) + }; + let close = |a: &crate::Rendered, b: &crate::Rendered, what: &str| { + let worst = a.pixels.iter().zip(&b.pixels).map(|(x, y)| x.abs_diff(*y)).max().unwrap_or(0); + assert!(a.pixels.len() == b.pixels.len() && worst <= 1, "{what}: off by {worst}"); + }; + let view = Affine::scale(1.5); + close(&render(&mut kept, &d, (500, 450), view), &fresh(&d, (500, 450), view), "first render"); + assert_eq!(kept.kept.drawn, 1); + for step in 1..=4 { + let it = d.item_mut(id).unwrap(); + it.xf = Affine::translate((2.0, 4.0 / 3.0)) * it.xf; + close(&render(&mut kept, &d, (500, 450), view), &fresh(&d, (500, 450), view), "after a move"); + // A patch of the view, as the canvas repaints after an edit. + let patch = Affine::translate((-150.0, -140.0)) * view; + close(&render(&mut kept, &d, (120, 90), patch), &fresh(&d, (120, 90), patch), "a patch"); + assert_eq!(kept.kept.drawn, 1, "step {step}: placed again, not drawn"); + } + // Panning by whole pixels. + let panned = Affine::translate((-37.0, 11.0)) * view; + close(&render(&mut kept, &d, (500, 450), panned), &fresh(&d, (500, 450), panned), "panned"); + assert_eq!(kept.kept.drawn, 1); + // Part of a pixel, another zoom, another size, another colour: drawn again. + let it = d.item_mut(id).unwrap(); + it.xf = Affine::translate((0.25, 0.0)) * it.xf; + render(&mut kept, &d, (500, 450), view); + assert_eq!(kept.kept.drawn, 2); + render(&mut kept, &d, (500, 450), Affine::scale(2.0)); + assert_eq!(kept.kept.drawn, 3); + d.item_mut(id).unwrap().effects.drop_shadow.size = 20.0; + close(&render(&mut kept, &d, (500, 450), view), &fresh(&d, (500, 450), view), "a new size"); + assert_eq!(kept.kept.drawn, 4); + d.swatches.push(designcraft_color::Swatch::color("Extra", designcraft_color::Color::rgb8(1, 2, 3))); + render(&mut kept, &d, (500, 450), view); + assert_eq!(kept.kept.drawn, 5, "a swatch change drops kept images"); + // An image changed in place (its only document owner editing it). + let asset = d.assets.values_mut().next().unwrap(); + Arc::make_mut(asset).name = "edited.png".into(); + render(&mut kept, &d, (500, 450), view); + assert_eq!(kept.kept.drawn, 6, "an edited image drops kept images"); + // Single-threaded renders (exports) draw as before and keep nothing. + let mut single = Renderer::new(); + single.threads = 0; + render(&mut single, &d, (500, 450), view); + assert_eq!(single.kept.drawn, 0); + } + + /// In the CMYK blend space view the ink passes keep an image per plate, placed again on a move. + #[test] + fn the_ink_passes_keep_their_shadows() { + use designcraft_doc::SpreadRef; + let (mut d, id) = shadowed_image(); + d.settings.blend_space = designcraft_doc::BlendSpace::Cmyk; + let cache = crate::Cache::new(); + let placed = [crate::Placed { spread: SpreadRef::Doc(0), xf: Affine::IDENTITY }]; + let opts = crate::RenderOptions { blend_space_view: true, background: Some([255, 255, 255, 255]), ..Default::default() }; + let mut r = Renderer::new(); + r.threads = 2; + r.render(&d, &cache, &placed, (500, 450), Affine::IDENTITY, &opts); + // One for each ink pass, and one for the ordinary drawing around their area. + assert_eq!(r.kept.drawn, 3); + for _ in 0..3 { + let it = d.item_mut(id).unwrap(); + it.xf = Affine::translate((3.0, 1.0)) * it.xf; + r.render(&d, &cache, &placed, (500, 450), Affine::IDENTITY, &opts); + } + assert_eq!(r.kept.drawn, 3); + } } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 25207b248..973f1eb21 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -155,6 +155,8 @@ pub struct Renderer { cmyk_view: Option, /// Whether the last render ran the CMYK ink passes (only then is its view worth keeping). cmyk_ran: bool, + /// Drop shadow and outer glow images kept between renders. + kept: fx::KeptEffects, #[cfg(test)] cmyk_passes: usize, /// Pixels the last ink pass covered (tests). From 229abafbe60e07574b37b9bf826b98670c765ccf Mon Sep 17 00:00:00 2001 From: "autocraft-bot[bot]" <354215646+autocraft-bot[bot]@users.noreply.github.com> Date: Thu, 12 Nov 2026 03:43:53 +0000 Subject: [PATCH 2/4] Apply review fixes - crates/render/src/driver.rs - crates/render/src/fx.rs --- crates/render/src/driver.rs | 2 +- crates/render/src/fx.rs | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/render/src/driver.rs b/crates/render/src/driver.rs index b9568a595..cee99590c 100644 --- a/crates/render/src/driver.rs +++ b/crates/render/src/driver.rs @@ -46,6 +46,7 @@ impl Renderer { let (width, height) = size; RICH_BLACK.with(|r| r.set(opts.rich_black)); PLATE.with(|p| p.set(opts.plate)); + self.kept.begin(doc); if let Some(hit) = self.cmyk_view.as_ref().filter(|entry| entry.matches(doc, spreads, width, height, view, opts, self.threads)) { let image = hit.image(); self.stats = hit.stats(); @@ -90,7 +91,6 @@ impl Renderer { self.stats = FrameStats::default(); self.stories.clear(); self.glyphs.tick(); - self.kept.begin(doc); if let Some(bg) = opts.background { ctx.set_transform(Affine::IDENTITY); ctx.set_paint(peniko::Color::from_rgba8(bg[0], bg[1], bg[2], bg[3])); diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 44513c3ac..5248bf9ba 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -513,7 +513,8 @@ impl Renderer { return; } let [a, b, c, d, e, g] = (f.view * xf).as_coeffs(); - let (linear, anchor) = ([a, b, c, d], Vec2::new(e, g)); + let [va, vb, vc, vd, _, _] = f.view.as_coeffs(); + let (linear, anchor) = ([a, b, c, d, va, vb, vc, vd], Vec2::new(e, g)); let plate = crate::PLATE.with(std::cell::Cell::get); let rich_black = crate::color::RICH_BLACK.with(std::cell::Cell::get); let key = (it.id, page, look.slot, plate); @@ -606,8 +607,8 @@ struct Look { struct Kept { /// The object, with the transform it had. item: Item, - /// The linear part of the object's spread → device transform. - linear: [f64; 4], + /// The linear parts of the object's spread → device transform and of the view (the effect offset turns with the view). + linear: [f64; 8], /// The device position of the object's origin. anchor: Vec2, look: Look, From 29f259bf077d42798cc5072fba5cc65bc59a742b Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 19:54:23 -0800 Subject: [PATCH 3/4] Render: test that a turned view and snapshot renders handle kept shadows A view turned 90 degrees with the frame turned back draws the shadow again and matches a fresh render. Renders served from the CMYK viewport snapshot drop kept images unused for 256 renders. --- crates/render/src/fx.rs | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 5248bf9ba..c6cbd532e 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -984,5 +984,41 @@ mod tests { r.render(&d, &cache, &placed, (500, 450), Affine::IDENTITY, &opts); } assert_eq!(r.kept.drawn, 3); + // Renders served from the CMYK viewport snapshot count towards the expiry too. + assert!(!r.kept.entries.is_empty()); + for _ in 0..=KEEP_RENDERS { + r.render(&d, &cache, &placed, (500, 450), Affine::IDENTITY, &opts); + } + assert_eq!(r.kept.drawn, 3, "the snapshot served every render"); + assert!(r.kept.entries.is_empty(), "images unused for {KEEP_RENDERS} renders are dropped"); + } + + /// A view turned 90° with the frame turned back by 90° leaves the frame where it was on the + /// device, but turns the shadow's offset: the shadow is drawn again. + #[test] + fn a_turned_view_draws_the_shadow_again() { + use designcraft_doc::SpreadRef; + let (mut d, id) = shadowed_image(); + let cache = crate::Cache::new(); + let placed = [crate::Placed { spread: SpreadRef::Doc(0), xf: Affine::IDENTITY }]; + let opts = crate::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let mut kept = Renderer::new(); + kept.threads = 2; + let view = Affine::scale(1.5); + kept.render(&d, &cache, &placed, (500, 450), view, &opts); + assert_eq!(kept.kept.drawn, 1); + // Exact quarter turns, so that the frame's spread → device transform stays the same. + let (turn, back) = (Affine::new([0.0, 1.0, -1.0, 0.0, 0.0, 0.0]), Affine::new([0.0, -1.0, 1.0, 0.0, 0.0, 0.0])); + let it = d.item_mut(id).unwrap(); + it.xf = back * it.xf; + assert_eq!((view * turn * it.xf).as_coeffs(), (view * Affine::translate((100.0, 100.0))).as_coeffs()); + let turned = view * turn; + let image = kept.render(&d, &cache, &placed, (500, 450), turned, &opts); + let mut r = Renderer::new(); + r.threads = 2; + let fresh = r.render(&d, &crate::Cache::new(), &placed, (500, 450), turned, &opts); + let worst = image.pixels.iter().zip(&fresh.pixels).map(|(x, y)| x.abs_diff(*y)).max().unwrap_or(0); + assert!(worst <= 1, "off by {worst}"); + assert_eq!(kept.kept.drawn, 2); } } From 092c46c1487203f3711fab335d7ba4b0aaaf1f09 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 07:39:06 -0800 Subject: [PATCH 4/4] Render: list layers among the kept shadow image's dependencies The doc comment on with_kept_filters now names the document's layers next to its colors, styles and images, as Deps holds them. --- crates/render/src/fx.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index c6cbd532e..0cfe63661 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -491,7 +491,7 @@ impl Renderer { /// [`Renderer::with_filters`] for a drop shadow or an outer glow of `it`, keeping its image /// between renders. While the object, its effect, the view's scale and rotation, the render - /// options and the document's colours, styles and images stay the same, a move of the object + /// options and the document's colors, styles, layers and images stay the same, a move of the object /// or of the view by whole device pixels places the kept image again instead of drawing and /// blurring it. Only multithreaded contexts (the canvas) keep images, and only of objects /// without text or nested items.