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    /// Discard all allocations and reinitialise with new dimensions.
155    ///
156    /// Any existing handles become invalid.  Uploading updated GPU texture data
157    /// is the caller's responsibility.
158    pub fn resize(&mut self, new_width: u32, new_height: u32) {
159        self.width = new_width;
160        self.height = new_height;
161        self.next_y = 0;
162        self.shelves.clear();
163        self.allocations.clear();
164        self.lru_order.clear();
165        self.free_list.clear();
166        self.next_id = 0;
167        self.used_area = 0;
168    }
169
170    // ── Private helpers ──────────────────────────────────────────────────────
171
172    /// Attempt to satisfy the request from the free list (first-fit).
173    fn alloc_from_free_list(&mut self, w: u32, h: u32) -> Option<AtlasRect> {
174        let pos = self.free_list.iter().position(|r| r.w >= w && r.h >= h)?;
175        let free_rect = self.free_list.remove(pos);
176        Some(AtlasRect {
177            x: free_rect.x,
178            y: free_rect.y,
179            w,
180            h,
181        })
182    }
183
184    /// Attempt to satisfy the request by appending to an existing shelf.
185    fn alloc_from_shelves(&mut self, w: u32, h: u32) -> Option<AtlasRect> {
186        for shelf in &mut self.shelves {
187            if shelf.height >= h && self.width.saturating_sub(shelf.cursor_x) >= w {
188                let rect = AtlasRect {
189                    x: shelf.cursor_x,
190                    y: shelf.y,
191                    w,
192                    h,
193                };
194                shelf.cursor_x += w;
195                return Some(rect);
196            }
197        }
198        None
199    }
200
201    /// Attempt to open a new shelf at `next_y`.
202    fn alloc_new_shelf(&mut self, w: u32, h: u32) -> Option<AtlasRect> {
203        if self.height.saturating_sub(self.next_y) < h || self.width < w {
204            return None;
205        }
206        let shelf = Shelf {
207            y: self.next_y,
208            height: h,
209            cursor_x: w,
210        };
211        let rect = AtlasRect {
212            x: 0,
213            y: self.next_y,
214            w,
215            h,
216        };
217        self.next_y += h;
218        self.shelves.push(shelf);
219        Some(rect)
220    }
221
222    /// Register a raw rect as a live allocation and return a fresh handle.
223    fn register(&mut self, rect: AtlasRect) -> AtlasHandle {
224        let handle = AtlasHandle(self.next_id);
225        self.next_id += 1;
226        self.used_area += u64::from(rect.w) * u64::from(rect.h);
227        self.allocations.insert(handle, rect);
228        self.lru_order.push_back(handle);
229        handle
230    }
231
232    /// Evict the oldest (front of queue) live handle.  Returns `Some(())` on
233    /// success, `None` if the LRU queue is empty.
234    fn evict_lru(&mut self) -> Option<()> {
235        // Skip any handles that are no longer live (already removed).
236        loop {
237            let candidate = self.lru_order.pop_front()?;
238            if let Some(rect) = self.allocations.remove(&candidate) {
239                self.used_area = self
240                    .used_area
241                    .saturating_sub(u64::from(rect.w) * u64::from(rect.h));
242                self.free_list.push(rect);
243                return Some(());
244            }
245        }
246    }
247}
248
249// ── Tests ─────────────────────────────────────────────────────────────────────
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254
255    #[test]
256    fn atlas_packs_100_rects_no_overlap() {
257        let mut atlas = TextureAtlas::new(128, 128);
258        let mut rects: Vec<(AtlasHandle, AtlasRect)> = Vec::new();
259
260        for _ in 0..100 {
261            if let Some(h) = atlas.insert(4, 4) {
262                let r = atlas.get(h).expect("handle must be valid");
263                rects.push((h, r));
264            }
265        }
266
267        // All returned handles must have been valid.
268        assert!(!rects.is_empty());
269
270        // No two rects may overlap.
271        for i in 0..rects.len() {
272            for j in (i + 1)..rects.len() {
273                let a = rects[i].1;
274                let b = rects[j].1;
275                let overlap =
276                    a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
277                assert!(!overlap, "rects {i} and {j} overlap: {a:?} vs {b:?}");
278            }
279        }
280    }
281
282    #[test]
283    fn atlas_utilization_above_threshold() {
284        // A 32×32 atlas packed with 1×1 tiles should approach 100%.
285        let mut atlas = TextureAtlas::new(32, 32);
286        let mut count = 0u32;
287        while atlas.insert(1, 1).is_some() {
288            count += 1;
289            if count > 1024 * 4 {
290                break; // guard against infinite loop
291            }
292        }
293        // We expect at least 70% utilization once the atlas is exhausted.
294        assert!(
295            atlas.utilization() >= 0.70,
296            "utilization was {}",
297            atlas.utilization()
298        );
299    }
300
301    #[test]
302    fn atlas_lru_eviction_keeps_invariants() {
303        // Use a tiny atlas so we can force eviction.
304        let mut atlas = TextureAtlas::new(4, 4);
305        // Fill with 4×1 strips.
306        let h0 = atlas.insert(4, 1).expect("first insert");
307        let h1 = atlas.insert(4, 1).expect("second insert");
308        let h2 = atlas.insert(4, 1).expect("third insert");
309        let h3 = atlas.insert(4, 1).expect("fourth insert");
310        // Atlas is now full. Next insert must evict the LRU (h0).
311        let h4 = atlas.insert(4, 1).expect("insert with eviction");
312        // h0 should have been evicted (get returns None), h4 live.
313        assert!(
314            atlas.get(h0).is_none(),
315            "LRU handle h0 must have been evicted"
316        );
317        assert!(atlas.get(h4).is_some(), "new handle h4 must be live");
318        // h1, h2, h3 must still be live.
319        assert!(atlas.get(h1).is_some());
320        assert!(atlas.get(h2).is_some());
321        assert!(atlas.get(h3).is_some());
322        // No two live rects overlap.
323        let live_rects: Vec<AtlasRect> = [h1, h2, h3, h4]
324            .iter()
325            .filter_map(|&h| atlas.get(h))
326            .collect();
327        for i in 0..live_rects.len() {
328            for j in (i + 1)..live_rects.len() {
329                let a = live_rects[i];
330                let b = live_rects[j];
331                let overlap =
332                    a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
333                assert!(!overlap, "live rects {i} and {j} overlap: {a:?} vs {b:?}");
334            }
335        }
336    }
337
338    #[test]
339    fn atlas_resize_clears() {
340        let mut atlas = TextureAtlas::new(16, 16);
341        let h = atlas.insert(4, 4).expect("insert before resize");
342        atlas.resize(32, 32);
343        // After resize, old handles must be invalid.
344        assert!(
345            atlas.get(h).is_none(),
346            "handle must be invalid after resize"
347        );
348        assert!(
349            (atlas.utilization() - 0.0).abs() < f32::EPSILON,
350            "utilization must be 0 after resize"
351        );
352        // New atlas must accept insertions.
353        assert!(
354            atlas.insert(4, 4).is_some(),
355            "insert after resize must succeed"
356        );
357    }
358}