Glyph Caches: Hinting Instances + Outline Paths (#1215)
We found that ~80% of the time in preparing a glyph to render is in
building hinting instances and extracting glyph outlines as shown by the
below profile.
<img width="1751" height="608" alt="image"
src="https://github.com/user-attachments/assets/6b862072-15b8-4ffc-b6e0-069ee38e4a9b"
/>
This PR adds LRU caches for hinting instances and getting outlines. The
strategy mimics the one used by [Vello
Classic](https://github.com/linebender/vello/blob/main/vello_encoding/src/glyph_cache.rs)
for simplicity.
The outline cache grows to accommodate all the glyphs that can be
rendered in a given frame and runs its eviction process within the
renderer's `flush` function. Since outlines are relatively cheap on
memory, I think that this approach is better than checking cache bounds
on each access.
Note: This implies that `flush` must always be called once per frame for
hybrid like vello cpu. But, I think this is fine. It's likely we'll want
to run some things in a multi threaded environment in hybrid at some
point.
Depending on the text being rendered, we've observed significant boosts
to performance using this PR. In Vello Hybrid testing, for a super
simple text field, we've observed 100+ FPS boost. It's hard to tell at
this stage what real world performance will be seen.
## Future Work
- Only pay for `font_ref.outline_glyphs()` when we actually need it or
use an acceleration data structure to generate it.
- Instead of storing outline paths in the outline cache, store
translatable strips (to totally bypass strip rendering).
diff --git a/Cargo.lock b/Cargo.lock
index 0bf4f31..d613fe1 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3715,6 +3715,7 @@
version = "0.0.0"
dependencies = [
"criterion",
+ "parley",
"rand",
"smallvec",
"usvg",
@@ -3729,6 +3730,7 @@
dependencies = [
"bytemuck",
"fearless_simd",
+ "hashbrown 0.15.3",
"libm",
"log",
"peniko",
diff --git a/sparse_strips/vello_bench/Cargo.toml b/sparse_strips/vello_bench/Cargo.toml
index f9b85f7..d35ebe2 100644
--- a/sparse_strips/vello_bench/Cargo.toml
+++ b/sparse_strips/vello_bench/Cargo.toml
@@ -14,6 +14,7 @@
vello_cpu = { workspace = true }
vello_dev_macros = { workspace = true }
criterion = { workspace = true }
+parley = { version = "0.5.0", default-features = true }
rand = { workspace = true }
smallvec = { workspace = true }
usvg = { workspace = true }
diff --git a/sparse_strips/vello_bench/README.md b/sparse_strips/vello_bench/README.md
index a190640..c9afaff 100644
--- a/sparse_strips/vello_bench/README.md
+++ b/sparse_strips/vello_bench/README.md
@@ -6,4 +6,21 @@
In order to run the benches, you can simply run `cargo bench`. However, in most cases you probably don't
want to rerun all benchmarks, in which case you can also provide a filter for the name of the benchmarks
-you want to run, like `cargo bench -- fine/fill`
\ No newline at end of file
+you want to run, like `cargo bench -- fine/fill`
+
+## Workflow
+
+Save a control run with:
+
+```shell
+cargo bench --bench main -- --save-baseline control [TEST NAME FILTER]
+```
+
+Then, apply some changes to the code and compare it to the control with:
+
+```shell
+# Rerun bench against new changes
+cargo bench -- [TEST NAME FILTER]
+# Compare it against control
+cargo bench --bench main -- --load-baseline new --baseline control
+```
diff --git a/sparse_strips/vello_bench/benches/main.rs b/sparse_strips/vello_bench/benches/main.rs
index c9d83a0..daf4b9d 100644
--- a/sparse_strips/vello_bench/benches/main.rs
+++ b/sparse_strips/vello_bench/benches/main.rs
@@ -5,7 +5,7 @@
#![allow(dead_code, reason = "Might be unused on platforms not supporting SIMD")]
use criterion::{criterion_group, criterion_main};
-use vello_bench::{fine, flatten, strip, tile};
+use vello_bench::{fine, flatten, glyph, strip, tile};
criterion_group!(fine_solid, fine::fill);
criterion_group!(fine_strip, fine::strip);
@@ -18,11 +18,13 @@
criterion_group!(flatten, flatten::flatten);
criterion_group!(strokes, flatten::strokes);
criterion_group!(render_strips, strip::render_strips);
+criterion_group!(glyph, glyph::glyph);
criterion_main!(
tile,
render_strips,
flatten,
strokes,
+ glyph,
fine_solid,
fine_strip,
fine_pack,
diff --git a/sparse_strips/vello_bench/src/glyph.rs b/sparse_strips/vello_bench/src/glyph.rs
new file mode 100644
index 0000000..32ff509
--- /dev/null
+++ b/sparse_strips/vello_bench/src/glyph.rs
@@ -0,0 +1,190 @@
+// Copyright 2025 the Vello Authors
+// SPDX-License-Identifier: Apache-2.0 OR MIT
+
+use std::time::{Duration, Instant};
+
+use criterion::Criterion;
+use parley::{
+ Alignment, AlignmentOptions, Font, FontContext, FontFamily, GlyphRun, Layout, LayoutContext,
+ PositionedLayoutItem,
+};
+use vello_common::fearless_simd::Level;
+use vello_common::glyph::{Glyph, GlyphCaches, GlyphRunBuilder};
+use vello_common::glyph::{GlyphRenderer, GlyphType};
+use vello_common::kurbo::Affine;
+use vello_common::peniko::Fill;
+use vello_common::strip_generator::{StripGenerator, StripStorage};
+
+pub fn glyph(c: &mut Criterion) {
+ let mut g = c.benchmark_group("glyph");
+
+ const WIDTH: u16 = 256;
+ const HEIGHT: u16 = 256;
+
+ let mut renderer = GlyphBenchRenderer {
+ strip_generator: StripGenerator::new(
+ WIDTH,
+ HEIGHT,
+ Level::try_detect().unwrap_or(Level::fallback()),
+ ),
+ strip_storage: StripStorage::default(),
+ glyph_caches: Default::default(),
+ };
+
+ const TEXT: &str = "The quick brown fox jumps over the lazy dog 0123456789";
+
+ let layout_for = |text: &str, scale: f32| {
+ let mut layout_cx = LayoutContext::new();
+ let mut font_cx = FontContext::new();
+ let mut builder = layout_cx.ranged_builder(&mut font_cx, text, scale, true);
+ builder.push_default(FontFamily::parse("Roboto").unwrap());
+ let mut layout: Layout<Brush> = builder.build(text);
+ let max_advance = Some(WIDTH as f32);
+ layout.break_all_lines(max_advance);
+ layout.align(max_advance, Alignment::Start, AlignmentOptions::default());
+ layout
+ };
+
+ for (hint_name, hint) in [("hinted", true), ("unhinted", false)] {
+ g.bench_function(format!("cached_{hint_name}"), |b| {
+ let layout = layout_for(TEXT, 1.0);
+ render_layout(&mut renderer, &layout, hint);
+
+ b.iter_custom(|iters| {
+ let mut total_time = Duration::from_nanos(0);
+ for _ in 0..iters {
+ // Don't include `clear` time in the benchmark.
+ renderer.strip_storage.clear();
+
+ let start = Instant::now();
+ render_layout(&mut renderer, &layout, hint);
+ total_time += start.elapsed();
+ }
+ total_time
+ });
+ });
+
+ g.bench_function(format!("uncached_{hint_name}"), |b| {
+ let layout = layout_for(TEXT, 1.0);
+
+ b.iter_custom(|iters| {
+ let mut total_time = Duration::from_nanos(0);
+ for _ in 0..iters {
+ // Don't include `clear` time in the benchmark.
+ renderer.glyph_caches.as_mut().unwrap().clear();
+ renderer.strip_storage.clear();
+
+ let start = Instant::now();
+ render_layout(&mut renderer, &layout, hint);
+ total_time += start.elapsed();
+ }
+ total_time
+ });
+ });
+ }
+
+ g.bench_function("maintain", |b| {
+ let layouts = (0..10)
+ .map(|i| layout_for(TEXT, 1.0 + i as f32 * 0.1))
+ .collect::<Vec<_>>();
+
+ b.iter_custom(|iters| {
+ let mut total_time = Duration::from_nanos(0);
+ for _ in 0..iters {
+ // Prepopulate cache with enough glyphs to overflow cache bounds.
+ // Don't include prepopulate time in the benchmark.
+ for layout in layouts.iter() {
+ render_layout(&mut renderer, layout, true);
+ }
+
+ let start = Instant::now();
+ renderer.glyph_caches.as_mut().unwrap().maintain();
+ total_time += start.elapsed();
+ }
+ total_time
+ });
+ });
+}
+
+#[derive(Clone, Copy, Default, Debug, PartialEq)]
+struct Brush {}
+
+struct GlyphBenchRenderer {
+ strip_generator: StripGenerator,
+ strip_storage: StripStorage,
+ glyph_caches: Option<GlyphCaches>,
+}
+
+impl GlyphBenchRenderer {
+ /// Creates a builder for drawing a run of glyphs that have the same attributes.
+ fn glyph_run(&mut self, font: &Font) -> GlyphRunBuilder<'_, Self> {
+ GlyphRunBuilder::new(font.clone(), Affine::IDENTITY, self)
+ }
+}
+
+impl GlyphRenderer for GlyphBenchRenderer {
+ fn fill_glyph(&mut self, glyph: vello_common::glyph::PreparedGlyph<'_>) {
+ match glyph.glyph_type {
+ GlyphType::Outline(outline_glyph) => {
+ self.strip_generator.generate_filled_path(
+ outline_glyph.path,
+ Fill::NonZero,
+ glyph.transform,
+ Some(128),
+ &mut self.strip_storage,
+ );
+ }
+ GlyphType::Bitmap(_) => {}
+ GlyphType::Colr(_) => {}
+ }
+ }
+
+ fn stroke_glyph(&mut self, _glyph: vello_common::glyph::PreparedGlyph<'_>) {
+ // We only care about filled glyphs for now.
+ unimplemented!()
+ }
+
+ fn take_glyph_caches(&mut self) -> GlyphCaches {
+ self.glyph_caches.take().unwrap_or_default()
+ }
+ fn restore_glyph_caches(&mut self, cache: GlyphCaches) {
+ self.glyph_caches = Some(cache);
+ }
+}
+
+fn render_layout(renderer: &mut GlyphBenchRenderer, layout: &Layout<Brush>, hint: bool) {
+ for line in layout.lines() {
+ for item in line.items() {
+ if let PositionedLayoutItem::GlyphRun(glyph_run) = item {
+ render_glyph_run(renderer, &glyph_run, hint);
+ }
+ }
+ }
+}
+
+fn render_glyph_run(
+ renderer: &mut GlyphBenchRenderer,
+ glyph_run: &GlyphRun<'_, Brush>,
+ hint: bool,
+) {
+ let mut run_x = glyph_run.offset();
+ let run_y = glyph_run.baseline();
+ let glyphs = glyph_run.glyphs().map(|glyph| {
+ let glyph_x = run_x + glyph.x;
+ let glyph_y = run_y - glyph.y;
+ run_x += glyph.advance;
+
+ Glyph {
+ id: glyph.id as u32,
+ x: glyph_x,
+ y: glyph_y,
+ }
+ });
+
+ let run = glyph_run.run();
+ renderer
+ .glyph_run(run.font())
+ .font_size(run.font_size())
+ .hint(hint)
+ .fill_glyphs(glyphs);
+}
diff --git a/sparse_strips/vello_bench/src/lib.rs b/sparse_strips/vello_bench/src/lib.rs
index 6e49214..a0d5f57 100644
--- a/sparse_strips/vello_bench/src/lib.rs
+++ b/sparse_strips/vello_bench/src/lib.rs
@@ -10,6 +10,7 @@
pub mod data;
pub mod fine;
pub mod flatten;
+pub mod glyph;
pub mod strip;
pub mod tile;
diff --git a/sparse_strips/vello_common/Cargo.toml b/sparse_strips/vello_common/Cargo.toml
index 68493a7..e4cf8b6 100644
--- a/sparse_strips/vello_common/Cargo.toml
+++ b/sparse_strips/vello_common/Cargo.toml
@@ -20,6 +20,7 @@
bytemuck = { workspace = true, features = [] }
peniko = { workspace = true, features = ["bytemuck"] }
fearless_simd = { workspace = true }
+hashbrown = { version = "0.15", optional = true }
png = { workspace = true, optional = true }
roxmltree = { version = "0.20.0", optional = true }
skrifa = { workspace = true, optional = true }
@@ -41,7 +42,7 @@
# Enable multi-threaded rendering.
multithreading = ["std"]
# Add support for text rendering
-text = ["dep:skrifa"]
+text = ["dep:skrifa", "dep:hashbrown"]
# Development only features
diff --git a/sparse_strips/vello_common/src/glyph.rs b/sparse_strips/vello_common/src/glyph.rs
index c43ded4..17c8e78 100644
--- a/sparse_strips/vello_common/src/glyph.rs
+++ b/sparse_strips/vello_common/src/glyph.rs
@@ -6,9 +6,12 @@
use crate::kurbo::{Affine, BezPath, Vec2};
use crate::peniko::Font;
use alloc::boxed::Box;
+use alloc::vec::Vec;
use core::fmt::{Debug, Formatter};
+use hashbrown::hash_map::{Entry, RawEntryMut};
+use hashbrown::{Equivalent, HashMap};
use skrifa::instance::{LocationRef, Size};
-use skrifa::outline::DrawSettings;
+use skrifa::outline::{DrawSettings, OutlineGlyphFormat};
use skrifa::raw::TableProvider;
use skrifa::{FontRef, OutlineGlyphCollection};
use skrifa::{
@@ -107,6 +110,16 @@
/// Stroke glyphs with the current paint and stroke settings.
fn stroke_glyph(&mut self, glyph: PreparedGlyph<'_>);
+
+ /// Takes the glyph caches from the renderer for use in a glyph run.
+ ///
+ /// NOTE: The caller must restore the caches after the glyph run is done.
+ fn take_glyph_caches(&mut self) -> GlyphCaches;
+
+ /// Restores the glyph caches after a glyph run.
+ ///
+ /// The caches must have been previously taken with `take_glyph_caches`.
+ fn restore_glyph_caches(&mut self, caches: GlyphCaches);
}
/// A builder for configuring and drawing glyphs.
@@ -181,21 +194,25 @@
let color_glyphs = font_ref.color_glyphs();
let bitmaps = font_ref.bitmap_strikes();
+ // TODO: Consider using a drop guard so that panics return the caches to the renderer.
+ let GlyphCaches {
+ mut hinting_cache,
+ mut outline_cache,
+ } = self.renderer.take_glyph_caches();
+ let mut outline_cache_session =
+ OutlineCacheSession::new(&mut outline_cache, VarLookupKey(self.run.normalized_coords));
let PreparedGlyphRun {
transform: initial_transform,
size,
normalized_coords,
hinting_instance,
- } = prepare_glyph_run(&self.run, &outlines);
+ } = prepare_glyph_run(&self.run, &outlines, &mut hinting_cache);
let render_glyph = match style {
Style::Fill => GlyphRenderer::fill_glyph,
Style::Stroke => GlyphRenderer::stroke_glyph,
};
- // Reuse the same `path` allocation for each glyph.
- let mut outline_path = OutlinePath::new();
-
for glyph in glyphs {
let bitmap_data = bitmaps
.glyph_for_size(Size::new(self.run.font_size), GlyphId::new(glyph.id))
@@ -238,12 +255,14 @@
prepare_outline_glyph(
glyph,
+ self.run.font.data.id(),
+ self.run.font.index,
+ &mut outline_cache_session,
size,
initial_transform,
self.run.transform,
- &mut outline_path,
&outline,
- hinting_instance.as_ref(),
+ hinting_instance,
normalized_coords,
)
};
@@ -255,29 +274,37 @@
render_glyph(self.renderer, prepared_glyph);
}
+
+ self.renderer.restore_glyph_caches(GlyphCaches {
+ outline_cache,
+ hinting_cache,
+ });
}
}
fn prepare_outline_glyph<'a>(
glyph: Glyph,
+ font_id: u64,
+ font_index: u32,
+ outline_cache: &'a mut OutlineCacheSession<'_>,
size: Size,
// The transform of the run + the per-glyph transform.
initial_transform: Affine,
// The transform of the run, without the per-glyph transform.
run_transform: Affine,
- path: &'a mut OutlinePath,
outline_glyph: &skrifa::outline::OutlineGlyph<'a>,
hinting_instance: Option<&HintingInstance>,
normalized_coords: &[skrifa::instance::NormalizedCoord],
) -> (GlyphType<'a>, Affine) {
- let draw_settings = if let Some(hinting_instance) = hinting_instance {
- DrawSettings::hinted(hinting_instance, false)
- } else {
- DrawSettings::unhinted(size, normalized_coords)
- };
-
- path.0.truncate(0);
- let _ = outline_glyph.draw(draw_settings, path);
+ let path = outline_cache.get_or_insert(
+ glyph.id,
+ font_id,
+ font_index,
+ size,
+ VarLookupKey(normalized_coords),
+ outline_glyph,
+ hinting_instance,
+ );
// Calculate the global glyph translation based on the glyph's local position within
// the run and the run's global transform.
@@ -500,7 +527,7 @@
/// The font size to generate glyph outlines for.
size: Size,
normalized_coords: &'a [skrifa::instance::NormalizedCoord],
- hinting_instance: Option<HintingInstance>,
+ hinting_instance: Option<&'a HintingInstance>,
}
/// Prepare a glyph run for rendering.
@@ -510,6 +537,7 @@
fn prepare_glyph_run<'a>(
run: &GlyphRun<'a>,
outlines: &OutlineGlyphCollection<'_>,
+ hint_cache: &'a mut HintCache,
) -> PreparedGlyphRun<'a> {
if !run.hint {
return PreparedGlyphRun {
@@ -530,7 +558,6 @@
//
// As the hinting is vertical-only, we can handle horizontal skew, but not vertical skew or
// rotations.
-
let total_transform = run.transform * run.glyph_transform.unwrap_or(Affine::IDENTITY);
let [t_a, t_b, t_c, t_d, t_e, t_f] = total_transform.as_coeffs();
@@ -540,8 +567,15 @@
if uniform_scale && vertically_uniform {
let vertical_font_size = run.font_size * t_d as f32;
let size = Size::new(vertical_font_size);
- let hinting_instance =
- HintingInstance::new(outlines, size, run.normalized_coords, HINTING_OPTIONS).ok();
+
+ let hinting_instance = hint_cache.get(&HintKey {
+ font_id: run.font.data.id(),
+ font_index: run.font.index,
+ outlines,
+ size,
+ coords: run.normalized_coords,
+ });
+
PreparedGlyphRun {
transform: Affine::new([1., 0., t_c, 1., t_e, t_f]),
size,
@@ -550,7 +584,7 @@
}
} else {
PreparedGlyphRun {
- transform: run.transform * run.glyph_transform.unwrap_or(Affine::IDENTITY),
+ transform: total_transform,
size: Size::new(run.font_size),
normalized_coords: run.normalized_coords,
hinting_instance: None,
@@ -569,6 +603,7 @@
},
};
+#[derive(Clone, Default)]
pub(crate) struct OutlinePath(pub(crate) BezPath);
impl OutlinePath {
@@ -623,3 +658,333 @@
const _NORMALISED_COORD_SIZE_MATCHES: () =
assert!(size_of::<skrifa::instance::NormalizedCoord>() == size_of::<NormalizedCoord>());
}
+
+/// Caches used for glyph rendering.
+// TODO: Consider capturing cache performance metrics like hit rate, etc.
+#[derive(Debug, Default)]
+pub struct GlyphCaches {
+ outline_cache: OutlineCache,
+ hinting_cache: HintCache,
+}
+
+impl GlyphCaches {
+ /// Creates a new `GlyphCaches` instance.
+ pub fn new() -> Self {
+ Default::default()
+ }
+
+ /// Clears the glyph caches.
+ pub fn clear(&mut self) {
+ self.outline_cache.clear();
+ self.hinting_cache.clear();
+ }
+
+ /// Maintains the glyph caches by evicting unused cache entries.
+ ///
+ /// Should be called once per scene rendering.
+ pub fn maintain(&mut self) {
+ self.outline_cache.maintain();
+ }
+}
+
+#[derive(Copy, Clone, PartialEq, Eq, Hash, Default, Debug)]
+struct OutlineKey {
+ font_id: u64,
+ font_index: u32,
+ glyph_id: u32,
+ size_bits: u32,
+ hint: bool,
+}
+
+struct OutlineEntry {
+ path: OutlinePath,
+ serial: u32,
+}
+
+impl OutlineEntry {
+ const fn new(path: OutlinePath, serial: u32) -> Self {
+ Self { path, serial }
+ }
+}
+
+/// Caches glyph outlines for reuse.
+/// Heavily inspired by `vello_encoding::glyph_cache`.
+#[derive(Default)]
+struct OutlineCache {
+ free_list: Vec<OutlinePath>,
+ static_map: HashMap<OutlineKey, OutlineEntry>,
+ variable_map: HashMap<VarKey, HashMap<OutlineKey, OutlineEntry>>,
+ cached_count: usize,
+ serial: u32,
+ last_prune_serial: u32,
+}
+
+impl Debug for OutlineCache {
+ fn fmt(&self, f: &mut Formatter<'_>) -> core::fmt::Result {
+ f.debug_struct("OutlineCache")
+ .field("free_list", &self.free_list.len())
+ .field("static_map", &self.static_map.len())
+ .field("variable_map", &self.variable_map.len())
+ .field("cached_count", &self.cached_count)
+ .field("serial", &self.serial)
+ .field("last_prune_serial", &self.last_prune_serial)
+ .finish()
+ }
+}
+
+impl OutlineCache {
+ fn maintain(&mut self) {
+ // Maximum number of full renders where we'll retain an unused glyph
+ const MAX_ENTRY_AGE: u32 = 64;
+ // Maximum number of full renders before we force a prune
+ const PRUNE_FREQUENCY: u32 = 64;
+ // Always prune if the cached count is greater than this value
+ const CACHED_COUNT_THRESHOLD: usize = 256;
+ // Number of encoding buffers we'll keep on the free list
+ const MAX_FREE_LIST_SIZE: usize = 128;
+
+ let free_list = &mut self.free_list;
+ let serial = self.serial;
+ self.serial += 1;
+ // Don't iterate over the whole cache every frame
+ if serial - self.last_prune_serial < PRUNE_FREQUENCY
+ && self.cached_count < CACHED_COUNT_THRESHOLD
+ {
+ return;
+ }
+ self.last_prune_serial = serial;
+ self.static_map.retain(|_, entry| {
+ if serial - entry.serial > MAX_ENTRY_AGE {
+ if free_list.len() < MAX_FREE_LIST_SIZE {
+ free_list.push(core::mem::take(&mut entry.path));
+ }
+ self.cached_count -= 1;
+ false
+ } else {
+ true
+ }
+ });
+ self.variable_map.retain(|_, map| {
+ map.retain(|_, entry| {
+ if serial - entry.serial > MAX_ENTRY_AGE {
+ if free_list.len() < MAX_FREE_LIST_SIZE {
+ free_list.push(core::mem::take(&mut entry.path));
+ }
+ self.cached_count -= 1;
+ false
+ } else {
+ true
+ }
+ });
+ !map.is_empty()
+ });
+ }
+
+ fn clear(&mut self) {
+ self.free_list.clear();
+ self.static_map.clear();
+ self.variable_map.clear();
+ self.cached_count = 0;
+ self.serial = 0;
+ self.last_prune_serial = 0;
+ }
+}
+
+struct OutlineCacheSession<'a> {
+ map: &'a mut HashMap<OutlineKey, OutlineEntry>,
+ free_list: &'a mut Vec<OutlinePath>,
+ serial: u32,
+ cached_count: &'a mut usize,
+}
+
+impl<'a> OutlineCacheSession<'a> {
+ fn new(outline_cache: &'a mut OutlineCache, var_key: VarLookupKey<'_>) -> Self {
+ let map = if var_key.0.is_empty() {
+ &mut outline_cache.static_map
+ } else {
+ match outline_cache
+ .variable_map
+ .raw_entry_mut()
+ .from_key(&var_key)
+ {
+ RawEntryMut::Occupied(entry) => entry.into_mut(),
+ RawEntryMut::Vacant(entry) => entry.insert(var_key.into(), HashMap::new()).1,
+ }
+ };
+ Self {
+ map,
+ free_list: &mut outline_cache.free_list,
+ serial: outline_cache.serial,
+ cached_count: &mut outline_cache.cached_count,
+ }
+ }
+
+ fn get_or_insert(
+ &mut self,
+ glyph_id: u32,
+ font_id: u64,
+ font_index: u32,
+ size: Size,
+ var_key: VarLookupKey<'_>,
+ outline_glyph: &skrifa::outline::OutlineGlyph<'_>,
+ hinting_instance: Option<&HintingInstance>,
+ ) -> &OutlinePath {
+ let key = OutlineKey {
+ glyph_id,
+ font_id,
+ font_index,
+ size_bits: size.ppem().unwrap().to_bits(),
+ hint: hinting_instance.is_some(),
+ };
+
+ match self.map.entry(key) {
+ Entry::Occupied(mut entry) => {
+ entry.get_mut().serial = self.serial;
+ &entry.into_mut().path
+ }
+ Entry::Vacant(entry) => {
+ let mut path = self.free_list.pop().unwrap_or_default();
+
+ let draw_settings = if let Some(hinting_instance) = hinting_instance {
+ DrawSettings::hinted(hinting_instance, false)
+ } else {
+ DrawSettings::unhinted(size, var_key.0)
+ };
+
+ path.0.truncate(0);
+ outline_glyph.draw(draw_settings, &mut path).unwrap();
+
+ let entry = entry.insert(OutlineEntry::new(path, self.serial));
+ *self.cached_count += 1;
+ &entry.path
+ }
+ }
+ }
+}
+
+/// Key for variable font caches.
+type VarKey = Vec<skrifa::instance::NormalizedCoord>;
+
+/// Lookup key for variable font caches.
+#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug)]
+struct VarLookupKey<'a>(&'a [skrifa::instance::NormalizedCoord]);
+
+impl Equivalent<VarKey> for VarLookupKey<'_> {
+ fn equivalent(&self, other: &VarKey) -> bool {
+ self.0 == *other
+ }
+}
+
+impl From<VarLookupKey<'_>> for VarKey {
+ fn from(key: VarLookupKey<'_>) -> Self {
+ key.0.to_vec()
+ }
+}
+
+/// We keep this small to enable a simple LRU cache with a linear
+/// search. Regenerating hinting data is low to medium cost so it's fine
+/// to redo it occasionally.
+const MAX_CACHED_HINT_INSTANCES: usize = 16;
+
+struct HintKey<'a> {
+ font_id: u64,
+ font_index: u32,
+ outlines: &'a OutlineGlyphCollection<'a>,
+ size: Size,
+ coords: &'a [skrifa::instance::NormalizedCoord],
+}
+
+impl HintKey<'_> {
+ fn instance(&self) -> Option<HintingInstance> {
+ HintingInstance::new(self.outlines, self.size, self.coords, HINTING_OPTIONS).ok()
+ }
+}
+
+/// LRU cache for hinting instances.
+///
+/// Heavily inspired by `vello_encoding::glyph_cache`.
+#[derive(Default)]
+struct HintCache {
+ // Split caches for glyf/cff because the instance type can reuse
+ // internal memory when reconfigured for the same format.
+ glyf_entries: Vec<HintEntry>,
+ cff_entries: Vec<HintEntry>,
+ serial: u64,
+}
+
+impl Debug for HintCache {
+ fn fmt(&self, f: &mut Formatter<'_>) -> core::fmt::Result {
+ f.debug_struct("HintCache")
+ .field("glyf_entries", &self.glyf_entries.len())
+ .field("cff_entries", &self.cff_entries.len())
+ .field("serial", &self.serial)
+ .finish()
+ }
+}
+
+impl HintCache {
+ fn get(&mut self, key: &HintKey<'_>) -> Option<&HintingInstance> {
+ let entries = match key.outlines.format()? {
+ OutlineGlyphFormat::Glyf => &mut self.glyf_entries,
+ OutlineGlyphFormat::Cff | OutlineGlyphFormat::Cff2 => &mut self.cff_entries,
+ };
+ let (entry_ix, is_current) = find_hint_entry(entries, key)?;
+ let entry = entries.get_mut(entry_ix)?;
+ self.serial += 1;
+ entry.serial = self.serial;
+ if !is_current {
+ entry.font_id = key.font_id;
+ entry.font_index = key.font_index;
+ entry
+ .instance
+ .reconfigure(key.outlines, key.size, key.coords, HINTING_OPTIONS)
+ .ok()?;
+ }
+ Some(&entry.instance)
+ }
+
+ fn clear(&mut self) {
+ self.glyf_entries.clear();
+ self.cff_entries.clear();
+ self.serial = 0;
+ }
+}
+
+struct HintEntry {
+ font_id: u64,
+ font_index: u32,
+ instance: HintingInstance,
+ serial: u64,
+}
+
+fn find_hint_entry(entries: &mut Vec<HintEntry>, key: &HintKey<'_>) -> Option<(usize, bool)> {
+ let mut found_serial = u64::MAX;
+ let mut found_index = 0;
+ for (ix, entry) in entries.iter().enumerate() {
+ if entry.font_id == key.font_id
+ && entry.font_index == key.font_index
+ && entry.instance.size() == key.size
+ && entry.instance.location().coords() == key.coords
+ {
+ return Some((ix, true));
+ }
+ if entry.serial < found_serial {
+ found_serial = entry.serial;
+ found_index = ix;
+ }
+ }
+ if entries.len() < MAX_CACHED_HINT_INSTANCES {
+ let instance = key.instance()?;
+ let ix = entries.len();
+ entries.push(HintEntry {
+ font_id: key.font_id,
+ font_index: key.font_index,
+ instance,
+ // This should be updated by the caller.
+ serial: 0,
+ });
+ Some((ix, true))
+ } else {
+ Some((found_index, false))
+ }
+}
diff --git a/sparse_strips/vello_common/src/recording.rs b/sparse_strips/vello_common/src/recording.rs
index dd6064f..4481b23 100644
--- a/sparse_strips/vello_common/src/recording.rs
+++ b/sparse_strips/vello_common/src/recording.rs
@@ -322,12 +322,23 @@
pub struct Recorder<'a> {
/// The recording to capture commands into.
recording: &'a mut Recording,
+
+ #[cfg(feature = "text")]
+ glyph_caches: Option<crate::glyph::GlyphCaches>,
}
impl<'a> Recorder<'a> {
/// Create a new recorder for the given recording.
- pub fn new(recording: &'a mut Recording, transform: Affine) -> Self {
- let mut s = Self { recording };
+ pub fn new(
+ recording: &'a mut Recording,
+ transform: Affine,
+ #[cfg(feature = "text")] glyph_caches: crate::glyph::GlyphCaches,
+ ) -> Self {
+ let mut s = Self {
+ recording,
+ #[cfg(feature = "text")]
+ glyph_caches: Some(glyph_caches),
+ };
// Ensure that the initial transform is saved on the recording.
s.set_transform(transform);
s
@@ -460,4 +471,11 @@
}
}
}
+
+ fn restore_glyph_caches(&mut self, caches: crate::glyph::GlyphCaches) {
+ self.glyph_caches = Some(caches);
+ }
+ fn take_glyph_caches(&mut self) -> crate::glyph::GlyphCaches {
+ self.glyph_caches.take().unwrap_or_default()
+ }
}
diff --git a/sparse_strips/vello_cpu/src/render.rs b/sparse_strips/vello_cpu/src/render.rs
index 393bb40..502911e 100644
--- a/sparse_strips/vello_cpu/src/render.rs
+++ b/sparse_strips/vello_cpu/src/render.rs
@@ -55,6 +55,8 @@
)]
pub(crate) render_settings: RenderSettings,
dispatcher: Box<dyn Dispatcher>,
+ #[cfg(feature = "text")]
+ pub(crate) glyph_caches: Option<vello_common::glyph::GlyphCaches>,
}
/// Settings to apply to the render context.
@@ -144,6 +146,8 @@
stroke,
temp_path,
encoded_paints,
+ #[cfg(feature = "text")]
+ glyph_caches: Some(Default::default()),
}
}
@@ -419,6 +423,8 @@
self.encoded_paints.clear();
self.reset_transform();
self.reset_paint_transform();
+ #[cfg(feature = "text")]
+ self.glyph_caches.as_mut().unwrap().maintain();
}
/// Flush any pending operations.
@@ -599,6 +605,14 @@
}
}
}
+
+ fn take_glyph_caches(&mut self) -> vello_common::glyph::GlyphCaches {
+ self.glyph_caches.take().unwrap()
+ }
+
+ fn restore_glyph_caches(&mut self, cache: vello_common::glyph::GlyphCaches) {
+ self.glyph_caches = Some(cache);
+ }
}
#[cfg(feature = "text")]
@@ -645,8 +659,17 @@
where
F: FnOnce(&mut Recorder<'_>),
{
- let mut recorder = Recorder::new(recording, self.transform);
+ let mut recorder = Recorder::new(
+ recording,
+ self.transform,
+ #[cfg(feature = "text")]
+ self.take_glyph_caches(),
+ );
f(&mut recorder);
+ #[cfg(feature = "text")]
+ {
+ self.glyph_caches = Some(recorder.take_glyph_caches());
+ }
}
fn prepare_recording(&mut self, recording: &mut Recording) {
diff --git a/sparse_strips/vello_hybrid/src/scene.rs b/sparse_strips/vello_hybrid/src/scene.rs
index 52bd696..806dda8 100644
--- a/sparse_strips/vello_hybrid/src/scene.rs
+++ b/sparse_strips/vello_hybrid/src/scene.rs
@@ -69,6 +69,7 @@
pub(crate) blend_mode: BlendMode,
pub(crate) strip_generator: StripGenerator,
pub(crate) strip_storage: StripStorage,
+ pub(crate) glyph_caches: Option<vello_common::glyph::GlyphCaches>,
}
impl Scene {
@@ -95,6 +96,7 @@
transform: render_state.transform,
fill_rule: render_state.fill_rule,
blend_mode: render_state.blend_mode,
+ glyph_caches: Some(Default::default()),
}
}
@@ -346,6 +348,8 @@
self.paint = render_state.paint;
self.stroke = render_state.stroke;
self.blend_mode = render_state.blend_mode;
+
+ self.glyph_caches.as_mut().unwrap().maintain();
}
/// Get the width of the render context.
@@ -392,6 +396,14 @@
GlyphType::Colr(_) => {}
}
}
+
+ fn take_glyph_caches(&mut self) -> vello_common::glyph::GlyphCaches {
+ self.glyph_caches.take().unwrap_or_default()
+ }
+
+ fn restore_glyph_caches(&mut self, cache: vello_common::glyph::GlyphCaches) {
+ self.glyph_caches = Some(cache);
+ }
}
impl Recordable for Scene {
@@ -399,9 +411,11 @@
where
F: FnOnce(&mut Recorder<'_>),
{
- let mut recorder = Recorder::new(recording, self.transform);
+ let mut recorder = Recorder::new(recording, self.transform, self.take_glyph_caches());
f(&mut recorder);
+ self.glyph_caches = Some(recorder.take_glyph_caches());
}
+
fn prepare_recording(&mut self, recording: &mut Recording) {
let buffers = recording.take_cached_strips();
let (strip_storage, strip_start_indices) =
diff --git a/sparse_strips/vello_sparse_tests/tests/renderer.rs b/sparse_strips/vello_sparse_tests/tests/renderer.rs
index 4d87b7e..ef5a6a0 100644
--- a/sparse_strips/vello_sparse_tests/tests/renderer.rs
+++ b/sparse_strips/vello_sparse_tests/tests/renderer.rs
@@ -702,4 +702,12 @@
fn stroke_glyph(&mut self, glyph: PreparedGlyph<'_>) {
self.scene.stroke_glyph(glyph);
}
+
+ fn take_glyph_caches(&mut self) -> vello_common::glyph::GlyphCaches {
+ self.scene.take_glyph_caches()
+ }
+
+ fn restore_glyph_caches(&mut self, caches: vello_common::glyph::GlyphCaches) {
+ self.scene.restore_glyph_caches(caches);
+ }
}