diff --git a/crates/render/src/brush_fx.rs b/crates/render/src/brush_fx.rs index 0d02f13e..0f8c8b31 100644 --- a/crates/render/src/brush_fx.rs +++ b/crates/render/src/brush_fx.rs @@ -13,25 +13,30 @@ use vectorcraft_doc::{AppearanceItem, Document, Node, StrokeLayer}; use vectorcraft_geom::{BezPath, Rect}; use vello_cpu::RenderContext; +use crate::lru::{self, FrameCache}; use crate::{Frame, Renderer}; -struct Entry { +pub(crate) struct Entry { path: BezPath, stroke: StrokeLayer, brush: Arc, pieces: Arc>, - stamp: u64, } /// Parsed brush library and generated brush art. -#[derive(Default)] pub(crate) struct BrushCache { /// The document value the library was parsed from (`None` = defaults). src: Option>, /// (frame stamp, document address) the library was last validated for. checked: (u64, usize), lib: HashMap>, - entries: HashMap<(u64, usize), Entry>, + pub(crate) entries: FrameCache<(u64, usize), Entry>, +} + +impl Default for BrushCache { + fn default() -> Self { + Self { src: None, checked: (0, 0), lib: HashMap::new(), entries: FrameCache::new(lru::BRUSH_BUDGET, 512) } + } } impl BrushCache { @@ -73,17 +78,12 @@ impl Renderer { let idx = n.appearance.items.iter().position(|i| matches!(i, AppearanceItem::Stroke(s) if std::ptr::eq(s, st))).unwrap_or(usize::MAX); let key = (n.id.0, idx); let stamp = self.stamp; - let pieces = match self.brushes.entries.get_mut(&key) { - Some(e) if Arc::ptr_eq(&e.brush, &brush) && e.stroke == *st && e.path == *bp => { - e.stamp = stamp; - e.pieces.clone() - } + let pieces = match self.brushes.entries.get(&key, stamp) { + Some(e) if Arc::ptr_eq(&e.brush, &brush) && e.stroke == *st && e.path == *bp => e.pieces.clone(), _ => { let pieces = Arc::new(vectorcraft_brush::stroke_pieces(&brush, bp, st)); - if self.brushes.entries.len() > 2048 { - self.brushes.entries.retain(|_, e| stamp.saturating_sub(e.stamp) <= 3); - } - self.brushes.entries.insert(key, Entry { path: bp.clone(), stroke: st.clone(), brush, pieces: pieces.clone(), stamp }); + let bytes = size_of::() + lru::path_bytes(bp) + pieces.iter().map(lru::node_bytes).sum::(); + self.brushes.entries.insert(key, Entry { path: bp.clone(), stroke: st.clone(), brush, pieces: pieces.clone() }, bytes, stamp); pieces } }; diff --git a/crates/render/src/freeform.rs b/crates/render/src/freeform.rs index f583af20..ece0a0fa 100644 --- a/crates/render/src/freeform.rs +++ b/crates/render/src/freeform.rs @@ -7,7 +7,6 @@ //! the gradient's content, so panning and redraws don't resample it. use std::cell::RefCell; -use std::collections::HashMap; use std::hash::{Hash, Hasher}; use std::sync::Arc; @@ -18,12 +17,11 @@ use vello_cpu::peniko::{Extend, ImageQuality, ImageSampler}; use vello_cpu::{Pixmap, RenderContext}; use crate::ink::Ink; - -/// Grids kept before the cache starts over. -const MAX_GRIDS: usize = 128; +use crate::lru::{self, FrameCache}; thread_local! { - static GRIDS: RefCell>> = RefCell::new(HashMap::new()); + /// Grids by [`key`], within [`lru::FREEFORM_BUDGET`], least recently used first out. + static GRIDS: RefCell>> = RefCell::new(FrameCache::new(lru::FREEFORM_BUDGET, usize::MAX)); } fn hash_color(c: &Color, h: &mut impl Hasher) { @@ -72,17 +70,21 @@ pub(crate) fn set_freeform_paint(ctx: &mut RenderContext, g: &GradientPaint, bou let (cols, rows) = grid_size(b, device); let f = g.freeform_on(b); let k = key(&f, b, cols, rows, ink); - let pm = GRIDS.with(|c| c.borrow().get(&k).cloned()).unwrap_or_else(|| { - let pm = Arc::new(rasterize(&f, b, cols, rows, ink)); - GRIDS.with(|c| { + let pm = GRIDS + .with(|c| { let mut c = c.borrow_mut(); - if c.len() >= MAX_GRIDS { - c.clear(); - } - c.insert(k, pm.clone()); + let t = c.tick(); + c.get(&k, t).cloned() + }) + .unwrap_or_else(|| { + let pm = Arc::new(rasterize(&f, b, cols, rows, ink)); + GRIDS.with(|c| { + let mut c = c.borrow_mut(); + let t = c.tick(); + c.insert(k, pm.clone(), cols as usize * rows as usize * 4, t); + }); + pm }); - pm - }); let sampler = ImageSampler { x_extend: Extend::Pad, y_extend: Extend::Pad, quality: ImageQuality::Medium, alpha: 1.0 }; ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(pm), sampler }); ctx.set_paint_transform(Affine::translate(b.origin().to_vec2()) * Affine::scale_non_uniform(b.width() / cols as f64, b.height() / rows as f64)); diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 0398338d..db417efd 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -482,7 +482,7 @@ impl Renderer { fn fx_art(&mut self, doc: &vectorcraft_doc::Document, a: &Arc, cache: bool) -> Arc { let key = Arc::as_ptr(a) as usize; if cache - && let Some((node, art)) = self.fx_arts.get(&key) + && let Some((node, art)) = self.fx_arts.get(&key, self.stamp) && Arc::ptr_eq(node, a) { return art.clone(); @@ -516,10 +516,8 @@ impl Renderer { ..art }); if cache { - if self.fx_arts.len() > 1024 { - self.fx_arts.clear(); - } - self.fx_arts.insert(key, (a.clone(), art.clone())); + let bytes = crate::lru::node_bytes(&art); + self.fx_arts.insert(key, (a.clone(), art.clone()), bytes, self.stamp); } art } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index cca21739..b62f06ce 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -14,6 +14,7 @@ mod group; mod images; mod ink; mod live; +mod lru; mod paint; mod pattern; pub mod placed_document; @@ -30,6 +31,7 @@ use vello_cpu::{Pixmap, RenderContext, Resources}; use group::Composite; use ink::Ink; +use lru::FrameCache; pub use brush_fx::instance_art; pub use effects::stroke::width_outline; @@ -350,12 +352,13 @@ type ClipPaintEntry = (Arc, Option>, Option>); /// 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)>, + /// Glyph geometry per text object, keyed by `Arc` identity like `geom` (see [`Self::text_geom_of`]). + texts: FrameCache, Arc)>, /// The [`vectorcraft_text::FontDb::generation`] `texts` was laid out with: type lays out /// again when fonts are added or rescanned. text_fonts: u64, /// Clip regions per clipping path (see [`Self::clip_of`]). - clips: PtrMap, + clips: FrameCache, /// Context reused when rendering single-threaded (`threads == 0`). ctx_st: Option, /// Worker threads for the multithreaded rasterizer (0 = single-threaded). @@ -392,7 +395,7 @@ pub struct Renderer { /// opacity and without its opacity mask (see [`Self::draw_knockout`]); 0 = none. shape_of: usize, /// The art drawn for objects with effects that apply through it (see `fx::has_object_fx`). - fx_arts: PtrMap, Arc)>, + fx_arts: FrameCache, Arc)>, /// Layers open around the drawing point in the current context: a non-isolated group can copy /// its backdrop only when there are none (see [`Self::group`]). nested: u32, @@ -405,7 +408,7 @@ pub struct Renderer { /// again from a picture of it pushes them again (see [`Self::group`]). clip_paths: Vec<(BezPath, FillRule, Affine)>, /// What clipping paths paint, per clipping path (see [`Self::clip_paint_of`]). - clip_paints: PtrMap, + clip_paints: FrameCache, /// Gradients along or across strokes, keyed like `strokes` (see [`Self::fill_path_gradient`]). stroke_slices: PtrMap<(usize, i32), SliceEntry>, /// What the image keys colour adjustments give their recoloured images name: the image @@ -479,9 +482,9 @@ impl Frame<'_> { impl Renderer { pub fn new() -> Self { Self { - texts: PtrMap::default(), + texts: FrameCache::new(lru::TEXT_BUDGET, 1024), text_fonts: 0, - clips: PtrMap::default(), + clips: FrameCache::new(lru::CLIP_BUDGET, 256), ctx_st: None, threads: default_threads(), geom: PtrMap::default(), @@ -500,12 +503,12 @@ impl Renderer { live: live::LiveCache::default(), knockout: false, shape_of: 0, - fx_arts: PtrMap::default(), + fx_arts: FrameCache::new(lru::FX_ART_BUDGET, 256), nested: 0, backdrop: None, blends: PtrMap::default(), clip_paths: vec![], - clip_paints: PtrMap::default(), + clip_paints: FrameCache::new(lru::CLIP_BUDGET, 256), stroke_slices: PtrMap::default(), adjusts: HashMap::new(), dim_images: None, @@ -599,6 +602,12 @@ impl Renderer { } // Filtered rasters are large: only those of the last frame stay. self.pixel_fx.retain(|_, e| e.stamp == g); + self.texts.sweep(g); + self.clips.sweep(g); + self.clip_paints.sweep(g); + self.fx_arts.sweep(g); + self.brushes.entries.sweep(g); + self.live.sweep(g); } /// Draw frame `f` (`w`×`h` pixels): the background, the artboards and the page's art. @@ -799,16 +808,14 @@ impl Renderer { /// outlining text and uniting shapes is too slow to repeat every frame. fn clip_of(&mut self, a: &Arc) -> Option> { let key = Arc::as_ptr(a) as usize; - if let Some((node, region)) = self.clips.get(&key) + if let Some((node, region)) = self.clips.get(&key, self.stamp) && Arc::ptr_eq(node, a) { return region.clone(); } let region = effects::clip_outline(a).map(Arc::new); - if self.clips.len() > 1024 { - self.clips.clear(); - } - self.clips.insert(key, (a.clone(), region.clone())); + let bytes = size_of::() + region.as_ref().map_or(0, |r| lru::path_bytes(&r.0)); + self.clips.insert(key, (a.clone(), region.clone()), bytes, self.stamp); region } @@ -816,17 +823,15 @@ impl Renderer { /// allocations between frames, so their own geometry stays cached. fn clip_paint_of(&mut self, a: &Arc) -> (Option>, Option>) { let key = Arc::as_ptr(a) as usize; - if let Some((node, fill, stroke)) = self.clip_paints.get(&key) + if let Some((node, fill, stroke)) = self.clip_paints.get(&key, self.stamp) && Arc::ptr_eq(node, a) { return (fill.clone(), stroke.clone()); } let paint = a.clip_paint(); let (fill, stroke) = (paint.fill.map(Arc::new), paint.stroke.map(Arc::new)); - if self.clip_paints.len() > 1024 { - self.clip_paints.clear(); - } - self.clip_paints.insert(key, (a.clone(), fill.clone(), stroke.clone())); + let bytes = size_of::() + [&fill, &stroke].iter().filter_map(|p| p.as_deref()).map(lru::node_bytes).sum::(); + self.clip_paints.insert(key, (a.clone(), fill.clone(), stroke.clone()), bytes, self.stamp); (fill, stroke) } @@ -1477,16 +1482,13 @@ impl Renderer { self.text_fonts = fonts; } let key = Arc::as_ptr(a) as usize; - if let Some((node, g)) = self.texts.get(&key) + if let Some((node, g)) = self.texts.get(&key, self.stamp) && Arc::ptr_eq(node, a) { return g.clone(); } let g = Arc::new(text_geom(t)); - if self.texts.len() > 4096 { - self.texts.clear(); - } - self.texts.insert(key, (a.clone(), g.clone())); + self.texts.insert(key, (a.clone(), g.clone()), g.bytes(), self.stamp); g } @@ -1535,7 +1537,8 @@ impl Renderer { } } for (i, run) in t.runs.iter().enumerate() { - let Some(path) = g.runs.get(i) else { continue }; + let path = g.run(i); + let path = path.as_ref(); if path.elements().is_empty() { continue; } @@ -1865,10 +1868,12 @@ pub fn default_threads() -> u16 { } } -/// Glyph outlines grouped by run, plus the whole text as one path. +/// The whole text's glyph outlines as one path, and which of them each run draws. struct TextGeom { - runs: Vec, all: BezPath, + /// Spans of `all`'s elements drawn by a run, in order: (run, start, end). Outlines of runs + /// the text doesn't have are in `all` only. + spans: Vec<(usize, usize, usize)>, bounds: Rect, /// The boxes of characters whose font is missing (drawn in a substitute), and of glyphs the /// font lacks (drawn from a fallback font): Document Setup's substitution highlights. @@ -1878,6 +1883,33 @@ struct TextGeom { inlines: Vec, } +impl TextGeom { + /// The outlines run `i` draws: the glyphs of `all` it has, in their order. Kept once rather + /// than per run as well, which held every outline twice. + fn run(&self, i: usize) -> std::borrow::Cow<'_, BezPath> { + let els = self.all.elements(); + if let [(r, 0, end)] = self.spans[..] + && r == i + && end == els.len() + { + return std::borrow::Cow::Borrowed(&self.all); + } + let mut p = BezPath::new(); + for &(_, start, end) in self.spans.iter().filter(|s| s.0 == i) { + p.extend(els.get(start..end).unwrap_or_default().iter().copied()); + } + std::borrow::Cow::Owned(p) + } + + /// Approximate heap bytes, for the cache's budget. + fn bytes(&self) -> usize { + size_of::() + + [&self.all, &self.substituted_fonts, &self.substituted_glyphs].into_iter().map(lru::path_bytes).sum::() + + self.spans.len() * size_of::<(usize, usize, usize)>() + + self.inlines.len() * size_of::() + } +} + fn text_geom(t: &TextObject) -> TextGeom { text_geom_snapped(t, None) } @@ -1889,8 +1921,21 @@ fn text_geom_snapped(t: &TextObject, snap: Option) -> TextGeom { if let Some(xf) = snap { layout.snap_to_pixels(xf); } - let mut runs = vec![BezPath::new(); t.runs.len()]; let mut all = BezPath::new(); + let mut spans: Vec<(usize, usize, usize)> = vec![]; + // Append `outline` to `all`, drawn by `run` when the text has that run. + let mut push = |all: &mut BezPath, run: usize, outline: &BezPath| { + let start = all.elements().len(); + all.extend(outline.iter()); + let end = all.elements().len(); + if run >= t.runs.len() || start == end { + return; + } + match spans.last_mut() { + Some((r, _, e)) if *r == run && *e == start => *e = end, + _ => spans.push((run, start, end)), + } + }; // Per run: is its family missing, and the face its glyphs should come from. let faces: Vec<(bool, Option)> = t .runs @@ -1906,10 +1951,7 @@ fn text_geom_snapped(t: &TextObject, snap: Option) -> TextGeom { .collect(); let (mut substituted_fonts, mut substituted_glyphs) = (BezPath::new(), BezPath::new()); for g in &layout.glyphs { - if let Some(r) = runs.get_mut(g.run) { - r.extend(g.outline.iter()); - } - all.extend(g.outline.iter()); + push(&mut all, g.run, &g.outline); let Some(&(missing, face)) = faces.get(g.run) else { continue }; let target = if missing { &mut substituted_fonts @@ -1925,12 +1967,9 @@ fn text_geom_snapped(t: &TextObject, snap: Option) -> TextGeom { } // Underline and strikethrough bars, painted as their run's type is (#847). for (run, bar) in vectorcraft_text::decorations(&layout, db, t) { - if let Some(r) = runs.get_mut(run) { - r.extend(bar.iter()); - } - all.extend(bar.iter()); + push(&mut all, run, &bar); } - TextGeom { runs, all, bounds: layout.bounds, substituted_fonts, substituted_glyphs, inlines: layout.inlines } + TextGeom { all, spans, bounds: layout.bounds, substituted_fonts, substituted_glyphs, inlines: layout.inlines } } /// Document Setup's highlight behind substituted fonts and glyphs (screen only). diff --git a/crates/render/src/live.rs b/crates/render/src/live.rs index 5b870d2d..2b35b752 100644 --- a/crates/render/src/live.rs +++ b/crates/render/src/live.rs @@ -9,7 +9,6 @@ //! grown by half a device pixel along its edges so neighbours overlap and no antialiasing seams //! show. -use std::collections::HashMap; use std::sync::Arc; use vectorcraft_doc::live::{GradientMesh, MeshQuad}; @@ -18,28 +17,30 @@ use vectorcraft_geom::{BezPath, Vec2}; use vello_cpu::RenderContext; use vello_cpu::peniko; +use crate::lru::{self, FrameCache}; use crate::{Frame, Renderer}; type Expanded = Arc>>; -type MeshEntry = (Arc, Arc>, u64); +type Quads = Arc>; -/// Per-renderer cache of evaluated live objects. -#[derive(Default)] +/// Per-renderer cache of evaluated live objects: (live node, its evaluation, [`resources_key`]), +/// and (mesh node, its quads) per node and subdivision level. pub(crate) struct LiveCache { - expanded: HashMap, Expanded, u64, u64)>, - meshes: HashMap<(usize, usize), MeshEntry>, - stamp: u64, + expanded: FrameCache, Expanded, u64)>, + meshes: FrameCache<(usize, usize), (Arc, Quads)>, +} + +impl Default for LiveCache { + fn default() -> Self { + Self { expanded: FrameCache::new(lru::LIVE_BUDGET, 256), meshes: FrameCache::new(lru::MESH_BUDGET, 256) } + } } impl LiveCache { - fn tick(&mut self, stamp: u64) { - self.stamp = stamp; - if self.expanded.len() > 256 { - self.expanded.retain(|_, e| stamp - e.2 <= 3); - } - if self.meshes.len() > 256 { - self.meshes.retain(|_, e| stamp - e.2 <= 3); - } + /// End of frame `stamp` (see [`FrameCache::sweep`]). + pub(crate) fn sweep(&mut self, stamp: u64) { + self.expanded.sweep(stamp); + self.meshes.sweep(stamp); } } @@ -84,11 +85,10 @@ impl Renderer { let key = Arc::as_ptr(a) as usize; let resources = resources_key(doc, a); if cache - && let Some(e) = self.live.expanded.get_mut(&key) + && let Some(e) = self.live.expanded.get(&key, self.stamp) && Arc::ptr_eq(&e.0, a) - && e.3 == resources + && e.2 == resources { - e.2 = self.live.stamp; return e.1.clone(); } let v: Expanded = match crate::effects::pathfinder_children(a, crate::effects::text_outliner()) { @@ -96,7 +96,8 @@ impl Renderer { None => Arc::new(crate::effects::expand_live(Some(doc), a).into_iter().map(Arc::new).collect()), }; if cache { - self.live.expanded.insert(key, (a.clone(), v.clone(), self.live.stamp, resources)); + let bytes = size_of::() + v.iter().map(|n| lru::node_bytes(n)).sum::(); + self.live.expanded.insert(key, (a.clone(), v.clone(), resources), bytes, self.stamp); } v } @@ -104,23 +105,21 @@ impl Renderer { fn mesh_quads(&mut self, a: &Arc, m: &GradientMesh, level: usize, cache: bool) -> Arc> { let key = (Arc::as_ptr(a) as usize, level); if cache - && let Some(e) = self.live.meshes.get_mut(&key) + && let Some(e) = self.live.meshes.get(&key, self.stamp) && Arc::ptr_eq(&e.0, a) { - e.2 = self.live.stamp; return e.1.clone(); } let q = Arc::new(m.quads(level)); if cache { - self.live.meshes.insert(key, (a.clone(), q.clone(), self.live.stamp)); + let bytes = q.len() * size_of::(); + self.live.meshes.insert(key, (a.clone(), q.clone()), bytes, self.stamp); } q } /// Draw a live node reached through the tree walk (handles its transparency group). pub(crate) fn draw_live(&mut self, ctx: &mut RenderContext, f: &Frame, a: &Arc) { - let stamp = self.stamp; - self.live.tick(stamp); let opacity = self.opacity_of(a); // A knockout blend's steps knock each other out, as in the exports (opaque ones can't show it). let knockout = !f.opts.outline diff --git a/crates/render/src/lru.rs b/crates/render/src/lru.rs new file mode 100644 index 00000000..d96dbc68 --- /dev/null +++ b/crates/render/src/lru.rs @@ -0,0 +1,257 @@ +//! Caches bounded by frames and by bytes. +//! +//! A renderer keeps what it works out per object between frames (glyph outlines, clip regions, +//! brush art, evaluated blends…). Each entry records the frame that last used it and roughly how +//! many bytes it holds. [`FrameCache::sweep`] (at the end of every frame) drops entries unused for +//! a few frames once the cache holds more than a handful, so the nodes they keep alive go with +//! them; [`FrameCache::insert`] keeps the total under a byte budget by dropping the least recently +//! used entries of earlier frames. +//! +//! Entries used in the frame being drawn are never dropped for a new one: when they alone fill the +//! budget the new entry isn't kept (it is worked out again when next needed). A document needing +//! more than the budget then keeps the same entries frame after frame and works out only the rest +//! again, where dropping everything (or the oldest entry, in a least-recently-used order) whenever +//! the cache filled worked out every entry again every frame. + +use std::borrow::Borrow; +use std::collections::HashMap; +use std::hash::{BuildHasher, Hash}; +use std::mem::size_of; + +use vectorcraft_doc::Node; +use vectorcraft_geom::{Anchor, BezPath, SubPath}; + +use crate::PtrHasher; + +/// Glyph outlines per text object (`Renderer::text_geom_of`). +pub(crate) const TEXT_BUDGET: usize = 128 << 20; +/// Clip regions per clipping path (`Renderer::clip_of`), and separately what clipping paths +/// paint (`Renderer::clip_paint_of`). +pub(crate) const CLIP_BUDGET: usize = 32 << 20; +/// The art drawn for objects with effects that apply through it (`Renderer::fx_art`). +pub(crate) const FX_ART_BUDGET: usize = 64 << 20; +/// Brush art per brushed stroke (`Renderer::draw_brush`). +pub(crate) const BRUSH_BUDGET: usize = 64 << 20; +/// Evaluated blends, envelopes and repeats (`Renderer::live_expanded`). +pub(crate) const LIVE_BUDGET: usize = 64 << 20; +/// Tessellated gradient meshes (`Renderer::mesh_quads`). +pub(crate) const MESH_BUDGET: usize = 32 << 20; +/// Pattern tile rasters, per thread and ink plane (`pattern::set_pattern_paint`): a tile is at +/// most 4096² pixels, 64 MiB. +pub(crate) const PATTERN_BUDGET: usize = 128 << 20; +/// Freeform gradient grids, per thread (`freeform::set_freeform_paint`). +pub(crate) const FREEFORM_BUDGET: usize = 32 << 20; + +/// Frames an entry may go unused before [`FrameCache::sweep`] drops it. +const AGE: u64 = 3; + +/// Bookkeeping of a node beyond its own struct: its allocation, `Arc` counts, appearance. +const NODE_OVERHEAD: usize = 128; + +pub(crate) type PtrBuild = std::hash::BuildHasherDefault; + +struct Slot { + value: V, + bytes: usize, + /// Frame (or, for caches without frames, request) that last used it. + stamp: u64, + /// [`FrameCache::clock`] when last used: the order entries are dropped in. + used: u64, +} + +/// Entries swept by frame and kept within a byte budget (see the module docs). +pub(crate) struct FrameCache { + map: HashMap, S>, + /// Sum of the entries' sizes. + bytes: usize, + budget: usize, + /// Entries kept whatever their age. + soft: usize, + /// Request counter. + clock: u64, + /// The frame whose own entries were found to fill the budget: nothing more is kept in it. + full: Option, +} + +impl FrameCache { + /// A cache holding at most `budget` bytes, sweeping entries of past frames beyond `soft` entries. + pub(crate) fn new(budget: usize, soft: usize) -> Self { + Self { map: HashMap::default(), bytes: 0, budget, soft, clock: 0, full: None } + } + + /// The entry for `k`, marked as used in frame `stamp`. + pub(crate) fn get(&mut self, k: &Q, stamp: u64) -> Option<&V> + where + K: Borrow, + Q: Hash + Eq + ?Sized, + { + self.clock += 1; + let s = self.map.get_mut(k)?; + (s.stamp, s.used) = (stamp, self.clock); + Some(&s.value) + } + + /// Keep `value` (about `bytes` bytes) as `k`, used in frame `stamp`, replacing any entry for + /// `k`. Least recently used entries of earlier frames make room; returns false when they can't + /// (the value isn't kept then). + pub(crate) fn insert(&mut self, k: K, value: V, bytes: usize, stamp: u64) -> bool { + self.clock += 1; + if let Some(old) = self.map.remove(&k) { + self.bytes -= old.bytes; + } + if self.bytes + bytes > self.budget { + if bytes > self.budget || self.full == Some(stamp) { + return false; + } + // Make room down to seven eighths of the budget, so the next few don't search again. + let target = self.budget - self.budget / 8; + let mut old: Vec<(u64, K)> = self.map.iter().filter(|(_, s)| s.stamp < stamp).map(|(k, s)| (s.used, k.clone())).collect(); + old.sort_unstable_by_key(|(used, _)| *used); + for (_, key) in old { + if self.bytes + bytes <= target { + break; + } + if let Some(s) = self.map.remove(&key) { + self.bytes -= s.bytes; + } + } + if self.bytes + bytes > self.budget { + self.full = Some(stamp); + return false; + } + } + self.bytes += bytes; + self.map.insert(k, Slot { value, bytes, stamp, used: self.clock }); + true + } + + /// End of frame `stamp`: past [`Self::new`]'s `soft` entries, drop those unused for a few frames. + pub(crate) fn sweep(&mut self, stamp: u64) { + if self.map.len() > self.soft { + let bytes = &mut self.bytes; + self.map.retain(|_, s| { + let keep = stamp.saturating_sub(s.stamp) <= AGE; + if !keep { + *bytes -= s.bytes; + } + keep + }); + } + } + + /// A new stamp for caches without frames: each request its own, so every other entry may make + /// room for it (least recently used first). + pub(crate) fn tick(&mut self) -> u64 { + self.clock += 1; + self.clock + } + + pub(crate) fn clear(&mut self) { + self.map.clear(); + self.bytes = 0; + self.full = None; + } + + #[cfg(test)] + pub(crate) fn len(&self) -> usize { + self.map.len() + } + + #[cfg(test)] + pub(crate) fn is_empty(&self) -> bool { + self.map.is_empty() + } + + /// The bytes the entries hold (as estimated when they were kept). + #[cfg(test)] + pub(crate) fn bytes(&self) -> usize { + self.bytes + } + + #[cfg(test)] + pub(crate) fn contains_key(&self, k: &Q) -> bool + where + K: Borrow, + Q: Hash + Eq + ?Sized, + { + self.map.contains_key(k) + } +} + +/// Heap bytes of a path's elements. +pub(crate) fn path_bytes(p: &BezPath) -> usize { + size_of_val(p.elements()) +} + +/// Approximate bytes of node `n` and its members: the nodes and their path anchors. +pub(crate) fn node_bytes(n: &Node) -> usize { + let mut b = 0; + n.walk(&mut |c| { + b += size_of::() + NODE_OVERHEAD; + if let Some(p) = c.path_data() { + b += p.subpaths.iter().map(|s| size_of::() + s.anchors.len() * size_of::()).sum::(); + } + }); + b +} + +#[cfg(test)] +mod tests { + use super::*; + + type Cache = FrameCache; + + #[test] + fn over_budget_drops_the_least_recently_used_entries_of_past_frames() { + let mut c = Cache::new(100, 0); + for k in 0..10 { + assert!(c.insert(k, "x", 10, 1)); + } + assert_eq!(c.bytes(), 100); + // Frame 2 uses 0..5 (so 5..10 are now the least recently used), then needs room. + for k in 0..5 { + assert!(c.get(&k, 2).is_some()); + } + assert!(c.insert(10, "y", 10, 2)); + assert!((0..5).all(|k| c.contains_key(&k)) && c.contains_key(&10), "recently used entries stay"); + assert!(c.bytes() <= 100 && c.bytes() < 110, "bytes went down: {}", c.bytes()); + assert!(!c.contains_key(&5), "the least recently used entry went first"); + } + + #[test] + fn entries_of_the_frame_being_drawn_stay() { + let mut c = Cache::new(100, 0); + // More than the budget, every frame: the first entries stay and only the rest are redone. + for frame in 1..4 { + let mut hits = 0; + for k in 0..20 { + if c.get(&k, frame).is_some() { + hits += 1; + } else { + c.insert(k, "x", 10, frame); + } + } + assert_eq!(c.len(), 10); + assert_eq!(hits, if frame == 1 { 0 } else { 10 }, "frame {frame}"); + } + // Too large for the budget: not kept. + assert!(!c.insert(99, "big", 1000, 5)); + } + + #[test] + fn sweep_drops_old_entries_past_the_soft_limit() { + let mut c = Cache::new(1000, 2); + for k in 0..4 { + c.insert(k, "x", 10, 1); + } + assert!(c.get(&0, 5).is_some()); + c.sweep(5); + assert_eq!((c.len(), c.bytes()), (1, 10)); + c.sweep(100); + assert_eq!(c.len(), 1, "within the soft limit nothing goes"); + let t = c.tick(); + assert!(c.insert(7, "y", 10, t)); + c.clear(); + assert!(c.is_empty() && c.bytes() == 0); + } +} diff --git a/crates/render/src/pattern.rs b/crates/render/src/pattern.rs index 0a4e05b7..c83adf7d 100644 --- a/crates/render/src/pattern.rs +++ b/crates/render/src/pattern.rs @@ -12,7 +12,6 @@ //! dimmed copies of it at the neighbouring tile positions and the tile edge. use std::cell::RefCell; -use std::collections::HashMap; use std::sync::Arc; use vectorcraft_doc::pattern::{Overlap, PatternDef, TileType}; @@ -22,6 +21,7 @@ use vello_cpu::peniko::{Extend, ImageQuality, ImageSampler}; use vello_cpu::{Pixmap, RenderContext}; use crate::ink::Ink; +use crate::lru::{self, FrameCache}; use crate::{Frame, RenderOptions, Rendered, Renderer}; /// Largest super-tile raster side (pixels). @@ -36,14 +36,19 @@ struct TileEntry { pixmap: Arc, } -#[derive(Default)] struct PatternCache { - /// Per ink ([`Ink`] as index). - tiles: [HashMap; 3], + /// Per ink ([`Ink`] as index), each within [`lru::PATTERN_BUDGET`], least recently used first out. + tiles: [FrameCache; 3], renderer: Option>, depth: u32, } +impl Default for PatternCache { + fn default() -> Self { + Self { tiles: std::array::from_fn(|_| FrameCache::new(lru::PATTERN_BUDGET, usize::MAX)), renderer: None, depth: 0 } + } +} + thread_local! { static CACHE: RefCell = RefCell::new(PatternCache::default()); } @@ -137,10 +142,10 @@ pub(crate) fn set_pattern_paint(ctx: &mut RenderContext, name: &str, xf: Affine, let scale = raster_scale(s, (pw, ph)); let params = (def.tile, def.tile_type, def.overlap); let hit = CACHE.with(|c| { - c.borrow().tiles[ink as usize] - .get(name) - .filter(|e| e.scale == scale && e.params == params && same_art(&e.art, &def.art)) - .map(|e| (e.pixmap.clone(), e.sx, e.sy)) + let mut c = c.borrow_mut(); + let tiles = &mut c.tiles[ink as usize]; + let t = tiles.tick(); + tiles.get(name, t).filter(|e| e.scale == scale && e.params == params && same_art(&e.art, &def.art)).map(|e| (e.pixmap.clone(), e.sx, e.sy)) }); let (pm, sx, sy) = match hit { Some(h) => h, @@ -156,10 +161,8 @@ pub(crate) fn set_pattern_paint(ctx: &mut RenderContext, name: &str, xf: Affine, CACHE.with(|c| { let mut c = c.borrow_mut(); let tiles = &mut c.tiles[ink as usize]; - if tiles.len() > 64 { - tiles.clear(); - } - tiles.insert(name.to_string(), TileEntry { art: def.art.clone(), params, scale, sx, sy, pixmap: pm.clone() }); + let (t, bytes) = (tiles.tick(), size_of::() + w as usize * h as usize * 4); + tiles.insert(name.to_string(), TileEntry { art: def.art.clone(), params, scale, sx, sy, pixmap: pm.clone() }, bytes, t); }); (pm, sx, sy) } diff --git a/crates/render/src/tests_memory.rs b/crates/render/src/tests_memory.rs index bd091bf7..e9b9d7b2 100644 --- a/crates/render/src/tests_memory.rs +++ b/crates/render/src/tests_memory.rs @@ -103,3 +103,99 @@ fn image_mips_are_opt_in_and_change_nothing_off() { 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); } + +/// A document of `n` point type objects reading `word`, in rows. +fn text_doc(n: usize, word: &str) -> Document { + use vectorcraft_doc::text::{CharStyle, TextObject}; + let mut d = Document::new(400.0, 400.0); + let l = d.layers[0].id; + for i in 0..n { + let at = vectorcraft_geom::Point::new(10.0 + (i % 8) as f64 * 48.0, 30.0 + (i / 8) as f64 * 30.0); + let style = CharStyle { size: 18.0, ..Default::default() }; + let mut t = Node::new(NodeId(0), NodeKind::Text(Box::new(TextObject::point(at, word, style)))); + t.id = d.alloc_id(); + d.insert(Some(l), 0, t).unwrap(); + } + d +} + +/// The cache keys of `d`'s type objects (their nodes' addresses). +fn text_keys(d: &Document) -> Vec { + let mut keys = vec![]; + d.walk(|n| { + if matches!(n.kind, NodeKind::Text(_)) { + keys.push(n as *const Node as usize); + } + }); + keys +} + +/// More type than the glyph cache's budget holds: the entries kept stay from frame to frame (the +/// rest is laid out again), where emptying the cache when full laid out every object every frame. +#[test] +fn type_over_the_text_budget_keeps_its_entries_between_frames() { + let d = text_doc(40, "Type"); + let opts = RenderOptions::default(); + let mut r = Renderer::new(); + let want = r.render(&d, 400, 400, Affine::IDENTITY, &opts).pixels; + assert_eq!(r.texts.len(), 40); + let all = r.texts.bytes(); + let mut r = Renderer::new(); + r.texts = FrameCache::new(all / 2, 1024); + let keys = text_keys(&d); + let kept = |r: &Renderer| keys.iter().filter(|k| r.texts.contains_key(*k)).copied().collect::>(); + assert_eq!(r.render(&d, 400, 400, Affine::IDENTITY, &opts).pixels, want); + let first = kept(&r); + assert!(!first.is_empty() && first.len() < 40 && r.texts.bytes() <= all / 2, "{} entries, {} bytes", first.len(), r.texts.bytes()); + for _ in 0..3 { + assert_eq!(r.render(&d, 400, 400, Affine::IDENTITY, &opts).pixels, want); + assert_eq!(kept(&r), first, "the same entries stay"); + } + // Other type: its entries replace the earlier frames' ones, within the budget. + let e = text_doc(40, "Other"); + r.render(&e, 400, 400, Affine::IDENTITY, &opts); + assert!(kept(&r).is_empty(), "entries of earlier frames went first"); + assert!(!r.texts.is_empty() && r.texts.bytes() <= all / 2); +} + +/// Type no longer drawn leaves the glyph cache after a few frames, and its bytes with it. +#[test] +fn swept_type_entries_give_back_their_bytes() { + let d = text_doc(12, "Swept"); + let mut r = Renderer::new(); + r.texts = FrameCache::new(lru::TEXT_BUDGET, 0); + r.render(&d, 400, 400, Affine::IDENTITY, &RenderOptions::default()); + let full = r.texts.bytes(); + assert!(full > 0 && r.texts.len() == 12); + let empty = Document::new(10.0, 10.0); + for _ in 0..5 { + r.render(&empty, 10, 10, Affine::IDENTITY, &RenderOptions::default()); + } + assert_eq!((r.texts.len(), r.texts.bytes()), (0, 0)); +} + +/// Each run draws exactly the outlines it drew when kept as a path of its own: its glyphs and +/// decorations in layout order (the whole text's path is kept once, the runs as spans of it). +#[test] +fn text_runs_draw_their_own_glyphs_from_the_shared_outlines() { + use vectorcraft_doc::text::{CharStyle, TextObject, TextRun}; + let plain = CharStyle { size: 24.0, ..Default::default() }; + let under = CharStyle { underline: true, ..plain.clone() }; + let mut t = TextObject::point(vectorcraft_geom::Point::new(10.0, 40.0), "Ab", plain.clone()); + t.runs.push(TextRun::new("cd", under)); + t.runs.push(TextRun::new("ef", plain)); + let g = text_geom(&t); + let db = vectorcraft_text::FontDb::global(); + let layout = vectorcraft_text::layout(db, &t); + let mut want = vec![BezPath::new(); t.runs.len()]; + for gl in &layout.glyphs { + want[gl.run].extend(gl.outline.iter()); + } + for (run, bar) in vectorcraft_text::decorations(&layout, db, &t) { + want[run].extend(bar.iter()); + } + for (i, w) in want.iter().enumerate() { + assert_eq!(g.run(i).elements(), w.elements(), "run {i}"); + } + assert!(g.run(7).elements().is_empty()); +}