Skip to main content

agg_gui/gl_renderer/
glyph_cache.rs

1//! Glyph vertex cache for the GL rendering path.
2//!
3//! # Problem
4//!
5//! `fill_text` called `shape_and_flatten_text_via_agg` every frame, running
6//! rustybuzz shaping + AGG ConvCurve Bézier flattening + tess2 tessellation
7//! for every visible text string.  On a frame with ~20 text strings this
8//! dominated render time (~60 ms/frame).
9//!
10//! # Solution
11//!
12//! [`GlyphCache`] tessellates each (font, glyph_id, size) triple once and
13//! stores the resulting triangle mesh in **glyph-local pixel coordinates**
14//! (origin 0, 0; scaled by `size / units_per_em`).  On subsequent frames the
15//! caller offsets those vertices by the glyph's screen position and uploads
16//! directly to the GPU — no Bézier evaluation or tessellation.
17//!
18//! # Key design choices
19//!
20//! * **One entry per (font_ptr, glyph_id, size_bits)** — different sizes
21//!   produce different tessellations; identical sizes share a single entry.
22//! * **Glyph-local coordinates** — the CTM is applied at draw time
23//!   (`transform_pt(pen_x + vx, y + vy)`), which is correct for any affine
24//!   transform including rotation.
25//! * **`None` entries are cached too** — so glyphs without outlines (space,
26//!   tab) never re-enter the shaper.
27//! * The cache is **never cleared between frames** (`reset()` must NOT call
28//!   `glyph_cache.clear()`); it grows until the widget tree changes fonts or
29//!   sizes, then entries for the old parameters simply become dead weight
30//!   (acceptable for typical UI workloads).
31
32use std::collections::HashMap;
33use std::sync::Arc;
34
35use crate::gl_renderer::tessellate_fill;
36use crate::text::{flatten_glyph_at_origin, Font};
37
38// ---------------------------------------------------------------------------
39// Public types
40// ---------------------------------------------------------------------------
41
42/// Pre-tessellated triangle mesh for one glyph at a specific pixel size.
43///
44/// All coordinates are in **glyph-local pixels** (origin 0, 0).
45/// To place on screen: for each `[vx, vy]` in `verts` compute
46/// `transform_pt(pen_x + vx as f64, baseline_y + vy as f64)`.
47pub struct CachedGlyph {
48    /// Flattened vertex list — each element is one screen-space `[x, y]`.
49    pub verts: Vec<[f32; 2]>,
50    /// Triangle index list — every three consecutive values index a triangle
51    /// into `verts`.
52    pub indices: Vec<u32>,
53}
54
55/// Per-frame glyph vertex cache shared by one [`GlGfxCtx`] instance.
56///
57/// Create once alongside `GlGfxCtx::new` and keep alive for the lifetime of
58/// the rendering context.  Do **not** clear between frames.
59pub struct GlyphCache {
60    /// `None` entries represent glyphs with no visible outline (spaces, tabs).
61    entries: HashMap<GlyphKey, Option<CachedGlyph>>,
62}
63
64impl GlyphCache {
65    /// Create an empty cache.
66    pub fn new() -> Self {
67        GlyphCache {
68            entries: HashMap::new(),
69        }
70    }
71
72    /// Return the cached tessellation for `(font, glyph_id, size)`, tessellating
73    /// on first access.
74    ///
75    /// Returns `None` for glyphs with no visible outline (space, tab, etc.).
76    pub fn get_or_insert(&mut self, font: &Font, glyph_id: u16, size: f64) -> Option<&CachedGlyph> {
77        let key = GlyphKey {
78            font_ptr: Arc::as_ptr(&font.data) as usize,
79            glyph_id,
80            size_bits: size.to_bits(),
81        };
82        self.entries
83            .entry(key)
84            .or_insert_with(|| tessellate_glyph(font, glyph_id, size))
85            .as_ref()
86    }
87
88    /// Number of entries (including None entries for non-outline glyphs).
89    pub fn len(&self) -> usize {
90        self.entries.len()
91    }
92
93    /// True when no glyphs have been cached yet.
94    pub fn is_empty(&self) -> bool {
95        self.entries.is_empty()
96    }
97
98    /// Discard all cached tessellations.  Only needed when the GL context is
99    /// recreated (e.g., window resize on some platforms that destroy the
100    /// surface).
101    pub fn clear(&mut self) {
102        self.entries.clear();
103    }
104}
105
106impl Default for GlyphCache {
107    fn default() -> Self {
108        Self::new()
109    }
110}
111
112// ---------------------------------------------------------------------------
113// Private helpers
114// ---------------------------------------------------------------------------
115
116#[derive(Hash, Eq, PartialEq, Clone)]
117struct GlyphKey {
118    /// Pointer identity of the font's backing data (`Arc<Vec<u8>>`).
119    font_ptr: usize,
120    glyph_id: u16,
121    /// Exact bit pattern of the `f64` size — guarantees identical sizes share
122    /// a single entry even under floating-point representation.
123    size_bits: u64,
124}
125
126/// Tessellate one glyph's outline at origin (0, 0) and return the mesh,
127/// or `None` if the glyph has no outline.
128fn tessellate_glyph(font: &Font, glyph_id: u16, size: f64) -> Option<CachedGlyph> {
129    let contours = flatten_glyph_at_origin(font, glyph_id, size)?;
130
131    let has_ccw = contours.iter().any(|c| contour_is_ccw(c));
132
133    let (verts_flat, indices) = if has_ccw {
134        // Mixed winding (e.g. 'O', 'D', 'B'): tessellate all contours together
135        // so EvenOdd punches counter holes correctly.
136        tessellate_fill(&contours)?
137    } else {
138        // All-CW strokes (e.g. 'T', 'E', 'N'): tessellate each contour
139        // independently to avoid spurious EvenOdd holes at stroke overlaps.
140        let mut all_vf: Vec<f32> = Vec::new();
141        let mut all_idx: Vec<u32> = Vec::new();
142        for contour in &contours {
143            if let Some((vf, idx)) = tessellate_fill(&[contour.clone()]) {
144                let base = (all_vf.len() / 2) as u32;
145                all_vf.extend_from_slice(&vf);
146                all_idx.extend(idx.iter().map(|&i| i + base));
147            }
148        }
149        if all_vf.is_empty() {
150            return None;
151        }
152        (all_vf, all_idx)
153    };
154
155    let verts: Vec<[f32; 2]> = verts_flat.chunks_exact(2).map(|c| [c[0], c[1]]).collect();
156
157    Some(CachedGlyph { verts, indices })
158}
159
160/// Returns `true` if the contour winds counter-clockwise in Y-up space
161/// (signed area > 0).  Inner counter contours (holes in O, D, B, R …)
162/// wind opposite to the outer boundary.
163fn contour_is_ccw(pts: &[[f32; 2]]) -> bool {
164    let n = pts.len();
165    if n < 3 {
166        return false;
167    }
168    let mut area = 0.0f32;
169    for i in 0..n {
170        let j = (i + 1) % n;
171        area += pts[i][0] * pts[j][1] - pts[j][0] * pts[i][1];
172    }
173    area > 0.0
174}
175
176// ---------------------------------------------------------------------------
177// Tests
178// ---------------------------------------------------------------------------
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183    use crate::text::Font;
184    use std::sync::Arc;
185
186    const FONT_BYTES: &[u8] = include_bytes!("../../../demo/assets/CascadiaCode.ttf");
187
188    fn test_font() -> Arc<Font> {
189        Arc::new(Font::from_slice(FONT_BYTES).expect("font ok"))
190    }
191
192    /// First access populates the cache; second access reuses the same entry.
193    #[test]
194    fn test_cache_hit_on_second_access() {
195        use crate::text::shape_glyphs;
196        let font = test_font();
197        let mut cache = GlyphCache::new();
198
199        let glyphs = shape_glyphs(&font, "H", 14.0);
200        let gid = glyphs[0].glyph_id;
201
202        assert!(cache.is_empty(), "cache starts empty");
203
204        let first = cache.get_or_insert(&font, gid, 14.0);
205        assert!(first.is_some(), "'H' should have an outline");
206        assert_eq!(cache.len(), 1, "one entry after first access");
207
208        let second = cache.get_or_insert(&font, gid, 14.0);
209        assert!(second.is_some());
210        assert_eq!(cache.len(), 1, "cache still has one entry — no duplicate");
211    }
212
213    /// Space has no outline; the cache stores a None entry and returns None.
214    #[test]
215    fn test_cache_none_for_space() {
216        use crate::text::shape_glyphs;
217        let font = test_font();
218        let mut cache = GlyphCache::new();
219
220        let glyphs = shape_glyphs(&font, " ", 14.0);
221        let gid = glyphs[0].glyph_id;
222
223        let result = cache.get_or_insert(&font, gid, 14.0);
224        assert!(result.is_none(), "space glyph has no outline");
225        assert_eq!(
226            cache.len(),
227            1,
228            "None is cached to avoid re-entering the shaper"
229        );
230    }
231
232    /// Different sizes must produce separate cache entries.
233    #[test]
234    fn test_different_sizes_are_separate_entries() {
235        use crate::text::shape_glyphs;
236        let font = test_font();
237        let mut cache = GlyphCache::new();
238
239        let gid = shape_glyphs(&font, "H", 14.0)[0].glyph_id;
240
241        cache.get_or_insert(&font, gid, 14.0);
242        cache.get_or_insert(&font, gid, 16.0);
243        assert_eq!(cache.len(), 2, "14px and 16px are separate entries");
244    }
245
246    /// Cached verts must be in glyph-local pixel range, not font units.
247    #[test]
248    fn test_cached_verts_are_in_pixel_range() {
249        use crate::text::shape_glyphs;
250        let font = test_font();
251        let mut cache = GlyphCache::new();
252        let size = 14.0_f64;
253
254        let gid = shape_glyphs(&font, "H", size)[0].glyph_id;
255        let cached = cache
256            .get_or_insert(&font, gid, size)
257            .expect("H has outline");
258
259        for &[x, y] in &cached.verts {
260            assert!(
261                x >= -2.0 && x <= 20.0,
262                "x={x} must be in glyph-local pixels, not font units"
263            );
264            assert!(
265                y >= -4.0 && y <= 18.0,
266                "y={y} must be in glyph-local pixels, not font units"
267            );
268        }
269    }
270}