From c4214be9d724503acce62335c6c115dc4dd3ee50 Mon Sep 17 00:00:00 2001 From: arhammusheer Date: Mon, 12 Oct 2026 02:32:50 +0530 Subject: [PATCH] #1244: render caches bounded by bytes, evicting the least recently used instead of clearing Text geometry, clips, clip paints, effect art, brushes, live effects, pattern tiles and freeform grids were capped by entry count and cleared wholesale on overflow, so a document past the cap (e.g. more than 4,096 type objects) recomputed every entry on every frame. A FrameCache keeps per-entry byte sizes and last-used frames, drops the least recently used entries of earlier frames, and sweeps stale ones. Text geometry keeps its glyph outlines once instead of twice. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VmMmc1J8XeNLxhRihWv9cc --- crates/render/src/brush_fx.rs | 26 +-- crates/render/src/freeform.rs | 30 ++-- crates/render/src/fx.rs | 8 +- crates/render/src/lib.rs | 111 ++++++++----- crates/render/src/live.rs | 47 +++--- crates/render/src/lru.rs | 257 ++++++++++++++++++++++++++++++ crates/render/src/pattern.rs | 27 ++-- crates/render/src/tests_memory.rs | 96 +++++++++++ 8 files changed, 498 insertions(+), 104 deletions(-) create mode 100644 crates/render/src/lru.rs 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()); +}