#1244: one shared, byte-budgeted store for decoded images; render caches that sweep and stay bounded

Decoded images were kept per Renderer at full resolution and never evicted, so the canvas,
render worker, thumbnails and panels each held their own copy (96 MB per 24 MP photo).
They now live once in a process-wide LRU store keyed by content, with optional mip levels
(RenderOptions::image_mips, off by default so exports stay identical). Thumbnail renders
now sweep their caches, and shadow/effect rasters share a 512 MiB budget and match by Arc.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmMmc1J8XeNLxhRihWv9cc
This commit is contained in:
arhammusheer 2026-10-11 23:28:10 +05:30 committed by bflatastic
parent c373fc233c
commit fe8a6983c5
5 changed files with 720 additions and 166 deletions

View File

@ -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<Node>>,
/// First shadow-cache slot of these effects (`None`: don't cache).
cache: Option<usize>,
/// Document-space bounds of the painted content.
@ -222,7 +224,7 @@ fn looks_around(fx: &RasterFx) -> Option<f64> {
/// 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<vello_cpu::Pixmap>
/// 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<Node>),
Copy(Box<Node>),
}
impl Drawn {
fn of(n: &Node, owner: Option<&Arc<Node>>) -> 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<Node>>,
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::<usize>();
let latest = r.shadows.values().filter(|e| e.stamp == newest).map(|e| raster_bytes(&e.image)).sum::<usize>();
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]

282
crates/render/src/images.rs Normal file
View File

@ -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<u64> {
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<u64> {
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<str>,
/// Recoloured by the colour adjustment with this hash (see `Renderer::adjusted_image`).
pub adjust: Option<u64>,
/// 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<str>,
pub cmyk: bool,
}
struct Entry {
pm: Arc<Pixmap>,
/// [`ImageStore::tick`] of the last request.
used: u64,
}
/// Decoded images within a byte budget (see the module docs).
pub(crate) struct ImageStore {
entries: HashMap<ImageKey, Entry>,
/// 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<usize, (Weak<Vec<u8>>, 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<Arc<Pixmap>> {
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<Arc<Pixmap>> {
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<Pixmap>) -> Arc<Pixmap> {
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<Vec<u8>>) -> Option<BlobId> {
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<Vec<u8>>, 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<Mutex<ImageStore>> = 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::<u32>() + 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");
}
}

View File

@ -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<String, (Arc<[ProofLut; 2]>, [Arc<Pixmap>; 2])>;
/// A table built for the colour settings it was built with, and its generation (see [`cached`]).
type Cached<T> = Mutex<Option<(Arc<Cms>, Arc<T>, u64)>>;
/// A table built for the colour settings it was built with.
type Cached<T> = Mutex<Option<(Arc<Cms>, Arc<T>)>>;
/// 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<InkLut> = 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<T>(slot: &Cached<T>, build: impl FnOnce(&Cms) -> T) -> Arc<T> {
/// The table in `slot` for the active colour settings, built by `build` when they changed, and
/// its generation.
fn cached<T>(slot: &Cached<T>, build: impl FnOnce(&Cms) -> T) -> (Arc<T>, 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<T>(slot: &Cached<T>, build: impl FnOnce(&Cms) -> T) -> Arc<T> {
/// [`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<u8> {
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<u8> {
.collect()
}
/// CMYK images as shown, per image key, with the table they were shown through.
pub(crate) type CmykImages = HashMap<String, (Arc<InkLut>, Arc<Pixmap>)>;
/// Ink amounts `inks` shown through `lut` (opaque).
fn inks_pixmap(inks: &Inks, lut: &InkLut) -> Option<Pixmap> {
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<Pixmap> {
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<Arc<Pixmap>> {
// 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<InkLut>, 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<Pixmap>, ink: Ink, blob: Option<&ImageBlob>) -> Arc<Pixmap> {
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<Pixmap> {
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<Pixmap>, ink: Ink, blob: Option<&ImageBlob>, touch: bool) -> (ImageKey, Arc<Pixmap>) {
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());

View File

@ -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<Node>, Option<Arc<(BezPath, FillRule)>>);
/// A clipping path (kept alive) and what it paints: its fills and its strokes ([`Node::clip_paint`]).
type ClipPaintEntry = (Arc<Node>, Option<Arc<Node>>, Option<Arc<Node>>);
/// 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<usize, (Arc<Node>, Arc<TextGeom>)>,
/// The [`vectorcraft_text::FontDb::generation`] `texts` was laid out with: type lays out
@ -367,7 +375,6 @@ pub struct Renderer {
alpha: f32,
ctx: Option<RenderContext>,
resources: Resources,
images: HashMap<String, Arc<Pixmap>>,
/// 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<usize, ClipPaintEntry>,
/// 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<String, (String, u64, vectorcraft_effects::ColorMap)>,
/// Layer Options → Dim Images to, of the layer being drawn (screen views only): images show
/// faded to this opacity over white.
dim_images: Option<f32>,
@ -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<Rendered> {
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<Rendered> {
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<Rendered> {
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<Arc<Pixmap>> {
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<Pixmap>)> {
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<Arc<Pixmap>> {
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<Pixmap>)> {
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<Pixmap>, m: Affine) -> Arc<Pixmap> {
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;

View File

@ -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<u8> {
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<u8>, 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);
}