#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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmMmc1J8XeNLxhRihWv9cc
This commit is contained in:
arhammusheer 2026-10-12 02:32:50 +05:30 committed by bflatastic
parent 454f1737a3
commit c4214be9d7
8 changed files with 498 additions and 104 deletions

View File

@ -13,25 +13,30 @@ use vectorcraft_doc::{AppearanceItem, Document, Node, StrokeLayer};
use vectorcraft_geom::{BezPath, Rect}; use vectorcraft_geom::{BezPath, Rect};
use vello_cpu::RenderContext; use vello_cpu::RenderContext;
use crate::lru::{self, FrameCache};
use crate::{Frame, Renderer}; use crate::{Frame, Renderer};
struct Entry { pub(crate) struct Entry {
path: BezPath, path: BezPath,
stroke: StrokeLayer, stroke: StrokeLayer,
brush: Arc<Brush>, brush: Arc<Brush>,
pieces: Arc<Vec<Node>>, pieces: Arc<Vec<Node>>,
stamp: u64,
} }
/// Parsed brush library and generated brush art. /// Parsed brush library and generated brush art.
#[derive(Default)]
pub(crate) struct BrushCache { pub(crate) struct BrushCache {
/// The document value the library was parsed from (`None` = defaults). /// The document value the library was parsed from (`None` = defaults).
src: Option<Option<vectorcraft_brush::serde_json::Value>>, src: Option<Option<vectorcraft_brush::serde_json::Value>>,
/// (frame stamp, document address) the library was last validated for. /// (frame stamp, document address) the library was last validated for.
checked: (u64, usize), checked: (u64, usize),
lib: HashMap<String, Arc<Brush>>, lib: HashMap<String, Arc<Brush>>,
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 { 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 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 key = (n.id.0, idx);
let stamp = self.stamp; let stamp = self.stamp;
let pieces = match self.brushes.entries.get_mut(&key) { let pieces = match self.brushes.entries.get(&key, stamp) {
Some(e) if Arc::ptr_eq(&e.brush, &brush) && e.stroke == *st && e.path == *bp => { Some(e) if Arc::ptr_eq(&e.brush, &brush) && e.stroke == *st && e.path == *bp => e.pieces.clone(),
e.stamp = stamp;
e.pieces.clone()
}
_ => { _ => {
let pieces = Arc::new(vectorcraft_brush::stroke_pieces(&brush, bp, st)); let pieces = Arc::new(vectorcraft_brush::stroke_pieces(&brush, bp, st));
if self.brushes.entries.len() > 2048 { let bytes = size_of::<Entry>() + lru::path_bytes(bp) + pieces.iter().map(lru::node_bytes).sum::<usize>();
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() }, bytes, stamp);
}
self.brushes.entries.insert(key, Entry { path: bp.clone(), stroke: st.clone(), brush, pieces: pieces.clone(), stamp });
pieces pieces
} }
}; };

View File

@ -7,7 +7,6 @@
//! the gradient's content, so panning and redraws don't resample it. //! the gradient's content, so panning and redraws don't resample it.
use std::cell::RefCell; use std::cell::RefCell;
use std::collections::HashMap;
use std::hash::{Hash, Hasher}; use std::hash::{Hash, Hasher};
use std::sync::Arc; use std::sync::Arc;
@ -18,12 +17,11 @@ use vello_cpu::peniko::{Extend, ImageQuality, ImageSampler};
use vello_cpu::{Pixmap, RenderContext}; use vello_cpu::{Pixmap, RenderContext};
use crate::ink::Ink; use crate::ink::Ink;
use crate::lru::{self, FrameCache};
/// Grids kept before the cache starts over.
const MAX_GRIDS: usize = 128;
thread_local! { thread_local! {
static GRIDS: RefCell<HashMap<u64, Arc<Pixmap>>> = RefCell::new(HashMap::new()); /// Grids by [`key`], within [`lru::FREEFORM_BUDGET`], least recently used first out.
static GRIDS: RefCell<FrameCache<u64, Arc<Pixmap>>> = RefCell::new(FrameCache::new(lru::FREEFORM_BUDGET, usize::MAX));
} }
fn hash_color(c: &Color, h: &mut impl Hasher) { 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 (cols, rows) = grid_size(b, device);
let f = g.freeform_on(b); let f = g.freeform_on(b);
let k = key(&f, b, cols, rows, ink); let k = key(&f, b, cols, rows, ink);
let pm = GRIDS.with(|c| c.borrow().get(&k).cloned()).unwrap_or_else(|| { let pm = GRIDS
let pm = Arc::new(rasterize(&f, b, cols, rows, ink)); .with(|c| {
GRIDS.with(|c| {
let mut c = c.borrow_mut(); let mut c = c.borrow_mut();
if c.len() >= MAX_GRIDS { let t = c.tick();
c.clear(); c.get(&k, t).cloned()
} })
c.insert(k, pm.clone()); .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 }; 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(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)); ctx.set_paint_transform(Affine::translate(b.origin().to_vec2()) * Affine::scale_non_uniform(b.width() / cols as f64, b.height() / rows as f64));

View File

@ -482,7 +482,7 @@ impl Renderer {
fn fx_art(&mut self, doc: &vectorcraft_doc::Document, a: &Arc<Node>, cache: bool) -> Arc<Node> { fn fx_art(&mut self, doc: &vectorcraft_doc::Document, a: &Arc<Node>, cache: bool) -> Arc<Node> {
let key = Arc::as_ptr(a) as usize; let key = Arc::as_ptr(a) as usize;
if cache 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) && Arc::ptr_eq(node, a)
{ {
return art.clone(); return art.clone();
@ -516,10 +516,8 @@ impl Renderer {
..art ..art
}); });
if cache { if cache {
if self.fx_arts.len() > 1024 { let bytes = crate::lru::node_bytes(&art);
self.fx_arts.clear(); self.fx_arts.insert(key, (a.clone(), art.clone()), bytes, self.stamp);
}
self.fx_arts.insert(key, (a.clone(), art.clone()));
} }
art art
} }

View File

@ -14,6 +14,7 @@ mod group;
mod images; mod images;
mod ink; mod ink;
mod live; mod live;
mod lru;
mod paint; mod paint;
mod pattern; mod pattern;
pub mod placed_document; pub mod placed_document;
@ -30,6 +31,7 @@ use vello_cpu::{Pixmap, RenderContext, Resources};
use group::Composite; use group::Composite;
use ink::Ink; use ink::Ink;
use lru::FrameCache;
pub use brush_fx::instance_art; pub use brush_fx::instance_art;
pub use effects::stroke::width_outline; pub use effects::stroke::width_outline;
@ -350,12 +352,13 @@ type ClipPaintEntry = (Arc<Node>, Option<Arc<Node>>, Option<Arc<Node>>);
/// Reusable renderer (keeps the render context, geometry and glyph caches between frames; decoded /// Reusable renderer (keeps the render context, geometry and glyph caches between frames; decoded
/// images are shared by all renderers, see [`set_image_cache_budget`]). /// images are shared by all renderers, see [`set_image_cache_budget`]).
pub struct Renderer { pub struct Renderer {
texts: PtrMap<usize, (Arc<Node>, Arc<TextGeom>)>, /// Glyph geometry per text object, keyed by `Arc` identity like `geom` (see [`Self::text_geom_of`]).
texts: FrameCache<usize, (Arc<Node>, Arc<TextGeom>)>,
/// The [`vectorcraft_text::FontDb::generation`] `texts` was laid out with: type lays out /// The [`vectorcraft_text::FontDb::generation`] `texts` was laid out with: type lays out
/// again when fonts are added or rescanned. /// again when fonts are added or rescanned.
text_fonts: u64, text_fonts: u64,
/// Clip regions per clipping path (see [`Self::clip_of`]). /// Clip regions per clipping path (see [`Self::clip_of`]).
clips: PtrMap<usize, ClipEntry>, clips: FrameCache<usize, ClipEntry>,
/// Context reused when rendering single-threaded (`threads == 0`). /// Context reused when rendering single-threaded (`threads == 0`).
ctx_st: Option<RenderContext>, ctx_st: Option<RenderContext>,
/// Worker threads for the multithreaded rasterizer (0 = single-threaded). /// 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. /// opacity and without its opacity mask (see [`Self::draw_knockout`]); 0 = none.
shape_of: usize, shape_of: usize,
/// The art drawn for objects with effects that apply through it (see `fx::has_object_fx`). /// The art drawn for objects with effects that apply through it (see `fx::has_object_fx`).
fx_arts: PtrMap<usize, (Arc<Node>, Arc<Node>)>, fx_arts: FrameCache<usize, (Arc<Node>, Arc<Node>)>,
/// Layers open around the drawing point in the current context: a non-isolated group can copy /// 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`]). /// its backdrop only when there are none (see [`Self::group`]).
nested: u32, nested: u32,
@ -405,7 +408,7 @@ pub struct Renderer {
/// again from a picture of it pushes them again (see [`Self::group`]). /// again from a picture of it pushes them again (see [`Self::group`]).
clip_paths: Vec<(BezPath, FillRule, Affine)>, clip_paths: Vec<(BezPath, FillRule, Affine)>,
/// What clipping paths paint, per clipping path (see [`Self::clip_paint_of`]). /// What clipping paths paint, per clipping path (see [`Self::clip_paint_of`]).
clip_paints: PtrMap<usize, ClipPaintEntry>, clip_paints: FrameCache<usize, ClipPaintEntry>,
/// Gradients along or across strokes, keyed like `strokes` (see [`Self::fill_path_gradient`]). /// Gradients along or across strokes, keyed like `strokes` (see [`Self::fill_path_gradient`]).
stroke_slices: PtrMap<(usize, i32), SliceEntry>, stroke_slices: PtrMap<(usize, i32), SliceEntry>,
/// What the image keys colour adjustments give their recoloured images name: the image /// What the image keys colour adjustments give their recoloured images name: the image
@ -479,9 +482,9 @@ impl Frame<'_> {
impl Renderer { impl Renderer {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
texts: PtrMap::default(), texts: FrameCache::new(lru::TEXT_BUDGET, 1024),
text_fonts: 0, text_fonts: 0,
clips: PtrMap::default(), clips: FrameCache::new(lru::CLIP_BUDGET, 256),
ctx_st: None, ctx_st: None,
threads: default_threads(), threads: default_threads(),
geom: PtrMap::default(), geom: PtrMap::default(),
@ -500,12 +503,12 @@ impl Renderer {
live: live::LiveCache::default(), live: live::LiveCache::default(),
knockout: false, knockout: false,
shape_of: 0, shape_of: 0,
fx_arts: PtrMap::default(), fx_arts: FrameCache::new(lru::FX_ART_BUDGET, 256),
nested: 0, nested: 0,
backdrop: None, backdrop: None,
blends: PtrMap::default(), blends: PtrMap::default(),
clip_paths: vec![], clip_paths: vec![],
clip_paints: PtrMap::default(), clip_paints: FrameCache::new(lru::CLIP_BUDGET, 256),
stroke_slices: PtrMap::default(), stroke_slices: PtrMap::default(),
adjusts: HashMap::new(), adjusts: HashMap::new(),
dim_images: None, dim_images: None,
@ -599,6 +602,12 @@ impl Renderer {
} }
// Filtered rasters are large: only those of the last frame stay. // Filtered rasters are large: only those of the last frame stay.
self.pixel_fx.retain(|_, e| e.stamp == g); 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. /// 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. /// outlining text and uniting shapes is too slow to repeat every frame.
fn clip_of(&mut self, a: &Arc<Node>) -> Option<Arc<(BezPath, FillRule)>> { fn clip_of(&mut self, a: &Arc<Node>) -> Option<Arc<(BezPath, FillRule)>> {
let key = Arc::as_ptr(a) as usize; 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) && Arc::ptr_eq(node, a)
{ {
return region.clone(); return region.clone();
} }
let region = effects::clip_outline(a).map(Arc::new); let region = effects::clip_outline(a).map(Arc::new);
if self.clips.len() > 1024 { let bytes = size_of::<ClipEntry>() + region.as_ref().map_or(0, |r| lru::path_bytes(&r.0));
self.clips.clear(); self.clips.insert(key, (a.clone(), region.clone()), bytes, self.stamp);
}
self.clips.insert(key, (a.clone(), region.clone()));
region region
} }
@ -816,17 +823,15 @@ impl Renderer {
/// allocations between frames, so their own geometry stays cached. /// allocations between frames, so their own geometry stays cached.
fn clip_paint_of(&mut self, a: &Arc<Node>) -> (Option<Arc<Node>>, Option<Arc<Node>>) { fn clip_paint_of(&mut self, a: &Arc<Node>) -> (Option<Arc<Node>>, Option<Arc<Node>>) {
let key = Arc::as_ptr(a) as usize; 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) && Arc::ptr_eq(node, a)
{ {
return (fill.clone(), stroke.clone()); return (fill.clone(), stroke.clone());
} }
let paint = a.clip_paint(); let paint = a.clip_paint();
let (fill, stroke) = (paint.fill.map(Arc::new), paint.stroke.map(Arc::new)); let (fill, stroke) = (paint.fill.map(Arc::new), paint.stroke.map(Arc::new));
if self.clip_paints.len() > 1024 { let bytes = size_of::<ClipPaintEntry>() + [&fill, &stroke].iter().filter_map(|p| p.as_deref()).map(lru::node_bytes).sum::<usize>();
self.clip_paints.clear(); self.clip_paints.insert(key, (a.clone(), fill.clone(), stroke.clone()), bytes, self.stamp);
}
self.clip_paints.insert(key, (a.clone(), fill.clone(), stroke.clone()));
(fill, stroke) (fill, stroke)
} }
@ -1477,16 +1482,13 @@ impl Renderer {
self.text_fonts = fonts; self.text_fonts = fonts;
} }
let key = Arc::as_ptr(a) as usize; 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) && Arc::ptr_eq(node, a)
{ {
return g.clone(); return g.clone();
} }
let g = Arc::new(text_geom(t)); let g = Arc::new(text_geom(t));
if self.texts.len() > 4096 { self.texts.insert(key, (a.clone(), g.clone()), g.bytes(), self.stamp);
self.texts.clear();
}
self.texts.insert(key, (a.clone(), g.clone()));
g g
} }
@ -1535,7 +1537,8 @@ impl Renderer {
} }
} }
for (i, run) in t.runs.iter().enumerate() { 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() { if path.elements().is_empty() {
continue; 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 { struct TextGeom {
runs: Vec<BezPath>,
all: BezPath, 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, bounds: Rect,
/// The boxes of characters whose font is missing (drawn in a substitute), and of glyphs the /// 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. /// font lacks (drawn from a fallback font): Document Setup's substitution highlights.
@ -1878,6 +1883,33 @@ struct TextGeom {
inlines: Vec<vectorcraft_text::InlineGlyph>, inlines: Vec<vectorcraft_text::InlineGlyph>,
} }
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>()
+ [&self.all, &self.substituted_fonts, &self.substituted_glyphs].into_iter().map(lru::path_bytes).sum::<usize>()
+ self.spans.len() * size_of::<(usize, usize, usize)>()
+ self.inlines.len() * size_of::<vectorcraft_text::InlineGlyph>()
}
}
fn text_geom(t: &TextObject) -> TextGeom { fn text_geom(t: &TextObject) -> TextGeom {
text_geom_snapped(t, None) text_geom_snapped(t, None)
} }
@ -1889,8 +1921,21 @@ fn text_geom_snapped(t: &TextObject, snap: Option<Affine>) -> TextGeom {
if let Some(xf) = snap { if let Some(xf) = snap {
layout.snap_to_pixels(xf); layout.snap_to_pixels(xf);
} }
let mut runs = vec![BezPath::new(); t.runs.len()];
let mut all = BezPath::new(); 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. // Per run: is its family missing, and the face its glyphs should come from.
let faces: Vec<(bool, Option<u32>)> = t let faces: Vec<(bool, Option<u32>)> = t
.runs .runs
@ -1906,10 +1951,7 @@ fn text_geom_snapped(t: &TextObject, snap: Option<Affine>) -> TextGeom {
.collect(); .collect();
let (mut substituted_fonts, mut substituted_glyphs) = (BezPath::new(), BezPath::new()); let (mut substituted_fonts, mut substituted_glyphs) = (BezPath::new(), BezPath::new());
for g in &layout.glyphs { for g in &layout.glyphs {
if let Some(r) = runs.get_mut(g.run) { push(&mut all, g.run, &g.outline);
r.extend(g.outline.iter());
}
all.extend(g.outline.iter());
let Some(&(missing, face)) = faces.get(g.run) else { continue }; let Some(&(missing, face)) = faces.get(g.run) else { continue };
let target = if missing { let target = if missing {
&mut substituted_fonts &mut substituted_fonts
@ -1925,12 +1967,9 @@ fn text_geom_snapped(t: &TextObject, snap: Option<Affine>) -> TextGeom {
} }
// Underline and strikethrough bars, painted as their run's type is (#847). // Underline and strikethrough bars, painted as their run's type is (#847).
for (run, bar) in vectorcraft_text::decorations(&layout, db, t) { for (run, bar) in vectorcraft_text::decorations(&layout, db, t) {
if let Some(r) = runs.get_mut(run) { push(&mut all, run, &bar);
r.extend(bar.iter());
}
all.extend(bar.iter());
} }
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). /// Document Setup's highlight behind substituted fonts and glyphs (screen only).

View File

@ -9,7 +9,6 @@
//! grown by half a device pixel along its edges so neighbours overlap and no antialiasing seams //! grown by half a device pixel along its edges so neighbours overlap and no antialiasing seams
//! show. //! show.
use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use vectorcraft_doc::live::{GradientMesh, MeshQuad}; use vectorcraft_doc::live::{GradientMesh, MeshQuad};
@ -18,28 +17,30 @@ use vectorcraft_geom::{BezPath, Vec2};
use vello_cpu::RenderContext; use vello_cpu::RenderContext;
use vello_cpu::peniko; use vello_cpu::peniko;
use crate::lru::{self, FrameCache};
use crate::{Frame, Renderer}; use crate::{Frame, Renderer};
type Expanded = Arc<Vec<Arc<Node>>>; type Expanded = Arc<Vec<Arc<Node>>>;
type MeshEntry = (Arc<Node>, Arc<Vec<MeshQuad>>, u64); type Quads = Arc<Vec<MeshQuad>>;
/// Per-renderer cache of evaluated live objects. /// Per-renderer cache of evaluated live objects: (live node, its evaluation, [`resources_key`]),
#[derive(Default)] /// and (mesh node, its quads) per node and subdivision level.
pub(crate) struct LiveCache { pub(crate) struct LiveCache {
expanded: HashMap<usize, (Arc<Node>, Expanded, u64, u64)>, expanded: FrameCache<usize, (Arc<Node>, Expanded, u64)>,
meshes: HashMap<(usize, usize), MeshEntry>, meshes: FrameCache<(usize, usize), (Arc<Node>, Quads)>,
stamp: u64, }
impl Default for LiveCache {
fn default() -> Self {
Self { expanded: FrameCache::new(lru::LIVE_BUDGET, 256), meshes: FrameCache::new(lru::MESH_BUDGET, 256) }
}
} }
impl LiveCache { impl LiveCache {
fn tick(&mut self, stamp: u64) { /// End of frame `stamp` (see [`FrameCache::sweep`]).
self.stamp = stamp; pub(crate) fn sweep(&mut self, stamp: u64) {
if self.expanded.len() > 256 { self.expanded.sweep(stamp);
self.expanded.retain(|_, e| stamp - e.2 <= 3); self.meshes.sweep(stamp);
}
if self.meshes.len() > 256 {
self.meshes.retain(|_, e| stamp - e.2 <= 3);
}
} }
} }
@ -84,11 +85,10 @@ impl Renderer {
let key = Arc::as_ptr(a) as usize; let key = Arc::as_ptr(a) as usize;
let resources = resources_key(doc, a); let resources = resources_key(doc, a);
if cache 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) && Arc::ptr_eq(&e.0, a)
&& e.3 == resources && e.2 == resources
{ {
e.2 = self.live.stamp;
return e.1.clone(); return e.1.clone();
} }
let v: Expanded = match crate::effects::pathfinder_children(a, crate::effects::text_outliner()) { 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()), None => Arc::new(crate::effects::expand_live(Some(doc), a).into_iter().map(Arc::new).collect()),
}; };
if cache { if cache {
self.live.expanded.insert(key, (a.clone(), v.clone(), self.live.stamp, resources)); let bytes = size_of::<Expanded>() + v.iter().map(|n| lru::node_bytes(n)).sum::<usize>();
self.live.expanded.insert(key, (a.clone(), v.clone(), resources), bytes, self.stamp);
} }
v v
} }
@ -104,23 +105,21 @@ impl Renderer {
fn mesh_quads(&mut self, a: &Arc<Node>, m: &GradientMesh, level: usize, cache: bool) -> Arc<Vec<MeshQuad>> { fn mesh_quads(&mut self, a: &Arc<Node>, m: &GradientMesh, level: usize, cache: bool) -> Arc<Vec<MeshQuad>> {
let key = (Arc::as_ptr(a) as usize, level); let key = (Arc::as_ptr(a) as usize, level);
if cache 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) && Arc::ptr_eq(&e.0, a)
{ {
e.2 = self.live.stamp;
return e.1.clone(); return e.1.clone();
} }
let q = Arc::new(m.quads(level)); let q = Arc::new(m.quads(level));
if cache { if cache {
self.live.meshes.insert(key, (a.clone(), q.clone(), self.live.stamp)); let bytes = q.len() * size_of::<MeshQuad>();
self.live.meshes.insert(key, (a.clone(), q.clone()), bytes, self.stamp);
} }
q q
} }
/// Draw a live node reached through the tree walk (handles its transparency group). /// 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<Node>) { pub(crate) fn draw_live(&mut self, ctx: &mut RenderContext, f: &Frame, a: &Arc<Node>) {
let stamp = self.stamp;
self.live.tick(stamp);
let opacity = self.opacity_of(a); 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). // A knockout blend's steps knock each other out, as in the exports (opaque ones can't show it).
let knockout = !f.opts.outline let knockout = !f.opts.outline

257
crates/render/src/lru.rs Normal file
View File

@ -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<PtrHasher>;
struct Slot<V> {
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<K, V, S = PtrBuild> {
map: HashMap<K, Slot<V>, 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<u64>,
}
impl<K: Hash + Eq + Clone, V, S: BuildHasher + Default> FrameCache<K, V, S> {
/// 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<Q>(&mut self, k: &Q, stamp: u64) -> Option<&V>
where
K: Borrow<Q>,
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<Q>(&self, k: &Q) -> bool
where
K: Borrow<Q>,
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>() + NODE_OVERHEAD;
if let Some(p) = c.path_data() {
b += p.subpaths.iter().map(|s| size_of::<SubPath>() + s.anchors.len() * size_of::<Anchor>()).sum::<usize>();
}
});
b
}
#[cfg(test)]
mod tests {
use super::*;
type Cache = FrameCache<usize, &'static str>;
#[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);
}
}

View File

@ -12,7 +12,6 @@
//! dimmed copies of it at the neighbouring tile positions and the tile edge. //! dimmed copies of it at the neighbouring tile positions and the tile edge.
use std::cell::RefCell; use std::cell::RefCell;
use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use vectorcraft_doc::pattern::{Overlap, PatternDef, TileType}; use vectorcraft_doc::pattern::{Overlap, PatternDef, TileType};
@ -22,6 +21,7 @@ use vello_cpu::peniko::{Extend, ImageQuality, ImageSampler};
use vello_cpu::{Pixmap, RenderContext}; use vello_cpu::{Pixmap, RenderContext};
use crate::ink::Ink; use crate::ink::Ink;
use crate::lru::{self, FrameCache};
use crate::{Frame, RenderOptions, Rendered, Renderer}; use crate::{Frame, RenderOptions, Rendered, Renderer};
/// Largest super-tile raster side (pixels). /// Largest super-tile raster side (pixels).
@ -36,14 +36,19 @@ struct TileEntry {
pixmap: Arc<Pixmap>, pixmap: Arc<Pixmap>,
} }
#[derive(Default)]
struct PatternCache { struct PatternCache {
/// Per ink ([`Ink`] as index). /// Per ink ([`Ink`] as index), each within [`lru::PATTERN_BUDGET`], least recently used first out.
tiles: [HashMap<String, TileEntry>; 3], tiles: [FrameCache<String, TileEntry>; 3],
renderer: Option<Box<Renderer>>, renderer: Option<Box<Renderer>>,
depth: u32, 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! { thread_local! {
static CACHE: RefCell<PatternCache> = RefCell::new(PatternCache::default()); static CACHE: RefCell<PatternCache> = 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 scale = raster_scale(s, (pw, ph));
let params = (def.tile, def.tile_type, def.overlap); let params = (def.tile, def.tile_type, def.overlap);
let hit = CACHE.with(|c| { let hit = CACHE.with(|c| {
c.borrow().tiles[ink as usize] let mut c = c.borrow_mut();
.get(name) let tiles = &mut c.tiles[ink as usize];
.filter(|e| e.scale == scale && e.params == params && same_art(&e.art, &def.art)) let t = tiles.tick();
.map(|e| (e.pixmap.clone(), e.sx, e.sy)) 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 { let (pm, sx, sy) = match hit {
Some(h) => h, Some(h) => h,
@ -156,10 +161,8 @@ pub(crate) fn set_pattern_paint(ctx: &mut RenderContext, name: &str, xf: Affine,
CACHE.with(|c| { CACHE.with(|c| {
let mut c = c.borrow_mut(); let mut c = c.borrow_mut();
let tiles = &mut c.tiles[ink as usize]; let tiles = &mut c.tiles[ink as usize];
if tiles.len() > 64 { let (t, bytes) = (tiles.tick(), size_of::<TileEntry>() + w as usize * h as usize * 4);
tiles.clear(); tiles.insert(name.to_string(), TileEntry { art: def.art.clone(), params, scale, sx, sy, pixmap: pm.clone() }, bytes, t);
}
tiles.insert(name.to_string(), TileEntry { art: def.art.clone(), params, scale, sx, sy, pixmap: pm.clone() });
}); });
(pm, sx, sy) (pm, sx, sy)
} }

View File

@ -103,3 +103,99 @@ fn image_mips_are_opt_in_and_change_nothing_off() {
let near = Affine::scale(0.75); 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); 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<usize> {
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::<Vec<_>>();
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());
}