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
113pub struct TexturePool {
114    /// All allocated descriptors (index acts as texture handle)
115    descriptors: Vec<Option<TextureDescriptor>>,
116    /// Currently allocated bytes
117    allocated_bytes: usize,
118    /// Maximum bytes the pool may use
119    max_bytes: usize,
120    /// Monotonic clock for LRU tracking (incremented on each touch/allocate)
121    access_clock: u64,
122    /// Last-access timestamp per slot (parallel to `descriptors`)
123    last_access: Vec<u64>,
124}
125
126impl TexturePool {
127    /// Create a pool with a budget of `max_gb` gigabytes
128    #[must_use]
129    pub fn new(max_gb: f64) -> Self {
130        Self {
131            descriptors: Vec::new(),
132            allocated_bytes: 0,
133            max_bytes: (max_gb * 1024.0 * 1024.0 * 1024.0) as usize,
134            access_clock: 0,
135            last_access: Vec::new(),
136        }
137    }
138
139    /// Allocate a texture in the pool
140    ///
141    /// Returns `Some(handle)` on success, or `None` if the budget is
142    /// exceeded.
143    pub fn allocate(&mut self, desc: TextureDescriptor) -> Option<usize> {
144        let bytes = desc.size_bytes();
145        if self.allocated_bytes + bytes > self.max_bytes {
146            return None;
147        }
148        // Reuse a freed slot if possible
149        self.access_clock += 1;
150        let ts = self.access_clock;
151        if let Some(idx) = self
152            .descriptors
153            .iter()
154            .position(std::option::Option::is_none)
155        {
156            self.descriptors[idx] = Some(desc);
157            self.last_access[idx] = ts;
158            self.allocated_bytes += bytes;
159            return Some(idx);
160        }
161        let idx = self.descriptors.len();
162        self.descriptors.push(Some(desc));
163        self.last_access.push(ts);
164        self.allocated_bytes += bytes;
165        Some(idx)
166    }
167
168    /// Allocate a texture, evicting the LRU slot if the budget is exceeded.
169    ///
170    /// Returns `Some(handle)` on success, or `None` if all live textures are
171    /// too large to free enough space for the new allocation.
172    pub fn allocate_with_lru_eviction(&mut self, desc: TextureDescriptor) -> Option<usize> {
173        let bytes = desc.size_bytes();
174        // Try ordinary allocation first.
175        if self.allocated_bytes + bytes <= self.max_bytes {
176            return self.allocate(desc);
177        }
178        // Evict LRU entries until enough space is reclaimed.
179        loop {
180            if self.allocated_bytes + bytes <= self.max_bytes {
181                return self.allocate(desc);
182            }
183            let lru = self.lru_handle()?;
184            self.free(lru);
185        }
186    }
187
188    /// Return the handle of the least-recently-used allocated texture, or
189    /// `None` if the pool is empty.
190    #[must_use]
191    pub fn lru_handle(&self) -> Option<usize> {
192        self.descriptors
193            .iter()
194            .enumerate()
195            .filter_map(|(i, slot)| slot.as_ref().map(|_| i))
196            .min_by_key(|&i| self.last_access[i])
197    }
198
199    /// Update the access timestamp of `handle` to mark it as recently used.
200    pub fn touch(&mut self, handle: usize) {
201        if handle < self.descriptors.len() && self.descriptors[handle].is_some() {
202            self.access_clock += 1;
203            self.last_access[handle] = self.access_clock;
204        }
205    }
206
207    /// Free a texture by handle
208    pub fn free(&mut self, id: usize) {
209        if let Some(slot) = self.descriptors.get_mut(id) {
210            if let Some(desc) = slot.take() {
211                let bytes = desc.size_bytes();
212                self.allocated_bytes = self.allocated_bytes.saturating_sub(bytes);
213                self.last_access[id] = 0;
214            }
215        }
216    }
217
218    /// Utilisation in [0.0, 1.0]
219    #[must_use]
220    pub fn utilization(&self) -> f64 {
221        if self.max_bytes == 0 {
222            return 0.0;
223        }
224        self.allocated_bytes as f64 / self.max_bytes as f64
225    }
226
227    /// Number of live (allocated) textures
228    #[must_use]
229    pub fn live_count(&self) -> usize {
230        self.descriptors.iter().filter(|s| s.is_some()).count()
231    }
232
233    /// Currently allocated bytes
234    #[must_use]
235    pub fn allocated_bytes(&self) -> usize {
236        self.allocated_bytes
237    }
238
239    /// Pool capacity in bytes
240    #[must_use]
241    pub fn max_bytes(&self) -> usize {
242        self.max_bytes
243    }
244}
245
246// ============================================================
247// Unit tests
248// ============================================================
249#[cfg(test)]
250mod tests {
251    use super::*;
252
253    #[test]
254    fn test_rgba8_bytes_per_pixel() {
255        assert!((TextureFormat::Rgba8.bytes_per_pixel() - 4.0).abs() < f32::EPSILON);
256    }
257
258    #[test]
259    fn test_yuv_formats_are_yuv() {
260        assert!(TextureFormat::Yuv420.is_yuv());
261        assert!(TextureFormat::Nv12.is_yuv());
262        assert!(!TextureFormat::Rgba8.is_yuv());
263    }
264
265    #[test]
266    fn test_channel_counts() {
267        assert_eq!(TextureFormat::R8.channel_count(), 1);
268        assert_eq!(TextureFormat::Rg8.channel_count(), 2);
269        assert_eq!(TextureFormat::Rgba8.channel_count(), 4);
270        assert_eq!(TextureFormat::Yuv420.channel_count(), 3);
271    }
272
273    #[test]
274    fn test_descriptor_new_defaults() {
275        let d = TextureDescriptor::new(1920, 1080, TextureFormat::Rgba8);
276        assert_eq!(d.mip_levels, 1);
277        assert_eq!(d.array_layers, 1);
278    }
279
280    #[test]
281    fn test_descriptor_total_pixels() {
282        let d = TextureDescriptor::new(100, 200, TextureFormat::R8);
283        assert_eq!(d.total_pixels(), 20_000);
284    }
285
286    #[test]
287    fn test_descriptor_size_bytes_rgba8() {
288        let d = TextureDescriptor::new(4, 4, TextureFormat::Rgba8);
289        // 4*4 = 16 pixels × 4 bytes = 64 bytes
290        assert_eq!(d.size_bytes(), 64);
291    }
292
293    #[test]
294    fn test_descriptor_size_bytes_with_mips() {
295        // 4×4 + 2×2 + 1×1 = 16 + 4 + 1 = 21 pixels × 4 bytes = 84
296        let mut d = TextureDescriptor::new(4, 4, TextureFormat::Rgba8);
297        d.mip_levels = 3;
298        assert_eq!(d.size_bytes(), 84);
299    }
300
301    #[test]
302    fn test_pool_basic_allocation() {
303        let mut pool = TexturePool::new(1.0);
304        let desc = TextureDescriptor::new(64, 64, TextureFormat::Rgba8);
305        let handle = pool.allocate(desc);
306        assert!(handle.is_some());
307        assert_eq!(pool.live_count(), 1);
308    }
309
310    #[test]
311    fn test_pool_free_reduces_bytes() {
312        let mut pool = TexturePool::new(1.0);
313        let desc = TextureDescriptor::new(4, 4, TextureFormat::Rgba8);
314        let handle = pool.allocate(desc).expect("allocation should succeed");
315        let before = pool.allocated_bytes();
316        pool.free(handle);
317        assert!(pool.allocated_bytes() < before);
318        assert_eq!(pool.live_count(), 0);
319    }
320
321    #[test]
322    fn test_pool_reuses_freed_slot() {
323        let mut pool = TexturePool::new(1.0);
324        let d1 = TextureDescriptor::new(4, 4, TextureFormat::R8);
325        let h1 = pool.allocate(d1).expect("allocation should succeed");
326        pool.free(h1);
327        let d2 = TextureDescriptor::new(4, 4, TextureFormat::R8);
328        let h2 = pool.allocate(d2).expect("allocation should succeed");
329        assert_eq!(h1, h2);
330    }
331
332    #[test]
333    fn test_pool_budget_exceeded_returns_none() {
334        // Pool with a tiny 1-byte budget
335        let mut pool = TexturePool::new(0.0);
336        pool.max_bytes = 1;
337        let desc = TextureDescriptor::new(1920, 1080, TextureFormat::Rgba8);
338        assert!(pool.allocate(desc).is_none());
339    }
340
341    #[test]
342    fn test_pool_utilization_after_alloc() {
343        let mut pool = TexturePool::new(0.0);
344        // Set a precise budget
345        let desc = TextureDescriptor::new(4, 4, TextureFormat::Rgba8); // 64 bytes
346        pool.max_bytes = 128;
347        pool.allocate(desc).expect("allocation should succeed");
348        let util = pool.utilization();
349        assert!((util - 0.5).abs() < 1e-6, "expected 0.5, got {util}");
350    }
351
352    // --- LRU eviction tests ---
353
354    #[test]
355    fn test_lru_handle_on_empty_pool() {
356        let pool = TexturePool::new(1.0);
357        assert!(pool.lru_handle().is_none());
358    }
359
360    #[test]
361    fn test_lru_handle_returns_oldest() {
362        let mut pool = TexturePool::new(1.0);
363        let h0 = pool
364            .allocate(TextureDescriptor::new(4, 4, TextureFormat::R8))
365            .expect("alloc");
366        let h1 = pool
367            .allocate(TextureDescriptor::new(4, 4, TextureFormat::R8))
368            .expect("alloc");
369        // h0 was allocated first → lower timestamp → LRU
370        assert_eq!(pool.lru_handle(), Some(h0));
371        // Touch h0 — now h1 becomes LRU
372        pool.touch(h0);
373        assert_eq!(pool.lru_handle(), Some(h1));
374        let _ = h1; // suppress unused warning
375    }
376
377    #[test]
378    fn test_touch_updates_lru_order() {
379        let mut pool = TexturePool::new(1.0);
380        let h0 = pool
381            .allocate(TextureDescriptor::new(4, 4, TextureFormat::R8))
382            .expect("alloc");
383        let h1 = pool
384            .allocate(TextureDescriptor::new(4, 4, TextureFormat::R8))
385            .expect("alloc");
386        let h2 = pool
387            .allocate(TextureDescriptor::new(4, 4, TextureFormat::R8))
388            .expect("alloc");
389        // Insertion order: h0 < h1 < h2 → LRU is h0
390        assert_eq!(pool.lru_handle(), Some(h0));
391        pool.touch(h0);
392        pool.touch(h1);
393        // Now order is: h2 < h0 < h1 → LRU is h2
394        assert_eq!(pool.lru_handle(), Some(h2));
395    }
396
397    #[test]
398    fn test_allocate_with_lru_eviction_makes_space() {
399        let mut pool = TexturePool::new(0.0);
400        // Budget = 64 bytes (one 4×4 Rgba8 texture)
401        pool.max_bytes = 64;
402        let h0 = pool
403            .allocate(TextureDescriptor::new(4, 4, TextureFormat::Rgba8))
404            .expect("first alloc should succeed");
405        assert_eq!(pool.live_count(), 1);
406        // Normal allocate fails — budget exhausted
407        assert!(pool
408            .allocate(TextureDescriptor::new(4, 4, TextureFormat::Rgba8))
409            .is_none());
410        // LRU eviction allocate should evict h0 and succeed
411        let h1 = pool
412            .allocate_with_lru_eviction(TextureDescriptor::new(4, 4, TextureFormat::Rgba8))
413            .expect("lru eviction alloc should succeed");
414        assert_eq!(pool.live_count(), 1);
415        // The evicted slot is reused
416        assert_eq!(h0, h1);
417    }
418
419    #[test]
420    fn test_allocate_with_lru_eviction_preserves_mru() {
421        let mut pool = TexturePool::new(0.0);
422        // Budget = 128 bytes (two 4×4 Rgba8 textures = 64 each)
423        pool.max_bytes = 128;
424        let h0 = pool
425            .allocate(TextureDescriptor::new(4, 4, TextureFormat::Rgba8))
426            .expect("alloc h0");
427        let _h1 = pool
428            .allocate(TextureDescriptor::new(4, 4, TextureFormat::Rgba8))
429            .expect("alloc h1");
430        // Touch h0 so h1 becomes LRU
431        pool.touch(h0);
432        // Now request a third texture — must evict h1 (LRU)
433        let h2 = pool
434            .allocate_with_lru_eviction(TextureDescriptor::new(4, 4, TextureFormat::Rgba8))
435            .expect("lru eviction");
436        // h1 index should be reused by h2
437        assert_eq!(h2, _h1);
438        // h0 must still be alive
439        assert_eq!(pool.live_count(), 2);
440    }
441
442    #[test]
443    fn test_lru_eviction_returns_none_when_budget_impossible() {
444        let mut pool = TexturePool::new(0.0);
445        // Budget only fits 16 bytes but we want a 64-byte texture
446        pool.max_bytes = 16;
447        // Put a 16-byte 4×4 R8 texture in
448        pool.allocate(TextureDescriptor::new(4, 4, TextureFormat::R8))
449            .expect("alloc small");
450        // Request 64-byte texture — cannot fit even after evicting the 16-byte one
451        let result =
452            pool.allocate_with_lru_eviction(TextureDescriptor::new(4, 4, TextureFormat::Rgba8));
453        assert!(result.is_none());
454    }
455}