Skip to main content

text_typeset/atlas/
cache.rs

1use std::collections::HashMap;
2
3use etagere::AllocId;
4
5use crate::types::FontFaceId;
6
7#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
8pub struct GlyphCacheKey {
9    pub font_face_id: FontFaceId,
10    pub glyph_id: u16,
11    pub size_bits: u32,
12    /// Font weight (variation axis) so that e.g. Inter Regular and Inter
13    /// Bold produce separate cache entries even though they share the
14    /// same `font_face_id` and `glyph_id` in a variable font.
15    pub weight: u32,
16    /// Whether the bitmap was rasterized with hinting. Rasters produced
17    /// under a raster scale (zoomed content) are unhinted, and the same
18    /// *physical* size can be reached both ways — 7px at raster_scale 2
19    /// and 14px at raster_scale 1 share `size_bits` but must not share
20    /// a bitmap.
21    pub hinted: bool,
22}
23
24impl GlyphCacheKey {
25    pub fn new(font_face_id: FontFaceId, glyph_id: u16, size_px: f32) -> Self {
26        Self {
27            font_face_id,
28            glyph_id,
29            size_bits: size_px.to_bits(),
30            weight: 400,
31            hinted: true,
32        }
33    }
34
35    pub fn with_weight(
36        font_face_id: FontFaceId,
37        glyph_id: u16,
38        size_px: f32,
39        weight: u32,
40        hinted: bool,
41    ) -> Self {
42        Self {
43            font_face_id,
44            glyph_id,
45            size_bits: size_px.to_bits(),
46            weight,
47            hinted,
48        }
49    }
50}
51
52pub struct CachedGlyph {
53    pub alloc_id: AllocId,
54    pub atlas_x: u32,
55    pub atlas_y: u32,
56    pub width: u32,
57    pub height: u32,
58    pub placement_left: i32,
59    pub placement_top: i32,
60    pub is_color: bool,
61    /// Frame generation when this glyph was last used.
62    pub last_used: u64,
63}
64
65/// A glyph removed by [`GlyphCache::evict_unused`]: the allocator id to
66/// deallocate plus the atlas rectangle the glyph occupied. Consumers that
67/// retain glyph quads across frames (paint caches) key their invalidation
68/// off the eviction; the rect lets debug builds poison-fill freed pixels
69/// so stale-UV sampling is visually unmistakable.
70#[derive(Clone, Copy, Debug)]
71pub struct EvictedGlyph {
72    pub alloc_id: AllocId,
73    pub atlas_x: u32,
74    pub atlas_y: u32,
75    pub width: u32,
76    pub height: u32,
77}
78
79/// Glyph cache with LRU eviction.
80///
81/// Tracks a frame generation counter. Each `get` marks the glyph as used
82/// in the current generation. `evict_unused` removes glyphs not used
83/// for `max_idle_frames` generations and deallocates their atlas space.
84pub struct GlyphCache {
85    pub(crate) entries: HashMap<GlyphCacheKey, CachedGlyph>,
86    generation: u64,
87    last_eviction_generation: u64,
88}
89
90/// Number of frames a glyph can go unused before being evicted.
91const MAX_IDLE_FRAMES: u64 = 120; // ~2 seconds at 60fps
92
93impl Default for GlyphCache {
94    fn default() -> Self {
95        Self::new()
96    }
97}
98
99impl GlyphCache {
100    pub fn new() -> Self {
101        Self {
102            entries: HashMap::new(),
103            generation: 0,
104            last_eviction_generation: 0,
105        }
106    }
107
108    /// Advance the frame generation counter. Call once per render frame.
109    pub fn advance_generation(&mut self) {
110        self.generation += 1;
111    }
112
113    pub fn generation(&self) -> u64 {
114        self.generation
115    }
116
117    /// Look up a cached glyph, marking it as used in the current generation.
118    pub fn get(&mut self, key: &GlyphCacheKey) -> Option<&CachedGlyph> {
119        if let Some(entry) = self.entries.get_mut(key) {
120            entry.last_used = self.generation;
121            Some(entry)
122        } else {
123            None
124        }
125    }
126
127    /// Look up without marking as used (for read-only queries).
128    pub fn peek(&self, key: &GlyphCacheKey) -> Option<&CachedGlyph> {
129        self.entries.get(key)
130    }
131
132    pub fn insert(&mut self, key: GlyphCacheKey, mut glyph: CachedGlyph) {
133        glyph.last_used = self.generation;
134        self.entries.insert(key, glyph);
135    }
136
137    /// Evict glyphs unused for MAX_IDLE_FRAMES generations.
138    /// Returns the evicted entries — each carries the `AllocId` to
139    /// deallocate from the atlas plus the atlas rectangle it occupied
140    /// (the bucketed allocator cannot resolve a rectangle from an
141    /// `AllocId` after the fact, so the rect is captured here, before
142    /// the entry is dropped; debug builds use it to poison-fill the
143    /// freed pixels).
144    /// Only runs the actual eviction scan every 60 calls (~1 second at 60fps)
145    /// to avoid iterating the entire cache on every render.
146    pub fn evict_unused(&mut self) -> Vec<EvictedGlyph> {
147        // Only scan every 60 generations (~1 second at 60fps)
148        if self.generation - self.last_eviction_generation < 60 {
149            return Vec::new();
150        }
151        self.last_eviction_generation = self.generation;
152
153        let threshold = self.generation.saturating_sub(MAX_IDLE_FRAMES);
154        let mut evicted = Vec::new();
155
156        self.entries.retain(|_key, glyph| {
157            if glyph.last_used < threshold {
158                evicted.push(EvictedGlyph {
159                    alloc_id: glyph.alloc_id,
160                    atlas_x: glyph.atlas_x,
161                    atlas_y: glyph.atlas_y,
162                    width: glyph.width,
163                    height: glyph.height,
164                });
165                false
166            } else {
167                true
168            }
169        });
170
171        evicted
172    }
173
174    /// Emergency eviction when the atlas is full: drop every glyph not
175    /// used in the *current* generation, regardless of the idle window.
176    ///
177    /// Unlike [`evict_unused`](Self::evict_unused) this runs
178    /// unconditionally (no every-60-generations gate) — it is only
179    /// called when an allocation has already failed at the atlas size
180    /// cap, where the alternative is silently dropping the new glyph.
181    /// Glyphs touched this generation are still referenced by quads in
182    /// the frame being built and must survive.
183    pub fn evict_for_pressure(&mut self) -> Vec<EvictedGlyph> {
184        let current = self.generation;
185        let mut evicted = Vec::new();
186
187        self.entries.retain(|_key, glyph| {
188            if glyph.last_used < current {
189                evicted.push(EvictedGlyph {
190                    alloc_id: glyph.alloc_id,
191                    atlas_x: glyph.atlas_x,
192                    atlas_y: glyph.atlas_y,
193                    width: glyph.width,
194                    height: glyph.height,
195                });
196                false
197            } else {
198                true
199            }
200        });
201
202        evicted
203    }
204
205    pub fn len(&self) -> usize {
206        self.entries.len()
207    }
208
209    pub fn is_empty(&self) -> bool {
210        self.entries.is_empty()
211    }
212
213    /// Mark multiple glyphs as used in the current generation without
214    /// returning their data. Used by callers that cache glyph output
215    /// externally (e.g. per-widget paint caches) and need to keep the
216    /// glyphs alive in the atlas even though they don't re-measure them
217    /// every frame.
218    pub fn touch(&mut self, keys: &[GlyphCacheKey]) {
219        let current = self.generation;
220        for key in keys {
221            if let Some(entry) = self.entries.get_mut(key) {
222                entry.last_used = current;
223            }
224        }
225    }
226}