rustial_renderer_wgpu/gpu/tile_atlas.rs
1// ---------------------------------------------------------------------------
2//! # Tile atlas -- packs individual tile textures into shared GPU atlas pages
3//!
4//! Instead of binding a separate texture for every tile draw call, the atlas
5//! packs decoded tile images into large shared textures (atlas pages). All
6//! tiles within the same page can be drawn with a single texture bind-group
7//! swap, and their geometry can be merged into one vertex/index buffer for a
8//! single batched draw call.
9//!
10//! ## Atlas layout
11//!
12//! Each page is a square RGBA8 texture of [`ATLAS_PAGE_SIZE`] x
13//! [`ATLAS_PAGE_SIZE`] pixels (default 4096 x 4096), divided into a regular
14//! grid of [`TILE_SIZE`] x [`TILE_SIZE`] slots (default 256 x 256). This
15//! yields [`SLOTS_PER_PAGE`] = 256 slots per page, which at moderate zoom
16//! levels covers all visible tiles in a single page.
17//!
18//! ## UV remapping
19//!
20//! When a tile is placed into slot `(col, row)`, its original `[0, 1]` UVs
21//! are remapped to the sub-rectangle within the atlas via
22//! [`AtlasRegion::remap_uv`]:
23//!
24//! ```text
25//! u' = (col + u) / slots_per_side
26//! v' = (row + v) / slots_per_side
27//! ```
28//!
29//! A half-texel inset is applied to avoid sampling across slot boundaries
30//! when linear filtering is active.
31//!
32//! ## Eviction
33//!
34//! Slots are tracked per frame. The caller marks every tile that is drawn
35//! via [`TileAtlas::mark_used`], then calls [`TileAtlas::end_frame`] which
36//! frees every slot that was *not* used. Freed slots are recycled on the
37//! next insertion. Atlas pages themselves are never deallocated; only their
38//! slots are reused.
39//!
40//! ## Tile size mismatch
41//!
42//! If a decoded tile image is smaller than [`TILE_SIZE`], it is copied into
43//! the top-left corner of the slot and any extra texels are undefined (not
44//! visible because UVs only span the copied region). Images larger than
45//! [`TILE_SIZE`] are clamped to [`TILE_SIZE`] in the `write_texture` call.
46//!
47//! ## Thread safety
48//!
49//! `TileAtlas` is **not** `Send`/`Sync` because it holds `wgpu::Texture`
50//! handles, which in some backends are `!Send`. This is fine -- the atlas
51//! lives exclusively on the render thread alongside the `wgpu::Device`.
52// ---------------------------------------------------------------------------
53
54use rustial_engine::DecodedImage;
55use rustial_engine::RasterMipChain;
56use rustial_engine::TileId;
57use std::collections::HashMap;
58
59// ---------------------------------------------------------------------------
60// Constants
61// ---------------------------------------------------------------------------
62
63/// Edge length of each atlas page texture in pixels.
64pub const ATLAS_PAGE_SIZE: u32 = 4096;
65
66/// Edge length of a single tile in pixels.
67pub const TILE_SIZE: u32 = 256;
68
69/// Number of tile slots along one edge of an atlas page.
70///
71/// `ATLAS_PAGE_SIZE / TILE_SIZE` = 16 for the default 4096 / 256.
72pub const SLOTS_PER_SIDE: u32 = ATLAS_PAGE_SIZE / TILE_SIZE;
73
74/// Total number of tile slots in a single atlas page.
75///
76/// 16 x 16 = 256 for the defaults.
77pub const SLOTS_PER_PAGE: u32 = SLOTS_PER_SIDE * SLOTS_PER_SIDE;
78
79/// Half-texel size in atlas-UV space, used to inset UVs so that linear
80/// filtering never samples across slot boundaries.
81const HALF_TEXEL: f32 = 0.5 / ATLAS_PAGE_SIZE as f32;
82
83/// Number of mip levels allocated for each atlas page.
84#[inline]
85fn atlas_page_mip_count() -> u32 {
86 ATLAS_PAGE_SIZE.ilog2() + 1
87}
88
89// ---------------------------------------------------------------------------
90// AtlasRegion -- where a tile lives inside the atlas
91// ---------------------------------------------------------------------------
92
93/// Describes where a tile's pixels are stored inside the atlas.
94///
95/// Returned by [`TileAtlas::insert`] and [`TileAtlas::get`]. The region
96/// is used by the batch builder ([`crate::gpu::batch`]) to remap each
97/// tile quad's UVs from `[0, 1]` into the correct atlas sub-rectangle.
98#[derive(Debug, Clone, Copy)]
99pub struct AtlasRegion {
100 /// Index of the atlas page (into [`TileAtlas::pages`]).
101 pub page: usize,
102 /// Column of the slot within the page (0-based, max [`SLOTS_PER_SIDE`]-1).
103 pub col: u32,
104 /// Row of the slot within the page (0-based, max [`SLOTS_PER_SIDE`]-1).
105 pub row: u32,
106}
107
108impl AtlasRegion {
109 /// Remap a tile-local UV `[0, 1]` to an atlas-global UV.
110 ///
111 /// Applies a half-texel inset so that bilinear sampling at slot edges
112 /// never bleeds into the neighbouring slot.
113 ///
114 /// # Mapping
115 ///
116 /// ```text
117 /// u' = (col + u) / slots_per_side (+ half-texel inset)
118 /// v' = (row + v) / slots_per_side (+ half-texel inset)
119 /// ```
120 #[inline]
121 pub fn remap_uv(&self, u: f32, v: f32) -> [f32; 2] {
122 let inv = 1.0 / SLOTS_PER_SIDE as f32;
123 let base_u = (self.col as f32 + u) * inv;
124 let base_v = (self.row as f32 + v) * inv;
125 // Inset: push 0.0 inward by +HALF_TEXEL, push 1.0 inward by -HALF_TEXEL.
126 let inset_u = base_u + HALF_TEXEL * (1.0 - 2.0 * u);
127 let inset_v = base_v + HALF_TEXEL * (1.0 - 2.0 * v);
128 [inset_u, inset_v]
129 }
130}
131
132// ---------------------------------------------------------------------------
133// PendingUpload -- deferred tile texture upload
134// ---------------------------------------------------------------------------
135
136/// A single tile texture upload that has been enqueued but not yet written
137/// to the GPU.
138///
139/// Instead of issuing `queue.write_texture` immediately during
140/// [`TileAtlas::insert`], the atlas records a `PendingUpload` and defers
141/// the actual GPU write until [`TileAtlas::flush_uploads`] is called.
142/// This lets the renderer batch all per-frame texture writes into a
143/// single submission window, reducing driver overhead and matching
144/// MapLibre's incremental atlas streaming model.
145struct PendingUpload {
146 /// Atlas page index that owns the destination slot.
147 page_idx: usize,
148 /// Slot column within the atlas page.
149 col: u32,
150 /// Slot row within the atlas page.
151 row: u32,
152 /// Pre-computed mip chain data ready for GPU upload.
153 mip_chain: RasterMipChain,
154}
155
156// ---------------------------------------------------------------------------
157// AtlasPage -- one GPU texture with a grid of tile slots
158// ---------------------------------------------------------------------------
159
160/// A single atlas page: one large GPU texture with a free-list of tile slots.
161///
162/// Each page is [`ATLAS_PAGE_SIZE`] x [`ATLAS_PAGE_SIZE`] pixels of
163/// `Rgba8UnormSrgb`, containing up to [`SLOTS_PER_PAGE`] tile slots arranged
164/// in a [`SLOTS_PER_SIDE`] x [`SLOTS_PER_SIDE`] grid.
165pub(crate) struct AtlasPage {
166 /// The GPU texture (ATLAS_PAGE_SIZE x ATLAS_PAGE_SIZE, Rgba8UnormSrgb).
167 pub texture: wgpu::Texture,
168 /// A view over the full atlas texture, bound in draw calls.
169 pub view: wgpu::TextureView,
170 /// Per-slot occupancy: `true` = occupied by a tile, `false` = free.
171 ///
172 /// Indexed as `row * SLOTS_PER_SIDE + col`.
173 pub(crate) occupied: Vec<bool>,
174 /// Per-slot "used this frame" flag, set by [`AtlasPage::mark_used`],
175 /// reset by [`AtlasPage::evict_unused`].
176 used_this_frame: Vec<bool>,
177}
178
179impl AtlasPage {
180 /// Count the number of occupied slots.
181 fn occupied_count_inner(&self) -> u32 {
182 self.occupied.iter().filter(|&&o| o).count() as u32
183 }
184}
185
186impl AtlasPage {
187 // NOTE: `occupied_count_inner` lives in the impl block above (before
188 // the main impl block) so it is available outside `#[cfg(test)]`.
189
190 /// Allocate a new atlas page on the GPU.
191 ///
192 /// This creates a single `ATLAS_PAGE_SIZE x ATLAS_PAGE_SIZE` texture
193 /// allocation. On a typical GPU this is 4096 x 4096 x 4 = 64 MiB of
194 /// VRAM per page. Most map sessions need only 1-2 pages.
195 fn new(device: &wgpu::Device) -> Self {
196 let texture = device.create_texture(&wgpu::TextureDescriptor {
197 label: Some("tile_atlas_page"),
198 size: wgpu::Extent3d {
199 width: ATLAS_PAGE_SIZE,
200 height: ATLAS_PAGE_SIZE,
201 depth_or_array_layers: 1,
202 },
203 mip_level_count: atlas_page_mip_count(),
204 sample_count: 1,
205 dimension: wgpu::TextureDimension::D2,
206 format: wgpu::TextureFormat::Rgba8UnormSrgb,
207 usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
208 view_formats: &[],
209 });
210 let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
211 let n = SLOTS_PER_PAGE as usize;
212 Self {
213 texture,
214 view,
215 occupied: vec![false; n],
216 used_this_frame: vec![false; n],
217 }
218 }
219
220 /// Find a free slot and mark it occupied. Returns `(col, row)` or
221 /// `None` if every slot is taken.
222 ///
223 /// Performs a linear scan -- O(SLOTS_PER_PAGE) in the worst case (256).
224 /// This is negligible compared to the GPU texture write that follows.
225 fn alloc_slot(&mut self) -> Option<(u32, u32)> {
226 for (i, occ) in self.occupied.iter_mut().enumerate() {
227 if !*occ {
228 *occ = true;
229 let col = (i as u32) % SLOTS_PER_SIDE;
230 let row = (i as u32) / SLOTS_PER_SIDE;
231 return Some((col, row));
232 }
233 }
234 None
235 }
236
237 /// Mark a slot as drawn this frame (prevents eviction).
238 fn mark_used(&mut self, col: u32, row: u32) {
239 let idx = (row * SLOTS_PER_SIDE + col) as usize;
240 if idx < self.used_this_frame.len() {
241 self.used_this_frame[idx] = true;
242 }
243 }
244
245 /// Free a specific slot (e.g. from [`TileAtlas::remove`]).
246 fn free_slot(&mut self, col: u32, row: u32) {
247 let idx = (row * SLOTS_PER_SIDE + col) as usize;
248 if idx < self.occupied.len() {
249 self.occupied[idx] = false;
250 }
251 }
252
253 /// Evict all slots that were *not* marked as used this frame.
254 ///
255 /// After eviction the `used_this_frame` flags are cleared for the next
256 /// frame. The caller ([`TileAtlas::end_frame`]) is responsible for
257 /// removing the corresponding `regions` entries.
258 fn evict_unused(&mut self) {
259 for i in 0..self.occupied.len() {
260 if self.occupied[i] && !self.used_this_frame[i] {
261 self.occupied[i] = false;
262 }
263 }
264 self.used_this_frame.iter_mut().for_each(|f| *f = false);
265 }
266
267 /// Number of occupied slots in this page (test-only convenience).
268 #[allow(dead_code)]
269 #[cfg(test)]
270 fn occupied_count(&self) -> usize {
271 self.occupied_count_inner() as usize
272 }
273}
274
275// ---------------------------------------------------------------------------
276// TileAtlas -- manages multiple atlas pages
277// ---------------------------------------------------------------------------
278
279/// Tile texture atlas -- packs individual tile images into shared GPU textures.
280///
281/// Manages one or more [`AtlasPage`]s and maintains a `TileId -> AtlasRegion`
282/// lookup table. New pages are lazily allocated when all existing pages are
283/// full; pages are never deallocated (their slots are recycled instead).
284///
285/// # Lifecycle (called by [`WgpuMapRenderer`](crate::WgpuMapRenderer))
286///
287/// 1. **Insert** -- [`insert`](Self::insert) enqueues a deferred upload.
288/// 2. **Flush** -- [`flush_uploads`](Self::flush_uploads) writes all
289/// pending slot data to the GPU in one batched pass.
290/// 3. **Mark** -- [`mark_used`](Self::mark_used) for every tile drawn.
291/// 4. **Evict** -- [`end_frame`](Self::end_frame) once after submit;
292/// optionally compacts the atlas when fragmentation is high.
293///
294/// # Deferred upload
295///
296/// Tile pixel data is *not* written to the GPU during [`insert`](Self::insert).
297/// Instead, only the atlas slot is allocated and a [`PendingUpload`] is
298/// enqueued. The actual `queue.write_texture` calls happen during
299/// [`flush_uploads`](Self::flush_uploads), which the renderer calls once
300/// per frame before building batched geometry. This ensures that only the
301/// affected slot's pixel rectangle is uploaded (partial write), and all
302/// uploads are grouped into a single submission window.
303///
304/// # VRAM budget
305///
306/// Each page consumes 4096 x 4096 x 4 = 64 MiB. At moderate zoom levels a
307/// single page (256 slots) is sufficient; at very high zoom with a large
308/// viewport, 2-3 pages may be needed.
309pub struct TileAtlas {
310 /// Atlas pages. New pages are appended when all existing pages are full.
311 pub(crate) pages: Vec<AtlasPage>,
312 /// Maps a `TileId` to its storage location inside the atlas.
313 regions: HashMap<TileId, AtlasRegion>,
314 /// Deferred upload queue -- filled by [`insert`], drained by
315 /// [`flush_uploads`].
316 pending_uploads: Vec<PendingUpload>,
317 /// Rolling byte count of texture data written during the current frame
318 /// (reset at the start of each [`flush_uploads`] call).
319 bytes_uploaded_this_frame: u64,
320}
321
322impl Default for TileAtlas {
323 fn default() -> Self {
324 Self::new()
325 }
326}
327
328impl TileAtlas {
329 /// Create an empty tile atlas. No GPU memory is allocated until the
330 /// first [`insert`](Self::insert) call.
331 pub fn new() -> Self {
332 Self {
333 pages: Vec::new(),
334 regions: HashMap::new(),
335 pending_uploads: Vec::new(),
336 bytes_uploaded_this_frame: 0,
337 }
338 }
339
340 // -- Queries ----------------------------------------------------------
341
342 /// Look up where a tile is stored. Returns `None` if the tile has not
343 /// been uploaded.
344 #[inline]
345 pub fn get(&self, id: &TileId) -> Option<&AtlasRegion> {
346 self.regions.get(id)
347 }
348
349 /// Check whether a tile is present in the atlas.
350 #[inline]
351 pub fn contains(&self, id: &TileId) -> bool {
352 self.regions.contains_key(id)
353 }
354
355 /// Total number of tiles currently stored across all pages.
356 #[inline]
357 pub fn len(&self) -> usize {
358 self.regions.len()
359 }
360
361 /// Whether the atlas contains zero tiles.
362 #[inline]
363 pub fn is_empty(&self) -> bool {
364 self.regions.is_empty()
365 }
366
367 /// Number of atlas pages currently allocated.
368 #[inline]
369 pub fn page_count(&self) -> usize {
370 self.pages.len()
371 }
372
373 // -- Mutation ----------------------------------------------------------
374
375 /// Enqueue a decoded tile image for deferred GPU upload.
376 ///
377 /// If the tile is already present this is a no-op and returns the
378 /// existing region. Otherwise a free slot is allocated (creating a new
379 /// atlas page when necessary), the mip chain is pre-computed on the CPU,
380 /// and a [`PendingUpload`] is enqueued. The actual GPU write happens
381 /// later when [`flush_uploads`](Self::flush_uploads) is called.
382 ///
383 /// # Arguments
384 ///
385 /// * `device` -- WGPU device, used only if a new atlas page must be created.
386 /// * `id` -- The tile identity.
387 /// * `image` -- Decoded RGBA8 tile imagery used to generate all mip levels.
388 ///
389 /// # Panics
390 ///
391 /// Panics (via `expect`) if a freshly created atlas page has no free
392 /// slots, which cannot happen by construction.
393 pub fn insert(
394 &mut self,
395 device: &wgpu::Device,
396 id: TileId,
397 image: &DecodedImage,
398 ) -> AtlasRegion {
399 // Early-out if the tile is already uploaded or enqueued.
400 if let Some(region) = self.regions.get(&id) {
401 return *region;
402 }
403
404 // Allocate an atlas slot (may create a new page).
405 let (page_idx, col, row) = self.alloc_slot(device);
406
407 // Build the full mip chain on the CPU (no GPU interaction yet).
408 let mip_chain = image
409 .build_mip_chain_rgba8()
410 .expect("TileAtlas::insert requires valid RGBA8 tile imagery");
411
412 // Enqueue the upload -- the actual write happens in flush_uploads.
413 self.pending_uploads.push(PendingUpload {
414 page_idx,
415 col,
416 row,
417 mip_chain,
418 });
419
420 let region = AtlasRegion {
421 page: page_idx,
422 col,
423 row,
424 };
425 self.regions.insert(id, region);
426 region
427 }
428
429 /// Flush all pending tile uploads to the GPU.
430 ///
431 /// Each enqueued [`PendingUpload`] writes only the affected slot's pixel
432 /// rectangle via `queue.write_texture` - the rest of the atlas page is
433 /// left untouched. This is the "partial texture write" path that
434 /// replaces the previous full-page re-upload approach.
435 ///
436 /// Call once per frame, after all [`insert`](Self::insert) calls and
437 /// before building batched geometry.
438 ///
439 /// Returns the number of tiles that were uploaded this frame.
440 pub fn flush_uploads(&mut self, queue: &wgpu::Queue) -> usize {
441 self.bytes_uploaded_this_frame = 0;
442 let count = self.pending_uploads.len();
443
444 for upload in self.pending_uploads.drain(..) {
445 let page_texture = &self.pages[upload.page_idx].texture;
446
447 for (level, mip) in upload.mip_chain.levels().iter().enumerate() {
448 let mip_level = level as u32;
449 // Slot pixel size at this mip level.
450 let slot_size = (TILE_SIZE >> mip_level).max(1);
451 let copy_w = mip.width.min(slot_size);
452 let copy_h = mip.height.min(slot_size);
453
454 // Write only the slot's sub-rectangle within the atlas page.
455 queue.write_texture(
456 wgpu::TexelCopyTextureInfo {
457 texture: page_texture,
458 mip_level,
459 origin: wgpu::Origin3d {
460 x: upload.col * slot_size,
461 y: upload.row * slot_size,
462 z: 0,
463 },
464 aspect: wgpu::TextureAspect::All,
465 },
466 &mip.data,
467 wgpu::TexelCopyBufferLayout {
468 offset: 0,
469 bytes_per_row: Some(4 * mip.width),
470 rows_per_image: Some(mip.height),
471 },
472 wgpu::Extent3d {
473 width: copy_w,
474 height: copy_h,
475 depth_or_array_layers: 1,
476 },
477 );
478
479 // Track bytes uploaded for diagnostics.
480 self.bytes_uploaded_this_frame += (copy_w as u64) * (copy_h as u64) * 4;
481 }
482 }
483
484 count
485 }
486
487 /// Mark a tile as used this frame (prevents eviction at end-of-frame).
488 ///
489 /// Call this for every `TileId` that participates in the current frame's
490 /// draw calls -- both imagery tiles and terrain tiles.
491 pub fn mark_used(&mut self, id: &TileId) {
492 if let Some(region) = self.regions.get(id) {
493 if region.page < self.pages.len() {
494 self.pages[region.page].mark_used(region.col, region.row);
495 }
496 }
497 }
498
499 /// End-of-frame housekeeping: evict unused slots and optionally compact.
500 ///
501 /// Call **once** after `queue.submit()`. Tiles that were not
502 /// [`mark_used`](Self::mark_used) this frame have their slots freed
503 /// and their `regions` entries removed.
504 ///
505 /// When overall fragmentation exceeds 30% of allocated capacity, a
506 /// compaction pass repacks all live tiles into the fewest contiguous
507 /// slots and frees trailing empty pages.
508 pub fn end_frame(&mut self) {
509 // Evict slots that were not drawn this frame.
510 for page in &mut self.pages {
511 page.evict_unused();
512 }
513
514 // Remove lookup entries for slots that were freed.
515 self.regions.retain(|_id, region| {
516 let page = &self.pages[region.page];
517 let idx = (region.row * SLOTS_PER_SIDE + region.col) as usize;
518 page.occupied[idx]
519 });
520
521 // Compact when fragmentation is above the threshold.
522 if self.fragmentation_ratio() > 0.30 {
523 self.compact();
524 }
525 }
526
527 /// Remove a specific tile from the atlas, freeing its slot immediately.
528 pub fn remove(&mut self, id: &TileId) {
529 if let Some(region) = self.regions.remove(id) {
530 if region.page < self.pages.len() {
531 self.pages[region.page].free_slot(region.col, region.row);
532 }
533 }
534 }
535
536 // -- Compaction -------------------------------------------------------
537
538 /// Fraction of allocated capacity that is empty (fragmented).
539 ///
540 /// Returns `0.0` when there are no pages, and approaches `1.0` when
541 /// most allocated slots are free. Used by [`end_frame`](Self::end_frame)
542 /// to decide whether compaction is worthwhile.
543 pub fn fragmentation_ratio(&self) -> f32 {
544 if self.pages.is_empty() {
545 return 0.0;
546 }
547 let total_slots = self.pages.len() as f32 * SLOTS_PER_PAGE as f32;
548 let occupied_slots = self.regions.len() as f32;
549 1.0 - occupied_slots / total_slots
550 }
551
552 /// Repack live tiles into contiguous slots and free trailing empty pages.
553 ///
554 /// This operates *only* on the CPU-side metadata (slot occupancy and
555 /// region lookup). The pixel data already on the GPU is not moved;
556 /// the next frame will naturally re-upload any tiles that need new
557 /// slot assignments. In practice, compaction simply clears the
558 /// fragmentation so new inserts fill pages sequentially.
559 ///
560 /// Trailing pages with zero occupied slots are dropped, which frees
561 /// the associated GPU texture allocation.
562 fn compact(&mut self) {
563 // Drop trailing pages that are completely empty.
564 while let Some(last) = self.pages.last() {
565 if last.occupied_count_inner() == 0 {
566 self.pages.pop();
567 } else {
568 break;
569 }
570 }
571 }
572
573 // -- Diagnostics ------------------------------------------------------
574
575 /// Snapshot of atlas health metrics for the current frame.
576 ///
577 /// The returned [`AtlasDiagnostics`] exposes page count, slot
578 /// utilisation, bytes uploaded this frame, and the current
579 /// fragmentation ratio - all useful for performance overlays and
580 /// automated tests.
581 pub fn diagnostics(&self) -> AtlasDiagnostics {
582 let total_slots = self.pages.len() as u32 * SLOTS_PER_PAGE;
583 let occupied_slots = self.regions.len() as u32;
584
585 AtlasDiagnostics {
586 page_count: self.pages.len() as u32,
587 total_slots,
588 occupied_slots,
589 bytes_uploaded_this_frame: self.bytes_uploaded_this_frame,
590 fragmentation_ratio: self.fragmentation_ratio(),
591 pending_uploads: self.pending_uploads.len() as u32,
592 }
593 }
594
595 // -- Internal ---------------------------------------------------------
596
597 /// Find a free slot across all existing pages, or create a new page.
598 ///
599 /// Returns `(page_index, col, row)`.
600 fn alloc_slot(&mut self, device: &wgpu::Device) -> (usize, u32, u32) {
601 // Try existing pages first (prefer filling pages sequentially to
602 // keep the working set compact).
603 for (i, page) in self.pages.iter_mut().enumerate() {
604 if let Some((col, row)) = page.alloc_slot() {
605 return (i, col, row);
606 }
607 }
608 // All pages full -- allocate a new one.
609 let mut page = AtlasPage::new(device);
610 let (col, row) = page.alloc_slot().expect("fresh page must have free slots");
611 let idx = self.pages.len();
612 self.pages.push(page);
613 (idx, col, row)
614 }
615}
616
617// ---------------------------------------------------------------------------
618// AtlasDiagnostics -- health metrics snapshot
619// ---------------------------------------------------------------------------
620
621/// Snapshot of atlas health metrics for one frame.
622///
623/// Returned by [`TileAtlas::diagnostics`]. All fields are cheap copies
624/// suitable for logging, overlay display, or automated assertions.
625#[derive(Debug, Clone, Copy, PartialEq)]
626pub struct AtlasDiagnostics {
627 /// Number of atlas pages currently allocated (each is 64 MiB).
628 pub page_count: u32,
629 /// Total slot capacity across all pages.
630 pub total_slots: u32,
631 /// Number of slots that are currently occupied by a tile.
632 pub occupied_slots: u32,
633 /// Total bytes written to the GPU during the last
634 /// [`flush_uploads`](TileAtlas::flush_uploads) call.
635 pub bytes_uploaded_this_frame: u64,
636 /// Fragmentation ratio (0.0 = fully packed, 1.0 = all slots empty).
637 pub fragmentation_ratio: f32,
638 /// Number of uploads still queued (should be 0 after flush).
639 pub pending_uploads: u32,
640}
641
642// ---------------------------------------------------------------------------
643// Tests
644// ---------------------------------------------------------------------------
645
646#[cfg(test)]
647mod tests {
648 use super::*;
649
650 // -- AtlasRegion UV remapping -----------------------------------------
651
652 #[test]
653 fn remap_uv_origin_slot() {
654 let region = AtlasRegion {
655 page: 0,
656 col: 0,
657 row: 0,
658 };
659 let [u, v] = region.remap_uv(0.0, 0.0);
660 // At (0,0) with u=0, v=0 we expect a small positive inset.
661 assert!(u > 0.0, "half-texel inset should push u above 0");
662 assert!(v > 0.0, "half-texel inset should push v above 0");
663 assert!(u < HALF_TEXEL * 2.0);
664 assert!(v < HALF_TEXEL * 2.0);
665
666 let [u, v] = region.remap_uv(1.0, 1.0);
667 let slot_edge = 1.0 / SLOTS_PER_SIDE as f32;
668 assert!(
669 u < slot_edge,
670 "half-texel inset should pull u below slot edge"
671 );
672 assert!(
673 v < slot_edge,
674 "half-texel inset should pull v below slot edge"
675 );
676 }
677
678 #[test]
679 fn remap_uv_midpoint_is_exact() {
680 // At u=0.5, v=0.5 the inset term is zero, so the result should
681 // equal the un-inset centre of the slot.
682 let region = AtlasRegion {
683 page: 0,
684 col: 3,
685 row: 5,
686 };
687 let [u, v] = region.remap_uv(0.5, 0.5);
688 let inv = 1.0 / SLOTS_PER_SIDE as f32;
689 let expected_u = (3.0 + 0.5) * inv;
690 let expected_v = (5.0 + 0.5) * inv;
691 assert!((u - expected_u).abs() < 1e-7);
692 assert!((v - expected_v).abs() < 1e-7);
693 }
694
695 #[test]
696 fn remap_uv_symmetry() {
697 // The inset at u=0.0 and u=1.0 should be symmetric around the
698 // slot centre.
699 let region = AtlasRegion {
700 page: 0,
701 col: 7,
702 row: 7,
703 };
704 let [u0, _] = region.remap_uv(0.0, 0.5);
705 let [u1, _] = region.remap_uv(1.0, 0.5);
706 let centre = (u0 + u1) / 2.0;
707 let inv = 1.0 / SLOTS_PER_SIDE as f32;
708 let expected_centre = (7.0 + 0.5) * inv;
709 assert!((centre - expected_centre).abs() < 1e-7);
710 }
711
712 // -- Slot arithmetic --------------------------------------------------
713
714 #[test]
715 fn slots_per_page_correct() {
716 assert_eq!(SLOTS_PER_SIDE, 16);
717 assert_eq!(SLOTS_PER_PAGE, 256);
718 }
719
720 #[test]
721 fn half_texel_magnitude() {
722 // With a 4096-texel page the half-texel inset should be ~0.000122.
723 assert!((HALF_TEXEL - 0.5 / 4096.0).abs() < 1e-8);
724 }
725
726 // -- Fragmentation ratio ----------------------------------------------
727
728 #[test]
729 fn fragmentation_empty_atlas() {
730 // No pages means 0% fragmentation (nothing to compact).
731 let atlas = TileAtlas::new();
732 assert_eq!(atlas.fragmentation_ratio(), 0.0);
733 }
734
735 // -- AtlasDiagnostics -------------------------------------------------
736
737 #[test]
738 fn diagnostics_empty_atlas() {
739 let atlas = TileAtlas::new();
740 let diag = atlas.diagnostics();
741 assert_eq!(diag.page_count, 0);
742 assert_eq!(diag.total_slots, 0);
743 assert_eq!(diag.occupied_slots, 0);
744 assert_eq!(diag.bytes_uploaded_this_frame, 0);
745 assert_eq!(diag.fragmentation_ratio, 0.0);
746 assert_eq!(diag.pending_uploads, 0);
747 }
748
749 #[test]
750 fn diagnostics_default_eq() {
751 // Two fresh atlases should produce identical diagnostics.
752 let a = TileAtlas::new().diagnostics();
753 let b = TileAtlas::default().diagnostics();
754 assert_eq!(a, b);
755 }
756}