Skip to main content

oxiui_render_wgpu/
atlas.rs

1//! Dynamic texture atlas with shelf-based bin packing and LRU eviction.
2//!
3//! The atlas allocates rectangular regions on a fixed-size 2-D texture using a
4//! shelf algorithm: rows of allocations ("shelves") are stacked top-to-bottom.
5//! When the atlas is full and a new insertion would fail, the least-recently-used
6//! allocation is evicted to make room (best-effort; eviction may fail if the
7//! freed region is too small).
8
9use std::collections::{HashMap, VecDeque};
10
11// ── Public types ─────────────────────────────────────────────────────────────
12
13/// The pixel rectangle occupied by an atlas allocation.
14#[derive(Clone, Copy, Debug, PartialEq, Eq)]
15pub struct AtlasRect {
16    /// Left edge in atlas-space pixels.
17    pub x: u32,
18    /// Top edge in atlas-space pixels.
19    pub y: u32,
20    /// Width in pixels.
21    pub w: u32,
22    /// Height in pixels.
23    pub h: u32,
24}
25
26/// Generation-based opaque handle to an atlas allocation.
27///
28/// Handles are invalidated when the atlas is resized or when the corresponding
29/// region is evicted via LRU.
30#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
31pub struct AtlasHandle(u64);
32
33// ── Internal shelf ────────────────────────────────────────────────────────────
34
35/// A single horizontal shelf inside the atlas.
36#[derive(Debug)]
37struct Shelf {
38    /// Y-coordinate of the shelf's top edge.
39    y: u32,
40    /// Height of the shelf (fixed at creation from the tallest item placed in it).
41    height: u32,
42    /// Next free X position within this shelf.
43    cursor_x: u32,
44}
45
46// ── TextureAtlas ──────────────────────────────────────────────────────────────
47
48/// Dynamic shelf-based texture atlas with LRU eviction.
49///
50/// # Allocation strategy
51///
52/// 1. Check the free-list for a first-fit region large enough.
53/// 2. Walk existing shelves for one that can accommodate the request.
54/// 3. Open a new shelf at the current bottom of the atlas.
55/// 4. If all of the above fail, evict the LRU handle and retry once.
56///
57/// # Eviction
58///
59/// Evicted regions are pushed onto the free-list; they are re-used on the
60/// next allocation attempt.  The atlas never compacts its shelf layout.
61pub struct TextureAtlas {
62    /// Atlas width in pixels.
63    pub width: u32,
64    /// Atlas height in pixels.
65    pub height: u32,
66    /// Y-coordinate of the next row of shelves.
67    next_y: u32,
68    /// All current shelves.
69    shelves: Vec<Shelf>,
70    /// Map from handle to allocated rectangle.
71    allocations: HashMap<AtlasHandle, AtlasRect>,
72    /// Insertion-order queue for LRU tracking (oldest at front).
73    lru_order: VecDeque<AtlasHandle>,
74    /// Freed regions available for reuse.
75    free_list: Vec<AtlasRect>,
76    /// Monotonically increasing handle counter.
77    next_id: u64,
78    /// Total area currently held in live allocations.
79    used_area: u64,
80}
81
82impl TextureAtlas {
83    /// Construct a new empty atlas of the given dimensions.
84    pub fn new(width: u32, height: u32) -> Self {
85        Self {
86            width,
87            height,
88            next_y: 0,
89            shelves: Vec::new(),
90            allocations: HashMap::new(),
91            lru_order: VecDeque::new(),
92            free_list: Vec::new(),
93            next_id: 0,
94            used_area: 0,
95        }
96    }
97
98    /// Insert a rectangle of size `w × h` into the atlas.
99    ///
100    /// Returns an [`AtlasHandle`] on success, or `None` if the rectangle is
101    /// larger than the atlas or all eviction attempts were exhausted.
102    pub fn insert(&mut self, w: u32, h: u32) -> Option<AtlasHandle> {
103        if w == 0 || h == 0 || w > self.width || h > self.height {
104            return None;
105        }
106
107        // 1. Try the free list (first-fit).
108        if let Some(rect) = self.alloc_from_free_list(w, h) {
109            return Some(self.register(rect));
110        }
111
112        // 2. Try existing shelves.
113        if let Some(rect) = self.alloc_from_shelves(w, h) {
114            return Some(self.register(rect));
115        }
116
117        // 3. Try opening a new shelf.
118        if let Some(rect) = self.alloc_new_shelf(w, h) {
119            return Some(self.register(rect));
120        }
121
122        // 4. Evict the LRU handle and retry once.
123        self.evict_lru()?;
124
125        if let Some(rect) = self.alloc_from_free_list(w, h) {
126            return Some(self.register(rect));
127        }
128        if let Some(rect) = self.alloc_from_shelves(w, h) {
129            return Some(self.register(rect));
130        }
131        if let Some(rect) = self.alloc_new_shelf(w, h) {
132            return Some(self.register(rect));
133        }
134
135        None
136    }
137
138    /// Retrieve the pixel rectangle for the given handle, if still live.
139    pub fn get(&self, h: AtlasHandle) -> Option<AtlasRect> {
140        self.allocations.get(&h).copied()
141    }
142
143    /// Fraction of the atlas area occupied by live allocations.
144    ///
145    /// Returns a value in `[0.0, 1.0]`.
146    pub fn utilization(&self) -> f32 {
147        let total = u64::from(self.width) * u64::from(self.height);
148        if total == 0 {
149            return 0.0;
150        }
151        (self.used_area as f32) / (total as f32)
152    }
153
154    /// Return the number of live (non-evicted) allocations.
155    pub fn allocation_count(&self) -> usize {
156        self.allocations.len()
157    }
158
159    /// Return `true` if atlas utilization has fallen below `threshold`
160    /// (0.0–1.0).  This indicates significant fragmentation and the caller
161    /// should consider triggering a defrag/rebuild.
162    ///
163    /// This check is cheap (a single float comparison).
164    pub fn is_fragmented(&self, threshold: f32) -> bool {
165        self.utilization() < threshold
166    }
167
168    /// Rebuild the atlas layout from scratch using only the currently live
169    /// allocations.
170    ///
171    /// After a defrag, all previously live handles continue to resolve to valid
172    /// (but potentially repositioned) rectangles.  Handles that had already
173    /// been evicted remain invalid.  The GPU texture content is **not** updated
174    /// by this call — the caller must re-upload all live texture regions.
175    ///
176    /// Returns a `Vec<(AtlasHandle, AtlasRect)>` mapping each live handle to
177    /// its new position, in insertion order.  The caller uses this to schedule
178    /// GPU texture copies.
179    ///
180    /// # Complexity
181    ///
182    /// O(n log n) in the number of live allocations.
183    pub fn defrag(&mut self) -> Vec<(AtlasHandle, AtlasRect)> {
184        // Snapshot the live set sorted by area descending (best-fit heuristic).
185        let mut live: Vec<(AtlasHandle, AtlasRect)> =
186            self.allocations.iter().map(|(&h, &r)| (h, r)).collect();
187        // Sort by area descending so large items get placed first.
188        live.sort_unstable_by(|(_, a), (_, b)| {
189            let area_a = u64::from(a.w) * u64::from(a.h);
190            let area_b = u64::from(b.w) * u64::from(b.h);
191            area_b.cmp(&area_a)
192        });
193
194        // Rebuild the shelf layout.
195        let width = self.width;
196        let height = self.height;
197        self.next_y = 0;
198        self.shelves.clear();
199        self.allocations.clear();
200        self.lru_order.clear();
201        self.free_list.clear();
202        self.used_area = 0;
203        // Keep next_id monotonically increasing so old handles staying live
204        // still compare equal to the new allocations we register below.
205
206        let mut result = Vec::with_capacity(live.len());
207        for (handle, old_rect) in live {
208            let w = old_rect.w;
209            let h = old_rect.h;
210            // Try to place this item using the normal allocation path.
211            let placed = self
212                .alloc_from_free_list(w, h)
213                .or_else(|| self.alloc_from_shelves(w, h))
214                .or_else(|| self.alloc_new_shelf(w, h));
215            if let Some(new_rect) = placed {
216                self.used_area += u64::from(new_rect.w) * u64::from(new_rect.h);
217                // Re-use the original handle so callers' existing handles are valid.
218                self.allocations.insert(handle, new_rect);
219                self.lru_order.push_back(handle);
220                result.push((handle, new_rect));
221            }
222            // Unreachable in practice: atlas is at least as large as before.
223            let _ = (width, height); // suppress unused warning
224        }
225        result
226    }
227
228    /// Check whether utilization is below the given `threshold` and, if so,
229    /// perform an in-place defrag.
230    ///
231    /// Returns `Some(relocations)` if a defrag was performed, `None` if
232    /// utilization was already above the threshold.
233    ///
234    /// # Example
235    ///
236    /// ```rust
237    /// # use oxiui_render_wgpu::TextureAtlas;
238    /// let mut atlas = TextureAtlas::new(64, 64);
239    /// // … insert/evict until fragmented …
240    /// if let Some(moves) = atlas.defrag_if_fragmented(0.5) {
241    ///     // re-upload the relocated regions
242    ///     for (_handle, _new_rect) in moves {}
243    /// }
244    /// ```
245    pub fn defrag_if_fragmented(
246        &mut self,
247        threshold: f32,
248    ) -> Option<Vec<(AtlasHandle, AtlasRect)>> {
249        if self.is_fragmented(threshold) {
250            Some(self.defrag())
251        } else {
252            None
253        }
254    }
255
256    /// Discard all allocations and reinitialise with new dimensions.
257    ///
258    /// Any existing handles become invalid.  Uploading updated GPU texture data
259    /// is the caller's responsibility.
260    pub fn resize(&mut self, new_width: u32, new_height: u32) {
261        self.width = new_width;
262        self.height = new_height;
263        self.next_y = 0;
264        self.shelves.clear();
265        self.allocations.clear();
266        self.lru_order.clear();
267        self.free_list.clear();
268        self.next_id = 0;
269        self.used_area = 0;
270    }
271
272    // ── Private helpers ──────────────────────────────────────────────────────
273
274    /// Attempt to satisfy the request from the free list (first-fit).
275    fn alloc_from_free_list(&mut self, w: u32, h: u32) -> Option<AtlasRect> {
276        let pos = self.free_list.iter().position(|r| r.w >= w && r.h >= h)?;
277        let free_rect = self.free_list.remove(pos);
278        Some(AtlasRect {
279            x: free_rect.x,
280            y: free_rect.y,
281            w,
282            h,
283        })
284    }
285
286    /// Attempt to satisfy the request by appending to an existing shelf.
287    fn alloc_from_shelves(&mut self, w: u32, h: u32) -> Option<AtlasRect> {
288        for shelf in &mut self.shelves {
289            if shelf.height >= h && self.width.saturating_sub(shelf.cursor_x) >= w {
290                let rect = AtlasRect {
291                    x: shelf.cursor_x,
292                    y: shelf.y,
293                    w,
294                    h,
295                };
296                shelf.cursor_x += w;
297                return Some(rect);
298            }
299        }
300        None
301    }
302
303    /// Attempt to open a new shelf at `next_y`.
304    fn alloc_new_shelf(&mut self, w: u32, h: u32) -> Option<AtlasRect> {
305        if self.height.saturating_sub(self.next_y) < h || self.width < w {
306            return None;
307        }
308        let shelf = Shelf {
309            y: self.next_y,
310            height: h,
311            cursor_x: w,
312        };
313        let rect = AtlasRect {
314            x: 0,
315            y: self.next_y,
316            w,
317            h,
318        };
319        self.next_y += h;
320        self.shelves.push(shelf);
321        Some(rect)
322    }
323
324    /// Register a raw rect as a live allocation and return a fresh handle.
325    fn register(&mut self, rect: AtlasRect) -> AtlasHandle {
326        let handle = AtlasHandle(self.next_id);
327        self.next_id += 1;
328        self.used_area += u64::from(rect.w) * u64::from(rect.h);
329        self.allocations.insert(handle, rect);
330        self.lru_order.push_back(handle);
331        handle
332    }
333
334    /// Evict the oldest (front of queue) live handle.  Returns `Some(())` on
335    /// success, `None` if the LRU queue is empty.
336    fn evict_lru(&mut self) -> Option<()> {
337        // Skip any handles that are no longer live (already removed).
338        loop {
339            let candidate = self.lru_order.pop_front()?;
340            if let Some(rect) = self.allocations.remove(&candidate) {
341                self.used_area = self
342                    .used_area
343                    .saturating_sub(u64::from(rect.w) * u64::from(rect.h));
344                self.free_list.push(rect);
345                return Some(());
346            }
347        }
348    }
349}
350
351// ── Tests ─────────────────────────────────────────────────────────────────────
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356
357    #[test]
358    fn atlas_packs_100_rects_no_overlap() {
359        let mut atlas = TextureAtlas::new(128, 128);
360        let mut rects: Vec<(AtlasHandle, AtlasRect)> = Vec::new();
361
362        for _ in 0..100 {
363            if let Some(h) = atlas.insert(4, 4) {
364                let r = atlas.get(h).expect("handle must be valid");
365                rects.push((h, r));
366            }
367        }
368
369        // All returned handles must have been valid.
370        assert!(!rects.is_empty());
371
372        // No two rects may overlap.
373        for i in 0..rects.len() {
374            for j in (i + 1)..rects.len() {
375                let a = rects[i].1;
376                let b = rects[j].1;
377                let overlap =
378                    a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
379                assert!(!overlap, "rects {i} and {j} overlap: {a:?} vs {b:?}");
380            }
381        }
382    }
383
384    #[test]
385    fn atlas_utilization_above_threshold() {
386        // A 32×32 atlas packed with 1×1 tiles should approach 100%.
387        let mut atlas = TextureAtlas::new(32, 32);
388        let mut count = 0u32;
389        while atlas.insert(1, 1).is_some() {
390            count += 1;
391            if count > 1024 * 4 {
392                break; // guard against infinite loop
393            }
394        }
395        // We expect at least 70% utilization once the atlas is exhausted.
396        assert!(
397            atlas.utilization() >= 0.70,
398            "utilization was {}",
399            atlas.utilization()
400        );
401    }
402
403    #[test]
404    fn atlas_lru_eviction_keeps_invariants() {
405        // Use a tiny atlas so we can force eviction.
406        let mut atlas = TextureAtlas::new(4, 4);
407        // Fill with 4×1 strips.
408        let h0 = atlas.insert(4, 1).expect("first insert");
409        let h1 = atlas.insert(4, 1).expect("second insert");
410        let h2 = atlas.insert(4, 1).expect("third insert");
411        let h3 = atlas.insert(4, 1).expect("fourth insert");
412        // Atlas is now full. Next insert must evict the LRU (h0).
413        let h4 = atlas.insert(4, 1).expect("insert with eviction");
414        // h0 should have been evicted (get returns None), h4 live.
415        assert!(
416            atlas.get(h0).is_none(),
417            "LRU handle h0 must have been evicted"
418        );
419        assert!(atlas.get(h4).is_some(), "new handle h4 must be live");
420        // h1, h2, h3 must still be live.
421        assert!(atlas.get(h1).is_some());
422        assert!(atlas.get(h2).is_some());
423        assert!(atlas.get(h3).is_some());
424        // No two live rects overlap.
425        let live_rects: Vec<AtlasRect> = [h1, h2, h3, h4]
426            .iter()
427            .filter_map(|&h| atlas.get(h))
428            .collect();
429        for i in 0..live_rects.len() {
430            for j in (i + 1)..live_rects.len() {
431                let a = live_rects[i];
432                let b = live_rects[j];
433                let overlap =
434                    a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
435                assert!(!overlap, "live rects {i} and {j} overlap: {a:?} vs {b:?}");
436            }
437        }
438    }
439
440    #[test]
441    fn atlas_fragmentation_detected_and_defrag_works() {
442        // Build a small atlas, fill it completely with 4×4 items on a 16×4 atlas,
443        // then verify defrag keeps live handles valid.
444        let mut atlas = TextureAtlas::new(16, 4);
445        // Insert 4 items of 4×4 — exactly fills the atlas.
446        let h0 = atlas.insert(4, 4).expect("h0");
447        let h1 = atlas.insert(4, 4).expect("h1");
448        let h2 = atlas.insert(4, 4).expect("h2");
449        let h3 = atlas.insert(4, 4).expect("h3");
450        assert_eq!(atlas.allocation_count(), 4);
451
452        // Defrag a full atlas: all 4 handles should be remapped.
453        let relocations = atlas.defrag();
454        assert_eq!(relocations.len(), 4, "defrag must remap all 4 live handles");
455
456        // Every remapped handle must be fetchable via get().
457        for (h, new_rect) in &relocations {
458            let fetched = atlas.get(*h).expect("handle must remain live after defrag");
459            assert_eq!(fetched, *new_rect, "relocation rect must match get()");
460        }
461
462        // Handles h0..h3 should still be live after the defrag.
463        for h in [h0, h1, h2, h3] {
464            assert!(atlas.get(h).is_some(), "handle must survive defrag: {h:?}");
465        }
466    }
467
468    #[test]
469    fn atlas_is_fragmented_threshold() {
470        let mut atlas = TextureAtlas::new(16, 16);
471        // Start empty — utilization == 0 → fragmented for any positive threshold.
472        assert!(
473            atlas.is_fragmented(0.1),
474            "empty atlas must be fragmented for threshold > 0"
475        );
476        // Insert one item to raise utilization above 0.
477        let _h = atlas.insert(4, 4).expect("insert");
478        // Threshold=0.0 → never fragmented.
479        assert!(
480            !atlas.is_fragmented(0.0),
481            "threshold=0 must never report fragmentation"
482        );
483    }
484
485    #[test]
486    fn atlas_defrag_if_fragmented_skips_when_healthy() {
487        let mut atlas = TextureAtlas::new(8, 8);
488        // Fill entire atlas with one item.
489        let _ = atlas.insert(8, 8).expect("insert");
490        assert!(atlas.utilization() >= 0.99);
491        // Threshold < utilization → no defrag needed.
492        let result = atlas.defrag_if_fragmented(0.5);
493        assert!(
494            result.is_none(),
495            "defrag_if_fragmented should return None when healthy"
496        );
497    }
498
499    #[test]
500    fn atlas_resize_clears() {
501        let mut atlas = TextureAtlas::new(16, 16);
502        let h = atlas.insert(4, 4).expect("insert before resize");
503        atlas.resize(32, 32);
504        // After resize, old handles must be invalid.
505        assert!(
506            atlas.get(h).is_none(),
507            "handle must be invalid after resize"
508        );
509        assert!(
510            (atlas.utilization() - 0.0).abs() < f32::EPSILON,
511            "utilization must be 0 after resize"
512        );
513        // New atlas must accept insertions.
514        assert!(
515            atlas.insert(4, 4).is_some(),
516            "insert after resize must succeed"
517        );
518    }
519}