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}