Skip to main content

canvas_renderer/
texture_cache.rs

1//! Texture cache for efficient GPU resource management.
2//!
3//! Caches decoded images to avoid repeated loading and decoding operations.
4
5use std::collections::HashMap;
6use std::time::{Duration, Instant};
7
8use crate::image::TextureData;
9
10/// Entry in the texture cache.
11#[derive(Debug)]
12struct CacheEntry {
13    /// The texture data.
14    data: TextureData,
15    /// Last access time.
16    last_accessed: Instant,
17    /// Size in bytes.
18    size_bytes: usize,
19    /// Reference count for usage tracking.
20    ref_count: u32,
21}
22
23/// Configuration for the texture cache.
24#[derive(Debug, Clone)]
25pub struct TextureCacheConfig {
26    /// Maximum cache size in bytes.
27    pub max_size_bytes: usize,
28    /// Maximum age before eviction (if not accessed).
29    pub max_age: Duration,
30    /// Maximum number of entries.
31    pub max_entries: usize,
32}
33
34impl Default for TextureCacheConfig {
35    fn default() -> Self {
36        Self {
37            max_size_bytes: 256 * 1024 * 1024, // 256 MB
38            max_age: Duration::from_secs(300), // 5 minutes
39            max_entries: 1000,
40        }
41    }
42}
43
44/// Texture cache for managing decoded image data.
45///
46/// Provides LRU-based eviction and size-based limits.
47pub struct TextureCache {
48    /// Cached textures by key.
49    entries: HashMap<String, CacheEntry>,
50    /// Cache configuration.
51    config: TextureCacheConfig,
52    /// Current total size in bytes.
53    current_size: usize,
54    /// Cache statistics.
55    stats: CacheStats,
56}
57
58/// Cache statistics for monitoring.
59#[derive(Debug, Clone, Default)]
60pub struct CacheStats {
61    /// Number of cache hits.
62    pub hits: u64,
63    /// Number of cache misses.
64    pub misses: u64,
65    /// Number of evictions.
66    pub evictions: u64,
67    /// Total bytes loaded.
68    pub bytes_loaded: u64,
69}
70
71impl TextureCache {
72    /// Create a new texture cache with default configuration.
73    #[must_use]
74    pub fn new() -> Self {
75        Self::with_config(TextureCacheConfig::default())
76    }
77
78    /// Create a new texture cache with custom configuration.
79    #[must_use]
80    pub fn with_config(config: TextureCacheConfig) -> Self {
81        Self {
82            entries: HashMap::new(),
83            config,
84            current_size: 0,
85            stats: CacheStats::default(),
86        }
87    }
88
89    /// Get a texture from the cache.
90    ///
91    /// Returns `None` if the texture is not cached.
92    pub fn get(&mut self, key: &str) -> Option<&TextureData> {
93        if let Some(entry) = self.entries.get_mut(key) {
94            entry.last_accessed = Instant::now();
95            entry.ref_count += 1;
96            self.stats.hits += 1;
97            Some(&entry.data)
98        } else {
99            self.stats.misses += 1;
100            None
101        }
102    }
103
104    /// Insert a texture into the cache.
105    ///
106    /// May trigger eviction if cache limits are exceeded.
107    pub fn insert(&mut self, key: String, data: TextureData) {
108        let size_bytes = data.data.len();
109
110        // Remove old entry if exists
111        if let Some(old) = self.entries.remove(&key) {
112            self.current_size -= old.size_bytes;
113        }
114
115        // Evict if necessary
116        self.evict_if_needed(size_bytes);
117
118        // Insert new entry
119        self.current_size += size_bytes;
120        self.stats.bytes_loaded += size_bytes as u64;
121
122        self.entries.insert(
123            key,
124            CacheEntry {
125                data,
126                last_accessed: Instant::now(),
127                size_bytes,
128                ref_count: 1,
129            },
130        );
131    }
132
133    /// Get or create a texture.
134    ///
135    /// If the texture is not in cache, calls the loader function to create it.
136    pub fn get_or_insert_with<F>(&mut self, key: &str, loader: F) -> &TextureData
137    where
138        F: FnOnce() -> TextureData,
139    {
140        if !self.entries.contains_key(key) {
141            let data = loader();
142            self.insert(key.to_string(), data);
143        }
144
145        // Update access time and return
146        // The entry must exist after insert, but we handle the impossible case
147        // gracefully with a static fallback instead of panicking
148        if let Some(entry) = self.entries.get_mut(key) {
149            entry.last_accessed = Instant::now();
150            entry.ref_count += 1;
151            self.stats.hits += 1;
152            &entry.data
153        } else {
154            // Fallback for the impossible case - avoids forbidden unreachable!() macro
155            static FALLBACK: std::sync::OnceLock<TextureData> = std::sync::OnceLock::new();
156            FALLBACK.get_or_init(|| TextureData {
157                width: 1,
158                height: 1,
159                data: vec![0, 0, 0, 0],
160                format: crate::image::ImageFormat::Unknown,
161            })
162        }
163    }
164
165    /// Remove a texture from the cache.
166    pub fn remove(&mut self, key: &str) -> Option<TextureData> {
167        if let Some(entry) = self.entries.remove(key) {
168            self.current_size -= entry.size_bytes;
169            Some(entry.data)
170        } else {
171            None
172        }
173    }
174
175    /// Check if a texture is cached.
176    #[must_use]
177    pub fn contains(&self, key: &str) -> bool {
178        self.entries.contains_key(key)
179    }
180
181    /// Clear all cached textures.
182    pub fn clear(&mut self) {
183        self.entries.clear();
184        self.current_size = 0;
185    }
186
187    /// Get the current number of cached textures.
188    #[must_use]
189    pub fn len(&self) -> usize {
190        self.entries.len()
191    }
192
193    /// Check if the cache is empty.
194    #[must_use]
195    pub fn is_empty(&self) -> bool {
196        self.entries.is_empty()
197    }
198
199    /// Get the current cache size in bytes.
200    #[must_use]
201    pub fn size_bytes(&self) -> usize {
202        self.current_size
203    }
204
205    /// Get cache statistics.
206    #[must_use]
207    pub fn stats(&self) -> &CacheStats {
208        &self.stats
209    }
210
211    /// Evict old entries if needed to make room for a new entry.
212    fn evict_if_needed(&mut self, needed_bytes: usize) {
213        // Evict if over size limit
214        while self.current_size + needed_bytes > self.config.max_size_bytes
215            && !self.entries.is_empty()
216        {
217            self.evict_lru();
218        }
219
220        // Evict if over entry limit
221        while self.entries.len() >= self.config.max_entries && !self.entries.is_empty() {
222            self.evict_lru();
223        }
224
225        // Evict expired entries
226        self.evict_expired();
227    }
228
229    /// Evict the least recently used entry.
230    fn evict_lru(&mut self) {
231        let oldest_key = self
232            .entries
233            .iter()
234            .min_by_key(|(_, entry)| entry.last_accessed)
235            .map(|(key, _)| key.clone());
236
237        if let Some(key) = oldest_key {
238            if let Some(entry) = self.entries.remove(&key) {
239                self.current_size -= entry.size_bytes;
240                self.stats.evictions += 1;
241            }
242        }
243    }
244
245    /// Evict entries that haven't been accessed recently.
246    fn evict_expired(&mut self) {
247        let now = Instant::now();
248        let max_age = self.config.max_age;
249
250        let expired_keys: Vec<String> = self
251            .entries
252            .iter()
253            .filter(|(_, entry)| now.duration_since(entry.last_accessed) > max_age)
254            .map(|(key, _)| key.clone())
255            .collect();
256
257        for key in expired_keys {
258            if let Some(entry) = self.entries.remove(&key) {
259                self.current_size -= entry.size_bytes;
260                self.stats.evictions += 1;
261            }
262        }
263    }
264
265    /// Perform cache maintenance (call periodically).
266    pub fn maintenance(&mut self) {
267        self.evict_expired();
268    }
269}
270
271impl Default for TextureCache {
272    fn default() -> Self {
273        Self::new()
274    }
275}
276
277/// Thread-safe texture cache wrapper.
278#[cfg(feature = "gpu")]
279pub mod sync {
280    use std::sync::{Arc, RwLock};
281
282    use super::{CacheStats, TextureCache, TextureCacheConfig};
283    use crate::image::TextureData;
284
285    /// Thread-safe texture cache.
286    #[derive(Clone)]
287    pub struct SyncTextureCache {
288        inner: Arc<RwLock<TextureCache>>,
289    }
290
291    impl SyncTextureCache {
292        /// Create a new thread-safe texture cache.
293        #[must_use]
294        pub fn new() -> Self {
295            Self {
296                inner: Arc::new(RwLock::new(TextureCache::new())),
297            }
298        }
299
300        /// Create with custom configuration.
301        #[must_use]
302        pub fn with_config(config: TextureCacheConfig) -> Self {
303            Self {
304                inner: Arc::new(RwLock::new(TextureCache::with_config(config))),
305            }
306        }
307
308        /// Get a cloned texture from the cache.
309        #[must_use]
310        pub fn get(&self, key: &str) -> Option<TextureData> {
311            let mut cache = self.inner.write().ok()?;
312            cache.get(key).cloned()
313        }
314
315        /// Insert a texture into the cache.
316        pub fn insert(&self, key: String, data: TextureData) {
317            if let Ok(mut cache) = self.inner.write() {
318                cache.insert(key, data);
319            }
320        }
321
322        /// Check if a texture is cached.
323        #[must_use]
324        pub fn contains(&self, key: &str) -> bool {
325            self.inner
326                .read()
327                .map(|cache| cache.contains(key))
328                .unwrap_or(false)
329        }
330
331        /// Get cache statistics.
332        #[must_use]
333        pub fn stats(&self) -> Option<CacheStats> {
334            self.inner.read().ok().map(|cache| cache.stats().clone())
335        }
336    }
337
338    impl Default for SyncTextureCache {
339        fn default() -> Self {
340            Self::new()
341        }
342    }
343}
344
345#[cfg(test)]
346mod tests {
347    use std::time::Duration;
348
349    use super::{TextureCache, TextureCacheConfig};
350    use crate::image::create_solid_color;
351
352    #[test]
353    fn test_cache_insert_and_get() {
354        let mut cache = TextureCache::new();
355        let texture = create_solid_color(10, 10, 255, 0, 0, 255);
356
357        cache.insert("test".to_string(), texture.clone());
358
359        assert!(cache.contains("test"));
360        assert_eq!(cache.len(), 1);
361
362        let retrieved = cache.get("test");
363        assert!(retrieved.is_some());
364        assert_eq!(retrieved.unwrap().width, 10);
365    }
366
367    #[test]
368    fn test_cache_miss() {
369        let mut cache = TextureCache::new();
370        assert!(cache.get("nonexistent").is_none());
371        assert_eq!(cache.stats().misses, 1);
372    }
373
374    #[test]
375    fn test_cache_eviction_by_size() {
376        let config = TextureCacheConfig {
377            max_size_bytes: 1000, // Very small
378            max_age: Duration::from_secs(3600),
379            max_entries: 100,
380        };
381
382        let mut cache = TextureCache::with_config(config);
383
384        // Insert a texture larger than max size
385        let texture = create_solid_color(20, 20, 255, 0, 0, 255); // 1600 bytes
386        cache.insert("big".to_string(), texture);
387
388        // Should still be inserted (we don't reject, just evict old)
389        assert!(cache.contains("big"));
390    }
391
392    #[test]
393    fn test_cache_eviction_by_count() {
394        let config = TextureCacheConfig {
395            max_size_bytes: 1024 * 1024,
396            max_age: Duration::from_secs(3600),
397            max_entries: 2,
398        };
399
400        let mut cache = TextureCache::with_config(config);
401
402        cache.insert("a".to_string(), create_solid_color(2, 2, 255, 0, 0, 255));
403        cache.insert("b".to_string(), create_solid_color(2, 2, 0, 255, 0, 255));
404        cache.insert("c".to_string(), create_solid_color(2, 2, 0, 0, 255, 255));
405
406        // Should have evicted the oldest
407        assert!(cache.len() <= 2);
408    }
409
410    #[test]
411    fn test_cache_remove() {
412        let mut cache = TextureCache::new();
413        let texture = create_solid_color(10, 10, 255, 0, 0, 255);
414
415        cache.insert("test".to_string(), texture);
416        assert!(cache.contains("test"));
417
418        let removed = cache.remove("test");
419        assert!(removed.is_some());
420        assert!(!cache.contains("test"));
421    }
422
423    #[test]
424    fn test_cache_clear() {
425        let mut cache = TextureCache::new();
426
427        cache.insert("a".to_string(), create_solid_color(2, 2, 255, 0, 0, 255));
428        cache.insert("b".to_string(), create_solid_color(2, 2, 0, 255, 0, 255));
429
430        assert_eq!(cache.len(), 2);
431
432        cache.clear();
433
434        assert_eq!(cache.len(), 0);
435        assert_eq!(cache.size_bytes(), 0);
436    }
437
438    #[test]
439    fn test_get_or_insert_with() {
440        let mut cache = TextureCache::new();
441
442        // First call should invoke loader
443        let data =
444            cache.get_or_insert_with("lazy", || create_solid_color(5, 5, 128, 128, 128, 255));
445
446        assert_eq!(data.width, 5);
447        assert!(cache.contains("lazy"));
448
449        // Second call should use cache
450        let data2 = cache.get_or_insert_with("lazy", || create_solid_color(10, 10, 0, 0, 0, 255));
451
452        assert_eq!(data2.width, 5); // Still the original
453    }
454
455    #[test]
456    fn test_cache_stats() {
457        let mut cache = TextureCache::new();
458
459        cache.insert("a".to_string(), create_solid_color(2, 2, 255, 0, 0, 255));
460
461        let _ = cache.get("a"); // Hit
462        let _ = cache.get("b"); // Miss
463        let _ = cache.get("a"); // Hit
464
465        let stats = cache.stats();
466        assert_eq!(stats.hits, 2);
467        assert_eq!(stats.misses, 1);
468    }
469}