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}