Skip to main content

oximedia_gpu/
texture_atlas.rs

1//! Texture atlas packing for GPU uploads.
2//!
3//! Implements a shelf-based rectangle packer (Next-Fit Decreasing Height / NFDH)
4//! that compacts many small textures into a single large atlas texture, reducing
5//! the number of GPU bind-group changes and improving cache coherence on the GPU.
6//!
7//! # Algorithm
8//!
9//! Shelves are horizontal strips.  Each incoming rectangle is placed on the
10//! current shelf if it fits; otherwise a new shelf is opened below the
11//! previous one.  Rectangles should be submitted in decreasing-height order
12//! for best packing efficiency (the caller is responsible for sorting if
13//! desired).
14//!
15//! # Example
16//!
17//! ```rust
18//! use oximedia_gpu::texture_atlas::{AtlasConfig, TextureAtlas, AtlasRect};
19//!
20//! let cfg = AtlasConfig { width: 2048, height: 2048, padding: 1 };
21//! let mut atlas = TextureAtlas::new(cfg);
22//!
23//! let id = atlas.insert(64, 64).expect("fits in atlas");
24//! let rect = atlas.rect(id).expect("valid id");
25//! assert_eq!(rect.w, 64);
26//! assert_eq!(rect.h, 64);
27//! ```
28
29#![allow(dead_code)]
30
31use std::collections::HashMap;
32
33use crate::{GpuError, Result};
34
35// ── Configuration ─────────────────────────────────────────────────────────────
36
37/// Configuration for a [`TextureAtlas`].
38#[derive(Debug, Clone)]
39pub struct AtlasConfig {
40    /// Total width of the backing atlas texture in texels.
41    pub width: u32,
42    /// Total height of the backing atlas texture in texels.
43    pub height: u32,
44    /// Number of transparent texels added around each sub-image to prevent
45    /// bleeding during bilinear sampling.
46    pub padding: u32,
47}
48
49impl Default for AtlasConfig {
50    fn default() -> Self {
51        Self {
52            width: 2048,
53            height: 2048,
54            padding: 1,
55        }
56    }
57}
58
59// ── Rect ──────────────────────────────────────────────────────────────────────
60
61/// An allocated sub-region within the atlas.
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub struct AtlasRect {
64    /// Left edge in atlas coordinates (texels).
65    pub x: u32,
66    /// Top edge in atlas coordinates (texels).
67    pub y: u32,
68    /// Width of the allocated region in texels (without padding).
69    pub w: u32,
70    /// Height of the allocated region in texels (without padding).
71    pub h: u32,
72}
73
74impl AtlasRect {
75    /// UV coordinates as `(u_min, v_min, u_max, v_max)` in `[0, 1]` space
76    /// given the atlas dimensions.
77    #[must_use]
78    pub fn uv_normalized(&self, atlas_w: u32, atlas_h: u32) -> (f32, f32, f32, f32) {
79        let aw = atlas_w as f32;
80        let ah = atlas_h as f32;
81        (
82            self.x as f32 / aw,
83            self.y as f32 / ah,
84            (self.x + self.w) as f32 / aw,
85            (self.y + self.h) as f32 / ah,
86        )
87    }
88}
89
90// ── Shelf ─────────────────────────────────────────────────────────────────────
91
92/// A horizontal strip inside the atlas where rectangles are packed
93/// left-to-right.
94#[derive(Debug)]
95struct Shelf {
96    /// Y coordinate of the top of this shelf (texels).
97    y: u32,
98    /// Height of the tallest rectangle placed on this shelf.
99    height: u32,
100    /// X cursor: the next free horizontal position on this shelf.
101    cursor_x: u32,
102}
103
104impl Shelf {
105    fn new(y: u32) -> Self {
106        Self {
107            y,
108            height: 0,
109            cursor_x: 0,
110        }
111    }
112
113    /// Try to place a rectangle of `w × h` on this shelf.
114    ///
115    /// Returns the allocated x position, or `None` if there is no room.
116    fn try_place(&mut self, w: u32, h: u32, atlas_width: u32, padding: u32) -> Option<u32> {
117        let padded_w = w + padding * 2;
118        if self.cursor_x + padded_w > atlas_width {
119            return None;
120        }
121        let x = self.cursor_x + padding;
122        self.cursor_x += padded_w;
123        if h + padding * 2 > self.height {
124            self.height = h + padding * 2;
125        }
126        Some(x)
127    }
128}
129
130// ── Atlas ─────────────────────────────────────────────────────────────────────
131
132/// Texture atlas packer.
133///
134/// Allocates rectangular regions inside a fixed-size backing texture using a
135/// shelf-packing strategy.  The CPU-side pixel data is stored in `pixels` so
136/// the atlas can be uploaded to the GPU at any time.
137pub struct TextureAtlas {
138    /// Configuration.
139    pub config: AtlasConfig,
140    /// CPU-side RGBA pixel data for the backing texture.
141    ///
142    /// Size = `config.width * config.height * 4` bytes.
143    pub pixels: Vec<u8>,
144    /// Active shelves.
145    shelves: Vec<Shelf>,
146    /// Allocated rectangles, keyed by opaque `u32` ID.
147    allocations: HashMap<u32, AtlasRect>,
148    /// Monotonically increasing ID counter.
149    next_id: u32,
150    /// Y coordinate of the next shelf's top edge.
151    next_shelf_y: u32,
152}
153
154impl TextureAtlas {
155    /// Create a new, empty atlas.
156    #[must_use]
157    pub fn new(config: AtlasConfig) -> Self {
158        let pixel_count = (config.width * config.height * 4) as usize;
159        Self {
160            pixels: vec![0u8; pixel_count],
161            shelves: Vec::new(),
162            allocations: HashMap::new(),
163            next_id: 0,
164            next_shelf_y: 0,
165            config,
166        }
167    }
168
169    /// Insert a sub-texture of `w × h` texels.
170    ///
171    /// Returns an opaque allocation ID on success.
172    ///
173    /// # Errors
174    ///
175    /// Returns [`GpuError::Internal`] if the atlas has no space left or if the
176    /// requested dimensions exceed the atlas size.
177    pub fn insert(&mut self, w: u32, h: u32) -> Result<u32> {
178        if w == 0 || h == 0 {
179            return Err(GpuError::InvalidDimensions {
180                width: w,
181                height: h,
182            });
183        }
184        if w + self.config.padding * 2 > self.config.width
185            || h + self.config.padding * 2 > self.config.height
186        {
187            return Err(GpuError::Internal(format!(
188                "Texture {w}×{h} does not fit in atlas {}×{}",
189                self.config.width, self.config.height
190            )));
191        }
192
193        // Try to place on an existing shelf.
194        let atlas_w = self.config.width;
195        let padding = self.config.padding;
196        for shelf in self.shelves.iter_mut() {
197            if h <= shelf.height || shelf.height == 0 {
198                if let Some(x) = shelf.try_place(w, h, atlas_w, padding) {
199                    let y = shelf.y + padding;
200                    let rect = AtlasRect { x, y, w, h };
201                    let id = self.next_id;
202                    self.next_id = self.next_id.wrapping_add(1);
203                    self.allocations.insert(id, rect);
204                    return Ok(id);
205                }
206            }
207        }
208
209        // Open a new shelf.
210        let needed_h = h + padding * 2;
211        if self.next_shelf_y + needed_h > self.config.height {
212            return Err(GpuError::Internal(
213                "Atlas is full — no vertical space remaining".to_string(),
214            ));
215        }
216
217        let mut shelf = Shelf::new(self.next_shelf_y);
218        let x = shelf
219            .try_place(w, h, atlas_w, padding)
220            .ok_or_else(|| GpuError::Internal("Failed to place rect on new shelf".to_string()))?;
221        let y = shelf.y + padding;
222        self.next_shelf_y += shelf.height;
223        self.shelves.push(shelf);
224
225        let rect = AtlasRect { x, y, w, h };
226        let id = self.next_id;
227        self.next_id = self.next_id.wrapping_add(1);
228        self.allocations.insert(id, rect);
229        Ok(id)
230    }
231
232    /// Upload RGBA pixel data for the sub-texture identified by `id`.
233    ///
234    /// `data` must be exactly `w * h * 4` bytes.
235    ///
236    /// # Errors
237    ///
238    /// Returns an error if `id` is invalid or `data` has wrong length.
239    pub fn upload_pixels(&mut self, id: u32, data: &[u8]) -> Result<()> {
240        let rect = *self
241            .allocations
242            .get(&id)
243            .ok_or_else(|| GpuError::Internal(format!("Unknown atlas ID {id}")))?;
244
245        let expected = (rect.w * rect.h * 4) as usize;
246        if data.len() != expected {
247            return Err(GpuError::InvalidBufferSize {
248                expected,
249                actual: data.len(),
250            });
251        }
252
253        let atlas_stride = (self.config.width * 4) as usize;
254        for row in 0..rect.h {
255            let src_start = (row * rect.w * 4) as usize;
256            let src_end = src_start + (rect.w * 4) as usize;
257            let dst_start = ((rect.y + row) as usize * atlas_stride) + rect.x as usize * 4;
258            let dst_end = dst_start + (rect.w * 4) as usize;
259            self.pixels[dst_start..dst_end].copy_from_slice(&data[src_start..src_end]);
260        }
261        Ok(())
262    }
263
264    /// Return the allocated [`AtlasRect`] for `id`, or `None` if unknown.
265    #[must_use]
266    pub fn rect(&self, id: u32) -> Option<AtlasRect> {
267        self.allocations.get(&id).copied()
268    }
269
270    /// Number of allocated sub-textures.
271    #[must_use]
272    pub fn len(&self) -> usize {
273        self.allocations.len()
274    }
275
276    /// `true` if no sub-textures have been allocated.
277    #[must_use]
278    pub fn is_empty(&self) -> bool {
279        self.allocations.is_empty()
280    }
281
282    /// Utilisation as a fraction in `[0, 1]` (pixels used / total pixels).
283    #[must_use]
284    pub fn utilisation(&self) -> f32 {
285        let total = (self.config.width * self.config.height) as f32;
286        if total <= 0.0 {
287            return 0.0;
288        }
289        let used: u32 = self.allocations.values().map(|r| r.w * r.h).sum();
290        used as f32 / total
291    }
292
293    /// Clear all allocations, retaining the backing pixel buffer.
294    pub fn clear(&mut self) {
295        self.shelves.clear();
296        self.allocations.clear();
297        self.next_id = 0;
298        self.next_shelf_y = 0;
299        // Zero out pixel data.
300        for b in self.pixels.iter_mut() {
301            *b = 0;
302        }
303    }
304
305    /// Atlas width in texels.
306    #[must_use]
307    pub fn width(&self) -> u32 {
308        self.config.width
309    }
310
311    /// Atlas height in texels.
312    #[must_use]
313    pub fn height(&self) -> u32 {
314        self.config.height
315    }
316}
317
318// ── TextureAtlasPacker ────────────────────────────────────────────────────────
319
320/// Simplified packer API that wraps [`TextureAtlas`].
321///
322/// Accepts a batch of `(width, height)` sprites and returns their allocated
323/// `(x, y, w, h)` rectangles in the same order.
324///
325/// # Example
326///
327/// ```rust
328/// use oximedia_gpu::texture_atlas::TextureAtlasPacker;
329///
330/// let mut packer = TextureAtlasPacker::new(512, 512);
331/// let rects = packer.pack(&[(64, 64), (32, 32)]);
332/// assert_eq!(rects.len(), 2);
333/// ```
334pub struct TextureAtlasPacker {
335    atlas: TextureAtlas,
336}
337
338impl TextureAtlasPacker {
339    /// Create a packer backed by an atlas of `max_w × max_h` texels.
340    #[must_use]
341    pub fn new(max_w: u32, max_h: u32) -> Self {
342        Self {
343            atlas: TextureAtlas::new(AtlasConfig {
344                width: max_w,
345                height: max_h,
346                padding: 1,
347            }),
348        }
349    }
350
351    /// Pack a slice of `(width, height)` sprites.
352    ///
353    /// Returns a `Vec` of `(x, y, w, h)` tuples in the same order as the
354    /// input.  Sprites that do not fit are represented by `(0, 0, 0, 0)`.
355    #[must_use]
356    pub fn pack(&mut self, sprites: &[(u32, u32)]) -> Vec<(u32, u32, u32, u32)> {
357        sprites
358            .iter()
359            .map(|&(w, h)| match self.atlas.insert(w, h) {
360                Ok(id) => {
361                    let r = self.atlas.rect(id).unwrap_or(AtlasRect {
362                        x: 0,
363                        y: 0,
364                        w: 0,
365                        h: 0,
366                    });
367                    (r.x, r.y, r.w, r.h)
368                }
369                Err(_) => (0, 0, 0, 0),
370            })
371            .collect()
372    }
373
374    /// Total number of sprites currently packed.
375    #[must_use]
376    pub fn len(&self) -> usize {
377        self.atlas.len()
378    }
379
380    /// `true` if no sprites have been packed.
381    #[must_use]
382    pub fn is_empty(&self) -> bool {
383        self.atlas.is_empty()
384    }
385
386    /// Current utilisation fraction `[0, 1]`.
387    #[must_use]
388    pub fn utilisation(&self) -> f32 {
389        self.atlas.utilisation()
390    }
391
392    /// Reset the packer, discarding all packed sprites.
393    pub fn clear(&mut self) {
394        self.atlas.clear();
395    }
396}
397
398// ── Tests ─────────────────────────────────────────────────────────────────────
399
400#[cfg(test)]
401mod tests {
402    use super::*;
403
404    fn small_atlas() -> TextureAtlas {
405        TextureAtlas::new(AtlasConfig {
406            width: 256,
407            height: 256,
408            padding: 1,
409        })
410    }
411
412    #[test]
413    fn test_insert_single_rect() {
414        let mut atlas = small_atlas();
415        let id = atlas.insert(64, 64).expect("should succeed");
416        let rect = atlas.rect(id).expect("should have rect");
417        assert_eq!(rect.w, 64);
418        assert_eq!(rect.h, 64);
419        assert_eq!(atlas.len(), 1);
420    }
421
422    #[test]
423    fn test_insert_multiple_rects() {
424        let mut atlas = small_atlas();
425        let ids: Vec<u32> = (0..4)
426            .map(|_| atlas.insert(32, 32).expect("fits"))
427            .collect();
428        assert_eq!(ids.len(), 4);
429        assert_eq!(atlas.len(), 4);
430        // All IDs unique
431        let unique: std::collections::HashSet<_> = ids.iter().collect();
432        assert_eq!(unique.len(), 4);
433    }
434
435    #[test]
436    fn test_rects_do_not_overlap() {
437        let mut atlas = small_atlas();
438        let mut rects = Vec::new();
439        for _ in 0..9 {
440            let id = atlas.insert(40, 40).expect("fits");
441            rects.push(atlas.rect(id).expect("valid"));
442        }
443        // Check pairwise non-overlap
444        for i in 0..rects.len() {
445            for j in (i + 1)..rects.len() {
446                let a = rects[i];
447                let b = rects[j];
448                let overlap_x = a.x < b.x + b.w && a.x + a.w > b.x;
449                let overlap_y = a.y < b.y + b.h && a.y + a.h > b.y;
450                assert!(
451                    !(overlap_x && overlap_y),
452                    "Rects {i} and {j} overlap: {:?} vs {:?}",
453                    a,
454                    b
455                );
456            }
457        }
458    }
459
460    #[test]
461    fn test_insert_too_large_returns_error() {
462        let mut atlas = small_atlas();
463        let result = atlas.insert(512, 512);
464        assert!(result.is_err());
465    }
466
467    #[test]
468    fn test_zero_dimension_returns_error() {
469        let mut atlas = small_atlas();
470        assert!(atlas.insert(0, 32).is_err());
471        assert!(atlas.insert(32, 0).is_err());
472    }
473
474    #[test]
475    fn test_upload_pixels_correct_dimensions() {
476        let mut atlas = small_atlas();
477        let id = atlas.insert(4, 4).expect("fits");
478        let data = vec![255u8; 4 * 4 * 4]; // 4×4 RGBA
479        atlas.upload_pixels(id, &data).expect("upload ok");
480    }
481
482    #[test]
483    fn test_upload_pixels_wrong_size_returns_error() {
484        let mut atlas = small_atlas();
485        let id = atlas.insert(4, 4).expect("fits");
486        let bad_data = vec![0u8; 10]; // wrong size
487        assert!(atlas.upload_pixels(id, &bad_data).is_err());
488    }
489
490    #[test]
491    fn test_upload_pixels_invalid_id_returns_error() {
492        let mut atlas = small_atlas();
493        let data = vec![0u8; 16];
494        assert!(atlas.upload_pixels(9999, &data).is_err());
495    }
496
497    #[test]
498    fn test_clear_resets_state() {
499        let mut atlas = small_atlas();
500        atlas.insert(64, 64).expect("ok");
501        atlas.clear();
502        assert_eq!(atlas.len(), 0);
503        assert!(atlas.is_empty());
504        // After clear we can use the full space again
505        let id2 = atlas.insert(128, 128).expect("should fit after clear");
506        assert!(atlas.rect(id2).is_some());
507    }
508
509    #[test]
510    fn test_utilisation_increases_with_inserts() {
511        let mut atlas = small_atlas();
512        let u0 = atlas.utilisation();
513        atlas.insert(64, 64).expect("ok");
514        let u1 = atlas.utilisation();
515        assert!(u1 > u0, "utilisation should increase");
516    }
517
518    #[test]
519    fn test_uv_normalized_range() {
520        let mut atlas = small_atlas();
521        let id = atlas.insert(64, 64).expect("ok");
522        let rect = atlas.rect(id).expect("valid");
523        let (u0, v0, u1, v1) = rect.uv_normalized(atlas.width(), atlas.height());
524        assert!(u0 >= 0.0 && u0 < 1.0);
525        assert!(v0 >= 0.0 && v0 < 1.0);
526        assert!(u1 > u0 && u1 <= 1.0);
527        assert!(v1 > v0 && v1 <= 1.0);
528    }
529
530    // ── TextureAtlasPacker tests ──────────────────────────────────────────────
531
532    #[test]
533    fn test_packer_pack_single_sprite() {
534        let mut packer = TextureAtlasPacker::new(512, 512);
535        let rects = packer.pack(&[(64, 64)]);
536        assert_eq!(rects.len(), 1);
537        let (_, _, w, h) = rects[0];
538        assert_eq!(w, 64);
539        assert_eq!(h, 64);
540    }
541
542    #[test]
543    fn test_packer_pack_multiple_sprites() {
544        let mut packer = TextureAtlasPacker::new(512, 512);
545        let sprites = vec![(32, 32), (64, 64), (16, 16)];
546        let rects = packer.pack(&sprites);
547        assert_eq!(rects.len(), 3);
548        assert_eq!(packer.len(), 3);
549    }
550
551    #[test]
552    fn test_packer_oversized_sprite_returns_zero_rect() {
553        let mut packer = TextureAtlasPacker::new(64, 64);
554        let rects = packer.pack(&[(256, 256)]);
555        assert_eq!(rects[0], (0, 0, 0, 0));
556    }
557
558    #[test]
559    fn test_packer_clear_resets() {
560        let mut packer = TextureAtlasPacker::new(256, 256);
561        let _ = packer.pack(&[(32, 32)]);
562        packer.clear();
563        assert!(packer.is_empty());
564    }
565
566    #[test]
567    fn test_packer_utilisation_increases() {
568        let mut packer = TextureAtlasPacker::new(256, 256);
569        let u0 = packer.utilisation();
570        let _ = packer.pack(&[(64, 64)]);
571        assert!(packer.utilisation() > u0);
572    }
573
574    #[test]
575    fn test_pixel_data_written_to_correct_position() {
576        let mut atlas = TextureAtlas::new(AtlasConfig {
577            width: 8,
578            height: 8,
579            padding: 0,
580        });
581        let id = atlas.insert(2, 2).expect("fits");
582        let rect = atlas.rect(id).expect("valid");
583        // Fill with 0xFF bytes
584        let data = vec![0xFFu8; 2 * 2 * 4];
585        atlas.upload_pixels(id, &data).expect("ok");
586        // Check first pixel in atlas
587        let idx = (rect.y as usize * 8 + rect.x as usize) * 4;
588        assert_eq!(atlas.pixels[idx], 0xFF);
589    }
590}