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}