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}
121
122impl TexturePool {
123    /// Create a pool with a budget of `max_gb` gigabytes
124    #[must_use]
125    pub fn new(max_gb: f64) -> Self {
126        Self {
127            descriptors: Vec::new(),
128            allocated_bytes: 0,
129            max_bytes: (max_gb * 1024.0 * 1024.0 * 1024.0) as usize,
130        }
131    }
132
133    /// Allocate a texture in the pool
134    ///
135    /// Returns `Some(handle)` on success, or `None` if the budget is
136    /// exceeded.
137    pub fn allocate(&mut self, desc: TextureDescriptor) -> Option<usize> {
138        let bytes = desc.size_bytes();
139        if self.allocated_bytes + bytes > self.max_bytes {
140            return None;
141        }
142        // Reuse a freed slot if possible
143        if let Some(idx) = self
144            .descriptors
145            .iter()
146            .position(std::option::Option::is_none)
147        {
148            self.descriptors[idx] = Some(desc);
149            self.allocated_bytes += bytes;
150            return Some(idx);
151        }
152        let idx = self.descriptors.len();
153        self.descriptors.push(Some(desc));
154        self.allocated_bytes += bytes;
155        Some(idx)
156    }
157
158    /// Free a texture by handle
159    pub fn free(&mut self, id: usize) {
160        if let Some(slot) = self.descriptors.get_mut(id) {
161            if let Some(desc) = slot.take() {
162                let bytes = desc.size_bytes();
163                self.allocated_bytes = self.allocated_bytes.saturating_sub(bytes);
164            }
165        }
166    }
167
168    /// Utilisation in [0.0, 1.0]
169    #[must_use]
170    pub fn utilization(&self) -> f64 {
171        if self.max_bytes == 0 {
172            return 0.0;
173        }
174        self.allocated_bytes as f64 / self.max_bytes as f64
175    }
176
177    /// Number of live (allocated) textures
178    #[must_use]
179    pub fn live_count(&self) -> usize {
180        self.descriptors.iter().filter(|s| s.is_some()).count()
181    }
182
183    /// Currently allocated bytes
184    #[must_use]
185    pub fn allocated_bytes(&self) -> usize {
186        self.allocated_bytes
187    }
188
189    /// Pool capacity in bytes
190    #[must_use]
191    pub fn max_bytes(&self) -> usize {
192        self.max_bytes
193    }
194}
195
196// ============================================================
197// Unit tests
198// ============================================================
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    #[test]
204    fn test_rgba8_bytes_per_pixel() {
205        assert!((TextureFormat::Rgba8.bytes_per_pixel() - 4.0).abs() < f32::EPSILON);
206    }
207
208    #[test]
209    fn test_yuv_formats_are_yuv() {
210        assert!(TextureFormat::Yuv420.is_yuv());
211        assert!(TextureFormat::Nv12.is_yuv());
212        assert!(!TextureFormat::Rgba8.is_yuv());
213    }
214
215    #[test]
216    fn test_channel_counts() {
217        assert_eq!(TextureFormat::R8.channel_count(), 1);
218        assert_eq!(TextureFormat::Rg8.channel_count(), 2);
219        assert_eq!(TextureFormat::Rgba8.channel_count(), 4);
220        assert_eq!(TextureFormat::Yuv420.channel_count(), 3);
221    }
222
223    #[test]
224    fn test_descriptor_new_defaults() {
225        let d = TextureDescriptor::new(1920, 1080, TextureFormat::Rgba8);
226        assert_eq!(d.mip_levels, 1);
227        assert_eq!(d.array_layers, 1);
228    }
229
230    #[test]
231    fn test_descriptor_total_pixels() {
232        let d = TextureDescriptor::new(100, 200, TextureFormat::R8);
233        assert_eq!(d.total_pixels(), 20_000);
234    }
235
236    #[test]
237    fn test_descriptor_size_bytes_rgba8() {
238        let d = TextureDescriptor::new(4, 4, TextureFormat::Rgba8);
239        // 4*4 = 16 pixels × 4 bytes = 64 bytes
240        assert_eq!(d.size_bytes(), 64);
241    }
242
243    #[test]
244    fn test_descriptor_size_bytes_with_mips() {
245        // 4×4 + 2×2 + 1×1 = 16 + 4 + 1 = 21 pixels × 4 bytes = 84
246        let mut d = TextureDescriptor::new(4, 4, TextureFormat::Rgba8);
247        d.mip_levels = 3;
248        assert_eq!(d.size_bytes(), 84);
249    }
250
251    #[test]
252    fn test_pool_basic_allocation() {
253        let mut pool = TexturePool::new(1.0);
254        let desc = TextureDescriptor::new(64, 64, TextureFormat::Rgba8);
255        let handle = pool.allocate(desc);
256        assert!(handle.is_some());
257        assert_eq!(pool.live_count(), 1);
258    }
259
260    #[test]
261    fn test_pool_free_reduces_bytes() {
262        let mut pool = TexturePool::new(1.0);
263        let desc = TextureDescriptor::new(4, 4, TextureFormat::Rgba8);
264        let handle = pool.allocate(desc).unwrap();
265        let before = pool.allocated_bytes();
266        pool.free(handle);
267        assert!(pool.allocated_bytes() < before);
268        assert_eq!(pool.live_count(), 0);
269    }
270
271    #[test]
272    fn test_pool_reuses_freed_slot() {
273        let mut pool = TexturePool::new(1.0);
274        let d1 = TextureDescriptor::new(4, 4, TextureFormat::R8);
275        let h1 = pool.allocate(d1).unwrap();
276        pool.free(h1);
277        let d2 = TextureDescriptor::new(4, 4, TextureFormat::R8);
278        let h2 = pool.allocate(d2).unwrap();
279        assert_eq!(h1, h2);
280    }
281
282    #[test]
283    fn test_pool_budget_exceeded_returns_none() {
284        // Pool with a tiny 1-byte budget
285        let mut pool = TexturePool::new(0.0);
286        pool.max_bytes = 1;
287        let desc = TextureDescriptor::new(1920, 1080, TextureFormat::Rgba8);
288        assert!(pool.allocate(desc).is_none());
289    }
290
291    #[test]
292    fn test_pool_utilization_after_alloc() {
293        let mut pool = TexturePool::new(0.0);
294        // Set a precise budget
295        let desc = TextureDescriptor::new(4, 4, TextureFormat::Rgba8); // 64 bytes
296        pool.max_bytes = 128;
297        pool.allocate(desc).unwrap();
298        let util = pool.utilization();
299        assert!((util - 0.5).abs() < 1e-6, "expected 0.5, got {util}");
300    }
301}