Skip to main content

goud_engine/rendering/text/
atlas_cache.rs

1//! Glyph atlas caching layer.
2//!
3//! Caches generated [`GlyphAtlas`] instances keyed by `(font_handle, size)`
4//! so that repeated text rendering at the same size reuses the same atlas.
5
6use std::collections::hash_map::Entry;
7use std::collections::HashMap;
8
9use crate::assets::{loaders::FontAsset, AssetHandle};
10use crate::libs::graphics::backend::render_backend::RenderBackend;
11use crate::libs::graphics::backend::types::TextureHandle;
12
13use super::glyph_atlas::GlyphAtlas;
14
15/// Cache for glyph atlases, keyed by `(font_handle, size_px_u32)`.
16///
17/// The size is stored as a `u32` (rounded to the nearest integer from `f32`) so that the key
18/// is `Hash + Eq`. Callers that need sub-pixel size variation should round
19/// to the nearest integer before querying.
20#[derive(Debug)]
21pub struct GlyphAtlasCache {
22    cache: HashMap<(AssetHandle<FontAsset>, u32), GlyphAtlas>,
23    /// GPU texture handles from invalidated atlases, pending destruction.
24    pending_destroy: Vec<TextureHandle>,
25}
26
27impl GlyphAtlasCache {
28    /// Creates an empty cache.
29    pub fn new() -> Self {
30        Self {
31            cache: HashMap::new(),
32            pending_destroy: Vec::new(),
33        }
34    }
35
36    /// Returns a cached atlas or generates (and caches) a new one.
37    ///
38    /// # Arguments
39    ///
40    /// * `font`        - The loaded font asset (used to parse the font data).
41    /// * `font_handle` - The asset handle identifying this font.
42    /// * `size_px`     - Desired pixel size (rounded to the nearest integer for cache key).
43    ///
44    /// # Errors
45    ///
46    /// Returns an error if font parsing or atlas generation fails.
47    pub fn get_or_create(
48        &mut self,
49        font: &FontAsset,
50        font_handle: AssetHandle<FontAsset>,
51        size_px: f32,
52    ) -> Result<&GlyphAtlas, String> {
53        let size_key = size_px.round() as u32;
54        let key = (font_handle, size_key);
55
56        if let Entry::Vacant(e) = self.cache.entry(key) {
57            let parsed_font = font.parse()?;
58            let atlas = GlyphAtlas::generate(&parsed_font, size_px)?;
59            e.insert(atlas);
60        }
61
62        // Invariant: we just ensured the key exists above (either pre-existing or just inserted).
63        self.cache
64            .get(&key)
65            .ok_or_else(|| "internal error: cache entry missing after insertion".to_string())
66    }
67
68    /// Removes all cached atlases for the given font handle (all sizes).
69    ///
70    /// Any GPU texture handles owned by the removed atlases are queued
71    /// for destruction. Call [`destroy_gpu_textures`](Self::destroy_gpu_textures)
72    /// to release them.
73    pub fn invalidate_font(&mut self, font_handle: AssetHandle<FontAsset>) {
74        self.cache.retain(|&(h, _), atlas| {
75            if h == font_handle {
76                if let Some(tex) = atlas.take_gpu_texture() {
77                    self.pending_destroy.push(tex);
78                }
79                false
80            } else {
81                true
82            }
83        });
84    }
85
86    /// Processes a batch of font hot-reloads by invalidating every font
87    /// in the provided slice.
88    ///
89    /// Returns the number of atlas entries that were removed.
90    pub fn process_reloads(&mut self, reloaded: &[AssetHandle<FontAsset>]) -> usize {
91        let before = self.cache.len();
92        for &handle in reloaded {
93            self.invalidate_font(handle);
94        }
95        before - self.cache.len()
96    }
97
98    /// Destroys all GPU textures that were queued by previous invalidations.
99    ///
100    /// This must be called with a valid render backend to release GPU memory.
101    pub fn destroy_gpu_textures(&mut self, backend: &mut dyn RenderBackend) {
102        for handle in self.pending_destroy.drain(..) {
103            backend.destroy_texture(handle);
104        }
105    }
106
107    /// Returns the number of GPU texture handles pending destruction.
108    pub fn pending_destroy_count(&self) -> usize {
109        self.pending_destroy.len()
110    }
111
112    /// Removes all cached atlases.
113    pub fn clear(&mut self) {
114        for atlas in self.cache.values_mut() {
115            if let Some(tex) = atlas.take_gpu_texture() {
116                self.pending_destroy.push(tex);
117            }
118        }
119        self.cache.clear();
120    }
121
122    /// Returns the number of cached atlases.
123    #[cfg(test)]
124    fn len(&self) -> usize {
125        self.cache.len()
126    }
127}
128
129impl Default for GlyphAtlasCache {
130    fn default() -> Self {
131        Self::new()
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138    use crate::assets::loaders::font::format::FontFormat;
139    use crate::assets::loaders::FontAsset;
140    use crate::assets::loaders::FontStyle;
141
142    /// Build a `FontAsset` from the test TTF fixture.
143    fn test_font_asset() -> FontAsset {
144        let bytes = include_bytes!("../../../test_assets/fonts/test_font.ttf").to_vec();
145        let font = fontdue::Font::from_bytes(bytes.as_slice(), fontdue::FontSettings::default())
146            .expect("parse");
147
148        FontAsset::new(
149            bytes,
150            "TestFont".to_string(),
151            FontStyle::Regular,
152            FontFormat::Ttf,
153            1000,
154            font.glyph_count() as u16,
155            0,
156        )
157    }
158
159    fn handle_a() -> AssetHandle<FontAsset> {
160        AssetHandle::new(0, 1)
161    }
162
163    fn handle_b() -> AssetHandle<FontAsset> {
164        AssetHandle::new(1, 1)
165    }
166
167    #[test]
168    fn test_cache_get_or_create_returns_atlas() {
169        let mut cache = GlyphAtlasCache::new();
170        let font = test_font_asset();
171
172        let result = cache.get_or_create(&font, handle_a(), 16.0);
173        assert!(result.is_ok());
174        assert_eq!(cache.len(), 1);
175    }
176
177    #[test]
178    fn test_cache_hit_returns_same_atlas() {
179        let mut cache = GlyphAtlasCache::new();
180        let font = test_font_asset();
181
182        let _ = cache.get_or_create(&font, handle_a(), 16.0).unwrap();
183        let _ = cache.get_or_create(&font, handle_a(), 16.0).unwrap();
184
185        // Should still have only 1 entry (cache hit).
186        assert_eq!(cache.len(), 1);
187
188        // Verify the cached atlas contains 'A'.
189        let atlas = cache.get_or_create(&font, handle_a(), 16.0).unwrap();
190        assert!(atlas.glyph_info('A').is_some());
191    }
192
193    #[test]
194    fn test_cache_different_sizes_get_different_entries() {
195        let mut cache = GlyphAtlasCache::new();
196        let font = test_font_asset();
197
198        let _ = cache.get_or_create(&font, handle_a(), 16.0).unwrap();
199        let _ = cache.get_or_create(&font, handle_a(), 32.0).unwrap();
200
201        assert_eq!(cache.len(), 2);
202    }
203
204    #[test]
205    fn test_cache_different_handles_get_different_entries() {
206        let mut cache = GlyphAtlasCache::new();
207        let font = test_font_asset();
208
209        let _ = cache.get_or_create(&font, handle_a(), 16.0).unwrap();
210        let _ = cache.get_or_create(&font, handle_b(), 16.0).unwrap();
211
212        assert_eq!(cache.len(), 2);
213    }
214
215    #[test]
216    fn test_invalidate_font_removes_all_sizes() {
217        let mut cache = GlyphAtlasCache::new();
218        let font = test_font_asset();
219
220        let _ = cache.get_or_create(&font, handle_a(), 16.0).unwrap();
221        let _ = cache.get_or_create(&font, handle_a(), 32.0).unwrap();
222        let _ = cache.get_or_create(&font, handle_b(), 16.0).unwrap();
223
224        cache.invalidate_font(handle_a());
225
226        // Only handle_b's entry should remain.
227        assert_eq!(cache.len(), 1);
228    }
229
230    #[test]
231    fn test_clear_removes_all_entries() {
232        let mut cache = GlyphAtlasCache::new();
233        let font = test_font_asset();
234
235        let _ = cache.get_or_create(&font, handle_a(), 16.0).unwrap();
236        let _ = cache.get_or_create(&font, handle_b(), 24.0).unwrap();
237
238        cache.clear();
239        assert_eq!(cache.len(), 0);
240    }
241
242    #[test]
243    fn test_default_creates_empty_cache() {
244        let cache = GlyphAtlasCache::default();
245        assert_eq!(cache.len(), 0);
246    }
247
248    #[test]
249    fn test_process_reloads_invalidates_multiple_fonts() {
250        let mut cache = GlyphAtlasCache::new();
251        let font = test_font_asset();
252
253        // Populate: handle_a at two sizes, handle_b at one size.
254        let _ = cache.get_or_create(&font, handle_a(), 16.0).unwrap();
255        let _ = cache.get_or_create(&font, handle_a(), 32.0).unwrap();
256        let _ = cache.get_or_create(&font, handle_b(), 16.0).unwrap();
257
258        assert_eq!(cache.len(), 3);
259
260        // Reload both fonts at once.
261        let invalidated = cache.process_reloads(&[handle_a(), handle_b()]);
262
263        assert_eq!(invalidated, 3, "all three atlases should be invalidated");
264        assert_eq!(cache.len(), 0);
265    }
266
267    #[test]
268    fn test_process_reloads_returns_zero_for_unknown_fonts() {
269        let mut cache = GlyphAtlasCache::new();
270        let font = test_font_asset();
271
272        let _ = cache.get_or_create(&font, handle_a(), 16.0).unwrap();
273
274        // Reload a font that is not in the cache.
275        let invalidated = cache.process_reloads(&[handle_b()]);
276
277        assert_eq!(invalidated, 0);
278        assert_eq!(cache.len(), 1, "existing entry should remain");
279    }
280
281    #[test]
282    fn test_process_reloads_partial_invalidation() {
283        let mut cache = GlyphAtlasCache::new();
284        let font = test_font_asset();
285
286        let _ = cache.get_or_create(&font, handle_a(), 16.0).unwrap();
287        let _ = cache.get_or_create(&font, handle_b(), 16.0).unwrap();
288
289        let invalidated = cache.process_reloads(&[handle_a()]);
290
291        assert_eq!(invalidated, 1);
292        assert_eq!(cache.len(), 1, "handle_b entry should remain");
293    }
294}