Browse / github.com/storytold/designcraft / patch
Render: faster canvas with soft effects and in the CMYK blend space view
Render: faster canvas with soft effects and in the CMYK blend space view Zooming and panning a page full of drop shadows, glows and other soft effects was slow, and much slower when the document blends transparency in CMYK. The canvas shows that blend space by default. On a real brochure (CMYK blend space, shadowed headings, large photos), a zoom step in the app took 60–260 ms. It now takes 20–36 ms. **What changed** - **Effects kept between renders.** Each effect image (drop shadow, outer/inner glow, inner shadow, satin, bevel, feather) is drawn once per object and zoom. It is placed again while nothing it depends on changes; panning only moves it. Kept images li
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- dwetscher (imported from pull request #1605, open)
- Base
e44662305f2367189e0fd11bac9d010e37e3f73b- Size
- +1516 −138 in 7 files
- Review
- pending
- Shared
- 2026-10-28
- Id
a5da49cf4a1247b130b26037
How it went
No other operator has reported using it yet.
Diff
raw · ROADMAP.md crates/render/src/blendspace.rs crates/render/src/fx.rs crates/render/src/fx_cache.rs crates/render/src/lib.rs crates/render/src/text.rs crates/render/tests/effects.rs
@@ -349,6 +349,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. - **2026-10-16** · Edit › Keyboard Shortcuts imports a set saved by InDesign's Show Set (.txt, Windows or Mac key names, UTF-8 or UTF-16): matched commands take its Default shortcuts or lose theirs, other commands give up keys it takes, and a report lists what it changed and the items it could not match (`window.importShortcuts`, #1180). Tool keys stay fixed. No readiness percentage change.+- **2026-10-22** · Canvas speed with soft effects and in CMYK blend space. Drawn into the multithreaded canvas, every drop shadow, glow, inner shadow, bevel, satin and feather was blurred on an offscreen image of its own, one after the other on one core, for every frame. They are now recorded first and drawn together on all cores before the canvas is rasterized, and kept between renders: each is drawn once per object, zoom and blend pass, over what is on screen and a margin, and placed again while the object, its text, the zoom, the render options and the document's swatches, inks, settings, images and colour settings stay as they are; panning only moves it. A blur wider than 3 pixels (σ) is drawn at a lower resolution, where it is that wide, and scaled up: it has no finer detail, and at 400 % a heading's shadow takes a hundredth of the pixels. A text frame's shadow or glow covers its text, not the whole frame; a large effect image is drawn in bands of rows shared out among the cores; and a drop shadow or glow is drawn as its silhouette, whose coverage is then blurred on one channel instead of vello's four-channel filter (which took 93 % of the time) and coloured as it is placed, so one image serves both CMYK blend passes. In CMYK blend space (shown on screen by default for documents that blend in CMYK) the passes' conversion back to RGB interpolates along one ink after another and remembers repeated colours (56 → 7 ms a frame), images are separated the same way, and the kept view reuses its buffer. A spread with 24 objects carrying every kind of effect and 24 shadowed headings: 70–110 → 7–14 ms a frame, a zoom step 170–195 → 12–22 ms; a two-page Affinity brochure with shadowed text: 55 → 6 ms at fit, 171 → 7 ms at 400 %, and a zoom step 13–96 → 9–26 ms; in its CMYK blend space a zoom step 55–272 → 28–44 ms (a large photo's first separation at a new resolution excepted) and panning 42 → 17–20 ms. In CMYK blend space, images are separated into both blend-pass plates at once on all cores and kept (384 MiB, least recently used let go first, instead of 64 MiB cleared whole), and the passes are converted back to RGB on all cores. A spread with 26 shadowed headings and four large photos: 50 → 6 ms a frame at fit, 170 → 7 ms at 400 %, and a zoom step in CMYK blend space on a HiDPI canvas 0.7–2.5 s → 0.18–0.47 s. - **2026-10-22** · A ruler guide dragged with the Selection tools, out of a ruler, on its own or with other selected guides, snaps along its axis as objects do (#1221): to other ruler guides, margins, columns, bleed, page and object edges and centers (with the smart-guide mark) and the document grid, within the snap zone and following Snap to Guides and Smart Guides. Shift snaps to the ruler's ticks and Cmd suspends snapping. The preview line, the position readout and the drop use the same snapped position. No readiness percentage change. - **2026-10-22** · macOS: the app menu has a Language submenu after Settings… with each interface language in its own name and the current one checked, as in the suite's other apps; Preferences › General › Interface Language stays. No readiness percentage change. - **2026-10-22** · macOS menu bar: the menus keep working after the interface language or Show CJK Features changes (a rebuilt menu bar lost every click, Quit included), and its key equivalents are the shortcuts in effect, so a shortcut moved in the Keyboard Shortcuts editor leaves its old menu item. No readiness percentage change.@@ -56,6 +56,8 @@ impl CachedView { pub(crate) fn stats(&self) -> crate::FrameStats { self.stats }+ /// A snapshot of `image`, copied into `spare` (the last snapshot's pixels) when it fits.+ #[allow(clippy::too_many_arguments)] pub(crate) fn new( doc: &Document, spreads: &[Placed],@@ -64,18 +66,31 @@ impl CachedView { image: &Rendered, stats: crate::FrameStats, threads: u16,+ spare: Option<Vec<u8>>, ) -> Self {+ let pixels = match spare {+ Some(mut buf) if buf.len() == image.pixels.len() => {+ crate::in_parallel(&mut buf, &image.pixels, |d, s| d.copy_from_slice(s));+ buf+ }+ _ => image.pixels.clone(),+ }; Self { doc: doc.clone(), settings: designcraft_color::cms::active_settings(), opts: opts.clone(), spreads: spreads.to_vec(), view,- image: image.clone(),+ image: Rendered { width: image.width, height: image.height, pixels }, stats, threads, } }++ /// The snapshot's pixel buffer, for the next one.+ pub(crate) fn into_pixels(self) -> Vec<u8> {+ self.image.pixels+ } } /// Compare every field, but keep large immutable data on an identity-only path. Exhaustive /// destructuring makes a new document field require an explicit cache invalidation decision.@@ -256,27 +271,22 @@ fn srgb_table() -> std::sync::Arc<Vec<[f32; 3]>> { } } -/// Quadrilinear lookup of `cmyk` in an [`srgb_table`].+/// Quadrilinear lookup of `cmyk` in an [`srgb_table`]: the 16 surrounding entries interpolated+/// along K, then Y, M and C. fn srgb_in(table: &[[f32; 3]], cmyk: [f32; 4]) -> [f32; 3] { const N: usize = RGB_N; let f = cmyk.map(|v| v.clamp(0.0, 1.0) * (N - 1) as f32); let i = f.map(|v| (v as usize).min(N - 2)); let d = [f[0] - i[0] as f32, f[1] - i[1] as f32, f[2] - i[2] as f32, f[3] - i[3] as f32];- let mut out = [0.0f32; 3];- for corner in 0..16usize {- let bit = |n: usize| corner >> (3 - n) & 1;- let w: f32 = (0..4).map(|n| if bit(n) == 1 { d[n] } else { 1.0 - d[n] }).product();- if w == 0.0 {- continue;- }- let idx = (i[0] + bit(0)) * N * N * N + (i[1] + bit(1)) * N * N + (i[2] + bit(2)) * N + i[3] + bit(3);- if let Some(rgb) = table.get(idx) {- for k in 0..3 {- out[k] += rgb[k] * w;- }- }- }- out+ let at =+ |c: usize, m: usize, y: usize, k: usize| table.get((((i[0] + c) * N + i[1] + m) * N + i[2] + y) * N + i[3] + k).copied().unwrap_or([0.0; 3]);+ let lerp = |a: [f32; 3], b: [f32; 3], t: f32| [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];+ // Corner (c, m) = (n >> 1, n & 1) at `y`, along K.+ let along_k = |n: usize, y: usize| lerp(at(n >> 1, n & 1, y, 0), at(n >> 1, n & 1, y, 1), d[3]);+ // Along Y, then M, then C.+ let square: [[f32; 3]; 4] = std::array::from_fn(|n| lerp(along_k(n, 0), along_k(n, 1), d[2]));+ let line = [lerp(square[0], square[1], d[1]), lerp(square[2], square[3], d[1])];+ lerp(line[0], line[1], d[0]) } impl Renderer {@@ -387,7 +397,10 @@ impl Renderer { } off.flush(); let mut pm = vello_cpu::Pixmap::new(aw, ah);- off.render(&mut pm, &mut self.resources);+ // The effect images the pass placed, drawn first (an image it names must be there).+ if self.raster_fx() {+ off.render(&mut pm, &mut self.resources);+ } #[cfg(test)] { self.cmyk_passes += 1;@@ -402,16 +415,34 @@ impl Renderer { self.stats.items = (self.stats.items - stats.items) / 2 + stats.items; self.stats.glyphs = (self.stats.glyphs - stats.glyphs) / 2 + stats.glyphs; let table = srgb_table();- for (c, k) in cmy.data_mut().iter_mut().zip(black.data()) {- if c.a == 0 {- continue;+ let rich_black = f.opts.rich_black;+ // Every pixel of the area through the table: on all the cores.+ crate::in_parallel(cmy.data_mut(), black.data(), |cmy, black| {+ // Pages repeat colours (paper, flat fills, text): each piece remembers the ones it+ // converted last, by their ink bytes.+ let mut memo = vec![(u64::MAX, [0u8; 3]); 4096];+ for (c, k) in cmy.iter_mut().zip(black) {+ if c.a == 0 {+ continue;+ }+ let key = u64::from(c.r) | u64::from(c.g) << 8 | u64::from(c.b) << 16 | u64::from(k.r) << 24 | u64::from(c.a) << 32;+ let slot = (key.wrapping_mul(0x9E37_79B9_7F4A_7C15) >> 52) as usize;+ if let Some((seen, rgb)) = memo.get(slot)+ && *seen == key+ {+ [c.r, c.g, c.b] = *rgb;+ continue;+ }+ let a = c.a as f32;+ let inks = [1.0 - c.r as f32 / a, 1.0 - c.g as f32 / a, 1.0 - c.b as f32 / a, 1.0 - k.r as f32 / a];+ let rgb = if rich_black && inks[0..3].iter().all(|v| *v <= 1e-3) && inks[3] >= 0.999 { [0.0; 3] } else { srgb_in(&table, inks) };+ let rgb = rgb.map(|v| (v.clamp(0.0, 1.0) * a).round() as u8);+ if let Some(m) = memo.get_mut(slot) {+ *m = (key, rgb);+ }+ [c.r, c.g, c.b] = rgb; }- let a = c.a as f32;- let inks = [1.0 - c.r as f32 / a, 1.0 - c.g as f32 / a, 1.0 - c.b as f32 / a, 1.0 - k.r as f32 / a];- let rgb = if f.opts.rich_black && inks[0..3].iter().all(|v| *v <= 1e-3) && inks[3] >= 0.999 { [0.0; 3] } else { srgb_in(&table, inks) };- let [r, g, b] = rgb.map(|v| (v.clamp(0.0, 1.0) * a).round() as u8);- (c.r, c.g, c.b) = (r, g, b);- }+ }); ctx.set_transform(Affine::translate((area.x0, area.y0))); ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(std::sync::Arc::new(cmy)), sampler: peniko::ImageSampler::default() }); ctx.fill_rect(&Rect::new(0.0, 0.0, aw as f64, ah as f64));@@ -422,6 +453,43 @@ impl Renderer { #[cfg(test)] mod tests { use super::*;++ /// The lookup interpolated along one ink after another is the 16-corner weighted sum.+ #[test]+ fn srgb_lookup_is_the_weighted_sum_of_its_16_corners() {+ const N: usize = RGB_N;+ let table = srgb_table();+ let reference = |cmyk: [f32; 4]| {+ let f = cmyk.map(|v| v.clamp(0.0, 1.0) * (N - 1) as f32);+ let i = f.map(|v| (v as usize).min(N - 2));+ let d = [f[0] - i[0] as f32, f[1] - i[1] as f32, f[2] - i[2] as f32, f[3] - i[3] as f32];+ let mut out = [0.0f32; 3];+ for corner in 0..16usize {+ let bit = |n: usize| corner >> (3 - n) & 1;+ let w: f32 = (0..4).map(|n| if bit(n) == 1 { d[n] } else { 1.0 - d[n] }).product();+ let idx = (i[0] + bit(0)) * N * N * N + (i[1] + bit(1)) * N * N + (i[2] + bit(2)) * N + i[3] + bit(3);+ for k in 0..3 {+ out[k] += table[idx][k] * w;+ }+ }+ out+ };+ let mut seed = 0x5eed_u64;+ let mut next = || {+ seed ^= seed << 13;+ seed ^= seed >> 7;+ seed ^= seed << 17;+ (seed % 10_001) as f32 / 10_000.0+ };+ for _ in 0..20_000 {+ let cmyk = [next(), next(), next(), next()];+ let (a, b) = (srgb_in(&table, cmyk), reference(cmyk));+ assert!(a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-4), "{cmyk:?}: {a:?} vs {b:?}");+ }+ for corner in [[0.0; 4], [1.0; 4], [1.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]] {+ assert_eq!(srgb_in(&table, corner).map(|v| (v * 255.0).round()), reference(corner).map(|v| (v * 255.0).round()));+ }+ } use designcraft_compose::Cache; use designcraft_doc::{Content, Fill, ItemId, Shape, build::NewDocument}; use std::sync::Arc;@@ -2,9 +2,10 @@ //! drawn with vello filter layers. //! //! 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; the object+//! pipeline each effect is recorded into an offscreen single-threaded context cropped to its+//! reach and placed as an image ([`Renderer::with_filters`]). The images are drawn together on+//! the cores before the canvas is rasterized ([`Renderer::raster_fx`]) and kept between renders+//! ([`crate::fx_cache`]); single-threaded contexts 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,@@ -23,7 +24,8 @@ use vello_common::filter_effects::{EdgeMode, Filter, FilterPrimitive}; use vello_cpu::peniko::{self, BlendMode, Compose, Mix}; use vello_cpu::{RenderContext, kurbo}; -use crate::{Frame, Renderer, color_of};+use crate::fx_cache::Slot;+use crate::{Frame, RenderOptions, Renderer, color_of}; use designcraft_compose::ShownOn; /// Shadow offset (spread units) for an InDesign angle (degrees, light direction) and distance.@@ -102,23 +104,36 @@ 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));+ below.push((Slot::OuterGlow, Vec2::ZERO, g.size, g.spread, g.color.clone(), g.opacity)); } 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));+ below.push((+ Slot::DropShadow,+ offset(doc.light_angle(d.angle, d.global_light), d.distance),+ d.size,+ d.spread,+ d.color.clone(),+ d.opacity,+ )); }- for (off, size, pct, color, opacity) in below {+ // A text frame's text casts them, not its frame: they need not cover the frame.+ let cast = if solid || below.is_empty() { reach } else { self.text_ink(f, it, xf, page).unwrap_or(reach) };+ for (slot, off, size, pct, color, opacity) 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))); // 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, None, |me, c, fr| {- let moved = Frame { view: fr.view * Affine::translate(off), ..*fr };+ let r = (cast + off).inflate(grow, grow);+ let tint = color_of(&c, opacity.clamp(0.0, 1.0));+ self.with_filters(ctx, f, Some((it, xf, page, slot)), r, sigma, Some(tint), None, |me, c, fr, filtered| {+ // What shows is cast from the offset's distance away (a band's neighbour casts into it).+ let moved = Frame { view: fr.view * Affine::translate(off), visible: fr.visible - off, ..*fr }; c.set_transform(moved.view);- c.push_layer(None, None, None, None, Some(filter));+ if filtered {+ c.push_layer(None, None, None, None, Some(filter.clone()));+ } me.draw_body(c, &moved, it, bp, xf, page); if grow > 0.0 && solid { c.set_transform(moved.view * xf);@@ -126,7 +141,9 @@ impl Renderer { c.set_stroke(kurbo::Stroke::new(grow * 2.0).with_join(kurbo::Join::Round)); c.stroke_path(bp); }- c.pop_layer();+ if filtered {+ c.pop_layer();+ } }); } // Gradient feather: the object (feathered or not) drawn in its own layer, then kept as@@ -155,7 +172,7 @@ impl Renderer { // 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, sigma, None, |_, c, fr| {+ self.with_filters(ctx, f, Some((it, xf, page, Slot::Feather)), reach, sigma, None, None, |_, c, fr, _| { c.set_transform(fr.view); c.push_layer(None, None, None, None, Some(blur(sigma))); c.set_transform(fr.view * xf);@@ -218,7 +235,18 @@ impl Renderer { let s = &e.inner_shadow; let (sigma, choke) = split(s.size, s.choke); let off = offset(doc.light_angle(s.angle, s.global_light), s.distance);- self.inner_edge(ctx, f, reach, bp, xf, off, sigma, choke, color_of(&c, s.opacity.clamp(0.0, 1.0)), Mix::Normal);+ self.inner_edge(+ ctx,+ f,+ (it, xf, page, Slot::InnerShadow),+ reach,+ bp,+ off,+ sigma,+ choke,+ color_of(&c, s.opacity.clamp(0.0, 1.0)),+ Mix::Normal,+ ); } // Inner glow: from the edges inwards (or out from the centre), screened over the object. if e.inner_glow.on@@ -231,7 +259,7 @@ impl Renderer { let mix = crate::blend_mode(g.mode).mix; let paint = color_of(&c, g.opacity.clamp(0.0, 1.0)); if g.center {- self.with_blended_filters(ctx, f, reach, sigma, mix, (bp, xf), |_, c, fr| {+ self.with_blended_filters(ctx, f, (it, xf, page, Slot::InnerGlow), reach, sigma, mix, bp, |_, c, fr, _| { c.set_transform(fr.view); c.push_layer(None, None, None, None, Some(blur(sigma))); c.set_transform(fr.view * xf);@@ -248,7 +276,7 @@ impl Renderer { c.pop_layer(); }); } else {- self.inner_edge(ctx, f, reach, bp, xf, Vec2::ZERO, sigma, choke, paint, mix);+ self.inner_edge(ctx, f, (it, xf, page, Slot::InnerGlow), reach, bp, Vec2::ZERO, sigma, choke, paint, mix); } } // Satin: the shape shifted both ways along the angle, blurred, where exactly one covers.@@ -260,7 +288,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, (bp, xf), |_, c, fr| {+ self.with_blended_filters(ctx, f, (it, xf, page, Slot::Satin), reach, sigma, Mix::Multiply, bp, |_, c, fr, _| { c.set_transform(fr.view); c.push_layer(None, None, None, None, Some(blur(sigma))); if invert {@@ -289,10 +317,32 @@ impl Renderer { let sigma = b.size.max(0.0) / 2.0; if let Some(c) = doc.resolve_color(&b.highlight, 1.0) { // The inverse shape moved away from the light lights the edges facing it.- self.inner_edge(ctx, f, reach, bp, xf, off, sigma, 0.0, color_of(&c, b.highlight_opacity.clamp(0.0, 1.0)), Mix::Screen);+ self.inner_edge(+ ctx,+ f,+ (it, xf, page, Slot::BevelHighlight),+ reach,+ bp,+ off,+ sigma,+ 0.0,+ color_of(&c, b.highlight_opacity.clamp(0.0, 1.0)),+ Mix::Screen,+ ); } if let Some(c) = doc.resolve_color(&b.shadow, 1.0) {- self.inner_edge(ctx, f, reach, bp, xf, -off, sigma, 0.0, color_of(&c, b.shadow_opacity.clamp(0.0, 1.0)), Mix::Multiply);+ self.inner_edge(+ ctx,+ f,+ (it, xf, page, Slot::BevelShadow),+ reach,+ bp,+ -off,+ sigma,+ 0.0,+ color_of(&c, b.shadow_opacity.clamp(0.0, 1.0)),+ Mix::Multiply,+ ); } } }@@ -304,16 +354,17 @@ impl Renderer { &mut self, ctx: &mut RenderContext, f: &Frame,+ keep: Keep, reach: Rect, bp: &BezPath,- xf: Affine, off: Vec2, sigma: f64, choke: f64, paint: peniko::Color, mix: Mix, ) {- self.with_blended_filters(ctx, f, reach, sigma, mix, (bp, xf), |_, c, fr| {+ let xf = keep.1;+ self.with_blended_filters(ctx, f, keep, reach, sigma, mix, bp, |_, 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.@@ -335,25 +386,26 @@ impl Renderer { }); } - /// [`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+ /// [`Renderer::with_filters`] clipped to `clip` (with the object's transform in `keep`), 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, f: &Frame,+ keep: Keep, reach: Rect, sigma: f64, mix: Mix,- clip: (&BezPath, Affine),- draw: impl FnOnce(&mut Self, &mut RenderContext, &Frame),+ clip: &BezPath,+ draw: impl Fn(&mut Self, &mut RenderContext, &Frame, bool), ) { 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, Some(clip), draw);+ self.with_filters(ctx, f, Some(keep), reach, sigma, None, Some((clip, keep.1)), draw); if mix != Mix::Normal { ctx.pop_layer(); }@@ -364,44 +416,207 @@ impl Renderer { /// an offscreen single-threaded context covering `reach` (spread space) plus the blur's spread, /// 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.+ ///+ /// With `keep` (the object, its parent transform, where it shows and which of its effects)+ /// the offscreen image covers what is on screen and a margin, and is placed again from+ /// [`crate::fx_cache`] while nothing it depends on changes. On a multithreaded context the+ /// image is only recorded here: [`Renderer::raster_fx`] draws all of them together on the+ /// cores before the context is rasterized.+ ///+ /// `draw` pushes its filter layer when told to (its last argument). A drop shadow or glow+ /// (`shadow`, its colour) drawn offscreen is drawn without it; its coverage is blurred+ /// afterwards ([`shadow_coverage`]) and placed in its colour: the same, at a fraction of the+ /// filter's cost, and kept once for every plate.+ #[allow(clippy::too_many_arguments)] pub(crate) fn with_filters( &mut self, ctx: &mut RenderContext, f: &Frame,+ keep: Option<Keep>, reach: Rect, sigma: f64,+ shadow: Option<peniko::Color>, clip: Option<(&BezPath, Affine)>,- draw: impl FnOnce(&mut Self, &mut RenderContext, &Frame),+ draw: impl Fn(&mut Self, &mut RenderContext, &Frame, bool), ) {+ use crate::fx_cache::{Band, FxCache, FxImage, Lookup, MAX_ENTRY_PIXELS, Source}; // 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 { push_clip(ctx, f, clip);- draw(self, ctx, f);+ draw(self, ctx, f, true); 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 };+ let all = f.view.transform_rect_bbox(reach);+ let screen = (ctx.width(), ctx.height());+ // A blur has no detail finer than itself: drawn at a lower resolution where it is+ // [`BLUR_SIGMA_PX`] wide, it looks the same and costs a fraction.+ let coarse = sigma.max(0.0) / f.px / BLUR_SIGMA_PX;+ // Placed again from what was kept, if it was; otherwise what to keep it by.+ let mut zooming = false;+ let mut keeping = None;+ if let Some((it, xf, page, slot)) = keep {+ let mut stories = Vec::new();+ it.walk(&mut |i: &Item| {+ if let designcraft_doc::Content::Text(tf) = &i.content {+ stories.push(tf.story);+ }+ });+ let stories = stories.into_iter().map(|sid| self.story(f, sid, page)).collect();+ // What must be in the image now: what shows, and what a blur brings onto the screen.+ let sw = f64::from(screen.0);+ let sh = f64::from(screen.1);+ let needed = all.inflate(spread, spread).intersect(Rect::new(0.0, 0.0, sw, sh).inflate(spread, spread));+ let src = Source { item: it, xf, view: f.view, px: f.px, stories, needed };+ // A shadow's coverage is the same on every plate (it is coloured as it is placed).+ let (plate, opts) = match shadow {+ Some(_) => (None, RenderOptions { plate: None, ..f.opts.clone() }),+ None => (f.opts.plate, f.opts.clone()),+ };+ let key = (it.id, page, slot, plate);+ match self.fx_cache.get(key, &src, &opts) {+ Lookup::Hit(p) => {+ push_clip(ctx, f, clip_if(p.scale > 1.0, clip));+ for b in p.image.bands {+ place(ctx, vello_cpu::ImageSource::Pixmap(b.image), (p.x0, p.y0, p.scale), p.image.width, b.top, b.core, shadow);+ }+ pop_clip(ctx, clip_if(p.scale > 1.0, clip));+ return;+ }+ Lookup::Miss { zoomed } => zooming = zoomed,+ }+ keeping = Some((key, src, opts));+ }+ // Kept: what is on screen and a margin around it, so panning within that only moves the+ // image (no margin while zooming: the next frame draws it anew anyway). Too large to keep:+ // what is on screen, drawn each time.+ let margin = if zooming { 0.0 } else { KEEP_MARGIN };+ let kept = keeping.and_then(|k| {+ offscreen(all, spread, screen, margin, coarse).filter(|o| f64::from(o.width) * f64::from(o.height) <= MAX_ENTRY_PIXELS).map(|o| (k, o))+ });+ let o = match &kept {+ Some((_, o)) => *o,+ None => match offscreen(all, spread, screen, 0.0, coarse) {+ Some(o) => o,+ None => 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) };- 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);+ let mut prepared = kept.map(|((key, src, opts), o)| FxCache::prepare(key, src, &opts, o.x0, o.y0, o.scale));+ let at = (o.x0, o.y0, o.scale);+ // Drawn with the others on the cores, a large image is drawn in bands of rows, each+ // reaching past its rows by the blur's spread.+ let deferred = f.mt && self.fx_deferred();+ // A shadow's blur (σ, offscreen pixels), put on its coverage after it is drawn.+ let post = shadow.map(|_| (sigma.max(0.0) / (f.px * o.scale)) as f32);+ let pad = (spread / o.scale).ceil().clamp(0.0, f64::from(u16::MAX)) as u32 + 2;+ let mut bands = Vec::new(); 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)));+ for core in bands_of(o.width, o.height, pad, deferred) {+ let top = core.0.saturating_sub(pad);+ let bottom = core.1.saturating_add(pad).min(u32::from(o.height));+ let rows = u16::try_from(bottom.saturating_sub(top)).unwrap_or(o.height).max(1);+ let mut off = RenderContext::new_with(o.width, rows, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() });+ let band_y0 = o.y0 + f64::from(top) * o.scale;+ let view = Affine::scale(1.0 / o.scale) * Affine::translate((-o.x0, -band_y0)) * f.view;+ // Culling inside sees the whole band as visible.+ let area = Rect::new(o.x0, band_y0, o.x0 + f64::from(o.width) * o.scale, band_y0 + f64::from(rows) * o.scale);+ let visible = if f.view.determinant().abs() > 1e-12 { f.view.inverse().transform_rect_bbox(area) } else { f.visible };+ let shifted = Frame { mt: false, view, px: f.px * o.scale, visible, ..*f };+ push_clip(&mut off, &shifted, inner);+ draw(self, &mut off, &shifted, post.is_none());+ pop_clip(&mut off, inner);+ off.flush();+ if deferred {+ let id = self.defer_fx(off, post);+ place(ctx, vello_cpu::ImageSource::OpaqueId { id, may_have_transparency: true }, at, o.width, top, core, shadow);+ bands.push((id, top, core));+ } else {+ let mut pm = vello_cpu::Pixmap::new(o.width, rows);+ off.render(&mut pm, &mut self.resources);+ if let Some(sigma) = post {+ shadow_coverage(&mut pm, sigma);+ }+ let image = std::sync::Arc::new(pm);+ place(ctx, vello_cpu::ImageSource::Pixmap(image.clone()), at, o.width, top, core, shadow);+ if let Some(p) = prepared.take() {+ self.fx_cache.put(p, FxImage { width: o.width, height: u32::from(o.height), bands: vec![Band { image, top, core }] });+ }+ }+ }+ if deferred {+ if let Some(p) = prepared {+ self.keep_fx(p, o.width, u32::from(o.height), bands);+ }+ self.fx_flush_if_full();+ } pop_clip(ctx, outer);- ctx.set_transform(Affine::IDENTITY); } } +/// An object whose effect image may be kept: the object, its parent transform, where it shows and+/// which of its effects.+pub(crate) type Keep<'a> = (&'a Item, Affine, ShownOn, Slot);++/// Device pixels kept around the screen of an effect image, for panning.+const KEEP_MARGIN: f64 = 256.0;++/// Rows `core` of an effect image `width` pixels wide whose top left is at (`x0`, `y0`) on the+/// device and each of whose pixels is `scale` device pixels wide, from `image`, which holds its+/// rows from `top` on; a shadow's coverage in its colour (`tint`).+#[allow(clippy::too_many_arguments)]+fn place(+ ctx: &mut RenderContext,+ image: vello_cpu::ImageSource,+ (x0, y0, scale): (f64, f64, f64),+ width: u16,+ top: u32,+ core: (u32, u32),+ tint: Option<peniko::Color>,+) {+ // The image is placed by its paint transform, filling device rows: the edge between two bands+ // falls between rows of pixels, so no row is covered partly by each (a faint line).+ ctx.set_transform(Affine::IDENTITY);+ ctx.set_paint_transform(Affine::translate((x0, y0)) * Affine::scale(scale) * Affine::translate((0.0, f64::from(top))));+ ctx.set_paint(vello_cpu::Image { image, sampler: peniko::ImageSampler::default() });+ if let Some(color) = tint {+ ctx.set_tint(Some(vello_common::paint::Tint { color, mode: vello_common::paint::TintMode::AlphaMask }));+ }+ let row = |r: u32| (y0 + f64::from(r) * scale).round();+ ctx.fill_rect(&Rect::new(x0, row(core.0), x0 + f64::from(width) * scale, row(core.1)));+ if tint.is_some() {+ ctx.set_tint(None);+ }+ ctx.reset_paint_transform();+}++/// Pixels of an effect image drawn as one band at most when it is drawn with the others on the+/// cores: a large one is shared out among them.+const BAND_PIXELS: u64 = 1 << 19;++/// The bands of rows an effect image `width` × `height` pixels large is drawn in (one unless+/// `split`): none thinner than twice the rows each reaches past its own (`pad`).+fn bands_of(width: u16, height: u16, pad: u32, split: bool) -> Vec<(u32, u32)> {+ let h = u32::from(height);+ let n = if split { (u64::from(width) * u64::from(h)).div_ceil(BAND_PIXELS).clamp(1, 64) as u32 } else { 1 };+ let rows = h.div_ceil(n.max(1)).max(pad.saturating_mul(2)).max(1);+ let mut out = Vec::new();+ let mut y = 0;+ while y < h {+ let end = y.saturating_add(rows).min(h);+ out.push((y, end));+ y = end;+ }+ out+}++/// `clip` if `cond`.+fn clip_if(cond: bool, clip: Option<(&BezPath, Affine)>) -> Option<(&BezPath, Affine)> {+ if cond { clip } else { None }+}+ fn push_clip(ctx: &mut RenderContext, f: &Frame, clip: Option<(&BezPath, Affine)>) { if let Some((bp, xf)) = clip { ctx.set_transform(f.view * xf);@@ -420,9 +635,15 @@ fn pop_clip(ctx: &mut RenderContext, clip: Option<(&BezPath, Affine)>) { /// instead, where it is this wide, and has no detail to lose there. const MAX_BLUR_SPREAD: f64 = 1024.0; +/// The blur (σ, pixels) an offscreen effect image is drawn with at most: a wider one is drawn at+/// a lower resolution, where it is this wide, and scaled up. Smoothed by the blur, a silhouette+/// drawn so differs from one drawn at full resolution by a level or two; at 400 % a heading's+/// shadow takes a hundredth of the pixels.+const BLUR_SIGMA_PX: f64 = 3.0;+ /// An offscreen image for [`Renderer::with_filters`]: where it goes on the device, how many device /// pixels one of its pixels covers, and its size.-#[derive(Debug, PartialEq)]+#[derive(Clone, Copy, Debug, PartialEq)] struct Offscreen { x0: f64, y0: f64,@@ -432,18 +653,85 @@ struct Offscreen { } /// The offscreen image an effect is drawn in: `reach` (device pixels) widened by the blur's-/// `spread`, within the window widened the same way, at a resolution that keeps the blur within-/// [`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<Offscreen> {- let screen = Rect::new(0.0, 0.0, f64::from(width), f64::from(height)).inflate(spread, spread);+/// `spread`, within the `screen` widened the same way and by `margin`, at a resolution that keeps+/// the blur within [`MAX_BLUR_SPREAD`] and each of its pixels at least `coarse` device pixels+/// wide, at most `MAX_SIDE` a side. `None` when none of it shows.+fn offscreen(reach: Rect, spread: f64, (width, height): (u16, u16), margin: f64, coarse: f64) -> Option<Offscreen> {+ let screen = Rect::new(0.0, 0.0, f64::from(width), f64::from(height)).inflate(spread + margin, spread + margin); let r = reach.inflate(spread, spread).intersect(screen);+ if !(r.width() > 0.0 && r.height() > 0.0) {+ return None;+ } let (x0, y0) = (r.x0.floor(), r.y0.floor());- let scale = (spread / MAX_BLUR_SPREAD).max(1.0);+ let scale = (spread / MAX_BLUR_SPREAD).max(coarse).max(1.0); let side = |len: f64| (len / scale).ceil().min(crate::MAX_SIDE as f64); let (w, h) = (side(r.x1.ceil() - x0), side(r.y1.ceil() - y0)); (w >= 1.0 && h >= 1.0).then_some(Offscreen { x0, y0, scale, width: w as u16, height: h as u16 }) } +/// What vello's drop-shadow filter (no offset) blurs of `pm`, without it: its coverage blurred by+/// `sigma` pixels (a Gaussian 3σ wide, nothing beyond the edges), as white of that coverage. It+/// is coloured as it is placed (an alpha-mask tint), so one image serves every colour and plate.+/// One channel where the filter blurs four, and nothing blurred where nothing is.+pub(crate) fn shadow_coverage(pm: &mut vello_cpu::Pixmap, sigma: f32) {+ let w = usize::from(pm.width());+ let mut cov: Vec<f32> = pm.data().iter().map(|p| f32::from(p.a)).collect();+ if sigma > 0.0 && w > 0 {+ blur_coverage(&mut cov, w, sigma);+ }+ for (p, v) in pm.data_mut().iter_mut().zip(&cov) {+ let a = v.round().clamp(0.0, 255.0) as u8;+ *p = vello_cpu::color::PremulRgba8 { r: a, g: a, b: a, a };+ }+}++/// The weights of a Gaussian of `sigma` pixels, 3σ either way (as vello's).+fn gaussian(sigma: f32) -> Vec<f32> {+ let radius = (3.0 * sigma).ceil().clamp(0.0, 255.0) as usize;+ let k: Vec<f32> = (0..=2 * radius)+ .map(|i| {+ let x = i as f32 - radius as f32;+ (-(x * x) / (2.0 * sigma * sigma)).exp()+ })+ .collect();+ let sum: f32 = k.iter().sum();+ k.into_iter().map(|v| if sum > 0.0 { v / sum } else { v }).collect()+}++/// `cov` (rows `w` wide) blurred by a Gaussian of `sigma` pixels, along the rows then the columns;+/// rows with nothing in them are passed over.+fn blur_coverage(cov: &mut [f32], w: usize, sigma: f32) {+ let k = gaussian(sigma);+ let r = k.len() / 2;+ let mut tmp = vec![0.0f32; cov.len()];+ let mut inked = Vec::with_capacity(cov.len() / w);+ for (src, dst) in cov.chunks(w).zip(tmp.chunks_mut(w)) {+ let any = src.iter().any(|v| *v > 0.0);+ inked.push(any);+ if !any {+ continue;+ }+ for (x, d) in dst.iter_mut().enumerate() {+ let lo = x.saturating_sub(r);+ let taps = k.iter().skip(lo + r - x);+ *d = src.iter().skip(lo).zip(taps).map(|(s, t)| s * t).sum();+ }+ }+ for (y, out) in cov.chunks_mut(w).enumerate() {+ out.fill(0.0);+ let lo = y.saturating_sub(r);+ for (j, (row, t)) in tmp.chunks(w).zip(&inked).enumerate().skip(lo).take(y + r + 1 - lo) {+ if !*t {+ continue;+ }+ let Some(weight) = k.get(j + r - y) else { continue };+ for (o, s) in out.iter_mut().zip(row) {+ *o += s * weight;+ }+ }+ }+}+ #[cfg(test)] mod tests { use super::*;@@ -452,10 +740,13 @@ mod tests { fn a_wide_blur_is_drawn_offscreen_at_a_lower_resolution() { let object = Rect::new(150.0, 150.0, 350.0, 350.0); let small = Offscreen { x0: 140.0, y0: 140.0, scale: 1.0, width: 220, height: 220 };- assert_eq!(offscreen(object, 10.0, 1440, 900), Some(small));+ assert_eq!(offscreen(object, 10.0, (1440, 900), 0.0, 1.0), Some(small)); // A 10,000 pt drop shadow at 100%: 3σ is 15,000 px, which asked for 30,204 px a side and as // much padding again inside vello's filter layer. Drawn at 1/14.65 it is 1,024 px wide. let wide = Offscreen { x0: -14852.0, y0: -14852.0, scale: 15_002.0 / 1024.0, width: 2062, height: 2062 };- assert_eq!(offscreen(object, 15_002.0, 1440, 900), Some(wide));+ assert_eq!(offscreen(object, 15_002.0, (1440, 900), 0.0, 1.0), Some(wide));+ // A 40 px σ drawn at a 3 px one: a pixel for every 13⅓ on the device.+ let coarse = offscreen(object, 122.0, (1440, 900), 0.0, 40.0 / BLUR_SIGMA_PX).unwrap();+ assert_eq!((coarse.scale, coarse.width, coarse.height), (40.0 / 3.0, 34, 34)); } }new file mode 100644@@ -0,0 +1,273 @@+//! Soft effects kept between renders (drop shadows, glows, inner shadows, bevels, satin and+//! feather masks). Drawn into a multithreaded context, each is a blur on an offscreen image of its+//! own; redrawing those for every frame made scrolling a page of shadowed headings slow (26 text+//! shadows took 44 of a frame's 50 ms). Each is now drawn once per object and zoom and placed+//! again while nothing it depends on changes: the object (its transform within its parent too),+//! its composed text, the zoom and rotation, the render options, and the document's swatches,+//! inks, stroke styles, layers, settings, images and the colour settings. Panning only moves it.++use std::collections::HashMap;+use std::sync::Arc;++use designcraft_color::Swatch;+use designcraft_color::cms::Cms;+use designcraft_compose::{ComposedStory, ShownOn};+use designcraft_doc::{Asset, AssetId, DocSettings, Document, InkManager, Item, ItemId, Layer, StrokeStyleDef};+use designcraft_geom::{Affine, Rect, Vec2};+use vello_cpu::Pixmap;++use crate::RenderOptions;++/// Pixels one kept image may have (a larger one is drawn each time, as before).+pub(crate) const MAX_ENTRY_PIXELS: f64 = 16_777_216.0;+/// Pixels all kept images may have together (128 MiB).+const MAX_PIXELS: u64 = 32 << 20;+/// Renders an image not drawn in is kept for (it may scroll back into view).+const KEEP_FRAMES: u64 = 2;++/// Which effect of which object, shown where, drawn for which plate (the CMYK blend passes draw+/// each object twice, once per plate).+pub(crate) type Key = (ItemId, ShownOn, Slot, Option<u8>);++/// The effects of one object an image may be kept for.+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]+pub(crate) enum Slot {+ OuterGlow,+ DropShadow,+ Feather,+ InnerShadow,+ InnerGlow,+ Satin,+ BevelHighlight,+ BevelShadow,+}++/// What a kept image was drawn from, compared with what is drawn now.+pub(crate) struct Source<'a> {+ pub item: &'a Item,+ /// The parent transform the object was drawn with.+ pub xf: Affine,+ /// Spread space to device pixels.+ pub view: Affine,+ pub px: f64,+ /// The composed stories of the text frames in the object.+ pub stories: Vec<Arc<ComposedStory>>,+ /// The device area the image must cover now (a large object's image covers only what was on+ /// screen when it was drawn, and a margin).+ pub needed: Rect,+}++struct Entry {+ opts: RenderOptions,+ item: Item,+ xf: Affine,+ linear: [f64; 4],+ px: f64,+ translation: Vec2,+ stories: Vec<Arc<ComposedStory>>,+ /// Where the image went on the device, and how many device pixels one of its pixels covers.+ x0: f64,+ y0: f64,+ scale: f64,+ image: FxImage,+ used: u64,+}++/// An effect image: one or more bands of rows, drawn on their own (on several cores) and placed+/// side by side.+#[derive(Clone)]+pub(crate) struct FxImage {+ /// Its width (pixels).+ pub width: u16,+ /// Its height (rows) in all.+ pub height: u32,+ pub bands: Vec<Band>,+}++/// Rows `core` of an [`FxImage`], from `image` whose first row is the image's row `top`. The+/// image reaches past the core by the blur's spread, so each band is blurred as the whole is.+#[derive(Clone)]+pub(crate) struct Band {+ pub image: Arc<Pixmap>,+ pub top: u32,+ pub core: (u32, u32),+}++impl FxImage {+ fn pixels(&self) -> u64 {+ self.bands.iter().map(|b| u64::from(b.image.width()) * u64::from(b.image.height())).sum()+ }+}++/// What every kept image depends on beyond its own object.+struct Deps {+ cms: Arc<Cms>,+ swatches: Vec<Swatch>,+ settings: DocSettings,+ inks: InkManager,+ stroke_styles: Vec<StrokeStyleDef>,+ layers: Vec<Layer>,+ assets: Vec<(AssetId, Arc<Asset>)>,+}++impl Deps {+ fn of(doc: &Document) -> Self {+ Deps {+ cms: designcraft_color::cms::active(),+ 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 matches(&self, doc: &Document) -> bool {+ Arc::ptr_eq(&self.cms, &designcraft_color::cms::active())+ && 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(|((id, a), (id2, b))| id == id2 && Arc::ptr_eq(a, b))+ }+}++/// An image being drawn, to keep once it is ([`FxCache::prepare`], [`FxCache::put`]).+pub(crate) struct Prepared {+ key: Key,+ opts: RenderOptions,+ item: Item,+ xf: Affine,+ linear: [f64; 4],+ px: f64,+ translation: Vec2,+ stories: Vec<Arc<ComposedStory>>,+ x0: f64,+ y0: f64,+ scale: f64,+}++/// What [`FxCache::get`] found.+pub(crate) enum Lookup {+ Hit(Placed),+ /// Nothing to place; `zoomed` when an image was kept for the object at another zoom or rotation+ /// in the last render (it is being zoomed: the next render draws it anew too).+ Miss {+ zoomed: bool,+ },+}++/// A kept image to place: the image, its top left on the device now, and its pixel scale.+pub(crate) struct Placed {+ pub image: FxImage,+ pub x0: f64,+ pub y0: f64,+ pub scale: f64,+}++#[derive(Default)]+pub(crate) struct FxCache {+ entries: HashMap<Key, Entry>,+ deps: Option<Deps>,+ pixels: u64,+ frame: u64,+}++fn linear(a: Affine) -> [f64; 4] {+ let [a, b, c, d, _, _] = a.as_coeffs();+ [a, b, c, d]+}++impl FxCache {+ /// A render of `doc` begins: images not drawn in the last few are let go, and all of them+ /// once something every one depends on changed.+ pub(crate) fn next_frame(&mut self, doc: &Document) {+ if !self.deps.as_ref().is_some_and(|d| d.matches(doc)) {+ self.clear();+ self.deps = Some(Deps::of(doc));+ }+ self.frame = self.frame.wrapping_add(1);+ let frame = self.frame;+ let before = self.entries.len();+ self.entries.retain(|_, e| e.used.saturating_add(KEEP_FRAMES) >= frame);+ if self.entries.len() != before {+ self.pixels = self.entries.values().map(|e| e.image.pixels()).sum();+ }+ }++ /// Forget everything.+ pub(crate) fn clear(&mut self) {+ self.entries.clear();+ self.pixels = 0;+ }++ /// The kept image for `key` where `src` is what it was drawn from, placed for `src.view`.+ pub(crate) fn get(&mut self, key: Key, src: &Source, opts: &RenderOptions) -> Lookup {+ let Some(e) = self.entries.get_mut(&key) else { return Lookup::Miss { zoomed: false } };+ if e.linear != linear(src.view) || e.px != src.px {+ return Lookup::Miss { zoomed: e.used.saturating_add(1) >= self.frame };+ }+ let same = e.opts == *opts+ && e.xf == src.xf+ && e.stories.len() == src.stories.len()+ && e.stories.iter().zip(&src.stories).all(|(a, b)| Arc::ptr_eq(a, b))+ && e.item == *src.item;+ if !same {+ return Lookup::Miss { zoomed: false };+ }+ // Panning moves the device position only, as long as the image still covers what shows.+ let t = src.view.translation();+ let (x0, y0) = (e.x0 + (t.x - e.translation.x), e.y0 + (t.y - e.translation.y));+ let (w, h) = (f64::from(e.image.width) * e.scale, f64::from(e.image.height) * e.scale);+ let n = src.needed;+ if n.x0 < x0 - 0.5 || n.y0 < y0 - 0.5 || n.x1 > x0 + w + 0.5 || n.y1 > y0 + h + 0.5 {+ return Lookup::Miss { zoomed: false };+ }+ e.used = self.frame;+ Lookup::Hit(Placed { image: e.image.clone(), x0, y0, scale: e.scale })+ }++ /// What to keep of an image about to be drawn from `src` with its top left at (`x0`, `y0`) on+ /// the device; [`Self::put`] keeps it once it is drawn.+ pub(crate) fn prepare(key: Key, src: Source, opts: &RenderOptions, x0: f64, y0: f64, scale: f64) -> Prepared {+ Prepared {+ key,+ opts: opts.clone(),+ item: src.item.clone(),+ xf: src.xf,+ linear: linear(src.view),+ px: src.px,+ translation: src.view.translation(),+ stories: src.stories,+ x0,+ y0,+ scale,+ }+ }++ /// Keep `image`, drawn as `p` describes.+ pub(crate) fn put(&mut self, p: Prepared, image: FxImage) {+ let size = image.pixels();+ if let Some(old) = self.entries.remove(&p.key) {+ self.pixels = self.pixels.saturating_sub(old.image.pixels());+ }+ if self.pixels.saturating_add(size) > MAX_PIXELS {+ // Make room: first what wasn't drawn lately, then everything else.+ let frame = self.frame;+ self.entries.retain(|_, e| e.used.saturating_add(KEEP_FRAMES) >= frame);+ self.pixels = self.entries.values().map(|e| e.image.pixels()).sum();+ if self.pixels.saturating_add(size) > MAX_PIXELS {+ self.clear();+ }+ }+ if size > MAX_PIXELS {+ return;+ }+ self.pixels += size;+ let Prepared { key, opts, item, xf, linear, px, translation, stories, x0, y0, scale } = p;+ self.entries.insert(key, Entry { opts, item, xf, linear, px, translation, stories, x0, y0, scale, image, used: self.frame });+ }+}@@ -27,6 +27,7 @@ mod blendspace; mod colorize; pub mod damage; mod fx;+mod fx_cache; mod grid; /// How far (points) an object's soft effects reach outside its geometry.@@ -229,6 +230,29 @@ pub struct Renderer { stories: HashMap<(StoryId, ShownOn), Arc<ComposedStory>>, drawing_items: Vec<designcraft_doc::ItemId>, cmyk_view: Option<blendspace::CachedView>,+ /// Soft effects kept between renders.+ fx_cache: fx_cache::FxCache,+ /// Effect images recorded for the multithreaded context and not drawn yet ([`Self::raster_fx`]).+ fx_jobs: Vec<FxJob>,+ /// Effect images to keep once their bands in `fx_jobs` are drawn.+ fx_keep: Vec<FxKeep>,+ /// Pixels of `fx_jobs`.+ fx_pixels: u64,+ /// The image id the next recorded effect image takes: the registry numbers images in the+ /// order they are registered, from 0 after it is cleared at the start of each render.+ fx_next_id: u32,+ /// Whether effect images are drawn together on the cores (off once the registry numbered+ /// one differently; they are then drawn one by one, as they come).+ fx_defer: bool,+ /// Whether an effect image recorded since the ids were last reset was numbered differently+ /// (the context placing it must not be rasterized).+ fx_misnumbered: bool,+ /// Whether an effect image of this render was numbered differently: the render is drawn again,+ /// effects one by one, rather than left blank where it could not be rasterized.+ fx_redraw: bool,+ /// Added to the image ids predicted for effect images (tests: a registry numbering otherwise).+ #[cfg(test)]+ fx_id_skew: u32, /// Whether the last render ran the CMYK ink passes (only then is its view worth keeping). cmyk_ran: bool, #[cfg(test)]@@ -286,6 +310,16 @@ impl Renderer { stories: HashMap::new(), drawing_items: Vec::new(), cmyk_view: None,+ fx_cache: fx_cache::FxCache::default(),+ fx_jobs: Vec::new(),+ fx_keep: Vec::new(),+ fx_pixels: 0,+ fx_next_id: 0,+ fx_defer: true,+ fx_misnumbered: false,+ fx_redraw: false,+ #[cfg(test)]+ fx_id_skew: 0, cmyk_ran: false, #[cfg(test)] cmyk_passes: 0,@@ -303,6 +337,7 @@ impl Renderer { self.cmyk_view = None; self.glyphs.clear(); self.stories.clear();+ self.fx_cache.clear(); } /// Number of text frames whose glyph paths are cached.@@ -330,13 +365,23 @@ impl Renderer { self.stats.micros = now().saturating_sub(start); return image; }- // Release the old snapshot before rendering an edited document or resized viewport.- self.cmyk_view = None;+ // Release the old snapshot before rendering an edited document or resized viewport. Its+ // pixel buffer is kept for the new snapshot when there will be one of the same size (a+ // fresh one costs more to touch than to fill), at the cost of holding it while rendering.+ let again = opts.blend_space_view && doc.settings.blend_space == designcraft_doc::BlendSpace::Cmyk && opts.plate.is_none();+ let size = (width.clamp(1, MAX_SIDE) as usize).saturating_mul(height.clamp(1, MAX_SIDE) as usize).saturating_mul(4);+ let spare = self.cmyk_view.take().map(blendspace::CachedView::into_pixels).filter(|p| again && p.len() == size); self.cmyk_ran = false;- let image = self.render_uncached(doc, cache, spreads, width, height, view, opts);+ self.fx_redraw = false;+ let mut image = self.render_uncached(doc, cache, spreads, width, height, view, opts);+ if self.fx_redraw {+ self.cmyk_ran = false;+ self.fx_redraw = false;+ image = self.render_uncached(doc, cache, spreads, width, height, view, opts);+ } // Kept only when the ink passes ran: other views render at their usual cost anyway. if self.cmyk_ran && image.pixels.len() <= blendspace::CachedView::MAX_BYTES {- self.cmyk_view = Some(blendspace::CachedView::new(doc, spreads, view, opts, &image, self.stats, self.threads));+ self.cmyk_view = Some(blendspace::CachedView::new(doc, spreads, view, opts, &image, self.stats, self.threads, spare)); } self.stats.micros = now().saturating_sub(start); image@@ -363,11 +408,13 @@ impl Renderer { _ => RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: self.threads, ..Default::default() }), }; let mt = ctx.render_settings().num_threads > 0;+ self.fx_begin(); RICH_BLACK.with(|r| r.set(opts.rich_black)); PLATE.with(|p| p.set(opts.plate)); self.stats = FrameStats::default(); self.stories.clear(); self.glyphs.tick();+ self.fx_cache.next_frame(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]));@@ -390,9 +437,13 @@ impl Renderer { ctx.flush(); // Render straight into the returned buffer (no extra copy of the frame). let mut pixels = vec![0u8; w as usize * h as usize * 4];- if let Some(pm) = vello_cpu::PixmapMut::new(w, h, &mut pixels) {+ if self.raster_fx()+ && let Some(pm) = vello_cpu::PixmapMut::new(w, h, &mut pixels)+ { ctx.render(pm, &mut self.resources); }+ // The effect images are referenced by the context only, which is reset before it is used again.+ self.fx_reset_ids(); if opts.blend_space_view && doc.settings.blend_space == designcraft_doc::BlendSpace::Cmyk && opts.plate.is_none() { let mut img = Rendered { width: w as u32, height: h as u32, pixels }; for pl in spreads {@@ -929,83 +980,349 @@ pub(crate) fn cmyk_table() -> std::sync::Arc<Vec<[f32; 4]>> { } } -/// Trilinear lookup of `rgb` in a [`cmyk_table`].+/// Trilinear lookup of `rgb` in a [`cmyk_table`]: the 8 surrounding entries interpolated along+/// blue, then green and red. pub(crate) fn cmyk_in(table: &[[f32; 4]], rgb: [f32; 3]) -> [f32; 4] { const N: usize = CMYK_N; let f = rgb.map(|v| v.clamp(0.0, 1.0) * (N - 1) as f32); let i = f.map(|v| (v as usize).min(N - 2)); let d = [f[0] - i[0] as f32, f[1] - i[1] as f32, f[2] - i[2] as f32];- let mut out = [0.0f32; 4];- for (corner, w) in (0..8).map(|c| {- let (a, b, cc) = (c >> 2 & 1, c >> 1 & 1, c & 1);- let w = (if a == 1 { d[0] } else { 1.0 - d[0] }) * (if b == 1 { d[1] } else { 1.0 - d[1] }) * (if cc == 1 { d[2] } else { 1.0 - d[2] });- ((i[0] + a) * N * N + (i[1] + b) * N + i[2] + cc, w)- }) {- if let Some(t) = table.get(corner) {- for k in 0..4 {- out[k] += t[k] * w;- }- }- }- out+ let at = |r: usize, g: usize, b: usize| table.get(((i[0] + r) * N + i[1] + g) * N + i[2] + b).copied().unwrap_or([0.0; 4]);+ let lerp = |a: [f32; 4], b: [f32; 4], t: f32| std::array::from_fn::<f32, 4, _>(|k| a[k] + (b[k] - a[k]) * t);+ // Corners (r, g) in the order r·2 + g, along blue; then along green and red.+ let square: [[f32; 4]; 4] = std::array::from_fn(|n| lerp(at(n >> 1 & 1, n & 1, 0), at(n >> 1 & 1, n & 1, 1), d[2]));+ lerp(lerp(square[0], square[1], d[1]), lerp(square[2], square[3], d[1]), d[0])+}++/// Separated images kept between renders, least recently used let go first (bytes).+const PLATE_BUDGET: usize = 384 << 20;++struct PlateEntry {+ /// Kept alive: its address is the key (never read).+ #[allow(dead_code)]+ source: std::sync::Arc<vello_cpu::Pixmap>,+ plate: std::sync::Arc<vello_cpu::Pixmap>,+ bytes: usize,+ used: u64, } -/// An image's ink coverage on one process plate, as grey (cached per image and plate).+struct PlateCache {+ settings: designcraft_color::cms::ColorSettings,+ map: HashMap<(usize, u8), PlateEntry>,+ used: usize,+ tick: u64,+}++/// An image's ink coverage on one process plate, as grey; plate 4 is the C, M and Y plates as the+/// complements of red, green and blue (the CMYK blend passes); a plate past black is black. Kept+/// per image and plate. The blend passes draw plate 4, then 3 (black): plate 4 separates both. fn plate_pixmap(pm: &std::sync::Arc<vello_cpu::Pixmap>, plate: u8) -> std::sync::Arc<vello_cpu::Pixmap> {- // Keep source allocations alive (their addresses are keys), invalidate on colour settings,- // and cap derived raster memory rather than merely counting differently sized images.- type Entry = (std::sync::Arc<vello_cpu::Pixmap>, std::sync::Arc<vello_cpu::Pixmap>);- type Cache = (designcraft_color::cms::ColorSettings, std::collections::HashMap<(usize, u8), Entry>, usize);- const BUDGET: usize = 64 << 20;- static CACHE: std::sync::Mutex<Option<Cache>> = std::sync::Mutex::new(None);- let key = (std::sync::Arc::as_ptr(pm) as usize, plate);+ let plate = if plate == 4 { 4 } else { plate.min(3) };+ static CACHE: std::sync::Mutex<Option<PlateCache>> = std::sync::Mutex::new(None);+ let id = std::sync::Arc::as_ptr(pm) as usize; let settings = designcraft_color::cms::active_settings(); { let mut guard = CACHE.lock().unwrap_or_else(|e| e.into_inner());- if let Some((previous, map, _)) = guard.as_ref() {- if *previous == settings {- if let Some((_, derived)) = map.get(&key) {- return derived.clone();- }- } else {- *guard = None;+ if let Some(c) = guard.as_mut().filter(|c| c.settings == settings) {+ c.tick += 1;+ let tick = c.tick;+ if let Some(e) = c.map.get_mut(&(id, plate)) {+ e.used = tick;+ return e.plate.clone(); } } }+ let plates: &[u8] = if plate == 4 { &[4, 3] } else { std::slice::from_ref(&plate) };+ let separated = separate(pm, plates);+ let mut guard = CACHE.lock().unwrap_or_else(|e| e.into_inner());+ let c = guard.get_or_insert_with(|| PlateCache { settings: settings.clone(), map: HashMap::new(), used: 0, tick: 0 });+ if c.settings != settings {+ *c = PlateCache { settings, map: HashMap::new(), used: 0, tick: 0 };+ }+ let mut wanted = None;+ for (p, out) in plates.iter().zip(separated) {+ let out = std::sync::Arc::new(out);+ if *p == plate {+ wanted = Some(out.clone());+ }+ let bytes = (out.width() as usize).saturating_mul(out.height() as usize).saturating_mul(4);+ if bytes > PLATE_BUDGET {+ continue;+ }+ // Let the least recently used go until it fits.+ while c.used.saturating_add(bytes) > PLATE_BUDGET {+ let Some(oldest) = c.map.iter().min_by_key(|(_, e)| e.used).map(|(k, _)| *k) else { break };+ if let Some(e) = c.map.remove(&oldest) {+ c.used = c.used.saturating_sub(e.bytes);+ }+ }+ c.tick += 1;+ let entry = PlateEntry { source: pm.clone(), plate: out, bytes, used: c.tick };+ if let Some(old) = c.map.insert((id, *p), entry) {+ c.used = c.used.saturating_sub(old.bytes);+ }+ c.used = c.used.saturating_add(bytes);+ }+ wanted.unwrap_or_else(|| pm.clone())+}++/// `pm` separated into `plates` (see [`plate_pixmap`]), each pixel's inks found once for all of+/// them, the rows shared out among the cores.+fn separate(pm: &vello_cpu::Pixmap, plates: &[u8]) -> Vec<vello_cpu::Pixmap> {+ use vello_cpu::color::PremulRgba8; let table = cmyk_table();- let data: Vec<vello_cpu::color::PremulRgba8> = pm- .data()- .iter()- .map(|p| {+ let src = pm.data();+ let clear = PremulRgba8 { r: 0, g: 0, b: 0, a: 0 };+ let mut outs: Vec<Vec<PremulRgba8>> = plates.iter().map(|_| vec![clear; src.len()]).collect();+ fn work(table: &[[f32; 4]], plates: &[u8], src: &[PremulRgba8], outs: &mut [&mut [PremulRgba8]]) {+ // Neighbouring pixels often share a colour: the last inks found, by the pixel's bytes.+ let mut memo = vec![(0u32, [0u8; 4]); 1 << 14];+ for (i, p) in src.iter().enumerate() { if p.a == 0 {- return *p;+ for o in outs.iter_mut() {+ if let Some(x) = o.get_mut(i) {+ *x = *p;+ }+ }+ continue; }- let k = 255.0 / p.a as f32;- let rgb = [p.r, p.g, p.b].map(|v| (v as f32 * k / 255.0).min(1.0));- let cmyk = cmyk_in(&table, rgb);- let ink = |i: usize| ((1.0 - cmyk[i].clamp(0.0, 1.0)) * p.a as f32).round() as u8;- if plate == 4 {- return vello_cpu::color::PremulRgba8 { r: ink(0), g: ink(1), b: ink(2), a: p.a };+ let key = u32::from(p.r) | u32::from(p.g) << 8 | u32::from(p.b) << 16 | u32::from(p.a) << 24;+ let slot = (key.wrapping_mul(0x9E37_79B1) >> 18) as usize;+ let inks = match memo.get(slot) {+ Some((seen, inks)) if *seen == key => *inks,+ _ => {+ let k = 255.0 / p.a as f32;+ let rgb = [p.r, p.g, p.b].map(|v| (v as f32 * k / 255.0).min(1.0));+ let cmyk = cmyk_in(table, rgb);+ let inks = cmyk.map(|v| ((1.0 - v.clamp(0.0, 1.0)) * p.a as f32).round() as u8);+ if let Some(m) = memo.get_mut(slot) {+ *m = (key, inks);+ }+ inks+ }+ };+ let ink = |i: usize| inks.get(i).copied().unwrap_or(p.a);+ for (o, plate) in outs.iter_mut().zip(plates) {+ let px = if *plate == 4 {+ PremulRgba8 { r: ink(0), g: ink(1), b: ink(2), a: p.a }+ } else {+ let v = ink((*plate).min(3) as usize);+ PremulRgba8 { r: v, g: v, b: v, a: p.a }+ };+ if let Some(x) = o.get_mut(i) {+ *x = px;+ } }- let v = ink(plate.min(3) as usize);- vello_cpu::color::PremulRgba8 { r: v, g: v, b: v, a: p.a }- })- .collect();- let out = std::sync::Arc::new(vello_cpu::Pixmap::from_parts(data, pm.width(), pm.height()));- let bytes = (pm.width() as usize).saturating_mul(pm.height() as usize).saturating_mul(8);- if bytes <= BUDGET {- let mut guard = CACHE.lock().unwrap_or_else(|e| e.into_inner());- let (previous, map, used) = guard.get_or_insert_with(|| (settings.clone(), Default::default(), 0));- if *previous != settings || used.saturating_add(bytes) > BUDGET {- map.clear();- *used = 0;- *previous = settings; }- if map.insert(key, (pm.clone(), out.clone())).is_none() {- *used += bytes;+ }+ let chunk = src.len().div_ceil(worker_threads()).max(1 << 15);+ let mut iters: Vec<_> = outs.iter_mut().map(|o| o.chunks_mut(chunk)).collect();+ let jobs: Vec<(&[PremulRgba8], Vec<&mut [PremulRgba8]>)> =+ src.chunks(chunk).map(|s| (s, iters.iter_mut().filter_map(|it| it.next()).collect())).collect();+ let table = &table[..];+ share_out(jobs, |(s, mut o)| work(table, plates, s, &mut o));+ outs.into_iter().map(|data| vello_cpu::Pixmap::from_parts(data, pm.width(), pm.height())).collect()+}++/// `work` over `dst` and `src` (the same length) in pieces shared out among the cores (one on+/// the web); the calling thread works too, so every piece is done whatever threads start.+pub(crate) fn in_parallel<T: Send, U: Sync>(dst: &mut [T], src: &[U], work: impl Fn(&mut [T], &[U]) + Sync) {+ let chunk = dst.len().div_ceil(worker_threads()).max(1 << 15);+ share_out(dst.chunks_mut(chunk).zip(src.chunks(chunk)).collect(), |(d, s)| work(d, s));+}++/// Threads to share work out among: the cores, up to 8 (one on the web).+fn worker_threads() -> usize {+ #[cfg(not(target_arch = "wasm32"))]+ let threads = std::thread::available_parallelism().map_or(1, |n| n.get()).clamp(1, 8);+ #[cfg(target_arch = "wasm32")]+ let threads = 1usize;+ threads+}++/// `work` on each of `jobs`, last first, taken in turn by the calling thread and helpers (up to+/// [`worker_threads`] in all): the calling thread works too, so every job is done whatever+/// threads start.+fn share_out<J: Send>(jobs: Vec<J>, work: impl Fn(J) + Sync) {+ let helpers = worker_threads().min(jobs.len()).saturating_sub(1);+ if helpers == 0 {+ for job in jobs.into_iter().rev() {+ work(job); }+ return; }- out+ let queue = std::sync::Mutex::new(jobs);+ let run = || {+ loop {+ let job = queue.lock().unwrap_or_else(std::sync::PoisonError::into_inner).pop();+ let Some(job) = job else { break };+ work(job);+ }+ };+ std::thread::scope(|scope| {+ for _ in 0..helpers {+ if let Err(e) = std::thread::Builder::new().spawn_scoped(scope, run) {+ log::warn!("working on fewer threads: {e}");+ break;+ }+ }+ run();+ });+}++/// A band of an effect image recorded for the multithreaded context: its single-threaded+/// context and the image id the multithreaded context places it by.+struct FxJob {+ ctx: RenderContext,+ id: vello_common::paint::ImageId,+ /// A shadow's blur (σ, pixels), put on its coverage once it is drawn ([`fx::shadow_coverage`]).+ post: Option<f32>,+}++/// An effect image to keep once its bands are drawn: what it was drawn from, its width and height,+/// and each band's image id, first row and own rows.+struct FxKeep {+ prepared: fx_cache::Prepared,+ width: u16,+ height: u32,+ bands: Vec<(vello_common::paint::ImageId, u32, (u32, u32))>,+}++/// Pixels of recorded effect images drawn at once at most: more are drawn before recording goes on.+const FX_BATCH_PIXELS: u64 = 48 << 20;++impl Renderer {+ /// A render begins: the image registry is cleared, so it numbers the effect images recorded+ /// from now on from 0, as [`Self::defer_fx`] expects.+ fn fx_begin(&mut self) {+ self.fx_jobs.clear();+ self.fx_keep.clear();+ self.fx_pixels = 0;+ self.fx_reset_ids();+ }++ /// The image registry is cleared: it numbers the effect images recorded from now on from 0.+ fn fx_reset_ids(&mut self) {+ self.resources.clear_images();+ self.fx_next_id = 0;+ self.fx_misnumbered = false;+ }++ /// Whether effect images drawn into a multithreaded context are recorded for+ /// [`Self::raster_fx`] (rather than drawn as they come).+ pub(crate) fn fx_deferred(&self) -> bool {+ self.fx_defer+ }++ /// Record `ctx` (a flushed single-threaded context holding a band of an effect image) to be+ /// drawn with the others in [`Self::raster_fx`]; the image id to place it by.+ pub(crate) fn defer_fx(&mut self, ctx: RenderContext, post: Option<f32>) -> vello_common::paint::ImageId {+ #[cfg(not(test))]+ let id = vello_common::paint::ImageId::new(self.fx_next_id);+ #[cfg(test)]+ let id = vello_common::paint::ImageId::new(self.fx_next_id.saturating_add(self.fx_id_skew));+ self.fx_next_id = self.fx_next_id.saturating_add(1);+ self.fx_pixels = self.fx_pixels.saturating_add(u64::from(ctx.width()) * u64::from(ctx.height()));+ self.fx_jobs.push(FxJob { ctx, id, post });+ id+ }++ /// Keep the effect image whose `bands` (image id, first row, own rows) were recorded with+ /// [`Self::defer_fx`], once they are drawn.+ pub(crate) fn keep_fx(+ &mut self,+ prepared: fx_cache::Prepared,+ width: u16,+ height: u32,+ bands: Vec<(vello_common::paint::ImageId, u32, (u32, u32))>,+ ) {+ self.fx_keep.push(FxKeep { prepared, width, height, bands });+ }++ /// Draw the recorded effect images now if they hold many pixels (between images: the bands of+ /// one are drawn together).+ pub(crate) fn fx_flush_if_full(&mut self) {+ if self.fx_pixels > FX_BATCH_PIXELS {+ self.raster_fx();+ }+ }++ /// Draw the recorded effect images, shared out among the cores, and register them under the+ /// ids they were placed by. `false` when the registry numbered one differently: the context+ /// that places them must not be rasterized then (an image it names would be missing), and+ /// effect images are drawn as they come from now on.+ pub(crate) fn raster_fx(&mut self) -> bool {+ let jobs = std::mem::take(&mut self.fx_jobs);+ self.fx_pixels = 0;+ if jobs.is_empty() {+ return !self.fx_misnumbered;+ }+ // Each context goes to the one thread that draws it.+ let (ctxs, ids): (Vec<_>, Vec<_>) = jobs.into_iter().map(|j| (std::sync::Mutex::new(Some((j.ctx, j.post))), j.id)).unzip();+ type Job = std::sync::Mutex<Option<(RenderContext, Option<f32>)>>;+ let size = |c: &Job| {+ c.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref().map_or(0, |(c, _)| u64::from(c.width()) * u64::from(c.height()))+ };+ // Largest first, so the last ones to finish are small.+ let mut order: Vec<usize> = (0..ctxs.len()).collect();+ order.sort_by_cached_key(|i| std::cmp::Reverse(ctxs.get(*i).map_or(0, size)));+ let images = run_parallel(&order, |i| {+ let (ctx, post) = ctxs.get(*i)?.lock().unwrap_or_else(std::sync::PoisonError::into_inner).take()?;+ let mut pm = vello_cpu::Pixmap::new(ctx.width(), ctx.height());+ // Each image is drawn on its own: its paints carry their pixels, nothing is registered.+ ctx.render(&mut pm, &mut Resources::new());+ if let Some(sigma) = post {+ fx::shadow_coverage(&mut pm, sigma);+ }+ Some(Arc::new(pm))+ });+ let mut drawn: Vec<Option<Arc<vello_cpu::Pixmap>>> = vec![None; ids.len()];+ for (i, image) in order.into_iter().zip(images) {+ if let Some(slot) = drawn.get_mut(i) {+ *slot = image;+ }+ }+ let mut ok = true;+ let mut images = HashMap::new();+ for (want, image) in ids.into_iter().zip(drawn) {+ let image = image.unwrap_or_else(|| Arc::new(vello_cpu::Pixmap::new(1, 1)));+ if self.resources.register_image(image.clone()) != want {+ ok = false;+ }+ images.insert(want, image);+ }+ for k in std::mem::take(&mut self.fx_keep) {+ let bands: Option<Vec<fx_cache::Band>> = k+ .bands+ .iter()+ .map(|(id, top, core)| images.get(id).map(|image| fx_cache::Band { image: image.clone(), top: *top, core: *core }))+ .collect();+ if let Some(bands) = bands {+ self.fx_cache.put(k.prepared, fx_cache::FxImage { width: k.width, height: k.height, bands });+ }+ }+ if !ok {+ log::error!("effect images were numbered differently than placed; drawing them one by one from now on");+ self.fx_defer = false;+ self.fx_misnumbered = true;+ self.fx_redraw = true;+ }+ !self.fx_misnumbered+ }+}++/// `work` on each of `items`, shared out among the cores (one on the web); the results in the+/// order of `items`. The calling thread works too, so every item is done whatever threads start.+pub(crate) fn run_parallel<T: Sync, R: Send>(items: &[T], work: impl Fn(&T) -> R + Sync) -> Vec<R> {+ let results = std::sync::Mutex::new(Vec::with_capacity(items.len()));+ // Taken last first: the first item is taken first.+ share_out(items.iter().enumerate().rev().collect(), |(i, item)| {+ let r = work(item);+ results.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push((i, r));+ });+ let mut results = results.into_inner().unwrap_or_else(std::sync::PoisonError::into_inner);+ results.sort_by_key(|(i, _)| *i);+ results.into_iter().map(|(_, r)| r).collect() } fn rect_overlaps(a: Rect, b: Rect) -> bool {@@ -1560,6 +1877,136 @@ pub fn image_mime(bytes: &[u8]) -> &'static str { #[cfg(test)] mod tests {++ /// The lookup interpolated along one channel after another is the 8-corner weighted sum.+ #[test]+ fn cmyk_lookup_is_the_weighted_sum_of_its_8_corners() {+ const N: usize = CMYK_N;+ let table = cmyk_table();+ let reference = |rgb: [f32; 3]| {+ let f = rgb.map(|v| v.clamp(0.0, 1.0) * (N - 1) as f32);+ let i = f.map(|v| (v as usize).min(N - 2));+ let d = [f[0] - i[0] as f32, f[1] - i[1] as f32, f[2] - i[2] as f32];+ let mut out = [0.0f32; 4];+ for c in 0..8usize {+ let (a, b, cc) = (c >> 2 & 1, c >> 1 & 1, c & 1);+ let w =+ (if a == 1 { d[0] } else { 1.0 - d[0] }) * (if b == 1 { d[1] } else { 1.0 - d[1] }) * (if cc == 1 { d[2] } else { 1.0 - d[2] });+ let t = table[(i[0] + a) * N * N + (i[1] + b) * N + i[2] + cc];+ for k in 0..4 {+ out[k] += t[k] * w;+ }+ }+ out+ };+ let mut seed = 0xc0ffee_u64;+ let mut next = || {+ seed ^= seed << 13;+ seed ^= seed >> 7;+ seed ^= seed << 17;+ (seed % 10_001) as f32 / 10_000.0+ };+ for _ in 0..20_000 {+ let rgb = [next(), next(), next()];+ let (a, b) = (cmyk_in(&table, rgb), reference(rgb));+ assert!(a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-4), "{rgb:?}: {a:?} vs {b:?}");+ }+ }++ /// Effect images recorded for drawing together are placed by the ids the image registry gives+ /// them when they are registered afterwards: it numbers images in order from 0 once cleared.+ #[test]+ fn the_image_registry_numbers_images_in_order_from_0_once_cleared() {+ let mut res = Resources::new();+ let image = || std::sync::Arc::new(vello_cpu::Pixmap::new(1, 1));+ let _ = res.register_image(image());+ res.clear_images();+ let ids: Vec<u32> = (0..3).map(|_| res.register_image(image()).as_u32()).collect();+ assert_eq!(ids, [0, 1, 2]);+ }++ /// Once effect images are drawn one by one (after the registry numbered one differently),+ /// renders still draw: before, every later render came back blank.+ #[test]+ fn renders_draw_after_falling_back_to_effects_one_by_one() {+ use designcraft_doc::build::NewDocument;+ use designcraft_doc::{Document, Fill, Item, ItemId, Shape, SpreadRef};+ use designcraft_geom::{Rect, shapes};+ let mut d = Document::new(&NewDocument { width: 160.0, height: 160.0, facing_pages: false, ..Default::default() });+ let id = ItemId(d.alloc());+ let lid = d.default_layer();+ let mut it = Item::new(id, lid, Shape::Rectangle, shapes::rectangle(Rect::new(30.0, 30.0, 100.0, 100.0)));+ it.fill = Fill::swatch("C=100 M=0 Y=0 K=0");+ it.effects.drop_shadow.on = true;+ it.effects.drop_shadow.distance = 12.0;+ it.effects.drop_shadow.size = 10.0;+ d.insert_item(SpreadRef::Doc(0), it, None).unwrap();+ let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };+ let spreads = [Placed { spread: SpreadRef::Doc(0), xf: Affine::IDENTITY }];+ let draw = |r: &mut Renderer| r.render(&d, &designcraft_compose::Cache::new(), &spreads, 160, 160, Affine::IDENTITY, &opts);+ let mut together = Renderer::new();+ together.threads = 3;+ let mut fallen_back = Renderer::new();+ fallen_back.threads = 3;+ fallen_back.fx_defer = false;+ for _ in 0..2 {+ let (a, b) = (draw(&mut together), draw(&mut fallen_back));+ assert!(b.pixels.chunks(4).any(|p| p[0] < 128), "the fallen-back render is blank");+ assert!(a.pixels.iter().zip(&b.pixels).all(|(x, y)| x.abs_diff(*y) <= 2));+ }+ // The render in which the registry numbers an effect image differently is drawn again,+ // effects one by one: not blank, also in the CMYK blend space view, whose snapshot is+ // then kept for the next render.+ for cmyk in [false, true] {+ d.settings.blend_space = if cmyk { designcraft_doc::BlendSpace::Cmyk } else { designcraft_doc::BlendSpace::Rgb };+ let opts = RenderOptions { blend_space_view: cmyk, ..opts.clone() };+ let draw = |r: &mut Renderer| r.render(&d, &designcraft_compose::Cache::new(), &spreads, 160, 160, Affine::IDENTITY, &opts);+ let expected = draw(&mut together);+ let mut misnumbering = Renderer::new();+ misnumbering.threads = 3;+ misnumbering.fx_id_skew = 7;+ for n in 0..2 {+ let got = draw(&mut misnumbering);+ assert!(!misnumbering.fx_defer, "effects fell back to one by one");+ let w = expected.pixels.iter().zip(&got.pixels).map(|(x, y)| x.abs_diff(*y)).max().unwrap_or(0);+ assert!(w <= 2, "cmyk {cmyk}, render {n}: max channel difference {w}");+ }+ }+ }++ /// The plates of an image large enough to be separated on several threads are, pixel for+ /// pixel, those of the inks found one pixel at a time; both blend-pass plates come at once.+ #[test]+ fn images_separate_on_several_threads_as_pixel_by_pixel() {+ use vello_cpu::color::PremulRgba8;+ let (w, h) = (400u16, 300u16);+ let data: Vec<PremulRgba8> = (0..u32::from(w) * u32::from(h))+ .map(|i| {+ let a = (i % 7 * 40).min(255) as u8;+ let c = |k: u32| ((i * k % 256) as u16 * u16::from(a) / 255) as u8;+ PremulRgba8 { r: c(3), g: c(5), b: c(11), a }+ })+ .collect();+ let pm = std::sync::Arc::new(vello_cpu::Pixmap::from_parts(data.clone(), w, h));+ let table = cmyk_table();+ let (cmy, black) = (plate_pixmap(&pm, 4), plate_pixmap(&pm, 3));+ for (i, p) in data.iter().enumerate() {+ let (c, k) = (cmy.data()[i], black.data()[i]);+ if p.a == 0 {+ assert_eq!((c, k), (*p, *p));+ continue;+ }+ let rgb = [p.r, p.g, p.b].map(|v| (v as f32 * (255.0 / p.a as f32) / 255.0).min(1.0));+ let inks = cmyk_in(&table, rgb);+ let ink = |n: usize| ((1.0 - inks[n].clamp(0.0, 1.0)) * p.a as f32).round() as u8;+ assert_eq!((c.r, c.g, c.b, c.a), (ink(0), ink(1), ink(2), p.a), "pixel {i}");+ assert_eq!((k.r, k.g, k.b, k.a), (ink(3), ink(3), ink(3), p.a), "pixel {i}");+ }+ // Kept: asked again, the same images.+ assert!(std::sync::Arc::ptr_eq(&cmy, &plate_pixmap(&pm, 4)) && std::sync::Arc::ptr_eq(&black, &plate_pixmap(&pm, 3)));+ // A plate past black is black.+ assert!(std::sync::Arc::ptr_eq(&black, &plate_pixmap(&pm, 9)));+ } use super::*; use designcraft_doc::build::NewDocument; use designcraft_doc::{Fill, ParaFormat};@@ -242,6 +242,42 @@ fn line_box(l: &Line) -> Option<Rect> { } impl Renderer {+ /// Spread-space bounds of everything a text frame without fill, stroke or form field draws:+ /// its lines (glyphs, their strokes, underlines and the marks drawn along them) and paragraph+ /// shading and rules. `None` when it may draw more (tables, footnotes, anchored objects, text+ /// on a path) or nothing is composed in it. A shadow of its text need not cover the frame.+ pub(crate) fn text_ink(&mut self, f: &Frame, it: &designcraft_doc::Item, xf: Affine, page: ShownOn) -> Option<Rect> {+ let designcraft_doc::Content::Text(tf) = &it.content else { return None };+ if tf.options.path.is_some() || !it.fill.is_none() || !it.stroke.is_none() || it.form_field.is_some() || it.has_nested_items() {+ return None;+ }+ let cs = self.story(f, tf.story, page);+ let ft = cs.frame(it.id)?;+ if !ft.tables.is_empty() || !ft.notes.is_empty() || !ft.objects.is_empty() {+ return None;+ }+ let fg = self.glyphs.get(&cs, ft);+ let mut ink: Option<Rect> = None;+ let mut add = |r: Rect| ink = Some(ink.map_or(r, |i| i.union(r)));+ for (i, l) in ft.lines.iter().enumerate() {+ if let Some(b) = line_box(l) {+ add(b);+ }+ if let Some(b) = fg.lines.get(i).and_then(|g| g.bounds) {+ add(b);+ }+ }+ for d in &ft.decos {+ add(d.rect);+ if let Some(p) = &d.path {+ add(p.bounding_box());+ }+ }+ let ink = ink?;+ // A point of slack: antialiasing and rounding at the edges.+ Some((xf * f.doc.text_local(it)).transform_rect_bbox(ink.inflate(1.0, 1.0)))+ }+ pub(crate) fn draw_text(&mut self, ctx: &mut RenderContext, f: &Frame, cs: &Arc<ComposedStory>, ft: &FrameText, xf: Affine) { let doc = f.doc; let m = f.view * xf;@@ -374,3 +374,265 @@ fn feather_choke_and_corners() { let rounded = render(&d); assert!(lum(sharp.pixel(41, 41)) + 20 < lum(rounded.pixel(41, 41)), "{:?} vs {:?}", sharp.pixel(41, 41), rounded.pixel(41, 41)); }++/// A shadowed square and a shadowed text frame, for the kept-shadow tests.+fn shadowed_text_doc() -> (Document, ItemId, designcraft_doc::StoryId) {+ let mut d = shadow_doc(20.0);+ let lid = d.default_layer();+ let (tid, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(20.0, 105.0, 150.0, 150.0), lid, "Shadow", ParaFormat::default()).unwrap();+ let ds = &mut d.item_mut(tid).unwrap().effects.drop_shadow;+ ds.on = true;+ ds.distance = 3.0;+ ds.size = 2.0;+ ds.opacity = 0.9;+ (d, tid, sid)+}++/// One render of `d` through `view` with the composition `cache`, which an app keeps between+/// renders as it keeps its renderer.+fn render_view(r: &mut Renderer, cache: &Cache, d: &Document, view: Affine) -> Rendered {+ let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };+ r.render(d, cache, &[Placed { spread: SpreadRef::Doc(0), xf: Affine::IDENTITY }], SIZE, SIZE, view, &opts)+}++fn fresh(d: &Document, view: Affine) -> Rendered {+ let mut r = Renderer::new();+ r.threads = 3;+ render_view(&mut r, &Cache::new(), d, view)+}++fn worst(a: &Rendered, b: &Rendered) -> u8 {+ a.pixels.iter().zip(&b.pixels).map(|(x, y)| x.abs_diff(*y)).max().unwrap()+}++fn mean(a: &Rendered, b: &Rendered) -> f64 {+ a.pixels.iter().zip(&b.pixels).map(|(x, y)| f64::from(x.abs_diff(*y))).sum::<f64>() / a.pixels.len() as f64+}++/// Multithreaded renders keep drop shadows and glows between frames: placed again, they look as+/// drawn afresh, also after panning, and an edit draws them anew.+#[test]+fn kept_shadows_look_as_drawn_afresh() {+ let (mut d, tid, sid) = shadowed_text_doc();+ let (mut r, cache) = (Renderer::new(), Cache::new());+ r.threads = 3;+ let first = render_view(&mut r, &cache, &d, Affine::IDENTITY);+ assert_eq!(render_view(&mut r, &cache, &d, Affine::IDENTITY).pixels, first.pixels, "placed again unchanged");+ // Panned by whole pixels: as drawn afresh.+ let view = Affine::translate((13.0, -7.0));+ let w = worst(&render_view(&mut r, &cache, &d, view), &fresh(&d, view));+ assert!(w <= 4, "panned by whole pixels: max channel difference {w}");+ // By fractions of a pixel the kept image is resampled: a few levels at shadow edges.+ let view = Affine::translate((-4.5, 6.25));+ let kept = render_view(&mut r, &cache, &d, view);+ let (w, m) = (worst(&kept, &fresh(&d, view)), mean(&kept, &fresh(&d, view)));+ assert!(w <= 24 && m < 1.0, "panned by fractions: max channel difference {w}, mean {m:.3}");+ // Zoomed: drawn anew.+ let zoom = Affine::scale(1.5);+ assert!(worst(&render_view(&mut r, &cache, &d, zoom), &fresh(&d, zoom)) <= 4);+ // Zoomed on (kept without a margin while zooming), then panned and zoomed back.+ for view in [Affine::scale(1.6), Affine::translate((13.0, -7.0)) * Affine::scale(1.6), Affine::IDENTITY] {+ assert!(worst(&render_view(&mut r, &cache, &d, view), &fresh(&d, view)) <= 4, "{view:?}");+ }+ // Edits: an object without text (only the object itself tells), the shadow, the text and the+ // frame, each after a render that kept what it drew.+ render_view(&mut r, &cache, &d, Affine::IDENTITY);+ let square = d.spreads[0].items.iter().find(|i| i.id != tid && i.effects.drop_shadow.on).unwrap().id;+ // (Its opacity: the same extent, so nothing but the object itself has changed.)+ d.item_mut(square).unwrap().effects.drop_shadow.opacity = 0.3;+ assert!(worst(&render_view(&mut r, &cache, &d, Affine::IDENTITY), &fresh(&d, Affine::IDENTITY)) <= 4, "square's shadow edited");+ render_view(&mut r, &cache, &d, Affine::IDENTITY);+ d.item_mut(tid).unwrap().effects.drop_shadow.opacity = 0.3;+ assert!(worst(&render_view(&mut r, &cache, &d, Affine::IDENTITY), &fresh(&d, Affine::IDENTITY)) <= 4, "shadow edited");+ render_view(&mut r, &cache, &d, Affine::IDENTITY);+ let story = d.story_mut(sid).unwrap();+ let fmt = story.char_format_at(0).clone();+ story.insert_with(0, "Long ", fmt);+ assert!(worst(&render_view(&mut r, &cache, &d, Affine::IDENTITY), &fresh(&d, Affine::IDENTITY)) <= 4, "text edited");+ render_view(&mut r, &cache, &d, Affine::IDENTITY);+ d.item_mut(tid).unwrap().xf = Affine::translate((0.0, -40.0));+ assert!(worst(&render_view(&mut r, &cache, &d, Affine::IDENTITY), &fresh(&d, Affine::IDENTITY)) <= 4, "frame moved");+ // A swatch the shadows use (their colour, [Black]), changed in the document.+ render_view(&mut r, &cache, &d, Affine::IDENTITY);+ let black = d.swatches.iter_mut().find(|s| s.name == "[Black]").unwrap();+ if let designcraft_color::SwatchValue::Color { color, .. } = &mut black.value {+ *color = designcraft_color::Color::rgb(1.0, 0.0, 0.0);+ }+ assert!(worst(&render_view(&mut r, &cache, &d, Affine::IDENTITY), &fresh(&d, Affine::IDENTITY)) <= 4, "swatch edited");+}++/// A kept shadow of a stroke in a stroke style of the document is drawn anew when the style changes.+#[test]+fn kept_shadows_follow_an_edited_stroke_style() {+ use designcraft_doc::{Stroke, StrokeStyleDef, StrokeType};+ let mut d = doc();+ let id = rect_item(&mut d, Rect::new(30.0, 30.0, 110.0, 110.0), "C=100 M=0 Y=0 K=0");+ let style = |pattern: Vec<f64>| StrokeStyleDef { name: "Dashes".into(), kind: StrokeType::Dashed { pattern } };+ d.stroke_styles.push(style(vec![12.0, 12.0]));+ let it = d.item_mut(id).unwrap();+ it.fill = Fill::none();+ it.stroke = Stroke { weight: 10.0, kind: StrokeType::Style { name: "Dashes".into() }, ..Default::default() };+ let ds = &mut it.effects.drop_shadow;+ ds.on = true;+ ds.distance = 8.0;+ ds.size = 2.0;+ ds.opacity = 1.0;+ let (mut r, cache) = (Renderer::new(), Cache::new());+ r.threads = 3;+ render_view(&mut r, &cache, &d, Affine::IDENTITY);+ render_view(&mut r, &cache, &d, Affine::IDENTITY);+ d.stroke_styles[0] = style(vec![3.0, 20.0]);+ let w = worst(&render_view(&mut r, &cache, &d, Affine::IDENTITY), &fresh(&d, Affine::IDENTITY));+ assert!(w <= 4, "style edited: max channel difference {w}");+}++/// A shadow too large to keep whole at a high zoom keeps what is on screen and a margin; panning+/// past that draws it anew.+#[test]+fn large_kept_shadows_are_redrawn_where_panning_leaves_them() {+ let d = shadow_doc(0.0);+ let (mut r, cache) = (Renderer::new(), Cache::new());+ r.threads = 3;+ // 40×: the square's shadow is thousands of pixels wide. The view starts on the square's+ // right edge (x 100 pt at 80 px) and pans across the shadow's soft edge, by less and by more+ // than what is kept around the screen.+ for x in [0.0, -100.0, -300.0, -600.0, -150.0, 200.0] {+ let view = Affine::translate((80.0 - 4000.0 + x, -2600.0)) * Affine::scale(40.0);+ let w = worst(&render_view(&mut r, &cache, &d, view), &fresh(&d, view));+ assert!(w <= 12, "at {x}: max channel difference {w}");+ }+}++/// Every kind of soft effect, on several objects, drawn on a multithreaded context: the effect+/// images are drawn together on the cores and kept. Drawn so, placed again, panned and after an+/// edit, they look as drawn on a single thread.+#[test]+fn all_effects_drawn_together_and_kept_look_as_drawn_one_by_one() {+ let mut d = doc();+ let mut ids = vec![];+ for (k, r) in [+ Rect::new(20.0, 20.0, 70.0, 70.0),+ Rect::new(90.0, 20.0, 140.0, 70.0),+ Rect::new(20.0, 90.0, 70.0, 140.0),+ Rect::new(90.0, 90.0, 140.0, 140.0),+ ]+ .into_iter()+ .enumerate()+ {+ let id = rect_item(&mut d, r, if k % 2 == 0 { "C=100 M=0 Y=0 K=0" } else { "C=0 M=100 Y=0 K=0" });+ let e = &mut d.item_mut(id).unwrap().effects;+ match k {+ 0 => {+ e.drop_shadow.on = true;+ e.inner_shadow.on = true;+ }+ 1 => {+ e.bevel.on = true;+ e.bevel.size = 6.0;+ e.outer_glow.on = true;+ }+ 2 => e.feather = 8.0,+ _ => {+ e.inner_glow.on = true;+ e.satin.on = true;+ }+ }+ ids.push(id);+ }+ let one_by_one = |d: &Document, view: Affine| {+ let mut r = Renderer::new();+ r.threads = 0;+ render_view(&mut r, &Cache::new(), d, view)+ };+ let (mut r, cache) = (Renderer::new(), Cache::new());+ r.threads = 3;+ let first = render_view(&mut r, &cache, &d, Affine::IDENTITY);+ let w = worst(&first, &one_by_one(&d, Affine::IDENTITY));+ assert!(w <= 4, "drawn together: max channel difference {w}");+ assert_eq!(render_view(&mut r, &cache, &d, Affine::IDENTITY).pixels, first.pixels, "placed again unchanged");+ let view = Affine::translate((11.0, -6.0));+ let w = worst(&render_view(&mut r, &cache, &d, view), &one_by_one(&d, view));+ assert!(w <= 4, "panned: max channel difference {w}");+ let zoom = Affine::scale(1.5);+ let w = worst(&render_view(&mut r, &cache, &d, zoom), &one_by_one(&d, zoom));+ assert!(w <= 4, "zoomed: max channel difference {w}");+ // Edited, each effect as far as before (only the objects themselves tell): the bevel's+ // highlight and the inner glow fainter.+ render_view(&mut r, &cache, &d, Affine::IDENTITY);+ d.item_mut(ids[1]).unwrap().effects.bevel.highlight_opacity = 0.2;+ d.item_mut(ids[3]).unwrap().effects.inner_glow.opacity = 0.2;+ let w = worst(&render_view(&mut r, &cache, &d, Affine::IDENTITY), &one_by_one(&d, Affine::IDENTITY));+ assert!(w <= 4, "edited: max channel difference {w}");+}++/// Zoomed in, a wide blur is drawn at a lower resolution and scaled up: it looks as drawn at full+/// resolution on a single thread, within a few levels.+#[test]+fn wide_blurs_drawn_coarser_look_as_drawn_at_full_resolution() {+ let mut d = shadow_doc(0.0);+ let id = rect_item(&mut d, Rect::new(110.0, 30.0, 150.0, 70.0), "C=0 M=100 Y=0 K=0");+ {+ let e = &mut d.item_mut(id).unwrap().effects;+ e.bevel.on = true;+ e.bevel.size = 6.0;+ e.inner_shadow.on = true;+ e.outer_glow.on = true;+ }+ // 6×: the shadow's σ is 30 pixels, drawn at a tenth of the resolution.+ let view = Affine::translate((-560.0, -100.0)) * Affine::scale(6.0);+ let mut r = Renderer::new();+ r.threads = 3;+ let coarse = render_view(&mut r, &Cache::new(), &d, view);+ let mut one = Renderer::new();+ one.threads = 0;+ let full = render_view(&mut one, &Cache::new(), &d, view);+ let (w, m) = (worst(&coarse, &full), mean(&coarse, &full));+ // (Shadows are coloured as they are placed, rounded a level apart from vello's filter.)+ assert!(w <= 10 && m < 1.0, "max channel difference {w}, mean {m:.3}");+}++/// A large effect image is drawn in bands of rows on several cores, each reaching past its rows+/// by the blur's spread: no seams, as drawn whole on a single thread; placed again from what was+/// kept, the same, and panned by a fraction of a pixel. Also with a shadow cast further than a band reaches past its rows: the text+/// above or below a band casting into it is drawn there.+#[test]+fn large_effect_images_drawn_in_bands_look_as_drawn_whole() {+ for (distance, at) in [(1.0, (-100.0, -100.0)), (6.0, (-100.0, -100.0)), (1.0, (-100.3, -100.45))] {+ banded_like_whole(distance, at);+ }+}++/// `at`: where the spread's origin is on the device (at a fraction of a pixel, band edges fall+/// inside rows of pixels).+fn banded_like_whole(distance: f64, at: (f64, f64)) {+ let mut d = doc();+ let lid = d.default_layer();+ let text = "Shadowed text fills the frame. ".repeat(40);+ let (tid, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(10.0, 10.0, 150.0, 150.0), lid, &text, ParaFormat::default()).unwrap();+ let ds = &mut d.item_mut(tid).unwrap().effects.drop_shadow;+ ds.on = true;+ ds.distance = distance;+ ds.size = 0.6;+ ds.opacity = 1.0;+ // 1,400 × 1,400 pixels at 10×: the text's shadow is drawn in several bands, their edges+ // across lines of text.+ let (w, h) = (1400, 1400);+ let view = Affine::translate(at) * Affine::scale(10.0);+ let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };+ let placed = [Placed { spread: SpreadRef::Doc(0), xf: Affine::IDENTITY }];+ let cache = Cache::new();+ let mut banded = Renderer::new();+ banded.threads = 3;+ let first = banded.render(&d, &cache, &placed, w, h, view, &opts);+ let mut one = Renderer::new();+ one.threads = 0;+ let whole = one.render(&d, &cache, &placed, w, h, view, &opts);+ let (w8, m) = (worst(&first, &whole), mean(&first, &whole));+ assert!(w8 <= 8 && m < 0.5, "distance {distance} at {at:?}: max channel difference {w8}, mean {m:.3}");+ assert_eq!(banded.render(&d, &cache, &placed, w, h, view, &opts).pixels, first.pixels, "placed again unchanged");+ // Panned by half a pixel, the bands are placed again across rows of pixels: no seams.+ let panned = Affine::translate((0.0, 0.5)) * view;+ let (kept, whole) = (banded.render(&d, &cache, &placed, w, h, panned, &opts), one.render(&d, &cache, &placed, w, h, panned, &opts));+ let (w8, m) = (worst(&kept, &whole), mean(&kept, &whole));+ assert!(w8 <= 8 && m < 0.5, "distance {distance} at {at:?}, panned: max channel difference {w8}, mean {m:.3}");+}