diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 33c57d98..0398338d 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -73,6 +73,8 @@ fn is_container(n: &Node) -> bool { struct Content<'a> { /// The object the effects belong to (cached shadows are checked against it). node: &'a Node, + /// The `Arc` holding `node`, when known: cached rasters keep it and match it by identity. + owner: Option<&'a Arc>, /// First shadow-cache slot of these effects (`None`: don't cache). cache: Option, /// Document-space bounds of the painted content. @@ -222,7 +224,7 @@ fn looks_around(fx: &RasterFx) -> Option { /// The content of an object with Photoshop-style effects as filtered pixels, drawn at /// (`anchor`'s view position + `rel`) scaled by 1 / `k` (see [`Renderer::pixel_content`]). pub(crate) struct PixelEntry { - node: Node, + node: Drawn, linear: [u64; 4], anchor: vectorcraft_geom::Point, rel: (f64, f64), @@ -252,7 +254,7 @@ fn draw_scaled(ctx: &mut RenderContext, image: std::sync::Arc /// A cached shadow / glow raster: premultiplied, already tinted and faded, positioned relative to /// the view-space position of `anchor` (a document point). pub(crate) struct ShadowEntry { - node: Node, + node: Drawn, linear: [u64; 4], anchor: vectorcraft_geom::Point, /// Pixel offset of the raster's top-left from the anchor's view position. @@ -261,6 +263,38 @@ pub(crate) struct ShadowEntry { pub(crate) stamp: u64, } +/// The object a cached raster was made for: the drawn `Arc` itself when known (kept alive, so its +/// address, the cache key, names it alone and a key match is exact), else a copy compared by value. +pub(crate) enum Drawn { + Shared(Arc), + Copy(Box), +} + +impl Drawn { + fn of(n: &Node, owner: Option<&Arc>) -> Self { + match owner { + Some(a) => Self::Shared(a.clone()), + None => Self::Copy(Box::new(n.clone())), + } + } + + /// Is `n` the object drawn? + fn is(&self, n: &Node) -> bool { + match self { + Self::Shared(a) => std::ptr::eq(&**a, n), + Self::Copy(c) => **c == *n, + } + } +} + +/// Most bytes the shadow and filtered-content rasters of one renderer hold together: the least +/// recently drawn go beyond it. +pub(crate) const FX_RASTER_BUDGET: usize = 512 << 20; + +fn raster_bytes(pm: &vello_cpu::Pixmap) -> usize { + pm.width() as usize * pm.height() as usize * 4 +} + /// Live-effect geometry of a node, kept across frames: the object-level result and each /// appearance item's own result on it (filled in as items are drawn). The node is kept alive, so /// its address names it alone and a hit is exact. @@ -285,6 +319,7 @@ impl Renderer { i: usize, fx: &RasterFx, reach: Rect, + owner: Option<&Arc>, paint: &mut dyn FnMut(&mut Self, &mut RenderContext, &Frame), ) -> bool { let (mode, opacity, dx, dy, blur, color) = match fx { @@ -295,7 +330,7 @@ impl Renderer { let linear = linear_key(f.view); let key = (n as *const Node as usize, i, f.ink); let stamp = self.stamp; - let hit = self.shadows.get_mut(&key).filter(|e| e.linear == linear && e.node == *n); + let hit = self.shadows.get_mut(&key).filter(|e| e.linear == linear && e.node.is(n)); let (image, x, y) = match hit { Some(e) => { e.stamp = stamp; @@ -331,7 +366,9 @@ impl Renderer { let image = std::sync::Arc::new(pm); let anchor = reach.origin(); let p = f.view * anchor; - self.shadows.insert(key, ShadowEntry { node: n.clone(), linear, anchor, rel: (x0 - p.x, y0 - p.y), image: image.clone(), stamp }); + let node = Drawn::of(n, owner); + self.shadows.insert(key, ShadowEntry { node, linear, anchor, rel: (x0 - p.x, y0 - p.y), image: image.clone(), stamp }); + self.trim_fx_rasters(FX_RASTER_BUDGET); (image, x0, y0) } }; @@ -394,7 +431,7 @@ impl Renderer { // Document-space reach of the painted geometry (strokes, arrowheads…). let outset = n.appearance.outset(); let reach = g.bounding_box().inflate(outset, outset); - let content = Content { node: n, cache: Some(0), reach, outline: Some((g, rule)) }; + let content = Content { node: n, owner: None, cache: Some(0), reach, outline: Some((g, rule)) }; let mut items = items; self.raster_fx(ctx, f, &content, &rfx, &mut |r, c, fr| r.paint_items(c, fr, n, g, rule, &gctx, items.as_deref_mut())); ctx.set_transform(Affine::IDENTITY); @@ -429,7 +466,7 @@ impl Renderer { let region = if clipped { self.clip_of(&art) } else { None }; // A symbol's art can change while the instance doesn't: no cached shadows. let cache = (cache && !matches!(a.kind, NodeKind::SymbolInstance { .. })).then_some(0); - let content = Content { node: a, cache, reach, outline: region.as_deref().map(|r| (&r.0, r.1)) }; + let content = Content { node: a, owner: Some(a), cache, reach, outline: region.as_deref().map(|r| (&r.0, r.1)) }; self.raster_fx(ctx, f, &content, &rfx, &mut |r, c, fr| r.draw_node(c, fr, &art, true)); ctx.set_transform(Affine::IDENTITY); } @@ -464,7 +501,7 @@ impl Renderer { None => effects::outline_art(a, symbol.as_ref()).unwrap_or_else(|| Node::group(a.id, vec![])), }; let art = if effects::has_revolve(a) { effects::evaluate_container(&art).unwrap_or(art) } else { art }; - let mut art = self.adjusted_art(doc, art, effects::color_map(&a.appearance.effects).as_ref()); + let mut art = self.adjusted_art(art, effects::color_map(&a.appearance.effects).as_ref()); // A path's geometry effects still apply when it is drawn. let path = matches!(art.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }); art.appearance.effects.retain(|e| path && effects::is_geometry(&e.id)); @@ -490,18 +527,19 @@ impl Renderer { /// `art` with the colour adjustments inside it applied, then `outer` (its object's own) on /// everything ([`effects::adjust`]). Embedded images become recoloured copies, cached with the /// decoded images under a key naming the adjustment. - fn adjusted_art(&mut self, doc: &vectorcraft_doc::Document, mut art: Node, outer: Option<&effects::ColorMap>) -> Node { + fn adjusted_art(&mut self, mut art: Node, outer: Option<&effects::ColorMap>) -> Node { art.appearance.effects.retain(|e| !effects::is_adjustment(&e.id)); - let mut wanted: Vec<(String, String, effects::ColorMap)> = vec![]; + let mut wanted: Vec<(String, String, u64, effects::ColorMap)> = vec![]; let adjusted = effects::adjust(&art, outer, &mut |key, map| { let mut h = std::hash::DefaultHasher::new(); std::hash::Hash::hash(&format!("{map:?}"), &mut h); - let derived = format!("{key}\u{0}adjust\u{0}{:016x}", std::hash::Hasher::finish(&h)); - wanted.push((key.to_string(), derived.clone(), map.clone())); + let hash = std::hash::Hasher::finish(&h); + let derived = format!("{key}\u{0}adjust\u{0}{hash:016x}"); + wanted.push((key.to_string(), derived.clone(), hash, map.clone())); Some(derived) }); - for (key, derived, map) in wanted { - self.adjusted_image(doc, &key, derived, &map); + for (key, derived, hash, map) in wanted { + self.adjusted_image(&key, derived, hash, &map); } adjusted.unwrap_or(art) } @@ -518,10 +556,12 @@ impl Renderer { paint: &mut dyn FnMut(&mut Self, &mut RenderContext, &Frame), ) { let reach = content.reach; + // The `Arc` drawn: the caller's, or the node `draw_arc` is drawing. + let owner = content.owner.cloned().or_else(|| self.fx_owner.clone().filter(|o| std::ptr::eq(&**o, content.node))); // Below the content: shadows and outer glows. for (i, fx) in rfx.iter().enumerate().filter(|(_, x)| x.is_below()) { if let Some(slot) = content.cache - && self.draw_shadow_cached(ctx, f, content.node, slot + i, fx, reach, paint) + && self.draw_shadow_cached(ctx, f, content.node, slot + i, fx, reach, owner.as_ref(), paint) { continue; } @@ -628,7 +668,7 @@ impl Renderer { let linear = linear_key(f.view); let key = content.cache.filter(|_| fits && k >= 1.0).map(|slot| (content.node as *const Node as usize, slot, f.ink)); let stamp = self.stamp; - if let Some(e) = key.and_then(|key| self.pixel_fx.get_mut(&key)).filter(|e| e.linear == linear && e.node == *content.node) { + if let Some(e) = key.and_then(|key| self.pixel_fx.get_mut(&key)).filter(|e| e.linear == linear && e.node.is(content.node)) { e.stamp = stamp; let p = f.view * e.anchor; let (image, at, k) = (e.image.clone(), ((p.x + e.rel.0).round(), (p.y + e.rel.1).round()), e.k); @@ -674,11 +714,38 @@ impl Renderer { let anchor = content.reach.origin(); let p = f.view * anchor; let rel = (x0 - p.x, y0 - p.y); - self.pixel_fx.insert(key, PixelEntry { node: content.node.clone(), linear, anchor, rel, k, image: image.clone(), stamp }); + let owner = content.owner.cloned().or_else(|| self.fx_owner.clone().filter(|o| std::ptr::eq(&**o, content.node))); + let node = Drawn::of(content.node, owner.as_ref()); + self.pixel_fx.insert(key, PixelEntry { node, linear, anchor, rel, k, image: image.clone(), stamp }); + self.trim_fx_rasters(FX_RASTER_BUDGET); } draw_scaled(ctx, image, (x0, y0), k); } + /// Drop the least recently drawn shadow and filtered-content rasters beyond `budget` bytes + /// ([`FX_RASTER_BUDGET`]). + fn trim_fx_rasters(&mut self, budget: usize) { + let shadows = self.shadows.iter().map(|(k, e)| (e.stamp, false, *k, raster_bytes(&e.image))); + let pixels = self.pixel_fx.iter().map(|(k, e)| (e.stamp, true, *k, raster_bytes(&e.image))); + let mut all: Vec<_> = shadows.chain(pixels).collect(); + let mut total: usize = all.iter().map(|e| e.3).sum(); + if total <= budget { + return; + } + all.sort_unstable_by_key(|e| e.0); + for (_, pixel, key, bytes) in all { + if total <= budget { + break; + } + if pixel { + self.pixel_fx.remove(&key); + } else { + self.shadows.remove(&key); + } + total -= bytes; + } + } + /// Keep `pm`'s pixels only inside `g` filled by `rule` as `frame` draws it (Feather's clip). fn clip_pixels(&mut self, pm: &mut vello_cpu::Pixmap, frame: &Frame, g: &BezPath, rule: FillRule) { let mut m = frame.offscreen_context(pm.width(), pm.height()); @@ -781,7 +848,8 @@ impl Renderer { let outset = n.appearance.outset(); let orule = if item.is_fill() { rule } else { FillRule::NonZero }; // Each item's shadows get their own cache slots. - let content = Content { node: n, cache: Some((j + 1) << 8), reach: ib.inflate(outset, outset), outline: Some((&outline, orule)) }; + let content = + Content { node: n, owner: None, cache: Some((j + 1) << 8), reach: ib.inflate(outset, outset), outline: Some((&outline, orule)) }; self.raster_fx(ctx, f, &content, &rfx, &mut |r, c, fr| r.paint_item(c, fr, n, item, ig, rule, ib)); ctx.set_transform(Affine::IDENTITY); } @@ -827,6 +895,7 @@ mod tests { use vectorcraft_doc::{Appearance, Document, Effect, Node}; use vectorcraft_geom::{Affine, Rect, shapes}; + use super::{Drawn, raster_bytes}; use crate::{RenderOptions, Rendered, Renderer}; fn doc_with(r: Rect, fill: Color, effects: Vec<(&str, serde_json::Value)>) -> Document { @@ -899,6 +968,27 @@ mod tests { assert!(edited.pixels == Renderer::new().render(&e, 400, 400, view, &opts).pixels, "and matches a fresh render"); } + /// Shadow rasters of objects in the tree are matched by the node's `Arc` (kept alive), not by + /// comparing nodes; over the byte budget the least recently drawn go first. + #[test] + fn shadow_rasters_keep_their_node_and_a_byte_budget() { + let d = effects_page(); + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let mut r = Renderer::new(); + let first = r.render(&d, 400, 400, Affine::scale(2.0), &opts); + assert!(!r.shadows.is_empty() && r.shadows.values().all(|e| matches!(e.node, Drawn::Shared(_)))); + // A frame of other objects makes rasters of its own; the older ones go first. + let _ = r.render(&effects_page(), 400, 400, Affine::scale(2.0), &opts); + let newest = r.stamp; + let bytes = |r: &Renderer| r.shadows.values().map(|e| raster_bytes(&e.image)).sum::(); + let latest = r.shadows.values().filter(|e| e.stamp == newest).map(|e| raster_bytes(&e.image)).sum::(); + assert!(bytes(&r) > latest); + r.trim_fx_rasters(latest); + assert_eq!(bytes(&r), latest); + assert!(r.shadows.values().all(|e| e.stamp == newest)); + assert!(r.render(&d, 400, 400, Affine::scale(2.0), &opts).pixels == first.pixels, "made again alike"); + } + /// The multithreaded rasterizer draws the same pixels with any number of workers, so the /// automatic thread count can follow the machine. #[test] diff --git a/crates/render/src/images.rs b/crates/render/src/images.rs new file mode 100644 index 00000000..4c41f71c --- /dev/null +++ b/crates/render/src/images.rs @@ -0,0 +1,282 @@ +//! Decoded images, shared by every [`Renderer`](crate::Renderer) in the process. +//! +//! The canvas, the render worker, thumbnails and panels each keep a renderer; were the decoded +//! pixels cached per renderer, a 24-megapixel photo would take 96 MB in each. Here they are kept +//! once, keyed by the image's content (not the document's key for it, which another document may +//! use for other pixels) and by what was made of them: greyscale (outline mode), recoloured by a +//! colour adjustment, separated into ink planes, or shrunk ([`RenderOptions::image_mips`](crate::RenderOptions::image_mips)). +//! +//! The store holds at most [`ImageStore::budget`] bytes, dropping the least recently used entries +//! beyond that, except ones still held elsewhere (a render context drawing them): dropping those +//! would free nothing and decode them again on the next request. + +use std::collections::HashMap; +use std::sync::{Arc, LazyLock, Mutex, MutexGuard, Weak}; + +use vectorcraft_doc::ImageBlob; +use vello_cpu::Pixmap; +use vello_cpu::color::PremulRgba8; + +use crate::ink::Ink; + +/// Largest default budget of the shared image store, in bytes; less on machines with under 8 GiB +/// of memory (an eighth of it). [`set_image_cache_budget`] changes it. +pub const IMAGE_CACHE_MAX: usize = 1 << 30; + +/// The default budget on the web (a tab's memory is tight). +#[cfg(target_arch = "wasm32")] +fn default_budget() -> usize { + 256 << 20 +} + +#[cfg(not(target_arch = "wasm32"))] +fn default_budget() -> usize { + physical_memory().map_or(IMAGE_CACHE_MAX, |m| usize::try_from(m / 8).unwrap_or(usize::MAX).min(IMAGE_CACHE_MAX)) +} + +/// Installed memory in bytes, where the system says without a native call (Linux). +#[cfg(target_os = "linux")] +fn physical_memory() -> Option { + let info = std::fs::read_to_string("/proc/meminfo").ok()?; + let line = info.lines().find(|l| l.starts_with("MemTotal:"))?; + let kib: u64 = line.split_whitespace().nth(1)?.parse().ok()?; + kib.checked_mul(1024) +} + +#[cfg(not(any(target_os = "linux", target_arch = "wasm32")))] +fn physical_memory() -> Option { + None +} + +/// What an image entry holds: an image's pixels as decoded or made from them. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub(crate) struct ImageKey { + /// The encoded image's content ([`ImageBlob::content_key`] and length). + pub content: Arc, + /// Recoloured by the colour adjustment with this hash (see `Renderer::adjusted_image`). + pub adjust: Option, + /// Greyscale (outline mode). + pub grey: bool, + /// The ink plane painted ([`Ink::Display`]: the colours themselves). + pub ink: Ink, + /// Generations of the colour tables the pixels were made with (0 = none): a CMYK image's + /// display table, and the separation table of ink planes (see `ink`). + pub luts: (u64, u64), + /// Halved this many times ([`half`]). + pub mip: u8, +} + +/// A blob's content key and whether it holds CMYK inks, worked out once per blob allocation. +#[derive(Clone)] +pub(crate) struct BlobId { + pub content: Arc, + pub cmyk: bool, +} + +struct Entry { + pm: Arc, + /// [`ImageStore::tick`] of the last request. + used: u64, +} + +/// Decoded images within a byte budget (see the module docs). +pub(crate) struct ImageStore { + entries: HashMap, + /// Bytes of pixels in `entries`. + bytes: usize, + budget: usize, + /// Request counter: the least recently used entries go first. + tick: u64, + /// [`BlobId`]s per blob byte buffer, by address (the `Weak` keeps it from being reused). + ids: HashMap>, BlobId)>, +} + +fn pixmap_bytes(pm: &Pixmap) -> usize { + pm.width() as usize * pm.height() as usize * 4 +} + +impl ImageStore { + pub(crate) fn new(budget: usize) -> Self { + Self { entries: HashMap::new(), bytes: 0, budget, tick: 0, ids: HashMap::new() } + } + + /// The pixels of `key`, marked as just used. + pub(crate) fn get(&mut self, key: &ImageKey) -> Option> { + self.tick += 1; + let e = self.entries.get_mut(key)?; + e.used = self.tick; + Some(e.pm.clone()) + } + + /// The pixels of `key` without marking them used: a shrunk copy is drawn instead, so the full + /// image may be dropped first. + pub(crate) fn peek(&self, key: &ImageKey) -> Option> { + self.entries.get(key).map(|e| e.pm.clone()) + } + + /// Keep `pm` as `key` (or, when another renderer made it meanwhile, return theirs), then drop + /// entries over budget. + pub(crate) fn insert(&mut self, key: ImageKey, pm: Arc) -> Arc { + self.tick += 1; + let tick = self.tick; + if let Some(e) = self.entries.get_mut(&key) { + e.used = tick; + return e.pm.clone(); + } + self.bytes += pixmap_bytes(&pm); + self.entries.insert(key, Entry { pm: pm.clone(), used: tick }); + self.evict(); + pm + } + + /// Drop the least recently used entries no one else holds until within budget. + fn evict(&mut self) { + if self.bytes <= self.budget { + return; + } + let mut free: Vec<(u64, ImageKey)> = + self.entries.iter().filter(|(_, e)| Arc::strong_count(&e.pm) == 1).map(|(k, e)| (e.used, k.clone())).collect(); + free.sort_unstable_by_key(|(used, _)| *used); + for (_, k) in free { + if self.bytes <= self.budget { + break; + } + if let Some(e) = self.entries.remove(&k) { + self.bytes = self.bytes.saturating_sub(pixmap_bytes(&e.pm)); + } + } + } + + pub(crate) fn set_budget(&mut self, budget: usize) { + self.budget = budget; + self.evict(); + } + + pub(crate) fn bytes(&self) -> usize { + self.bytes + } + + fn known_id(&self, bytes: &Arc>) -> Option { + let (w, id) = self.ids.get(&(Arc::as_ptr(bytes) as usize))?; + std::ptr::eq(w.as_ptr(), Arc::as_ptr(bytes)).then(|| id.clone()) + } + + fn remember_id(&mut self, bytes: &Arc>, id: BlobId) { + if self.ids.len() > 1024 { + self.ids.retain(|_, (w, _)| w.strong_count() > 0); + } + self.ids.insert(Arc::as_ptr(bytes) as usize, (Arc::downgrade(bytes), id)); + } +} + +static STORE: LazyLock> = LazyLock::new(|| Mutex::new(ImageStore::new(default_budget()))); + +/// The process's image store (still usable after a panic elsewhere while it was locked: every +/// change to it is complete when made). +pub(crate) fn store() -> MutexGuard<'static, ImageStore> { + STORE.lock().unwrap_or_else(|e| e.into_inner()) +} + +/// Set the byte budget of the decoded images all renderers share (see [`IMAGE_CACHE_MAX`]). +pub fn set_image_cache_budget(bytes: usize) { + store().set_budget(bytes); +} + +/// Bytes of decoded images the renderers share now. +pub fn image_cache_bytes() -> usize { + store().bytes() +} + +/// `blob`'s content key and kind, hashed and sniffed once per blob allocation. +pub(crate) fn blob_id(blob: &ImageBlob) -> BlobId { + if let Some(id) = store().known_id(&blob.bytes) { + return id; + } + // Outside the lock: hashing a large image takes a while. + let id = BlobId { content: format!("{}:{}", blob.content_key(), blob.bytes.len()).into(), cmyk: blob.is_cmyk() }; + store().remember_id(&blob.bytes, id.clone()); + id +} + +/// `pm` at half the size (rounded up), each pixel the mean of the 2×2 it covers (the last row and +/// column repeat on odd sizes). +pub(crate) fn half(pm: &Pixmap) -> Pixmap { + let (w, h) = (pm.width() as usize, pm.height() as usize); + let (ow, oh) = (w.div_ceil(2).max(1), h.div_ceil(2).max(1)); + let src = pm.data(); + let at = |x: usize, y: usize| { + src.get(y.min(h.saturating_sub(1)) * w + x.min(w.saturating_sub(1))).copied().unwrap_or(PremulRgba8 { r: 0, g: 0, b: 0, a: 0 }) + }; + let mut data = Vec::with_capacity(ow * oh); + for y in 0..oh { + for x in 0..ow { + let q = [at(2 * x, 2 * y), at(2 * x + 1, 2 * y), at(2 * x, 2 * y + 1), at(2 * x + 1, 2 * y + 1)]; + let mean = |c: fn(&PremulRgba8) -> u8| ((q.iter().map(|p| u32::from(c(p))).sum::() + 2) / 4) as u8; + data.push(PremulRgba8 { r: mean(|p| p.r), g: mean(|p| p.g), b: mean(|p| p.b), a: mean(|p| p.a) }); + } + } + Pixmap::from_parts(data, ow as u16, oh as u16) +} + +/// How many times to halve an image drawn with `m` (image pixels → device pixels): while a pixel +/// of the halved image still covers at most one device pixel each way, at most down to one pixel. +pub(crate) fn mip_level(m: vectorcraft_geom::Affine, w: u16, h: u16) -> u8 { + let [a, b, c, d, _, _] = m.as_coeffs(); + // The largest stretch of the linear part (its greater singular value). + let (p, q) = (a * a + b * b + c * c + d * d, a * d - b * c); + let s = ((p + ((p * p - 4.0 * q * q).max(0.0)).sqrt()) / 2.0).sqrt(); + if !(s.is_finite() && s > 0.0 && s <= 0.5) { + return 0; + } + // Halvings until the longer side is one pixel: ⌈log₂ side⌉. + let most = (u32::BITS - (u32::from(w.max(h).max(1)) - 1).leading_zeros()) as u8; + ((-s.log2()).floor().clamp(0.0, 15.0) as u8).min(most) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn key(content: &str) -> ImageKey { + ImageKey { content: content.into(), adjust: None, grey: false, ink: Ink::Display, luts: (0, 0), mip: 0 } + } + + #[test] + fn budget_drops_least_recently_used_unless_held() { + // 10×10 pixmaps: 400 bytes each; room for two. + let mut s = ImageStore::new(800); + let a = s.insert(key("a"), Arc::new(Pixmap::new(10, 10))); + drop(a); + let held = s.insert(key("b"), Arc::new(Pixmap::new(10, 10))); + assert!(s.get(&key("a")).is_some(), "a is now the most recent"); + let _ = s.insert(key("c"), Arc::new(Pixmap::new(10, 10))); + assert_eq!(s.bytes(), 800); + assert!(s.peek(&key("b")).is_some(), "b is held outside the store: kept though least recently used"); + assert!(s.peek(&key("a")).is_none(), "a went instead"); + drop(held); + s.set_budget(400); + assert!(s.peek(&key("b")).is_none() && s.peek(&key("c")).is_some(), "now b goes first"); + assert_eq!(s.bytes(), 400); + } + + #[test] + fn half_averages_and_rounds_up_odd_sizes() { + let px = |v: u8| PremulRgba8 { r: v, g: v, b: v, a: 255 }; + let pm = Pixmap::from_parts(vec![px(0), px(100), px(200), px(40), px(60), px(80)], 3, 2); + let h = half(&pm); + assert_eq!((h.width(), h.height()), (2, 1)); + assert_eq!(h.data()[0].r, 50); + assert_eq!(h.data()[1].r, 140, "the last column repeats"); + } + + #[test] + fn mip_level_follows_the_largest_stretch() { + use vectorcraft_geom::Affine; + assert_eq!(mip_level(Affine::scale(1.0), 1000, 1000), 0); + assert_eq!(mip_level(Affine::scale(0.6), 1000, 1000), 0); + assert_eq!(mip_level(Affine::scale(0.5), 1000, 1000), 1); + assert_eq!(mip_level(Affine::scale(0.2), 1000, 1000), 2); + assert_eq!(mip_level(Affine::scale_non_uniform(0.1, 0.9), 1000, 1000), 0, "never blurrier than one axis needs"); + assert_eq!(mip_level(Affine::scale(0.001), 8, 8), 3, "down to one pixel at most"); + } +} diff --git a/crates/render/src/ink.rs b/crates/render/src/ink.rs index 144f818a..48e0fe49 100644 --- a/crates/render/src/ink.rs +++ b/crates/render/src/ink.rs @@ -15,6 +15,7 @@ use std::cell::RefCell; use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use vectorcraft_color::Color; @@ -26,7 +27,8 @@ use vello_cpu::Pixmap; use vello_cpu::color::PremulRgba8; use vello_cpu::peniko; -use crate::{RenderOptions, Renderer}; +use crate::RenderOptions; +use crate::images::{self, ImageKey}; /// What a frame paints for colours. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] @@ -138,27 +140,29 @@ impl InkLut { } } -/// Placed images as painted with [`Ink::Cmy`] and [`Ink::K`], per image key, with the tables they -/// were separated by. -pub(crate) type InkImages = HashMap, [Arc; 2])>; +/// A table built for the colour settings it was built with, and its generation (see [`cached`]). +type Cached = Mutex, Arc, u64)>>; -/// A table built for the colour settings it was built with. -type Cached = Mutex, Arc)>>; +/// The last table generation handed out: images made with a table are cached under its +/// generation (see [`crate::images`]), and none is ever reused. +static GENERATION: AtomicU64 = AtomicU64::new(0); static DISPLAY: Cached = Mutex::new(None); /// Screen colours → [`Ink::Cmy`] and [`Ink::K`] values (placed images). static SEPARATE: Cached<[ProofLut; 2]> = Mutex::new(None); -/// The table in `slot` for the active colour settings, built by `build` when they changed. -fn cached(slot: &Cached, build: impl FnOnce(&Cms) -> T) -> Arc { +/// The table in `slot` for the active colour settings, built by `build` when they changed, and +/// its generation. +fn cached(slot: &Cached, build: impl FnOnce(&Cms) -> T) -> (Arc, u64) { let cms = cms::active(); let mut s = slot.lock().unwrap_or_else(|e| e.into_inner()); match &*s { - Some((owner, t)) if Arc::ptr_eq(owner, &cms) => t.clone(), + Some((owner, t, g)) if Arc::ptr_eq(owner, &cms) => (t.clone(), *g), _ => { let t = Arc::new(build(&cms)); - *s = Some((cms, t.clone())); - t + let g = GENERATION.fetch_add(1, Ordering::Relaxed) + 1; + *s = Some((cms, t.clone(), g)); + (t, g) } } } @@ -167,7 +171,7 @@ fn cached(slot: &Cached, build: impl FnOnce(&Cms) -> T) -> Arc { /// [`Ink::Cmy`] and [`Ink::K`]): each pixel's inks shown through the working CMYK profile. Where /// only the frame's `background` shows it keeps its exact screen colour. pub(crate) fn compose(cmy: &[u8], k: &[u8], background: Option<[u8; 4]>) -> Vec { - let lut = cached(&DISPLAY, InkLut::build); + let (lut, _) = cached(&DISPLAY, InkLut::build); let bg = background.map(|b| (Ink::Cmy.fixed8(b), Ink::K.fixed8(b), b)); let mut out = Vec::with_capacity(cmy.len()); // Runs of one colour convert once. @@ -205,9 +209,6 @@ pub(crate) fn amounts(cmy: &[u8], k: &[u8]) -> Vec { .collect() } -/// CMYK images as shown, per image key, with the table they were shown through. -pub(crate) type CmykImages = HashMap, Arc)>; - /// Ink amounts `inks` shown through `lut` (opaque). fn inks_pixmap(inks: &Inks, lut: &InkLut) -> Option { let (w, h) = (u16::try_from(inks.width).ok()?, u16::try_from(inks.height).ok()?); @@ -231,70 +232,65 @@ fn inks_pixmap(inks: &Inks, lut: &InkLut) -> Option { Some(Pixmap::from_parts(data, w, h)) } -impl Renderer { - /// The pixels of CMYK image `blob` (cached as `cache_key`): its inks shown through the working - /// CMYK profile, as CMYK colours are, again when the colour settings change. `None` for - /// other images. - pub(crate) fn cmyk_image(&mut self, blob: &ImageBlob, cache_key: &str) -> Option> { - // Other images are cached as they are once decoded: only CMYK ones come here again. - let shown = self.cmyk_images.get(cache_key).cloned(); - if shown.is_none() && !blob.is_cmyk() { - return None; - } - let lut = cached(&DISPLAY, InkLut::build); - if let Some((owner, pm)) = shown - && Arc::ptr_eq(&owner, &lut) - { - return Some(pm); - } - let pm = Arc::new(inks_pixmap(&blob.cmyk()?, &lut)?); - self.cmyk_images.insert(cache_key.to_string(), (lut, pm.clone())); - Some(pm) - } +/// The working CMYK profile's display table (what CMYK images show through) and its generation. +pub(crate) fn display_lut() -> (Arc, u64) { + cached(&DISPLAY, InkLut::build) +} - /// Placed image `key` (decoded as `pm`, from `blob`) as painted with `ink`, cached. - pub(crate) fn ink_image(&mut self, key: &str, pm: &Arc, ink: Ink, blob: Option<&ImageBlob>) -> Arc { - if ink == Ink::Display { - return pm.clone(); - } - let luts = cached(&SEPARATE, |cms| { - let intent = cms.settings().intent; - [Ink::Cmy, Ink::K].map(|ink| ProofLut::build(|rgb| ink.plane(cms.srgb_to_cmyk(rgb, intent)))) - }); - let at = (ink == Ink::K) as usize; - if let Some((owner, planes)) = self.ink_images.get(key) - && Arc::ptr_eq(owner, &luts) - { - return planes[at].clone(); - } - // CMYK images paint their own inks; the others are separated. - let size = (pm.width() as u32, pm.height() as u32); - let inks = blob.filter(|_| self.cmyk_images.contains_key(key)).and_then(ImageBlob::cmyk).filter(|i| (i.width, i.height) == size); - let planes = [Ink::Cmy, Ink::K].map(|plane| { - let mut out = Pixmap::new(pm.width(), pm.height()); - if let Some(inks) = &inks { - for (o, p) in out.data_mut().iter_mut().zip(inks.data.as_chunks::<4>().0) { - let [r, g, b] = plane.plane(p.map(|v| v as f32 / 255.0)).map(q); - *o = PremulRgba8 { r, g, b, a: 255 }; - } - return Arc::new(out); - } - let lut = &luts[(plane == Ink::K) as usize]; - for (o, s) in out.data_mut().iter_mut().zip(pm.data()) { - let a = s.a; - if a == 0 { - continue; - } - let un = |v: u8| (v as f32 / a as f32).min(1.0); - let [r, g, b] = lut.apply([un(s.r), un(s.g), un(s.b)]).map(|v| (v.clamp(0.0, 1.0) * a as f32).round() as u8); - *o = PremulRgba8 { r, g, b, a }; - } - Arc::new(out) - }); - let out = planes[at].clone(); - self.ink_images.insert(key.to_string(), (luts, planes)); - out +/// The pixels of CMYK image `blob`: its inks shown through `lut`. `None` for other images. +pub(crate) fn cmyk_pixmap(blob: &ImageBlob, lut: &InkLut) -> Option { + inks_pixmap(&blob.cmyk()?, lut) +} + +/// Image pixels `pm` (cached as `key`, from `blob` when it is a document's image as it is) as +/// painted with `ink`, cached with the images, and their key. +pub(crate) fn ink_image(key: &ImageKey, pm: &Arc, ink: Ink, blob: Option<&ImageBlob>, touch: bool) -> (ImageKey, Arc) { + if ink == Ink::Display { + return (key.clone(), pm.clone()); } + let (luts, generation) = cached(&SEPARATE, |cms| { + let intent = cms.settings().intent; + [Ink::Cmy, Ink::K].map(|ink| ProofLut::build(|rgb| ink.plane(cms.srgb_to_cmyk(rgb, intent)))) + }); + let plane_key = |plane: Ink| ImageKey { ink: plane, luts: (key.luts.0, generation), ..key.clone() }; + let want = plane_key(ink); + let hit = { + let mut s = images::store(); + if touch { s.get(&want) } else { s.peek(&want) } + }; + if let Some(p) = hit { + return (want, p); + } + // CMYK images (shown through the display table) paint their own inks; the others are separated. + let size = (pm.width() as u32, pm.height() as u32); + let cmyk = key.luts.0 != 0 && key.adjust.is_none() && !key.grey; + let inks = blob.filter(|_| cmyk).and_then(ImageBlob::cmyk).filter(|i| (i.width, i.height) == size); + let planes = [Ink::Cmy, Ink::K].map(|plane| { + let mut out = Pixmap::new(pm.width(), pm.height()); + if let Some(inks) = &inks { + for (o, p) in out.data_mut().iter_mut().zip(inks.data.as_chunks::<4>().0) { + let [r, g, b] = plane.plane(p.map(|v| v as f32 / 255.0)).map(q); + *o = PremulRgba8 { r, g, b, a: 255 }; + } + return Arc::new(out); + } + let lut = &luts[(plane == Ink::K) as usize]; + for (o, s) in out.data_mut().iter_mut().zip(pm.data()) { + let a = s.a; + if a == 0 { + continue; + } + let un = |v: u8| (v as f32 / a as f32).min(1.0); + let [r, g, b] = lut.apply([un(s.r), un(s.g), un(s.b)]).map(|v| (v.clamp(0.0, 1.0) * a as f32).round() as u8); + *o = PremulRgba8 { r, g, b, a }; + } + Arc::new(out) + }); + let [cmy, k] = planes; + let mut s = images::store(); + let cmy = s.insert(plane_key(Ink::Cmy), cmy); + let k = s.insert(plane_key(Ink::K), k); + (want, if ink == Ink::K { k } else { cmy }) } #[cfg(test)] @@ -324,6 +320,8 @@ mod tests { use vectorcraft_doc::{Appearance, ImageObject, Node, NodeKind}; use vectorcraft_geom::{Affine, Rect, shapes}; + use crate::Renderer; + let ink = [0u8, 102, 255, 25]; let mut d = Document::new_with_mode(20.0, 10.0, ColorMode::Cmyk); d.images.insert("img".into(), ImageBlob::cmyk_tiff(&Inks::new(2, 2, ink.repeat(4)).unwrap()).unwrap()); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 36bc4b06..cca21739 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -11,6 +11,7 @@ pub mod encode; mod freeform; mod fx; mod group; +mod images; mod ink; mod live; mod paint; @@ -32,6 +33,7 @@ use ink::Ink; pub use brush_fx::instance_art; pub use effects::stroke::width_outline; +pub use images::{IMAGE_CACHE_MAX, image_cache_bytes, set_image_cache_budget}; pub use live::expand_live; pub use pattern::render_pattern_swatch; pub use vectorcraft_effects as effects; @@ -119,6 +121,10 @@ pub struct RenderOptions { /// edge leave no seam of the background between them (#983). Off, edges are smoothed shape by /// shape (the canvas). pub supersample: bool, + /// Screen views: an image drawn at half its size or smaller samples a copy shrunk by halves + /// (box-filtered from the full image, kept with the decoded images), so the full image needn't + /// stay in memory or be sampled. Off, images always sample their full pixels (exports). + pub image_mips: bool, } /// The opacity of the art around an isolated group or layer. @@ -192,6 +198,7 @@ impl Default for RenderOptions { isolated: None, precise: false, supersample: false, + image_mips: false, } } } @@ -340,7 +347,8 @@ type ClipEntry = (Arc, Option>); /// A clipping path (kept alive) and what it paints: its fills and its strokes ([`Node::clip_paint`]). type ClipPaintEntry = (Arc, Option>, Option>); -/// Reusable renderer (keeps the render context, decoded images and glyph caches between frames). +/// Reusable renderer (keeps the render context, geometry and glyph caches between frames; decoded +/// images are shared by all renderers, see [`set_image_cache_budget`]). pub struct Renderer { texts: PtrMap, Arc)>, /// The [`vectorcraft_text::FontDb::generation`] `texts` was laid out with: type lays out @@ -367,7 +375,6 @@ pub struct Renderer { alpha: f32, ctx: Option, resources: Resources, - images: HashMap>, /// Statistics of the last frame. pub stats: FrameStats, /// Brush art per brushed stroke. @@ -399,15 +406,11 @@ pub struct Renderer { clip_paths: Vec<(BezPath, FillRule, Affine)>, /// What clipping paths paint, per clipping path (see [`Self::clip_paint_of`]). clip_paints: PtrMap, - /// Placed images as painted in ink planes (see [`ink`]). - ink_images: ink::InkImages, - /// CMYK images as shown through the working CMYK profile (see [`ink`]). - cmyk_images: ink::CmykImages, /// Gradients along or across strokes, keyed like `strokes` (see [`Self::fill_path_gradient`]). stroke_slices: PtrMap<(usize, i32), SliceEntry>, - /// The keys and sizes of the recoloured images colour adjustments made in `images`, oldest - /// first (see [`Self::adjusted_image`]). - adjusted: std::collections::VecDeque<(String, usize)>, + /// What the image keys colour adjustments give their recoloured images name: the image + /// recoloured and the adjustment, with its hash (see [`Self::adjusted_image`]). + adjusts: HashMap, /// Layer Options → Dim Images to, of the layer being drawn (screen views only): images show /// faded to this opacity over white. dim_images: Option, @@ -490,7 +493,6 @@ impl Renderer { alpha: 1.0, ctx: None, resources: Resources::new(), - images: HashMap::new(), stats: FrameStats::default(), brushes: Default::default(), shadows: PtrMap::default(), @@ -504,10 +506,8 @@ impl Renderer { blends: PtrMap::default(), clip_paths: vec![], clip_paints: PtrMap::default(), - ink_images: Default::default(), - cmyk_images: Default::default(), stroke_slices: PtrMap::default(), - adjusted: Default::default(), + adjusts: HashMap::new(), dim_images: None, inline_depth: 0, raster: vello_cpu::RasterizerSettings::default(), @@ -547,10 +547,11 @@ impl Renderer { let visible = inv.transform_rect_bbox(Rect::new(0.0, 0.0, w as f64, h as f64)); let px = 1.0 / view.determinant().abs().sqrt().max(1e-12); let frame = Frame { mt: threads > 0, doc, view, visible, px, opts, ink: Ink::Display }; - self.stamp += 1; + self.begin_frame(); // View Opacity Mask: the mask's coverage in greyscale instead of the artwork. if let Some(m) = opts.mask_view.and_then(|id| doc.node(id)).and_then(|n| n.mask.as_deref()) { let pixels = self.mask_values(&frame, m, w, h).into_iter().flat_map(|v| [v, v, v, 255]).collect(); + self.end_frame(); self.stats.micros = now().saturating_sub(start); return Rendered { width: w as u32, height: h as u32, pixels }; } @@ -564,7 +565,22 @@ impl Renderer { } else { self.draw_frame(&frame, w, h) }; - // Drop cache entries not seen for a few frames. + self.end_frame(); + if !inks { + proof::post(&mut pixels, opts); + } + self.stats.micros = now().saturating_sub(start); + Rendered { width: w as u32, height: h as u32, pixels } + } + + /// Start a frame: cache entries used from now on are marked with its stamp. + fn begin_frame(&mut self) { + self.stamp += 1; + } + + /// End a frame: drop cache entries not used for a few frames (every render, thumbnails too, + /// so the nodes they hold go with them). + fn end_frame(&mut self) { let g = self.stamp; if self.geom.len() > 1024 { self.geom.retain(|_, e| g - e.stamp <= 3); @@ -583,11 +599,6 @@ impl Renderer { } // Filtered rasters are large: only those of the last frame stay. self.pixel_fx.retain(|_, e| e.stamp == g); - if !inks { - proof::post(&mut pixels, opts); - } - self.stats.micros = now().saturating_sub(start); - Rendered { width: w as u32, height: h as u32, pixels } } /// Draw frame `f` (`w`×`h` pixels): the background, the artboards and the page's art. @@ -705,18 +716,37 @@ impl Renderer { let mut ctx = single_threaded_context(w, h); let opts = RenderOptions::default(); let frame = Frame { mt: false, doc, view, visible, px, opts: &opts, ink: Ink::Display }; + self.begin_frame(); (self.knockout, self.nested, self.backdrop) = (false, 0, None); self.clip_paths.clear(); self.draw_arc(&mut ctx, &frame, n); ctx.flush(); let mut pm = Pixmap::new(w, h); ctx.render(&mut pm, &mut self.resources); + self.end_frame(); Some(Rendered { width: w as u32, height: h as u32, pixels: pm.data_as_u8_slice().to_vec() }) } /// Render any node (also one outside the tree, e.g. opacity-mask art) fitted into /// `size`×`size` pixels, optionally over a solid premultiplied background. pub fn render_node_thumbnail(&mut self, doc: &Document, n: &Node, size: u32, background: Option<[u8; 4]>) -> Option { + self.render_node_thumbnail_with(doc, n, size, background, &RenderOptions::default()) + } + + /// [`Self::render_thumbnail`] with `opts` (e.g. [`RenderOptions::image_mips`]). + pub fn render_thumbnail_with(&mut self, doc: &Document, id: NodeId, size: u32, opts: &RenderOptions) -> Option { + self.render_node_thumbnail_with(doc, doc.node(id)?, size, None, opts) + } + + /// [`Self::render_node_thumbnail`] with `opts` (e.g. [`RenderOptions::image_mips`]). + pub fn render_node_thumbnail_with( + &mut self, + doc: &Document, + n: &Node, + size: u32, + background: Option<[u8; 4]>, + opts: &RenderOptions, + ) -> Option { let b = brush_fx::cull_bounds(n)?; let s = (size as f64 - 2.0) / b.width().max(b.height()).max(1e-6); let view = Affine::translate((size as f64 / 2.0, size as f64 / 2.0)) * Affine::scale(s) * Affine::translate(-b.center().to_vec2()); @@ -726,13 +756,15 @@ impl Renderer { ctx.set_paint(peniko::Color::from_rgba8(bg[0], bg[1], bg[2], bg[3])); ctx.fill_rect(&kurbo::Rect::new(0.0, 0.0, w as f64, w as f64)); } - let frame = Frame { mt: false, doc, view, visible: b.inflate(1.0, 1.0), px: 1.0 / s, opts: &RenderOptions::default(), ink: Ink::Display }; + let frame = Frame { mt: false, doc, view, visible: b.inflate(1.0, 1.0), px: 1.0 / s, opts, ink: Ink::Display }; + self.begin_frame(); (self.knockout, self.nested, self.backdrop) = (false, 0, None); self.clip_paths.clear(); self.draw_node(&mut ctx, &frame, n, true); ctx.flush(); let mut pm = Pixmap::new(w, w); ctx.render(&mut pm, &mut self.resources); + self.end_frame(); Some(Rendered { width: size, height: size, pixels: pm.data_as_u8_slice().to_vec() }) } @@ -1569,14 +1601,12 @@ impl Renderer { // Outline mode draws the image's frame, and with Document Setup → Show Images in Outline // Mode its pixels in greyscale under the frame. let outline = f.opts.outline; - // A linked image's preview is cached apart from the file's pixels, which share its key. - let cache_key = match f.doc.images.get(&im.key) { - Some(b) if b.is_proxy() => std::borrow::Cow::Owned(format!("{}\u{0}proxy", im.key)), - _ => std::borrow::Cow::Borrowed(im.key.as_str()), - }; - let pixels = if outline && !f.doc.setup.outline_images { None } else { self.image_pixmap(f.doc, &im.key, &cache_key, outline) }; - if let Some(pm) = pixels { - let pm = if outline { pm } else { self.ink_image(&cache_key, &pm, f.ink, f.doc.images.get(&im.key)) }; + // With mips the full pixels are only looked at (not marked used) until they're drawn. + let mips = f.opts.image_mips; + let pixels = if outline && !f.doc.setup.outline_images { None } else { self.image_pixmap(f.doc, &im.key, outline, !mips) }; + if let Some((key, pm)) = pixels { + let (key, pm) = if outline { (key, pm) } else { ink::ink_image(&key, &pm, f.ink, f.doc.images.get(&im.key), !mips) }; + let pm = if mips { image_mip(&key, pm, f.view * im.xf * Affine::scale_non_uniform(im.width as f64, im.height as f64)) } else { pm }; let sx = im.width as f64 / pm.width().max(1) as f64; let sy = im.height as f64 / pm.height().max(1) as f64; let quality = if f.opts.smooth_images { peniko::ImageQuality::Medium } else { peniko::ImageQuality::Low }; @@ -1619,60 +1649,57 @@ impl Renderer { } } - /// Cache the pixels of image blob `key` recoloured by `map` as `derived` (the key the adjusted - /// art's image takes). The latest ones are kept up to a memory budget: dropping one drops the - /// cached art too, so it is made again. - fn adjusted_image(&mut self, doc: &Document, key: &str, derived: String, map: &vectorcraft_effects::ColorMap) { - const BUDGET: usize = 256 << 20; - if self.images.contains_key(&derived) { + /// Name image `key` recoloured by `map` (its hash `hash`) as `derived` (the key the adjusted + /// art's image takes): drawing `derived` draws the recoloured pixels, made when first drawn and + /// kept with the decoded images. Names past a limit drop the art made with them too, so it is + /// made again. + fn adjusted_image(&mut self, key: &str, derived: String, hash: u64, map: &vectorcraft_effects::ColorMap) { + if self.adjusts.contains_key(&derived) { return; } - let Some(src) = self.image_pixmap(doc, key, key, false) else { return }; - let mut pm = (*src).clone(); - let mut memo: HashMap<[u8; 4], [u8; 3]> = HashMap::new(); - for px in pm.data_mut().iter_mut().filter(|p| p.a > 0) { - let a = px.a as u32; - let [r, g, b] = *memo.entry([px.r, px.g, px.b, px.a]).or_insert_with(|| { - // Premultiplied to straight, adjusted, then premultiplied again. - let straight = [px.r, px.g, px.b].map(|v| ((v as u32 * 255 + a / 2) / a).min(255) as u8); - map.apply_rgb8(straight).map(|v| ((v as u32 * a + 127) / 255) as u8) - }); - (px.r, px.g, px.b) = (r, g, b); - } - let bytes = pm.data().len() * 4; - self.images.insert(derived.clone(), Arc::new(pm)); - self.adjusted.push_back((derived, bytes)); - let mut total: usize = self.adjusted.iter().map(|(_, b)| b).sum(); - while total > BUDGET && self.adjusted.len() > 1 { - let Some((old, b)) = self.adjusted.pop_front() else { break }; - self.images.remove(&old); + if self.adjusts.len() >= 1024 { + self.adjusts.clear(); self.fx_arts.clear(); - total -= b; } + self.adjusts.insert(derived, (key.to_string(), hash, map.clone())); } - /// The decoded pixels of image blob `key` (cached as `cache_key`), or a greyscale copy of them. - fn image_pixmap(&mut self, doc: &Document, key: &str, cache_key: &str, grey: bool) -> Option> { - let colour = match self.images.get(cache_key) { - Some(p) => p.clone(), + /// The pixels of image `key` (a blob of `doc`, or a recoloured one named by + /// [`Self::adjusted_image`]), or a greyscale copy of them, with their key in the shared store. + /// `touch` marks them used there. + fn image_pixmap(&mut self, doc: &Document, key: &str, grey: bool, touch: bool) -> Option<(images::ImageKey, Arc)> { + let (key, colour) = match doc.images.get(key) { + Some(blob) => blob_pixmap(blob, touch)?, None => { - let blob = doc.images.get(key)?; - match self.cmyk_image(blob, cache_key) { - Some(pm) => pm, + let (src, hash, map) = self.adjusts.get(key)?.clone(); + let (src_key, src) = self.image_pixmap(doc, &src, false, touch)?; + let key = images::ImageKey { adjust: Some(hash), ..src_key }; + match lookup(&key, touch) { + Some(pm) => (key, pm), None => { - let pm = Arc::new(paint::decode_pixmap(&blob.bytes)?); - self.images.insert(cache_key.to_string(), pm.clone()); - pm + let mut pm = (*src).clone(); + let mut memo: HashMap<[u8; 4], [u8; 3]> = HashMap::new(); + for px in pm.data_mut().iter_mut().filter(|p| p.a > 0) { + let a = px.a as u32; + let [r, g, b] = *memo.entry([px.r, px.g, px.b, px.a]).or_insert_with(|| { + // Premultiplied to straight, adjusted, then premultiplied again. + let straight = [px.r, px.g, px.b].map(|v| ((v as u32 * 255 + a / 2) / a).min(255) as u8); + map.apply_rgb8(straight).map(|v| ((v as u32 * a + 127) / 255) as u8) + }); + (px.r, px.g, px.b) = (r, g, b); + } + let pm = images::store().insert(key.clone(), Arc::new(pm)); + (key, pm) } } } }; if !grey { - return Some(colour); + return Some((key, colour)); } - let grey_key = format!("{cache_key}\u{0}grey"); - if let Some(p) = self.images.get(&grey_key) { - return Some(p.clone()); + let key = images::ImageKey { grey: true, ..key }; + if let Some(p) = lookup(&key, touch) { + return Some((key, p)); } let mut pm = (*colour).clone(); for px in pm.data_mut() { @@ -1680,12 +1707,62 @@ impl Renderer { let l = (0.2126 * px.r as f32 + 0.7152 * px.g as f32 + 0.0722 * px.b as f32 + 0.5) as u8; (px.r, px.g, px.b) = (l, l, l); } - let pm = Arc::new(pm); - self.images.insert(grey_key, pm.clone()); - Some(pm) + let pm = images::store().insert(key.clone(), Arc::new(pm)); + Some((key, pm)) } } +/// `key`'s pixels in the shared image store; `touch` marks them used. +fn lookup(key: &images::ImageKey, touch: bool) -> Option> { + let mut s = images::store(); + if touch { s.get(key) } else { s.peek(key) } +} + +/// The decoded pixels of `blob` (a CMYK image's inks shown through the working CMYK profile, again +/// when the colour settings change), from the shared store, and their key there. +fn blob_pixmap(blob: &vectorcraft_doc::ImageBlob, touch: bool) -> Option<(images::ImageKey, Arc)> { + let id = images::blob_id(blob); + let lut = id.cmyk.then(ink::display_lut); + let key = + images::ImageKey { content: id.content, adjust: None, grey: false, ink: Ink::Display, luts: (lut.as_ref().map_or(0, |l| l.1), 0), mip: 0 }; + if let Some(pm) = lookup(&key, touch) { + return Some((key, pm)); + } + // Decoded outside the lock: other renderers carry on meanwhile. + let pm = match lut.and_then(|(lut, _)| ink::cmyk_pixmap(blob, &lut)) { + Some(pm) => pm, + None => paint::decode_pixmap(&blob.bytes)?, + }; + let pm = images::store().insert(key.clone(), Arc::new(pm)); + Some((key, pm)) +} + +/// Image pixels `pm` (stored as `key`) to draw through `m` (unit square → device pixels), shrunk +/// by halves while still at least as fine as the device pixels ([`images::mip_level`]); the +/// pixels drawn are marked used in the store. +fn image_mip(key: &images::ImageKey, pm: Arc, m: Affine) -> Arc { + let (w, h) = (pm.width(), pm.height()); + let level = images::mip_level(m * Affine::scale_non_uniform(1.0 / w.max(1) as f64, 1.0 / h.max(1) as f64), w, h); + let mut s = images::store(); + if level == 0 { + let _ = s.get(key); // Marks the full pixels used (they were only looked at). + return pm; + } + let at = |l: u8| images::ImageKey { mip: l, ..key.clone() }; + if let Some(p) = s.get(&at(level)) { + return p; + } + // From the finest level kept below it. + let (mut l, mut cur) = (1..level).rev().find_map(|l| s.peek(&at(l)).map(|p| (l, p))).unwrap_or((0, pm)); + drop(s); + while l < level { + l += 1; + let half = Arc::new(images::half(&cur)); + cur = images::store().insert(at(l), half); + } + cur +} + /// Can image node `n` (`im`) clipped by `region` be painted straight into the region: visible, /// with no transparency, effects or paint of its own, and the region inside the image's frame /// (within a pixel), so no paint past the image's edge shows? @@ -1934,6 +2011,8 @@ mod tests_knockout; #[cfg(test)] mod tests_layeropts; #[cfg(test)] +mod tests_memory; +#[cfg(test)] mod tests_objectfx; #[cfg(test)] mod tests_placed_document; diff --git a/crates/render/src/tests_memory.rs b/crates/render/src/tests_memory.rs new file mode 100644 index 00000000..bd091bf7 --- /dev/null +++ b/crates/render/src/tests_memory.rs @@ -0,0 +1,105 @@ +//! What renderers keep between frames: decoded images shared by all of them, image mips, and +//! caches swept by every kind of render. + +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind}; + +use super::*; + +/// A `w`×`h` PNG whose pixels depend on `seed` (each test its own image: the store is shared by +/// the tests running alongside). +fn png(w: u32, h: u32, seed: u8) -> Vec { + let img = image::RgbaImage::from_fn(w, h, |x, y| { + let v = (x * 7 + y * 13) as u8; + image::Rgba([v ^ seed, v.wrapping_mul(3), seed, 255]) + }); + let mut out = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap(); + out +} + +/// A document showing `bytes` as image `key` of `w`×`h` pixels, one point a pixel. +fn doc_with_image(key: &str, bytes: Vec, w: u32, h: u32) -> Document { + let mut d = Document::new(w as f64, h as f64); + d.images.insert(key.into(), ImageBlob::new("image/png", bytes)); + let (id, layer) = (d.alloc_id(), d.layers[0].id); + let im = ImageObject { key: key.into(), width: w, height: h, xf: Affine::IDENTITY, link: None, placement: Default::default() }; + d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(im))).unwrap(); + d +} + +#[test] +fn renderers_share_one_decode_of_an_image() { + let bytes = png(16, 16, 101); + let a = doc_with_image("img", bytes.clone(), 16, 16); + // Another document holding the same image under another key (its own copy of the bytes). + let b = doc_with_image("photo", bytes, 16, 16); + let (mut r1, mut r2) = (Renderer::new(), Renderer::new()); + let (k1, p1) = r1.image_pixmap(&a, "img", false, true).unwrap(); + let (k2, p2) = r2.image_pixmap(&b, "photo", false, true).unwrap(); + assert_eq!(k1, k2); + assert!(Arc::ptr_eq(&p1, &p2), "decoded once for both renderers"); + // Drawing goes through the store too: the pixels stay the ones decoded. + let _ = r2.render(&b, 16, 16, Affine::IDENTITY, &RenderOptions::default()); + assert!(Arc::ptr_eq(&r1.image_pixmap(&b, "photo", false, true).unwrap().1, &p1)); + // A document using the same key for other pixels gets its own. + let c = doc_with_image("img", png(16, 16, 102), 16, 16); + let (k3, p3) = r1.image_pixmap(&c, "img", false, true).unwrap(); + assert!(k3 != k1 && !Arc::ptr_eq(&p3, &p1)); + assert!( + r1.render(&c, 16, 16, Affine::IDENTITY, &RenderOptions::default()).pixels + != r1.render(&a, 16, 16, Affine::IDENTITY, &RenderOptions::default()).pixels + ); +} + +/// Thumbnails and single-node renders end their frame as full renders do: entries of nodes no +/// longer drawn are dropped, with the nodes they kept alive. +#[test] +fn node_renders_sweep_stale_cache_entries() { + let mut d = Document::new(10.0, 10.0); + let id = d.alloc_id(); + let square = |x: f64| { + let p = vectorcraft_geom::shapes::rectangle(Rect::new(x, 0.0, x + 5.0, 5.0)); + Arc::new(Node::path(id, p, vectorcraft_doc::Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0))) + }; + let first = square(0.0); + let mut r = Renderer::new(); + r.render_node(&d, &first, 10, 10, Affine::IDENTITY).unwrap(); + assert!(Arc::strong_count(&first) > 1, "cached while drawn"); + for i in 0..1100 { + let n = square(i as f64); + if i % 2 == 0 { + r.render_node(&d, &n, 10, 10, Affine::IDENTITY).unwrap(); + } else { + // A thumbnail of a group caches its member's geometry. + r.render_node_thumbnail(&d, &Node::group(id, vec![n]), 16, None).unwrap(); + } + } + assert_eq!(Arc::strong_count(&first), 1, "swept: the renderer no longer keeps the node"); + assert!(r.geom.len() <= 1025, "{} geometry entries", r.geom.len()); +} + +/// With `image_mips` off (the default; exports) an image draws from its full pixels whatever the +/// store holds; on, a reduced image draws from a box-filtered half (quarter…) instead, and at +/// more than half size from the full pixels as before. +#[test] +fn image_mips_are_opt_in_and_change_nothing_off() { + let d = doc_with_image("img", png(64, 64, 103), 64, 64); + let (off, on) = (RenderOptions::default(), RenderOptions { image_mips: true, ..Default::default() }); + let quarter = Affine::scale(0.25); + let cold = Renderer::new().render(&d, 16, 16, quarter, &off); + // Mips made by another renderer don't change what this one draws with them off. + let mipped = Renderer::new().render(&d, 16, 16, quarter, &on); + let mut warm = Renderer::new(); + assert!(warm.render(&d, 16, 16, quarter, &off).pixels == cold.pixels); + assert!(warm.render(&d, 16, 16, quarter, &off).pixels == cold.pixels, "warm"); + // On, the quarter-size image samples the quarter-size mip: close to the full pixels' average + // but not the same pixels. + assert!(mipped.pixels != cold.pixels); + let (key, _) = warm.image_pixmap(&d, "img", false, true).unwrap(); + let mip = images::store().peek(&images::ImageKey { mip: 2, ..key }).expect("quarter mip kept"); + assert_eq!((mip.width(), mip.height()), (16, 16)); + // Above half size, mips on draw the full pixels. + let near = Affine::scale(0.75); + assert!(Renderer::new().render(&d, 48, 48, near, &on).pixels == Renderer::new().render(&d, 48, 48, near, &off).pixels); +}