Skip to main content

oximedia_gpu/
texture.rs

1//! GPU texture management
2//!
3//! Describes texture formats, computes memory requirements, and provides a
4//! simple pooled allocator for GPU textures.
5
6#![allow(dead_code)]
7#![allow(clippy::cast_precision_loss)]
8
9/// Supported GPU texture formats
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum TextureFormat {
12    /// 8-bit RGBA (4 bytes / pixel)
13    Rgba8,
14    /// 16-bit half-float RGBA (8 bytes / pixel)
15    Rgba16f,
16    /// 10-bit RGB + 2-bit alpha packed (4 bytes / pixel)
17    Rgb10A2,
18    /// Single 8-bit red channel (1 byte / pixel)
19    R8,
20    /// Dual 8-bit RG channels (2 bytes / pixel)
21    Rg8,
22    /// Planar YUV 4:2:0 (1.5 bytes / pixel)
23    Yuv420,
24    /// Semi-planar NV12 YUV 4:2:0 (1.5 bytes / pixel)
25    Nv12,
26}
27
28impl TextureFormat {
29    /// Average bytes per pixel (may be fractional for planar formats)
30    #[must_use]
31    pub fn bytes_per_pixel(&self) -> f32 {
32        match self {
33            Self::Rgba8 | Self::Rgb10A2 => 4.0,
34            Self::Rgba16f => 8.0,
35            Self::R8 => 1.0,
36            Self::Rg8 => 2.0,
37            Self::Yuv420 | Self::Nv12 => 1.5,
38        }
39    }
40
41    /// Whether this format is a YUV variant
42    #[must_use]
43    pub fn is_yuv(&self) -> bool {
44        matches!(self, Self::Yuv420 | Self::Nv12)
45    }
46
47    /// Logical channel count (Y, Cb, Cr are each counted separately)
48    #[must_use]
49    pub fn channel_count(&self) -> u8 {
50        match self {
51            Self::R8 => 1,
52            Self::Rg8 => 2,
53            Self::Rgba8 | Self::Rgba16f | Self::Rgb10A2 => 4,
54            Self::Yuv420 | Self::Nv12 => 3,
55        }
56    }
57}
58
59/// Descriptor for a single GPU texture
60#[derive(Debug, Clone)]
61pub struct TextureDescriptor {
62    /// Width in texels
63    pub width: u32,
64    /// Height in texels
65    pub height: u32,
66    /// Texel format
67    pub format: TextureFormat,
68    /// Number of mip levels (1 = base level only)
69    pub mip_levels: u8,
70    /// Number of array layers (1 = single texture)
71    pub array_layers: u16,
72}
73
74impl TextureDescriptor {
75    /// Create a simple 2-D texture with no mip chain and a single layer
76    #[must_use]
77    pub fn new(width: u32, height: u32, format: TextureFormat) -> Self {
78        Self {
79            width,
80            height,
81            format,
82            mip_levels: 1,
83            array_layers: 1,
84        }
85    }
86
87    /// Total size in bytes for the full mip chain and all array layers
88    ///
89    /// Each successive mip level has half the dimensions of the previous.
90    /// Minimum mip size is 1×1.
91    #[must_use]
92    pub fn size_bytes(&self) -> usize {
93        let bpp = self.format.bytes_per_pixel();
94        let layers = self.array_layers as usize;
95        let mut total_pixels: f64 = 0.0;
96        let (mut w, mut h) = (f64::from(self.width), f64::from(self.height));
97        for _ in 0..self.mip_levels {
98            total_pixels += w * h;
99            w = (w / 2.0).max(1.0);
100            h = (h / 2.0).max(1.0);
101        }
102        (total_pixels * f64::from(bpp) * layers as f64) as usize
103    }
104
105    /// Total number of texels in the base mip level (ignoring arrays / mips)
106    #[must_use]
107    pub fn total_pixels(&self) -> u64 {
108        u64::from(self.width) * u64::from(self.height)
109    }
110}
111
112/// A pool of GPU textures backed by a fixed memory budget and an optional
113/// count-based capacity limit.
114pub struct TexturePool {
115    /// All allocated descriptors (index acts as texture handle)
116    descriptors: Vec<Option<TextureDescriptor>>,
117    /// Currently allocated bytes
118    allocated_bytes: usize,
119    /// Maximum bytes the pool may use
120    pub(crate) max_bytes: usize,
121    /// Maximum number of live textures (0 = unlimited).
122    max_textures: usize,
123    /// Monotonic clock for LRU tracking (incremented on each touch/allocate)
124    access_clock: u64,
125    /// Last-access timestamp per slot (parallel to `descriptors`)
126    last_access: Vec<u64>,
127}
128
129impl TexturePool {
130    /// Create a pool with a budget of `max_gb` gigabytes and no count limit.
131    #[must_use]
132    pub fn new(max_gb: f64) -> Self {
133        Self {
134            descriptors: Vec::new(),
135            allocated_bytes: 0,
136            max_bytes: (max_gb * 1024.0 * 1024.0 * 1024.0) as usize,
137            max_textures: 0,
138            access_clock: 0,
139            last_access: Vec::new(),
140        }
141    }
142
143    /// Create a pool with an explicit maximum texture **count**.
144    ///
145    /// When the pool holds `max` live textures, `allocate` returns `None`.
146    /// Use `evict_lru` or `allocate_with_lru_eviction` to make room.
147    /// The byte budget is set to `usize::MAX` (effectively unlimited).
148    #[must_use]
149    pub fn with_capacity(max: usize) -> Self {
150        Self {
151            descriptors: Vec::with_capacity(max),
152            allocated_bytes: 0,
153            max_bytes: usize::MAX,
154            max_textures: max,
155            access_clock: 0,
156            last_access: Vec::with_capacity(max),
157        }
158    }
159
160    /// Evict all LRU textures until the pool is below its `max_textures` limit
161    /// (or until the pool is empty).
162    ///
163    /// Returns the number of textures evicted.
164    pub fn evict_lru(&mut self) -> usize {
165        let mut evicted = 0usize;
166        while self.max_textures > 0 && self.live_count() > self.max_textures {
167            match self.lru_handle() {
168                Some(h) => {
169                    self.free(h);
170                    evicted += 1;
171                }
172                None => break,
173            }
174        }
175        evicted
176    }
177
178    /// Allocate a texture in the pool
179    ///
180    /// Returns `Some(handle)` on success, or `None` if the byte budget or the
181    /// count capacity (`max_textures`) is exceeded.
182    pub fn allocate(&mut self, desc: TextureDescriptor) -> Option<usize> {
183        let bytes = desc.size_bytes();
184        if self.allocated_bytes + bytes > self.max_bytes {
185            return None;
186        }
187        if self.max_textures > 0 && self.live_count() >= self.max_textures {
188            return None;
189        }
190        // Reuse a freed slot if possible
191        self.access_clock += 1;
192        let ts = self.access_clock;
193        if let Some(idx) = self
194            .descriptors
195            .iter()
196            .position(std::option::Option::is_none)
197        {
198            self.descriptors[idx] = Some(desc);
199            self.last_access[idx] = ts;
200            self.allocated_bytes += bytes;
201            return Some(idx);
202        }
203        let idx = self.descriptors.len();
204        self.descriptors.push(Some(desc));
205        self.last_access.push(ts);
206        self.allocated_bytes += bytes;
207        Some(idx)
208    }
209
210    /// Allocate a texture, evicting the LRU slot if the budget or count limit
211    /// is exceeded.
212    ///
213    /// Returns `Some(handle)` on success, or `None` if eviction cannot free
214    /// enough resources (e.g. a single remaining texture is larger than the
215    /// requested one's byte budget).
216    pub fn allocate_with_lru_eviction(&mut self, desc: TextureDescriptor) -> Option<usize> {
217        let bytes = desc.size_bytes();
218        // Try ordinary allocation first.
219        let count_ok = self.max_textures == 0 || self.live_count() < self.max_textures;
220        if self.allocated_bytes + bytes <= self.max_bytes && count_ok {
221            return self.allocate(desc);
222        }
223        // Evict LRU entries until both byte budget and count limit are satisfied.
224        loop {
225            let bytes_ok = self.allocated_bytes + bytes <= self.max_bytes;
226            let cnt_ok = self.max_textures == 0 || self.live_count() < self.max_textures;
227            if bytes_ok && cnt_ok {
228                return self.allocate(desc);
229            }
230            let lru = self.lru_handle()?;
231            self.free(lru);
232        }
233    }
234
235    /// Return the handle of the least-recently-used allocated texture, or
236    /// `None` if the pool is empty.
237    #[must_use]
238    pub fn lru_handle(&self) -> Option<usize> {
239        self.descriptors
240            .iter()
241            .enumerate()
242            .filter_map(|(i, slot)| slot.as_ref().map(|_| i))
243            .min_by_key(|&i| self.last_access[i])
244    }
245
246    /// Update the access timestamp of `handle` to mark it as recently used.
247    pub fn touch(&mut self, handle: usize) {
248        if handle < self.descriptors.len() && self.descriptors[handle].is_some() {
249            self.access_clock += 1;
250            self.last_access[handle] = self.access_clock;
251        }
252    }
253
254    /// Free a texture by handle
255    pub fn free(&mut self, id: usize) {
256        if let Some(slot) = self.descriptors.get_mut(id) {
257            if let Some(desc) = slot.take() {
258                let bytes = desc.size_bytes();
259                self.allocated_bytes = self.allocated_bytes.saturating_sub(bytes);
260                self.last_access[id] = 0;
261            }
262        }
263    }
264
265    /// Utilisation in [0.0, 1.0]
266    #[must_use]
267    pub fn utilization(&self) -> f64 {
268        if self.max_bytes == 0 {
269            return 0.0;
270        }
271        self.allocated_bytes as f64 / self.max_bytes as f64
272    }
273
274    /// Number of live (allocated) textures
275    #[must_use]
276    pub fn live_count(&self) -> usize {
277        self.descriptors.iter().filter(|s| s.is_some()).count()
278    }
279
280    /// Currently allocated bytes
281    #[must_use]
282    pub fn allocated_bytes(&self) -> usize {
283        self.allocated_bytes
284    }
285
286    /// Pool capacity in bytes
287    #[must_use]
288    pub fn max_bytes(&self) -> usize {
289        self.max_bytes
290    }
291}
292
293// ============================================================
294// Unit tests
295// ============================================================
296#[cfg(test)]
297mod tests {
298    use super::*;
299
300    #[test]
301    fn test_rgba8_bytes_per_pixel() {
302        assert!((TextureFormat::Rgba8.bytes_per_pixel() - 4.0).abs() < f32::EPSILON);
303    }
304
305    #[test]
306    fn test_yuv_formats_are_yuv() {
307        assert!(TextureFormat::Yuv420.is_yuv());
308        assert!(TextureFormat::Nv12.is_yuv());
309        assert!(!TextureFormat::Rgba8.is_yuv());
310    }
311
312    #[test]
313    fn test_channel_counts() {
314        assert_eq!(TextureFormat::R8.channel_count(), 1);
315        assert_eq!(TextureFormat::Rg8.channel_count(), 2);
316        assert_eq!(TextureFormat::Rgba8.channel_count(), 4);
317        assert_eq!(TextureFormat::Yuv420.channel_count(), 3);
318    }
319
320    #[test]
321    fn test_descriptor_new_defaults() {
322        let d = TextureDescriptor::new(1920, 1080, TextureFormat::Rgba8);
323        assert_eq!(d.mip_levels, 1);
324        assert_eq!(d.array_layers, 1);
325    }
326
327    #[test]
328    fn test_descriptor_total_pixels() {
329        let d = TextureDescriptor::new(100, 200, TextureFormat::R8);
330        assert_eq!(d.total_pixels(), 20_000);
331    }
332
333    #[test]
334    fn test_descriptor_size_bytes_rgba8() {
335        let d = TextureDescriptor::new(4, 4, TextureFormat::Rgba8);
336        // 4*4 = 16 pixels × 4 bytes = 64 bytes
337        assert_eq!(d.size_bytes(), 64);
338    }
339
340    #[test]
341    fn test_descriptor_size_bytes_with_mips() {
342        // 4×4 + 2×2 + 1×1 = 16 + 4 + 1 = 21 pixels × 4 bytes = 84
343        let mut d = TextureDescriptor::new(4, 4, TextureFormat::Rgba8);
344        d.mip_levels = 3;
345        assert_eq!(d.size_bytes(), 84);
346    }
347
348    #[test]
349    fn test_pool_basic_allocation() {
350        let mut pool = TexturePool::new(1.0);
351        let desc = TextureDescriptor::new(64, 64, TextureFormat::Rgba8);
352        let handle = pool.allocate(desc);
353        assert!(handle.is_some());
354        assert_eq!(pool.live_count(), 1);
355    }
356
357    #[test]
358    fn test_pool_free_reduces_bytes() {
359        let mut pool = TexturePool::new(1.0);
360        let desc = TextureDescriptor::new(4, 4, TextureFormat::Rgba8);
361        let handle = pool.allocate(desc).expect("allocation should succeed");
362        let before = pool.allocated_bytes();
363        pool.free(handle);
364        assert!(pool.allocated_bytes() < before);
365        assert_eq!(pool.live_count(), 0);
366    }
367
368    #[test]
369    fn test_pool_reuses_freed_slot() {
370        let mut pool = TexturePool::new(1.0);
371        let d1 = TextureDescriptor::new(4, 4, TextureFormat::R8);
372        let h1 = pool.allocate(d1).expect("allocation should succeed");
373        pool.free(h1);
374        let d2 = TextureDescriptor::new(4, 4, TextureFormat::R8);
375        let h2 = pool.allocate(d2).expect("allocation should succeed");
376        assert_eq!(h1, h2);
377    }
378
379    #[test]
380    fn test_pool_budget_exceeded_returns_none() {
381        // Pool with a tiny 1-byte budget
382        let mut pool = TexturePool::new(0.0);
383        pool.max_bytes = 1;
384        let desc = TextureDescriptor::new(1920, 1080, TextureFormat::Rgba8);
385        assert!(pool.allocate(desc).is_none());
386    }
387
388    #[test]
389    fn test_pool_utilization_after_alloc() {
390        let mut pool = TexturePool::new(0.0);
391        // Set a precise budget
392        let desc = TextureDescriptor::new(4, 4, TextureFormat::Rgba8); // 64 bytes
393        pool.max_bytes = 128;
394        pool.allocate(desc).expect("allocation should succeed");
395        let util = pool.utilization();
396        assert!((util - 0.5).abs() < 1e-6, "expected 0.5, got {util}");
397    }
398
399    // --- LRU eviction tests ---
400
401    #[test]
402    fn test_lru_handle_on_empty_pool() {
403        let pool = TexturePool::new(1.0);
404        assert!(pool.lru_handle().is_none());
405    }
406
407    #[test]
408    fn test_lru_handle_returns_oldest() {
409        let mut pool = TexturePool::new(1.0);
410        let h0 = pool
411            .allocate(TextureDescriptor::new(4, 4, TextureFormat::R8))
412            .expect("alloc");
413        let h1 = pool
414            .allocate(TextureDescriptor::new(4, 4, TextureFormat::R8))
415            .expect("alloc");
416        // h0 was allocated first → lower timestamp → LRU
417        assert_eq!(pool.lru_handle(), Some(h0));
418        // Touch h0 — now h1 becomes LRU
419        pool.touch(h0);
420        assert_eq!(pool.lru_handle(), Some(h1));
421        let _ = h1; // suppress unused warning
422    }
423
424    #[test]
425    fn test_touch_updates_lru_order() {
426        let mut pool = TexturePool::new(1.0);
427        let h0 = pool
428            .allocate(TextureDescriptor::new(4, 4, TextureFormat::R8))
429            .expect("alloc");
430        let h1 = pool
431            .allocate(TextureDescriptor::new(4, 4, TextureFormat::R8))
432            .expect("alloc");
433        let h2 = pool
434            .allocate(TextureDescriptor::new(4, 4, TextureFormat::R8))
435            .expect("alloc");
436        // Insertion order: h0 < h1 < h2 → LRU is h0
437        assert_eq!(pool.lru_handle(), Some(h0));
438        pool.touch(h0);
439        pool.touch(h1);
440        // Now order is: h2 < h0 < h1 → LRU is h2
441        assert_eq!(pool.lru_handle(), Some(h2));
442    }
443
444    #[test]
445    fn test_allocate_with_lru_eviction_makes_space() {
446        let mut pool = TexturePool::new(0.0);
447        // Budget = 64 bytes (one 4×4 Rgba8 texture)
448        pool.max_bytes = 64;
449        let h0 = pool
450            .allocate(TextureDescriptor::new(4, 4, TextureFormat::Rgba8))
451            .expect("first alloc should succeed");
452        assert_eq!(pool.live_count(), 1);
453        // Normal allocate fails — budget exhausted
454        assert!(pool
455            .allocate(TextureDescriptor::new(4, 4, TextureFormat::Rgba8))
456            .is_none());
457        // LRU eviction allocate should evict h0 and succeed
458        let h1 = pool
459            .allocate_with_lru_eviction(TextureDescriptor::new(4, 4, TextureFormat::Rgba8))
460            .expect("lru eviction alloc should succeed");
461        assert_eq!(pool.live_count(), 1);
462        // The evicted slot is reused
463        assert_eq!(h0, h1);
464    }
465
466    #[test]
467    fn test_allocate_with_lru_eviction_preserves_mru() {
468        let mut pool = TexturePool::new(0.0);
469        // Budget = 128 bytes (two 4×4 Rgba8 textures = 64 each)
470        pool.max_bytes = 128;
471        let h0 = pool
472            .allocate(TextureDescriptor::new(4, 4, TextureFormat::Rgba8))
473            .expect("alloc h0");
474        let _h1 = pool
475            .allocate(TextureDescriptor::new(4, 4, TextureFormat::Rgba8))
476            .expect("alloc h1");
477        // Touch h0 so h1 becomes LRU
478        pool.touch(h0);
479        // Now request a third texture — must evict h1 (LRU)
480        let h2 = pool
481            .allocate_with_lru_eviction(TextureDescriptor::new(4, 4, TextureFormat::Rgba8))
482            .expect("lru eviction");
483        // h1 index should be reused by h2
484        assert_eq!(h2, _h1);
485        // h0 must still be alive
486        assert_eq!(pool.live_count(), 2);
487    }
488
489    #[test]
490    fn test_lru_eviction_returns_none_when_budget_impossible() {
491        let mut pool = TexturePool::new(0.0);
492        // Budget only fits 16 bytes but we want a 64-byte texture
493        pool.max_bytes = 16;
494        // Put a 16-byte 4×4 R8 texture in
495        pool.allocate(TextureDescriptor::new(4, 4, TextureFormat::R8))
496            .expect("alloc small");
497        // Request 64-byte texture — cannot fit even after evicting the 16-byte one
498        let result =
499            pool.allocate_with_lru_eviction(TextureDescriptor::new(4, 4, TextureFormat::Rgba8));
500        assert!(result.is_none());
501    }
502
503    // ─── Task F: with_capacity and evict_lru ─────────────────────────────────
504
505    #[test]
506    fn test_with_capacity_rejects_when_full() {
507        let mut pool = TexturePool::with_capacity(2);
508        let d = TextureDescriptor::new(4, 4, TextureFormat::Rgba8);
509        assert!(
510            pool.allocate(d.clone()).is_some(),
511            "first alloc should succeed"
512        );
513        assert!(
514            pool.allocate(d.clone()).is_some(),
515            "second alloc should succeed"
516        );
517        // Third allocation must be rejected — count limit reached
518        assert!(
519            pool.allocate(d.clone()).is_none(),
520            "third alloc must fail (capacity = 2)"
521        );
522    }
523
524    #[test]
525    fn test_evict_lru_reduces_count_to_capacity() {
526        let mut pool = TexturePool::with_capacity(2);
527        let d = TextureDescriptor::new(4, 4, TextureFormat::Rgba8);
528        // Force-allocate 3 textures by bypassing the count limit temporarily
529        pool.max_textures = 0; // unlimited
530        pool.allocate(d.clone()).expect("alloc 1");
531        pool.allocate(d.clone()).expect("alloc 2");
532        pool.allocate(d.clone()).expect("alloc 3");
533        assert_eq!(pool.live_count(), 3);
534
535        pool.max_textures = 2; // re-enable limit
536        let evicted = pool.evict_lru();
537        assert_eq!(
538            evicted, 1,
539            "one texture should be evicted to reach capacity 2"
540        );
541        assert_eq!(pool.live_count(), 2);
542    }
543
544    #[test]
545    fn test_evict_lru_correct_order() {
546        let mut pool = TexturePool::with_capacity(3);
547        let d = TextureDescriptor::new(4, 4, TextureFormat::R8);
548        // Allocate in order h0, h1, h2 — all within capacity initially
549        pool.max_textures = 0;
550        let h0 = pool.allocate(d.clone()).expect("h0");
551        let h1 = pool.allocate(d.clone()).expect("h1");
552        let h2 = pool.allocate(d.clone()).expect("h2");
553        // Touch h0 and h1 → h2 becomes LRU
554        pool.touch(h0);
555        pool.touch(h1);
556        pool.max_textures = 2;
557        let evicted = pool.evict_lru();
558        assert_eq!(evicted, 1, "one eviction expected");
559        // h2 (LRU) should be gone; h0, h1 should survive
560        assert!(
561            pool.descriptors[h2].is_none(),
562            "h2 should have been evicted (LRU)"
563        );
564        assert!(pool.descriptors[h0].is_some(), "h0 should still be alive");
565        assert!(pool.descriptors[h1].is_some(), "h1 should still be alive");
566    }
567
568    #[test]
569    fn test_evict_lru_noop_when_under_capacity() {
570        let mut pool = TexturePool::with_capacity(5);
571        let d = TextureDescriptor::new(4, 4, TextureFormat::R8);
572        pool.allocate(d.clone()).expect("alloc");
573        pool.allocate(d.clone()).expect("alloc");
574        // Two live textures; capacity is 5 — no eviction needed.
575        let evicted = pool.evict_lru();
576        assert_eq!(evicted, 0, "no eviction expected when under capacity");
577    }
578
579    #[test]
580    fn test_evict_lru_on_empty_pool() {
581        let mut pool = TexturePool::with_capacity(2);
582        let evicted = pool.evict_lru();
583        assert_eq!(evicted, 0, "no eviction on empty pool");
584    }
585
586    #[test]
587    fn test_with_capacity_allocate_after_evict() {
588        let mut pool = TexturePool::with_capacity(1);
589        let d = TextureDescriptor::new(4, 4, TextureFormat::R8);
590        let h0 = pool.allocate(d.clone()).expect("first alloc");
591        // Pool is full — direct allocation must fail
592        assert!(pool.allocate(d.clone()).is_none());
593        // Evict via LRU, then allocate_with_lru_eviction
594        let h1 = pool
595            .allocate_with_lru_eviction(d.clone())
596            .expect("evict+alloc");
597        assert_eq!(pool.live_count(), 1, "still 1 live after evict+alloc");
598        // The freed slot should be reused
599        assert_eq!(h0, h1, "freed slot should be reused");
600    }
601}