Skip to main content

oximedia_gpu/
texture_cache.rs

1//! 2D tile-based texture cache simulation.
2//!
3//! Models the texture caching subsystem present on GPU hardware — a fixed-size
4//! cache organised in 2D tiles.  Each cache line holds a rectangular tile of
5//! texels.  The cache tracks:
6//!
7//! * **Hit/miss counts** per request.
8//! * **LRU eviction** — the least-recently-used tile is evicted when the cache
9//!   is full and a new tile must be fetched.
10//! * **Prefetch hints** — callers can warm the cache for anticipated accesses
11//!   without incrementing the hit counter.
12//!
13//! All structures are CPU-side simulations; no actual GPU memory is allocated.
14
15use std::collections::{BTreeMap, VecDeque};
16use thiserror::Error;
17
18// ─── Error ────────────────────────────────────────────────────────────────────
19
20/// Errors returned by texture cache operations.
21#[derive(Debug, Clone, PartialEq, Error)]
22pub enum CacheError {
23    /// The requested texel coordinate is outside the texture bounds.
24    #[error("Texel coordinate ({x}, {y}) out of bounds ({width}x{height})")]
25    OutOfBounds {
26        x: u32,
27        y: u32,
28        width: u32,
29        height: u32,
30    },
31    /// The cache capacity is zero, which is not permitted.
32    #[error("Cache capacity must be at least 1")]
33    ZeroCapacity,
34    /// The tile size is zero.
35    #[error("Tile size must be at least 1")]
36    ZeroTileSize,
37    /// Texture dimensions are zero.
38    #[error("Texture dimensions must be at least 1×1")]
39    ZeroDimensions,
40}
41
42// ─── TileKey ─────────────────────────────────────────────────────────────────
43
44/// Identifies a 2D tile by its tile-grid coordinates.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
46pub struct TileKey {
47    /// Column index in the tile grid.
48    pub tile_x: u32,
49    /// Row index in the tile grid.
50    pub tile_y: u32,
51}
52
53impl TileKey {
54    /// Construct a `TileKey` from texel coordinates and tile size.
55    #[must_use]
56    pub fn from_texel(x: u32, y: u32, tile_size: u32) -> Self {
57        Self {
58            tile_x: x / tile_size,
59            tile_y: y / tile_size,
60        }
61    }
62}
63
64// ─── CacheStats ───────────────────────────────────────────────────────────────
65
66/// Aggregate cache performance statistics.
67#[derive(Debug, Clone, Default)]
68pub struct TexCacheStats {
69    /// Number of cache hits (tile found in cache).
70    pub hits: u64,
71    /// Number of cache misses (tile not in cache; fetched from main memory).
72    pub misses: u64,
73    /// Number of cache lines written by prefetch operations.
74    pub prefetches: u64,
75    /// Number of tiles evicted to make room for new tiles.
76    pub evictions: u64,
77}
78
79impl TexCacheStats {
80    /// Hit rate: `hits / (hits + misses)`, or 0.0 if no accesses have occurred.
81    #[must_use]
82    pub fn hit_rate(&self) -> f64 {
83        let total = self.hits + self.misses;
84        if total == 0 {
85            0.0
86        } else {
87            self.hits as f64 / total as f64
88        }
89    }
90
91    /// Total number of texel accesses (hits + misses).
92    #[must_use]
93    pub fn total_accesses(&self) -> u64 {
94        self.hits + self.misses
95    }
96}
97
98// ─── TextureCache ─────────────────────────────────────────────────────────────
99
100/// 2D tile-based LRU texture cache.
101///
102/// The simulated texture has `width × height` texels divided into
103/// `tile_size × tile_size` tiles.  At most `capacity` tiles reside in the
104/// cache simultaneously.
105///
106/// # Cache line model
107///
108/// Each cache "line" corresponds to one complete tile.  On a miss the whole
109/// tile is loaded from simulated main memory.  The tile's data is a flat
110/// `Vec<u8>` with `tile_size * tile_size` bytes (one byte per texel).
111pub struct TextureCache {
112    /// Width of the texture in texels.
113    pub width: u32,
114    /// Height of the texture in texels.
115    pub height: u32,
116    /// Edge length (in texels) of each square tile / cache line.
117    pub tile_size: u32,
118    /// Maximum number of tiles the cache can hold.
119    capacity: usize,
120    /// Cached tiles: key → tile data.
121    lines: BTreeMap<TileKey, Vec<u8>>,
122    /// LRU order: front = most recent, back = oldest.
123    lru: VecDeque<TileKey>,
124    /// Aggregate statistics.
125    stats: TexCacheStats,
126    /// Simulated "main memory" — the backing texture data.
127    texture_data: Vec<u8>,
128}
129
130impl TextureCache {
131    /// Create a new texture cache backed by a flat texture buffer.
132    ///
133    /// `texture_data` must have exactly `width * height` bytes.  If it is
134    /// shorter it is padded with zeros; if longer, the excess is ignored.
135    ///
136    /// # Errors
137    ///
138    /// Returns an error if `width`, `height`, `tile_size`, or `capacity` is 0.
139    pub fn new(
140        width: u32,
141        height: u32,
142        tile_size: u32,
143        capacity: usize,
144        texture_data: Vec<u8>,
145    ) -> Result<Self, CacheError> {
146        if width == 0 || height == 0 {
147            return Err(CacheError::ZeroDimensions);
148        }
149        if tile_size == 0 {
150            return Err(CacheError::ZeroTileSize);
151        }
152        if capacity == 0 {
153            return Err(CacheError::ZeroCapacity);
154        }
155        let total = (width as usize) * (height as usize);
156        let mut padded = texture_data;
157        padded.resize(total, 0);
158
159        Ok(Self {
160            width,
161            height,
162            tile_size,
163            capacity,
164            lines: BTreeMap::new(),
165            lru: VecDeque::new(),
166            stats: TexCacheStats::default(),
167            texture_data: padded,
168        })
169    }
170
171    /// Fetch the texel value at `(x, y)`.
172    ///
173    /// If the tile containing `(x, y)` is not in the cache, it is loaded (a
174    /// *miss*); otherwise the cached copy is used (a *hit*).
175    ///
176    /// # Errors
177    ///
178    /// Returns [`CacheError::OutOfBounds`] if `x >= width` or `y >= height`.
179    pub fn fetch(&mut self, x: u32, y: u32) -> Result<u8, CacheError> {
180        self.check_bounds(x, y)?;
181        let key = TileKey::from_texel(x, y, self.tile_size);
182        if self.lines.contains_key(&key) {
183            self.stats.hits += 1;
184            self.promote_lru(key);
185        } else {
186            self.stats.misses += 1;
187            self.load_tile(key);
188        }
189        let tile = &self.lines[&key];
190        let local_x = (x % self.tile_size) as usize;
191        let local_y = (y % self.tile_size) as usize;
192        Ok(tile[local_y * self.tile_size as usize + local_x])
193    }
194
195    /// Prefetch the tile containing `(x, y)` into the cache.
196    ///
197    /// If the tile is already cached, this is a no-op (the hit counter is *not*
198    /// incremented).  If it is not cached, it is loaded and the prefetch counter
199    /// is incremented.
200    ///
201    /// # Errors
202    ///
203    /// Returns [`CacheError::OutOfBounds`] if `x >= width` or `y >= height`.
204    pub fn prefetch(&mut self, x: u32, y: u32) -> Result<(), CacheError> {
205        self.check_bounds(x, y)?;
206        let key = TileKey::from_texel(x, y, self.tile_size);
207        if !self.lines.contains_key(&key) {
208            self.load_tile(key);
209            self.stats.prefetches += 1;
210        }
211        Ok(())
212    }
213
214    /// Invalidate (evict) all tiles from the cache.
215    ///
216    /// Statistics are preserved; only the tile storage is cleared.
217    pub fn flush(&mut self) {
218        self.lines.clear();
219        self.lru.clear();
220    }
221
222    /// Invalidate a specific tile if present.
223    ///
224    /// Returns `true` if the tile was in the cache and was removed.
225    pub fn invalidate_tile(&mut self, key: TileKey) -> bool {
226        if self.lines.remove(&key).is_some() {
227            self.lru.retain(|k| k != &key);
228            true
229        } else {
230            false
231        }
232    }
233
234    /// Number of tiles currently resident in the cache.
235    #[must_use]
236    pub fn resident_count(&self) -> usize {
237        self.lines.len()
238    }
239
240    /// Whether a specific tile is currently resident in the cache.
241    #[must_use]
242    pub fn is_resident(&self, key: TileKey) -> bool {
243        self.lines.contains_key(&key)
244    }
245
246    /// A reference to the current aggregate statistics.
247    #[must_use]
248    pub fn stats(&self) -> &TexCacheStats {
249        &self.stats
250    }
251
252    /// Reset all statistics counters to zero.
253    pub fn reset_stats(&mut self) {
254        self.stats = TexCacheStats::default();
255    }
256
257    /// Maximum number of tiles the cache can hold.
258    #[must_use]
259    pub fn capacity(&self) -> usize {
260        self.capacity
261    }
262
263    /// Number of tiles in each dimension of the tile grid.
264    #[must_use]
265    pub fn grid_size(&self) -> (u32, u32) {
266        let tiles_x = (self.width + self.tile_size - 1) / self.tile_size;
267        let tiles_y = (self.height + self.tile_size - 1) / self.tile_size;
268        (tiles_x, tiles_y)
269    }
270
271    // ── Private helpers ───────────────────────────────────────────────────────
272
273    fn check_bounds(&self, x: u32, y: u32) -> Result<(), CacheError> {
274        if x >= self.width || y >= self.height {
275            Err(CacheError::OutOfBounds {
276                x,
277                y,
278                width: self.width,
279                height: self.height,
280            })
281        } else {
282            Ok(())
283        }
284    }
285
286    /// Load a tile from simulated main memory into the cache, evicting if needed.
287    fn load_tile(&mut self, key: TileKey) {
288        // Evict LRU tile if at capacity.
289        if self.lines.len() >= self.capacity {
290            if let Some(victim) = self.lru.pop_back() {
291                self.lines.remove(&victim);
292                self.stats.evictions += 1;
293            }
294        }
295        // Build the tile data from the backing texture.
296        let ts = self.tile_size as usize;
297        let w = self.width as usize;
298        let tile_x0 = key.tile_x as usize * ts;
299        let tile_y0 = key.tile_y as usize * ts;
300        let mut tile = vec![0u8; ts * ts];
301        for local_y in 0..ts {
302            let global_y = tile_y0 + local_y;
303            if global_y >= self.height as usize {
304                break;
305            }
306            for local_x in 0..ts {
307                let global_x = tile_x0 + local_x;
308                if global_x >= self.width as usize {
309                    break;
310                }
311                tile[local_y * ts + local_x] = self.texture_data[global_y * w + global_x];
312            }
313        }
314        self.lines.insert(key, tile);
315        self.lru.push_front(key);
316    }
317
318    /// Move an existing cache line to the front of the LRU queue.
319    fn promote_lru(&mut self, key: TileKey) {
320        self.lru.retain(|k| k != &key);
321        self.lru.push_front(key);
322    }
323}
324
325// ─── Tests ───────────────────────────────────────────────────────────────────
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330
331    /// Build a simple 16×16 texture where texel (x,y) = (y*16 + x) as u8.
332    fn make_texture(width: u32, height: u32) -> Vec<u8> {
333        (0..(width * height)).map(|i| (i % 256) as u8).collect()
334    }
335
336    fn make_cache(capacity: usize, tile_size: u32) -> TextureCache {
337        let data = make_texture(16, 16);
338        TextureCache::new(16, 16, tile_size, capacity, data).unwrap()
339    }
340
341    // ── TileKey ───────────────────────────────────────────────────────────────
342
343    #[test]
344    fn test_tile_key_from_texel() {
345        let key = TileKey::from_texel(9, 5, 4);
346        assert_eq!(key.tile_x, 2); // 9/4=2
347        assert_eq!(key.tile_y, 1); // 5/4=1
348    }
349
350    #[test]
351    fn test_tile_key_origin() {
352        let key = TileKey::from_texel(0, 0, 8);
353        assert_eq!(key.tile_x, 0);
354        assert_eq!(key.tile_y, 0);
355    }
356
357    // ── TexCacheStats ─────────────────────────────────────────────────────────
358
359    #[test]
360    fn test_stats_hit_rate_zero_accesses() {
361        let s = TexCacheStats::default();
362        assert_eq!(s.hit_rate(), 0.0);
363    }
364
365    #[test]
366    fn test_stats_hit_rate_all_hits() {
367        let s = TexCacheStats {
368            hits: 10,
369            misses: 0,
370            ..Default::default()
371        };
372        assert!((s.hit_rate() - 1.0).abs() < 1e-9);
373    }
374
375    #[test]
376    fn test_stats_total_accesses() {
377        let s = TexCacheStats {
378            hits: 3,
379            misses: 7,
380            ..Default::default()
381        };
382        assert_eq!(s.total_accesses(), 10);
383    }
384
385    // ── TextureCache construction ─────────────────────────────────────────────
386
387    #[test]
388    fn test_new_zero_capacity_error() {
389        let err = TextureCache::new(8, 8, 4, 0, vec![]);
390        assert!(matches!(err, Err(CacheError::ZeroCapacity)));
391    }
392
393    #[test]
394    fn test_new_zero_tile_size_error() {
395        let err = TextureCache::new(8, 8, 0, 4, vec![]);
396        assert!(matches!(err, Err(CacheError::ZeroTileSize)));
397    }
398
399    #[test]
400    fn test_new_zero_dimensions_error() {
401        let err = TextureCache::new(0, 8, 4, 4, vec![]);
402        assert!(matches!(err, Err(CacheError::ZeroDimensions)));
403    }
404
405    // ── fetch: hit / miss tracking ────────────────────────────────────────────
406
407    #[test]
408    fn test_fetch_first_access_is_miss() {
409        let mut cache = make_cache(8, 4);
410        let _ = cache.fetch(0, 0).unwrap();
411        assert_eq!(cache.stats().misses, 1);
412        assert_eq!(cache.stats().hits, 0);
413    }
414
415    #[test]
416    fn test_fetch_second_access_same_tile_is_hit() {
417        let mut cache = make_cache(8, 4);
418        let _ = cache.fetch(0, 0).unwrap();
419        let _ = cache.fetch(1, 1).unwrap(); // same tile (tile 0,0)
420        assert_eq!(cache.stats().misses, 1);
421        assert_eq!(cache.stats().hits, 1);
422    }
423
424    #[test]
425    fn test_fetch_correct_texel_value() {
426        let data = make_texture(16, 16);
427        let expected_at_3_5 = data[5 * 16 + 3];
428        let mut cache = TextureCache::new(16, 16, 4, 8, data).unwrap();
429        let val = cache.fetch(3, 5).unwrap();
430        assert_eq!(val, expected_at_3_5);
431    }
432
433    #[test]
434    fn test_fetch_out_of_bounds() {
435        let mut cache = make_cache(4, 4);
436        let err = cache.fetch(16, 0);
437        assert!(matches!(err, Err(CacheError::OutOfBounds { .. })));
438    }
439
440    // ── LRU eviction ─────────────────────────────────────────────────────────
441
442    #[test]
443    fn test_lru_eviction_when_full() {
444        // 2-tile cache; access 3 different tiles → first tile should be evicted.
445        let mut cache = make_cache(2, 4); // 16×16 / 4×4 = 16 tiles
446        cache.fetch(0, 0).unwrap(); // tile (0,0) loaded
447        cache.fetch(4, 0).unwrap(); // tile (1,0) loaded — cache full
448                                    // Access a third tile → (0,0) should be evicted (LRU)
449        cache.fetch(8, 0).unwrap(); // tile (2,0) loaded
450        assert_eq!(cache.stats().evictions, 1);
451        assert_eq!(cache.resident_count(), 2);
452    }
453
454    #[test]
455    fn test_lru_promotion_prevents_eviction() {
456        let mut cache = make_cache(2, 4);
457        cache.fetch(0, 0).unwrap(); // (0,0) loaded → LRU = [(0,0)]
458        cache.fetch(4, 0).unwrap(); // (1,0) loaded → LRU = [(1,0), (0,0)]
459        cache.fetch(0, 0).unwrap(); // (0,0) hit → promoted → LRU = [(0,0),(1,0)]
460                                    // Load new tile → (1,0) evicted, not (0,0)
461        cache.fetch(8, 0).unwrap();
462        let key_0_0 = TileKey {
463            tile_x: 0,
464            tile_y: 0,
465        };
466        assert!(
467            cache.is_resident(key_0_0),
468            "promoted tile should survive eviction"
469        );
470    }
471
472    // ── prefetch ──────────────────────────────────────────────────────────────
473
474    #[test]
475    fn test_prefetch_loads_tile_without_hit_count() {
476        let mut cache = make_cache(8, 4);
477        cache.prefetch(0, 0).unwrap();
478        assert_eq!(cache.stats().prefetches, 1);
479        assert_eq!(cache.stats().hits, 0);
480        assert_eq!(cache.stats().misses, 0);
481        // Subsequent fetch should be a hit.
482        cache.fetch(0, 0).unwrap();
483        assert_eq!(cache.stats().hits, 1);
484    }
485
486    #[test]
487    fn test_prefetch_already_resident_is_noop() {
488        let mut cache = make_cache(8, 4);
489        cache.fetch(0, 0).unwrap(); // miss → tile loaded
490        cache.prefetch(0, 0).unwrap(); // already resident → no prefetch count
491        assert_eq!(cache.stats().prefetches, 0);
492    }
493
494    // ── flush and invalidate ──────────────────────────────────────────────────
495
496    #[test]
497    fn test_flush_clears_cache() {
498        let mut cache = make_cache(8, 4);
499        cache.fetch(0, 0).unwrap();
500        cache.fetch(4, 0).unwrap();
501        assert_eq!(cache.resident_count(), 2);
502        cache.flush();
503        assert_eq!(cache.resident_count(), 0);
504        // Stats should be preserved
505        assert_eq!(cache.stats().misses, 2);
506    }
507
508    #[test]
509    fn test_invalidate_tile() {
510        let mut cache = make_cache(8, 4);
511        cache.fetch(0, 0).unwrap();
512        let key = TileKey {
513            tile_x: 0,
514            tile_y: 0,
515        };
516        assert!(cache.invalidate_tile(key));
517        assert!(!cache.is_resident(key));
518        // Second invalidation should return false.
519        assert!(!cache.invalidate_tile(key));
520    }
521
522    // ── grid_size ─────────────────────────────────────────────────────────────
523
524    #[test]
525    fn test_grid_size_exact_division() {
526        let data = vec![0u8; 64];
527        let cache = TextureCache::new(8, 8, 4, 4, data).unwrap();
528        assert_eq!(cache.grid_size(), (2, 2));
529    }
530
531    #[test]
532    fn test_grid_size_non_exact() {
533        let data = vec![0u8; 100];
534        let cache = TextureCache::new(10, 10, 4, 4, data).unwrap();
535        // ceil(10/4) = 3
536        assert_eq!(cache.grid_size(), (3, 3));
537    }
538}