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);
+    }
 }