Renderer: character strokes are expanded once, not every frame

The outlines of a run's character stroke are kept with the glyph geometry the renderer keeps for the type, in a byte-limited cache swept by frame, so type that changes in every frame (an animation) frees them within a few frames.
This commit is contained in:
Jürg Lehni
2026-10-24 20:04:10 +07:00
committed by bflatastic
parent eb4c5e7bf8
commit 0776323e27
4 changed files with 180 additions and 36 deletions
+1 -1
View File
@@ -2396,7 +2396,7 @@ impl Renderer {
if st.brush.is_some() && self.draw_brush(ctx, f, n, ig, st) {
return;
}
self.draw_stroke(ctx, f, Affine::IDENTITY, ig, rule, st, ib);
self.draw_stroke(ctx, f, Affine::IDENTITY, ig, rule, st, None, ib);
}
}
}
+86 -35
View File
@@ -395,6 +395,10 @@ impl std::hash::Hasher for PtrHasher {
type PtrMap<K, V> = HashMap<K, V, std::hash::BuildHasherDefault<PtrHasher>>;
/// The key of a cached stroke outline: ([`Renderer::stroke_owner`] of what owns the stroke, the
/// stroke's place in it, width bits, tolerance level).
type StrokeKey = (usize, usize, u64, i32);
/// A stroke expanded to a fill outline: (owning node, outline, last frame used).
struct StrokeEntry {
node: Arc<Node>,
@@ -470,7 +474,13 @@ pub struct Renderer {
plugin_revision: u64,
/// Expanded stroke outlines keyed by ([`Self::stroke_owner`], stroke layer address, width bits,
/// tolerance level): panning re-renders reuse them instead of re-expanding every stroke.
strokes: PtrMap<(usize, usize, u64, i32), StrokeEntry>,
strokes: PtrMap<StrokeKey, StrokeEntry>,
/// Expanded character stroke outlines, with the glyph geometry kept in `texts` they belong to,
/// keyed by ([`Self::stroke_owner`] of that geometry, run, width bits, tolerance level): type
/// that doesn't change draws them again instead of expanding its strokes in every frame. Within
/// a byte budget, and swept by frame, so the outlines of type that changes in every frame go
/// within a few frames.
char_strokes: FrameCache<StrokeKey, (Arc<TextGeom>, Arc<BezPath>)>,
/// The node being drawn on the plain-path fast path, or the path of brush art being drawn
/// ([`Self::draw_brush_pieces`]): the owner of cached stroke outlines.
cur: Option<Arc<Node>>,
@@ -648,6 +658,7 @@ impl Renderer {
symbols: vec![],
plugin_revision: 0,
strokes: PtrMap::default(),
char_strokes: FrameCache::new(lru::CHAR_STROKE_BUDGET, 0),
cur: None,
made_again: false,
fx_owner: None,
@@ -789,6 +800,7 @@ 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.char_strokes.sweep(g);
self.clips.sweep(g);
self.clip_paints.sweep(g);
self.fx_arts.sweep(g);
@@ -1173,11 +1185,11 @@ impl Renderer {
&& !fx::has_fx(a)
{
let t = &*f.doc.inline_resolved(t);
let g = match f.text_snap(t) {
Some(xf) => Arc::new(text_geom_snapped(t, Some(xf))),
let (g, kept) = match f.text_snap(t) {
Some(xf) => (Arc::new(text_geom_snapped(t, Some(xf))), false),
None => self.text_geom_of(a, t),
};
self.draw_text_geom(ctx, f, a, t, &g);
self.draw_text_geom(ctx, f, a, t, &g, kept);
self.stats.drawn += 1;
return;
}
@@ -1609,7 +1621,7 @@ impl Renderer {
if st.brush.is_some() && self.draw_brush(ctx, f, n, bp, st) {
continue;
}
self.draw_stroke(ctx, f, Affine::IDENTITY, bp, rule, st, bounds);
self.draw_stroke(ctx, f, Affine::IDENTITY, bp, rule, st, None, bounds);
}
}
}
@@ -1626,8 +1638,20 @@ impl Renderer {
/// Paint stroke `st` of the shape `bp` (in the space `xf` maps to the document; `bounds`: its
/// geometric bounds there) with its alignment, dashes, profile, arrowheads, opacity and blend.
/// `chars` is the glyph geometry kept in `texts` and the run that `st` is the character stroke
/// of, when it is one.
#[allow(clippy::too_many_arguments)]
fn draw_stroke(&mut self, ctx: &mut RenderContext, f: &Frame, xf: Affine, bp: &BezPath, rule: FillRule, st: &StrokeLayer, bounds: Rect) {
fn draw_stroke(
&mut self,
ctx: &mut RenderContext,
f: &Frame,
xf: Affine,
bp: &BezPath,
rule: FillRule,
st: &StrokeLayer,
chars: Option<(&Arc<TextGeom>, usize)>,
bounds: Rect,
) {
let pieces = effects::stroke::stroke_pieces(bp, st);
// The line and its heads overlap: they take the opacity once, in one layer, instead of
// each folding it into its paint.
@@ -1650,11 +1674,11 @@ impl Renderer {
// One paint box for the line and the heads, so a gradient runs on into the heads.
let paint_bounds = bounds.inflate(st.width / 2.0, st.width / 2.0);
if let Some(g) = st.path_gradient() {
self.fill_path_gradient(ctx, f, bp, rule, st, &pieces, width, &g.gradient);
self.fill_path_gradient(ctx, f, bp, rule, st, chars, &pieces, width, &g.gradient);
} else if paint::set_paint(ctx, &st.paint, paint_bounds, f) {
self.fold_alpha(ctx, f.ink, &st.paint);
ctx.set_fill_rule(peniko::Fill::NonZero);
match self.cached_stroke(f, &pieces.line, st, width) {
match self.cached_stroke(f, chars, &pieces.line, st, width) {
Some(o) => ctx.fill_path(&o),
None => ctx.fill_path(&effects::stroke::line_outline(&pieces.line, st, width, f.px * 0.25)),
}
@@ -1693,6 +1717,7 @@ impl Renderer {
bp: &BezPath,
rule: FillRule,
st: &StrokeLayer,
chars: Option<(&Arc<TextGeom>, usize)>,
pieces: &effects::stroke::StrokePieces,
width: f64,
g: &vectorcraft_color::Gradient,
@@ -1714,7 +1739,7 @@ impl Renderer {
};
let slices = match self.cur.clone().filter(|n| owns_stroke(n, st)) {
Some(node) => {
let key = (self.stroke_owner(&node), st as *const StrokeLayer as usize, level);
let key = (self.stroke_owner(Arc::as_ptr(&node) as usize), st as *const StrokeLayer as usize, level);
match self.stroke_slices.get_mut(&key) {
Some(e) if Arc::ptr_eq(&e.node, &node) => {
e.stamp = self.stamp;
@@ -1729,7 +1754,7 @@ impl Renderer {
}
None => make(),
};
let line = match self.cached_stroke(f, &pieces.line, st, width) {
let line = match self.cached_stroke(f, chars, &pieces.line, st, width) {
Some(o) => o,
None => Arc::new(effects::stroke::line_outline(&pieces.line, st, width, f.px * 0.25)),
};
@@ -1751,30 +1776,51 @@ impl Renderer {
}
}
/// The line part of a stroke of `self.cur` (see [`effects::stroke::line_outline`]), cached
/// across frames under [`Self::stroke_owner`] and the stroke layer. The tolerance (vello's 0.25
/// device px) is bucketed to powers of two, never coarser than needed, so zooming reuses
/// outlines until the level changes. Only `self.cur`'s own strokes are cached: for other
/// strokes, this returns `None`.
fn cached_stroke(&mut self, f: &Frame, line: &BezPath, st: &StrokeLayer, width: f64) -> Option<Arc<BezPath>> {
let node = self.cur.clone().filter(|n| owns_stroke(n, st))?;
/// The line part of a stroke (see [`effects::stroke::line_outline`]), cached across frames: a
/// character stroke of run `chars.1` of kept glyph geometry `chars.0` in `char_strokes`, a
/// stroke of `self.cur` in `strokes` (under [`Self::stroke_owner`] and the stroke layer). The
/// tolerance (vello's 0.25 device px) is bucketed to powers of two, never coarser than needed,
/// so zooming reuses outlines until the level changes. Other strokes aren't cached: for them
/// this returns `None`.
fn cached_stroke(
&mut self,
f: &Frame,
chars: Option<(&Arc<TextGeom>, usize)>,
line: &BezPath,
st: &StrokeLayer,
width: f64,
) -> Option<Arc<BezPath>> {
let level = (f.px * 0.25).log2().floor() as i32;
let key = (self.stroke_owner(&node), st as *const StrokeLayer as usize, width.to_bits(), level);
let expand = || Arc::new(effects::stroke::line_outline(line, st, width, 2f64.powi(level)));
if let Some((g, run)) = chars {
let key = (self.stroke_owner(Arc::as_ptr(g) as usize), run, width.to_bits(), level);
if let Some((kept, outline)) = self.char_strokes.get(&key, self.stamp)
&& Arc::ptr_eq(kept, g)
{
return Some(outline.clone());
}
let outline = expand();
self.char_strokes.insert(key, (g.clone(), outline.clone()), lru::path_bytes(&outline), self.stamp);
return Some(outline);
}
let node = self.cur.clone().filter(|n| owns_stroke(n, st))?;
let key = (self.stroke_owner(Arc::as_ptr(&node) as usize), st as *const StrokeLayer as usize, width.to_bits(), level);
if let Some(e) = self.strokes.get_mut(&key)
&& Arc::ptr_eq(&e.node, &node)
{
e.stamp = self.stamp;
return Some(e.outline.clone());
}
let outline = Arc::new(effects::stroke::line_outline(line, st, width, 2f64.powi(level)));
let outline = expand();
self.strokes.insert(key, StrokeEntry { node, outline: outline.clone(), stamp: self.stamp });
Some(outline)
}
/// What the `strokes` and `stroke_slices` keys hold for `node` (`self.cur`): its address,
/// or 0 while art made again in every frame is drawn ([`Self::made_again`]).
fn stroke_owner(&self, node: &Arc<Node>) -> usize {
if self.made_again { 0 } else { Arc::as_ptr(node) as usize }
/// What the `strokes`, `char_strokes` and `stroke_slices` keys hold for a stroke owner at
/// `address` (`self.cur` or kept glyph geometry): the address, or 0 while art made again in
/// every frame is drawn ([`Self::made_again`]).
fn stroke_owner(&self, address: usize) -> usize {
if self.made_again { 0 } else { address }
}
fn draw_text(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, t: &TextObject) {
@@ -1783,11 +1829,11 @@ impl Renderer {
// keeps, see `draw_fx_art`) takes its glyph geometry from `texts`, as on the fast path,
// except in frames that snap type to pixels.
let owner = self.fx_owner.as_ref().filter(|o| std::ptr::eq(&***o, n)).cloned();
let g = match (f.text_snap(t), owner) {
let (g, kept) = match (f.text_snap(t), owner) {
(None, Some(a)) => self.text_geom_of(&a, t),
(snap, _) => Arc::new(text_geom_snapped(t, snap)),
(snap, _) => (Arc::new(text_geom_snapped(t, snap)), false),
};
self.draw_text_geom(ctx, f, n, t, &g);
self.draw_text_geom(ctx, f, n, t, &g, kept);
}
/// Draw the inline graphics of type `t` (laid out in `g`): each symbol's art through the
@@ -1854,28 +1900,32 @@ impl Renderer {
/// Cached glyph geometry for a text node (keyed by Arc identity like paths).
/// Text `t` that is not the node's own, such as the copy whose inline graphics
/// `Document::inline_resolved` sized again, is laid out without the cache.
fn text_geom_of(&mut self, a: &Arc<Node>, t: &TextObject) -> Arc<TextGeom> {
/// `Document::inline_resolved` sized again, is laid out without the cache. Also returns
/// whether the geometry is kept in the cache.
fn text_geom_of(&mut self, a: &Arc<Node>, t: &TextObject) -> (Arc<TextGeom>, bool) {
if !matches!(&a.kind, NodeKind::Text(own) if std::ptr::eq(&**own, t)) {
return Arc::new(text_geom(t));
return (Arc::new(text_geom(t)), false);
}
let fonts = vectorcraft_text::FontDb::global().generation();
if fonts != self.text_fonts {
self.texts.clear();
self.char_strokes.clear();
self.text_fonts = fonts;
}
let key = Arc::as_ptr(a) as usize;
if let Some((node, g)) = self.texts.get(&key, self.stamp)
&& Arc::ptr_eq(node, a)
{
return g.clone();
return (g.clone(), true);
}
let g = Arc::new(text_geom(t));
self.texts.insert(key, (a.clone(), g.clone()), g.bytes(), self.stamp);
g
let kept = self.texts.insert(key, (a.clone(), g.clone()), g.bytes(), self.stamp);
(g, kept)
}
fn draw_text_geom(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, t: &TextObject, g: &TextGeom) {
/// Draw type `n` (`t`, laid out in `g`). The outlines of its character strokes are cached
/// when `g` is kept in `texts` (`kept`).
fn draw_text_geom(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, t: &TextObject, g: &Arc<TextGeom>, kept: bool) {
let xf = f.view * t.xf;
if f.opts.outline {
let mut p = g.all.clone();
@@ -1958,7 +2008,8 @@ impl Renderer {
if run.style.has_stroke() {
// Character strokes are drawn in text space, with their cap, join and dashes.
overprint(ctx, run.style.overprint_stroke);
self.draw_stroke(ctx, f, t.xf, path, FillRule::NonZero, &run.style.stroke_layer(), g.bounds);
let chars = kept.then_some((g, i));
self.draw_stroke(ctx, f, t.xf, path, FillRule::NonZero, &run.style.stroke_layer(), chars, g.bounds);
}
}
overprint(ctx, false);
@@ -1991,7 +2042,7 @@ impl Renderer {
}
AppearanceItem::Stroke(st) if st.width > 0.0 => {
if st.brush.is_none() || !self.draw_brush(ctx, f, n, all, st) {
self.draw_stroke(ctx, f, Affine::IDENTITY, all, FillRule::NonZero, st, tb);
self.draw_stroke(ctx, f, Affine::IDENTITY, all, FillRule::NonZero, st, None, tb);
}
}
AppearanceItem::Stroke(_) => {}
+9
View File
@@ -25,6 +25,9 @@ use crate::PtrHasher;
/// Glyph outlines per text object (`Renderer::text_geom_of`).
pub(crate) const TEXT_BUDGET: usize = 128 << 20;
/// Expanded character strokes per run of the glyph outlines kept in `texts`
/// (`Renderer::cached_stroke`).
pub(crate) const CHAR_STROKE_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;
@@ -176,6 +179,12 @@ impl<K: Hash + Eq + Clone, V, S: BuildHasher + Default> FrameCache<K, V, S> {
self.map.len()
}
/// The values of the entries frame `stamp` made or used.
#[cfg(test)]
pub(crate) fn used_in(&self, stamp: u64) -> impl Iterator<Item = &V> {
self.map.values().filter(move |s| s.stamp == stamp).map(|s| &s.value)
}
#[cfg(test)]
pub(crate) fn is_empty(&self) -> bool {
self.map.is_empty()
+84
View File
@@ -7,6 +7,7 @@ use vectorcraft_doc::{AppearanceItem, Dash, LineJoin, Node, NodeKind};
use vectorcraft_geom::Point;
use super::*;
use crate::tests_fontchange::fonts_steady;
const SIZE: u32 = 200;
@@ -103,3 +104,86 @@ fn aligned_character_strokes_keep_to_their_side_of_the_glyphs() {
assert!(!dark(&outside, mid.0, mid.1), "an outside stroke leaves the glyph itself unpainted");
assert!(dark(&inside, mid.0, mid.1), "an inside stroke paints into the glyph");
}
/// A document with 60 pt `text` without a fill and with a 1 pt character stroke, and the type's id.
fn stroked_text_doc(text: &str) -> (Document, NodeId) {
let mut d = Document::new(SIZE as f64, SIZE as f64);
let style = CharStyle { size: 60.0, fill: Paint::None, stroke: Paint::solid(Color::BLACK), stroke_width: 1.0, ..Default::default() };
let mut n = Node::new(NodeId(0), NodeKind::Text(Box::new(TextObject::point(Point::new(20.0, 120.0), text, style))));
n.id = d.alloc_id();
let id = n.id;
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
(d, id)
}
/// The character stroke outlines that `r` keeps and that its last frame made or used.
fn character_outlines(r: &Renderer) -> Vec<Arc<BezPath>> {
r.char_strokes.used_in(r.stamp).map(|(_, outline)| outline.clone()).collect()
}
/// The outlines of type that changes in every frame (an animation) go within a few frames: the
/// renderer keeps no more than the last few frames' strokes, however long it runs.
#[test]
fn the_character_strokes_of_type_that_changes_in_every_frame_go() {
let _fonts = fonts_steady();
let (mut d, id) = stroked_text_doc("Type");
let opts = RenderOptions::default();
let mut r = Renderer::new();
for i in 0..40 {
if let Some(Node { kind: NodeKind::Text(t), .. }) = d.node_mut(id) {
t.xf = Affine::translate((f64::from(i), 0.0));
}
r.render(&d, SIZE, SIZE, Affine::IDENTITY, &opts);
}
assert!(r.char_strokes.len() <= 4, "{} outlines kept", r.char_strokes.len());
}
/// A character stroke is expanded once: the next frame of the same view draws the outline the
/// first made. A change of the zoom level or of the text expands it again.
#[test]
fn character_stroke_outlines_are_kept_between_frames() {
let _fonts = fonts_steady();
let (mut d, id) = stroked_text_doc("Type");
let opts = RenderOptions::default();
let mut r = Renderer::new();
r.render(&d, SIZE, SIZE, Affine::IDENTITY, &opts);
let first = character_outlines(&r);
assert_eq!(first.len(), 1, "the outline of the one run's stroke");
r.render(&d, SIZE, SIZE, Affine::IDENTITY, &opts);
let again = character_outlines(&r);
assert!(again.len() == 1 && Arc::ptr_eq(&again[0], &first[0]), "the second frame draws the outline the first made");
r.render(&d, SIZE, SIZE, Affine::translate((-40.0, -300.0)) * Affine::scale(4.0), &opts);
let zoomed = character_outlines(&r);
assert!(zoomed.len() == 1 && !Arc::ptr_eq(&zoomed[0], &first[0]), "zoomed in 4×, it is expanded again");
if let Some(Node { kind: NodeKind::Text(t), .. }) = d.node_mut(id) {
t.runs[0].text = "Typo".into();
}
r.render(&d, SIZE, SIZE, Affine::IDENTITY, &opts);
let changed = character_outlines(&r);
assert!(changed.len() == 1 && !Arc::ptr_eq(&changed[0], &first[0]), "the changed text is expanded again");
}
/// Frames that draw character strokes from the cache draw the same pixels, and what a renderer
/// draws whose glyph cache has no room, so that it expands them in every frame: exactly at 100%,
/// where both take a tolerance of 1/4 device pixel, and within antialiasing at 105% and 150%,
/// where the cache takes 1/8 device pixel and the frames without it about 1/4 and 1/6.
#[test]
fn cached_character_strokes_draw_what_uncached_ones_draw() {
let _fonts = fonts_steady();
let (d, _) = stroked_text_doc("Type");
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
for (zoom, most) in [(1.0, 0), (1.05, 24), (1.5, 24)] {
let view = Affine::scale(zoom);
let mut r = Renderer::new();
let first = r.render(&d, SIZE, SIZE, view, &opts).pixels;
assert!(r.render(&d, SIZE, SIZE, view, &opts).pixels == first, "{zoom}×: the cached frames match");
let mut uncached = Renderer::new();
uncached.texts = FrameCache::new(0, 0);
let plain = uncached.render(&d, SIZE, SIZE, view, &opts).pixels;
assert!(character_outlines(&uncached).is_empty());
let diff = first.iter().zip(&plain).map(|(a, b)| a.abs_diff(*b)).max().unwrap_or(0);
assert!(diff <= most, "{zoom}×: cached and uncached frames differ by {diff}");
assert!(plain.iter().any(|&c| c < 128), "{zoom}×: the strokes show");
}
}