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::text::{Font, flatten_glyph_at_origin};
36use crate::gl_renderer::tessellate_fill;
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 { entries: HashMap::new() }
68    }
69
70    /// Return the cached tessellation for `(font, glyph_id, size)`, tessellating
71    /// on first access.
72    ///
73    /// Returns `None` for glyphs with no visible outline (space, tab, etc.).
74    pub fn get_or_insert(
75        &mut self,
76        font:     &Font,
77        glyph_id: u16,
78        size:     f64,
79    ) -> Option<&CachedGlyph> {
80        let key = GlyphKey {
81            font_ptr:  Arc::as_ptr(&font.data) as usize,
82            glyph_id,
83            size_bits: size.to_bits(),
84        };
85        self.entries
86            .entry(key)
87            .or_insert_with(|| tessellate_glyph(font, glyph_id, size))
88            .as_ref()
89    }
90
91    /// Number of entries (including None entries for non-outline glyphs).
92    pub fn len(&self) -> usize {
93        self.entries.len()
94    }
95
96    /// True when no glyphs have been cached yet.
97    pub fn is_empty(&self) -> bool {
98        self.entries.is_empty()
99    }
100
101    /// Discard all cached tessellations.  Only needed when the GL context is
102    /// recreated (e.g., window resize on some platforms that destroy the
103    /// surface).
104    pub fn clear(&mut self) {
105        self.entries.clear();
106    }
107}
108
109impl Default for GlyphCache {
110    fn default() -> Self { Self::new() }
111}
112
113// ---------------------------------------------------------------------------
114// Private helpers
115// ---------------------------------------------------------------------------
116
117#[derive(Hash, Eq, PartialEq, Clone)]
118struct GlyphKey {
119    /// Pointer identity of the font's backing data (`Arc<Vec<u8>>`).
120    font_ptr: usize,
121    glyph_id: u16,
122    /// Exact bit pattern of the `f64` size — guarantees identical sizes share
123    /// a single entry even under floating-point representation.
124    size_bits: u64,
125}
126
127/// Tessellate one glyph's outline at origin (0, 0) and return the mesh,
128/// or `None` if the glyph has no outline.
129fn tessellate_glyph(font: &Font, glyph_id: u16, size: f64) -> Option<CachedGlyph> {
130    let contours = flatten_glyph_at_origin(font, glyph_id, size)?;
131
132    let has_ccw = contours.iter().any(|c| contour_is_ccw(c));
133
134    let (verts_flat, indices) = if has_ccw {
135        // Mixed winding (e.g. 'O', 'D', 'B'): tessellate all contours together
136        // so EvenOdd punches counter holes correctly.
137        tessellate_fill(&contours)?
138    } else {
139        // All-CW strokes (e.g. 'T', 'E', 'N'): tessellate each contour
140        // independently to avoid spurious EvenOdd holes at stroke overlaps.
141        let mut all_vf: Vec<f32>  = Vec::new();
142        let mut all_idx: Vec<u32> = Vec::new();
143        for contour in &contours {
144            if let Some((vf, idx)) = tessellate_fill(&[contour.clone()]) {
145                let base = (all_vf.len() / 2) as u32;
146                all_vf.extend_from_slice(&vf);
147                all_idx.extend(idx.iter().map(|&i| i + base));
148            }
149        }
150        if all_vf.is_empty() { return None; }
151        (all_vf, all_idx)
152    };
153
154    let verts: Vec<[f32; 2]> = verts_flat
155        .chunks_exact(2)
156        .map(|c| [c[0], c[1]])
157        .collect();
158
159    Some(CachedGlyph { verts, indices })
160}
161
162/// Returns `true` if the contour winds counter-clockwise in Y-up space
163/// (signed area > 0).  Inner counter contours (holes in O, D, B, R …)
164/// wind opposite to the outer boundary.
165fn contour_is_ccw(pts: &[[f32; 2]]) -> bool {
166    let n = pts.len();
167    if n < 3 { return false; }
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 std::sync::Arc;
184    use crate::text::Font;
185
186    const FONT_BYTES: &[u8] =
187        include_bytes!("../../../demo/assets/CascadiaCode.ttf");
188
189    fn test_font() -> Arc<Font> {
190        Arc::new(Font::from_slice(FONT_BYTES).expect("font ok"))
191    }
192
193    /// First access populates the cache; second access reuses the same entry.
194    #[test]
195    fn test_cache_hit_on_second_access() {
196        use crate::text::shape_glyphs;
197        let font  = test_font();
198        let mut cache = GlyphCache::new();
199
200        let glyphs = shape_glyphs(&font, "H", 14.0);
201        let gid = glyphs[0].glyph_id;
202
203        assert!(cache.is_empty(), "cache starts empty");
204
205        let first = cache.get_or_insert(&font, gid, 14.0);
206        assert!(first.is_some(), "'H' should have an outline");
207        assert_eq!(cache.len(), 1, "one entry after first access");
208
209        let second = cache.get_or_insert(&font, gid, 14.0);
210        assert!(second.is_some());
211        assert_eq!(cache.len(), 1, "cache still has one entry — no duplicate");
212    }
213
214    /// Space has no outline; the cache stores a None entry and returns None.
215    #[test]
216    fn test_cache_none_for_space() {
217        use crate::text::shape_glyphs;
218        let font  = test_font();
219        let mut cache = GlyphCache::new();
220
221        let glyphs = shape_glyphs(&font, " ", 14.0);
222        let gid = glyphs[0].glyph_id;
223
224        let result = cache.get_or_insert(&font, gid, 14.0);
225        assert!(result.is_none(), "space glyph has no outline");
226        assert_eq!(cache.len(), 1, "None is cached to avoid re-entering the shaper");
227    }
228
229    /// Different sizes must produce separate cache entries.
230    #[test]
231    fn test_different_sizes_are_separate_entries() {
232        use crate::text::shape_glyphs;
233        let font  = test_font();
234        let mut cache = GlyphCache::new();
235
236        let gid = shape_glyphs(&font, "H", 14.0)[0].glyph_id;
237
238        cache.get_or_insert(&font, gid, 14.0);
239        cache.get_or_insert(&font, gid, 16.0);
240        assert_eq!(cache.len(), 2, "14px and 16px are separate entries");
241    }
242
243    /// Cached verts must be in glyph-local pixel range, not font units.
244    #[test]
245    fn test_cached_verts_are_in_pixel_range() {
246        use crate::text::shape_glyphs;
247        let font  = test_font();
248        let mut cache = GlyphCache::new();
249        let size = 14.0_f64;
250
251        let gid = shape_glyphs(&font, "H", size)[0].glyph_id;
252        let cached = cache.get_or_insert(&font, gid, size).expect("H has outline");
253
254        for &[x, y] in &cached.verts {
255            assert!(
256                x >= -2.0 && x <= 20.0,
257                "x={x} must be in glyph-local pixels, not font units"
258            );
259            assert!(
260                y >= -4.0 && y <= 18.0,
261                "y={y} must be in glyph-local pixels, not font units"
262            );
263        }
264    }
265}