Skip to main content

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}