| // Copyright 2025 the Vello Authors | |
| // SPDX-License-Identifier: Apache-2.0 OR MIT | |
| //! Multi-atlas management for texture atlases. | |
| //! | |
| //! This module provides support for managing multiple texture atlases, allowing for handling of | |
| //! large numbers of images. | |
| //! | |
| //! The allocator backend is [guillotiere](https://github.com/nical/guillotiere)'s tree-based | |
| //! guillotine algorithm, providing O(1) neighbor lookup during deallocation and automatic | |
| //! free-rect coalescing. | |
| use alloc::vec::Vec; | |
| pub use guillotiere::AllocId; | |
| use guillotiere::AtlasAllocator; | |
| use thiserror::Error; | |
| /// The result of a successful rectangle allocation within a single atlas. | |
| #[derive(Debug)] | |
| pub struct Allocation { | |
| /// Opaque handle used for deallocation. | |
| pub id: AllocId, | |
| /// X coordinate of the top-left corner of the allocated rectangle. | |
| pub x: u16, | |
| /// Y coordinate of the top-left corner of the allocated rectangle. | |
| pub y: u16, | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Unified Atlas type | |
| // --------------------------------------------------------------------------- | |
| /// Represents a single atlas in the multi-atlas system. | |
| pub struct Atlas { | |
| /// Unique identifier for this atlas. | |
| pub id: AtlasId, | |
| /// Rectangle allocator backend. | |
| allocator: AtlasAllocator, | |
| /// Current usage statistics. | |
| stats: AtlasUsageStats, | |
| /// Allocation counter. | |
| allocation_counter: u32, | |
| } | |
| impl Atlas { | |
| /// Create a new atlas with the given ID and size. | |
| pub fn new(id: AtlasId, width: u16, height: u16) -> Self { | |
| Self { | |
| id, | |
| allocator: AtlasAllocator::new(guillotiere::size2(i32::from(width), i32::from(height))), | |
| stats: AtlasUsageStats { | |
| allocated_area: 0, | |
| total_area: u32::from(width) * u32::from(height), | |
| allocated_count: 0, | |
| }, | |
| allocation_counter: 0, | |
| } | |
| } | |
| /// Try to allocate an image in this atlas. | |
| pub fn allocate(&mut self, width: u16, height: u16) -> Option<Allocation> { | |
| let alloc = self | |
| .allocator | |
| .allocate(guillotiere::size2(i32::from(width), i32::from(height)))?; | |
| self.stats.allocated_area += u32::from(width) * u32::from(height); | |
| self.stats.allocated_count += 1; | |
| self.allocation_counter += 1; | |
| Some(Allocation { | |
| id: alloc.id, | |
| x: u16::try_from(alloc.rectangle.min.x) | |
| .expect("guillotiere returned an out-of-range x coordinate"), | |
| y: u16::try_from(alloc.rectangle.min.y) | |
| .expect("guillotiere returned an out-of-range y coordinate"), | |
| }) | |
| } | |
| /// Deallocate an image from this atlas. | |
| pub fn deallocate(&mut self, alloc_id: AllocId, width: u16, height: u16) { | |
| self.allocator.deallocate(alloc_id); | |
| self.stats.allocated_area = self | |
| .stats | |
| .allocated_area | |
| .saturating_sub(u32::from(width) * u32::from(height)); | |
| self.stats.allocated_count = self.stats.allocated_count.saturating_sub(1); | |
| } | |
| /// Get current usage statistics. | |
| pub fn stats(&self) -> &AtlasUsageStats { | |
| &self.stats | |
| } | |
| } | |
| impl core::fmt::Debug for Atlas { | |
| fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { | |
| f.debug_struct("Atlas") | |
| .field("id", &self.id) | |
| .field("stats", &self.stats) | |
| .field("allocation_counter", &self.allocation_counter) | |
| .finish_non_exhaustive() | |
| } | |
| } | |
| // --------------------------------------------------------------------------- | |
| // MultiAtlasManager | |
| // --------------------------------------------------------------------------- | |
| /// Manages multiple texture atlases. | |
| pub struct MultiAtlasManager { | |
| /// All atlases managed by this instance. | |
| atlases: Vec<Atlas>, | |
| /// Configuration for atlas management. | |
| config: AtlasConfig, | |
| /// Round-robin counter for allocation strategy. | |
| round_robin_counter: usize, | |
| } | |
| impl MultiAtlasManager { | |
| /// Create a new multi-atlas manager with the given configuration. | |
| pub fn new(config: AtlasConfig) -> Self { | |
| let mut manager = Self { | |
| atlases: Vec::new(), | |
| config, | |
| round_robin_counter: 0, | |
| }; | |
| for _ in 0..config.initial_atlas_count { | |
| manager | |
| .create_atlas() | |
| .expect("Failed to create initial atlas"); | |
| } | |
| manager | |
| } | |
| /// Get the current configuration. | |
| pub fn config(&self) -> &AtlasConfig { | |
| &self.config | |
| } | |
| /// Create a new atlas and return its ID. | |
| pub fn create_atlas(&mut self) -> Result<AtlasId, AtlasError> { | |
| if self.atlases.len() >= self.config.max_atlases { | |
| return Err(AtlasError::AtlasLimitReached { | |
| max_atlases: self.config.max_atlases, | |
| diagnostics: AtlasSpaceDiagnostics::Unavailable, | |
| }); | |
| } | |
| let atlas_id = AtlasId::new(self.next_atlas_id()); | |
| let atlas = Atlas::new(atlas_id, self.config.atlas_size.0, self.config.atlas_size.1); | |
| self.atlases.push(atlas); | |
| Ok(atlas_id) | |
| } | |
| /// Get the next available atlas ID. | |
| pub fn next_atlas_id(&self) -> u32 { | |
| u32::try_from(self.atlases.len()).unwrap() | |
| } | |
| /// Try to allocate space for an image with the given dimensions. | |
| pub fn try_allocate(&mut self, width: u16, height: u16) -> Result<AtlasAllocation, AtlasError> { | |
| // Check if the image is too large for any atlas | |
| if width > self.config.atlas_size.0 || height > self.config.atlas_size.1 { | |
| return Err(AtlasError::TextureTooLarge { | |
| width: u32::from(width), | |
| height: u32::from(height), | |
| max_width: self.config.atlas_size.0, | |
| max_height: self.config.atlas_size.1, | |
| }); | |
| } | |
| // Try allocation based on strategy | |
| match self.config.allocation_strategy { | |
| AllocationStrategy::FirstFit => self.allocate_first_fit(width, height), | |
| AllocationStrategy::BestFit => self.allocate_best_fit(width, height), | |
| AllocationStrategy::LeastUsed => self.allocate_least_used(width, height), | |
| AllocationStrategy::RoundRobin => self.allocate_round_robin(width, height), | |
| } | |
| } | |
| fn space_diagnostics(&self, width: u16, height: u16) -> AtlasSpaceDiagnostics { | |
| let mut atlases = Vec::new(); | |
| for atlas in &self.atlases { | |
| let mut free_area = 0_u64; | |
| let mut free_rectangle_count = 0; | |
| let mut largest_free_width = 0; | |
| let mut largest_free_height = 0; | |
| let mut largest_free_area = 0_u64; | |
| atlas.allocator.for_each_free_rectangle(|rect| { | |
| let rect_width = u16::try_from(rect.width()) | |
| .expect("guillotiere returned an out-of-range rectangle width"); | |
| let rect_height = u16::try_from(rect.height()) | |
| .expect("guillotiere returned an out-of-range rectangle height"); | |
| let rect_area = u64::from(rect_width) * u64::from(rect_height); | |
| free_area += rect_area; | |
| free_rectangle_count += 1; | |
| if rect_area > largest_free_area { | |
| largest_free_area = rect_area; | |
| largest_free_width = rect_width; | |
| largest_free_height = rect_height; | |
| } | |
| }); | |
| atlases.push(AtlasLayerDiagnostics { | |
| atlas_id: atlas.id, | |
| total_area: u64::from(atlas.stats.total_area), | |
| free_area, | |
| free_rectangle_count, | |
| largest_free_width, | |
| largest_free_height, | |
| }); | |
| } | |
| AtlasSpaceDiagnostics::Allocation { | |
| width, | |
| height, | |
| atlas_width: self.config.atlas_size.0, | |
| atlas_height: self.config.atlas_size.1, | |
| max_atlases: self.config.max_atlases, | |
| atlases, | |
| } | |
| } | |
| fn no_space_available(&self, width: u16, height: u16) -> AtlasError { | |
| AtlasError::NoSpaceAvailable(self.space_diagnostics(width, height)) | |
| } | |
| fn atlas_limit_reached(&self, width: u16, height: u16) -> AtlasError { | |
| AtlasError::AtlasLimitReached { | |
| max_atlases: self.config.max_atlases, | |
| diagnostics: self.space_diagnostics(width, height), | |
| } | |
| } | |
| /// Allocate using first-fit strategy: try atlases in order until one has space. | |
| fn allocate_first_fit( | |
| &mut self, | |
| width: u16, | |
| height: u16, | |
| ) -> Result<AtlasAllocation, AtlasError> { | |
| for atlas in &mut self.atlases { | |
| if let Some(allocation) = atlas.allocate(width, height) { | |
| return Ok(AtlasAllocation { | |
| atlas_id: atlas.id, | |
| allocation, | |
| }); | |
| } | |
| } | |
| // Try creating a new atlas if auto-grow is enabled | |
| if self.config.auto_grow { | |
| let atlas_id = self | |
| .create_atlas() | |
| .map_err(|_| self.atlas_limit_reached(width, height))?; | |
| let atlas = self.atlases.last_mut().unwrap(); | |
| if let Some(allocation) = atlas.allocate(width, height) { | |
| return Ok(AtlasAllocation { | |
| atlas_id, | |
| allocation, | |
| }); | |
| } | |
| } | |
| Err(self.no_space_available(width, height)) | |
| } | |
| /// Allocate using best-fit strategy: choose the atlas with the smallest remaining space that | |
| /// can fit the image. | |
| fn allocate_best_fit( | |
| &mut self, | |
| width: u16, | |
| height: u16, | |
| ) -> Result<AtlasAllocation, AtlasError> { | |
| let mut best_atlas_idx = None; | |
| let mut best_remaining_space = u32::MAX; | |
| // Find the atlas with the least remaining space that can fit the image | |
| for (idx, atlas) in self.atlases.iter().enumerate() { | |
| let stats = atlas.stats(); | |
| let remaining_space = stats.total_area - stats.allocated_area; | |
| if remaining_space >= u32::from(width) * u32::from(height) | |
| && remaining_space < best_remaining_space | |
| { | |
| best_remaining_space = remaining_space; | |
| best_atlas_idx = Some(idx); | |
| } | |
| } | |
| if let Some(idx) = best_atlas_idx { | |
| let atlas = &mut self.atlases[idx]; | |
| if let Some(allocation) = atlas.allocate(width, height) { | |
| return Ok(AtlasAllocation { | |
| atlas_id: atlas.id, | |
| allocation, | |
| }); | |
| } | |
| } | |
| // Fallback to first-fit if best-fit didn't work | |
| self.allocate_first_fit(width, height) | |
| } | |
| /// Allocate using least-used strategy: prefer the atlas with the lowest usage percentage. | |
| fn allocate_least_used( | |
| &mut self, | |
| width: u16, | |
| height: u16, | |
| ) -> Result<AtlasAllocation, AtlasError> { | |
| let mut best_atlas_idx = None; | |
| let mut lowest_usage = f32::MAX; | |
| // Find the atlas with the lowest usage percentage | |
| for (idx, atlas) in self.atlases.iter().enumerate() { | |
| let usage = atlas.stats().usage_percentage(); | |
| if usage < lowest_usage { | |
| lowest_usage = usage; | |
| best_atlas_idx = Some(idx); | |
| } | |
| } | |
| if let Some(idx) = best_atlas_idx | |
| && let Some(allocation) = self.atlases[idx].allocate(width, height) | |
| { | |
| let atlas_id = self.atlases[idx].id; | |
| return Ok(AtlasAllocation { | |
| atlas_id, | |
| allocation, | |
| }); | |
| } | |
| // Fallback to first-fit if least-used didn't work | |
| self.allocate_first_fit(width, height) | |
| } | |
| /// Allocate using round-robin strategy: cycle through atlases using a round-robin counter. | |
| fn allocate_round_robin( | |
| &mut self, | |
| width: u16, | |
| height: u16, | |
| ) -> Result<AtlasAllocation, AtlasError> { | |
| if self.atlases.is_empty() { | |
| return self.allocate_first_fit(width, height); | |
| } | |
| let start_idx = self.round_robin_counter % self.atlases.len(); | |
| // Try starting from the round-robin position | |
| for i in 0..self.atlases.len() { | |
| let idx = (start_idx + i) % self.atlases.len(); | |
| if let Some(allocation) = self.atlases[idx].allocate(width, height) { | |
| let atlas_id = self.atlases[idx].id; | |
| self.round_robin_counter = (idx + 1) % self.atlases.len(); | |
| return Ok(AtlasAllocation { | |
| atlas_id, | |
| allocation, | |
| }); | |
| } | |
| } | |
| // Try creating a new atlas if auto-grow is enabled | |
| if self.config.auto_grow { | |
| let atlas_id = self | |
| .create_atlas() | |
| .map_err(|_| self.atlas_limit_reached(width, height))?; | |
| let atlas = self.atlases.last_mut().unwrap(); | |
| if let Some(allocation) = atlas.allocate(width, height) { | |
| self.round_robin_counter = self.atlases.len() - 1; | |
| return Ok(AtlasAllocation { | |
| atlas_id, | |
| allocation, | |
| }); | |
| } | |
| } | |
| Err(self.no_space_available(width, height)) | |
| } | |
| /// Deallocate space in the specified atlas. | |
| pub fn deallocate( | |
| &mut self, | |
| atlas_id: AtlasId, | |
| alloc_id: AllocId, | |
| width: u16, | |
| height: u16, | |
| ) -> Result<(), AtlasError> { | |
| // Since atlases only grow (never deallocate) and id is the index into the atlases vec, | |
| // we can do a lookup instead of a linear search | |
| let atlas = self | |
| .atlases | |
| .get_mut(atlas_id.0 as usize) | |
| .ok_or(AtlasError::AtlasNotFound(atlas_id))?; | |
| atlas.deallocate(alloc_id, width, height); | |
| Ok(()) | |
| } | |
| /// Get statistics for all atlases. | |
| pub fn atlas_stats(&self) -> Vec<(AtlasId, &AtlasUsageStats)> { | |
| self.atlases | |
| .iter() | |
| .map(|atlas| (atlas.id, atlas.stats())) | |
| .collect() | |
| } | |
| /// Get the number of atlases. | |
| pub fn atlas_count(&self) -> usize { | |
| self.atlases.len() | |
| } | |
| } | |
| impl core::fmt::Debug for MultiAtlasManager { | |
| fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { | |
| f.debug_struct("MultiAtlasManager") | |
| .field("atlas_count", &self.atlases.len()) | |
| .field("config", &self.config) | |
| .field("next_atlas_id", &self.next_atlas_id()) | |
| .field("round_robin_counter", &self.round_robin_counter) | |
| .field("atlases", &self.atlases) | |
| .finish() | |
| } | |
| } | |
| /// Errors that can occur during atlas operations. | |
| #[derive(Debug, Clone, Error)] | |
| pub enum AtlasError { | |
| /// No space available in any atlas. | |
| #[error("No space available in any atlas{0}")] | |
| NoSpaceAvailable(AtlasSpaceDiagnostics), | |
| /// Maximum number of atlases reached. | |
| #[error("Maximum atlas count reached ({max_atlases}){diagnostics}")] | |
| AtlasLimitReached { | |
| /// The configured maximum number of atlases. | |
| max_atlases: usize, | |
| /// Details about the failed allocation, when available. | |
| diagnostics: AtlasSpaceDiagnostics, | |
| }, | |
| /// The requested texture size is too large for any atlas. | |
| #[error("Texture too large ({width}x{height}) for atlas (maximum {max_width}x{max_height})")] | |
| TextureTooLarge { | |
| /// The width of the requested texture. | |
| width: u32, | |
| /// The height of the requested texture. | |
| height: u32, | |
| /// The maximum texture width supported by the atlas. | |
| max_width: u16, | |
| /// The maximum texture height supported by the atlas. | |
| max_height: u16, | |
| }, | |
| /// The specified atlas was not found. | |
| #[error("Atlas with Id {0:?} not found")] | |
| AtlasNotFound(AtlasId), | |
| } | |
| /// Free-space details collected after an atlas allocation fails. | |
| #[derive(Clone)] | |
| pub enum AtlasSpaceDiagnostics { | |
| /// No allocation context is available. | |
| Unavailable, | |
| /// Details about the requested allocation and available atlas space. | |
| Allocation { | |
| /// The requested allocation width. | |
| width: u16, | |
| /// The requested allocation height. | |
| height: u16, | |
| /// The width shared by all atlas layers. | |
| atlas_width: u16, | |
| /// The height shared by all atlas layers. | |
| atlas_height: u16, | |
| /// The configured maximum number of atlas layers. | |
| max_atlases: usize, | |
| /// Per-layer details for each atlas considered for the allocation. | |
| atlases: Vec<AtlasLayerDiagnostics>, | |
| }, | |
| } | |
| impl core::fmt::Debug for AtlasSpaceDiagnostics { | |
| fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { | |
| let Self::Allocation { | |
| width, | |
| height, | |
| atlas_width, | |
| atlas_height, | |
| max_atlases, | |
| atlases, | |
| } = self | |
| else { | |
| return f.write_str("Unavailable"); | |
| }; | |
| f.debug_struct("Allocation") | |
| .field("requested", &Dimensions(*width, *height)) | |
| .field("layer_size", &Dimensions(*atlas_width, *atlas_height)) | |
| .field("max_atlases", max_atlases) | |
| .field("atlas_layers", atlases) | |
| .finish() | |
| } | |
| } | |
| impl core::fmt::Display for AtlasSpaceDiagnostics { | |
| fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { | |
| let Self::Allocation { | |
| width, | |
| height, | |
| atlas_width: _, | |
| atlas_height: _, | |
| max_atlases, | |
| atlases, | |
| } = self | |
| else { | |
| return Ok(()); | |
| }; | |
| let total_area = atlases.iter().map(|atlas| atlas.total_area).sum::<u64>(); | |
| let free_area = atlases.iter().map(|atlas| atlas.free_area).sum::<u64>(); | |
| let used_percentage = if total_area == 0 { | |
| 0.0 | |
| } else { | |
| (1.0 - free_area as f64 / total_area as f64) * 100.0 | |
| }; | |
| write!( | |
| f, | |
| ": failed to allocate {width}x{height} across {} atlas layers \ | |
| (maximum {max_atlases}; {used_percentage:.1}% used)", | |
| atlases.len(), | |
| ) | |
| } | |
| } | |
| /// Free-space details for one atlas texture-array layer. | |
| #[derive(Clone)] | |
| pub struct AtlasLayerDiagnostics { | |
| /// The atlas represented by this layer. | |
| pub atlas_id: AtlasId, | |
| /// The total layer area, in texels. | |
| pub total_area: u64, | |
| /// The total free layer area, in texels. | |
| pub free_area: u64, | |
| /// The number of disjoint free rectangles in the layer. | |
| pub free_rectangle_count: usize, | |
| /// The width of the largest free rectangle by area. | |
| pub largest_free_width: u16, | |
| /// The height of the largest free rectangle by area. | |
| pub largest_free_height: u16, | |
| } | |
| impl AtlasLayerDiagnostics { | |
| /// Calculate layer utilization as a percentage from 0 to 100. | |
| pub fn utilization_percentage(&self) -> f64 { | |
| if self.total_area == 0 { | |
| 0.0 | |
| } else { | |
| (1.0 - self.free_area as f64 / self.total_area as f64) * 100.0 | |
| } | |
| } | |
| /// Calculate layer fragmentation as a percentage from 0 to 100. | |
| pub fn fragmentation_percentage(&self) -> f64 { | |
| if self.free_area == 0 { | |
| 0.0 | |
| } else { | |
| let largest_free_area = | |
| u64::from(self.largest_free_width) * u64::from(self.largest_free_height); | |
| (1.0 - largest_free_area as f64 / self.free_area as f64) * 100.0 | |
| } | |
| } | |
| } | |
| impl core::fmt::Debug for AtlasLayerDiagnostics { | |
| fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { | |
| f.debug_struct("Layer") | |
| .field("atlas_id", &self.atlas_id) | |
| .field("utilization", &Percentage(self.utilization_percentage())) | |
| .field("capacity", &self.total_area) | |
| .field("free", &self.free_area) | |
| .field( | |
| "largest_free_rectangle", | |
| &Dimensions(self.largest_free_width, self.largest_free_height), | |
| ) | |
| .field("free_rectangles", &self.free_rectangle_count) | |
| .field( | |
| "fragmentation", | |
| &Percentage(self.fragmentation_percentage()), | |
| ) | |
| .finish() | |
| } | |
| } | |
| struct Dimensions(u16, u16); | |
| impl core::fmt::Debug for Dimensions { | |
| fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { | |
| write!(f, "{}x{}", self.0, self.1) | |
| } | |
| } | |
| struct Percentage(f64); | |
| impl core::fmt::Debug for Percentage { | |
| fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { | |
| write!(f, "{:.1}%", self.0) | |
| } | |
| } | |
| /// Unique identifier for an atlas. | |
| #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] | |
| pub struct AtlasId(pub u32); | |
| impl AtlasId { | |
| /// Create a new atlas ID. | |
| pub fn new(id: u32) -> Self { | |
| Self(id) | |
| } | |
| /// Get the raw ID value. | |
| pub fn as_u32(self) -> u32 { | |
| self.0 | |
| } | |
| } | |
| /// Usage statistics for an atlas. | |
| #[derive(Debug, Clone)] | |
| pub struct AtlasUsageStats { | |
| /// Total allocated area in pixels. | |
| pub allocated_area: u32, | |
| /// Total available area in pixels. | |
| pub total_area: u32, | |
| /// Number of allocated images. | |
| pub allocated_count: u32, | |
| } | |
| impl AtlasUsageStats { | |
| /// Calculate usage percentage (0.0 to 1.0). | |
| pub fn usage_percentage(&self) -> f32 { | |
| if self.total_area == 0 { | |
| 0.0 | |
| } else { | |
| self.allocated_area as f32 / self.total_area as f32 | |
| } | |
| } | |
| } | |
| /// Result of an atlas allocation attempt. | |
| #[derive(Debug)] | |
| pub struct AtlasAllocation { | |
| /// The atlas where the allocation was made. | |
| pub atlas_id: AtlasId, | |
| /// The allocation details. | |
| pub allocation: Allocation, | |
| } | |
| /// Configuration for multiple atlas support. | |
| /// | |
| /// Note that any values provided here are recommendations and might not be fully | |
| /// honored depending on the capabilities of the backend. For example, if you define | |
| /// the atlas size to be 8192x8192 but the device only supports texture sizes up to 4096x4096, | |
| /// the backend will likely decide to instead use the value that is compatible with the device. | |
| #[derive(Debug, Clone, Copy)] | |
| pub struct AtlasConfig { | |
| /// Initial number of atlases to create. | |
| /// | |
| /// Set this to zero to allocate the first atlas lazily. | |
| pub initial_atlas_count: usize, | |
| /// Maximum number of atlases to create. | |
| pub max_atlases: usize, | |
| /// Size of each atlas texture. | |
| pub atlas_size: (u16, u16), | |
| /// Whether to automatically create new atlases when needed. | |
| pub auto_grow: bool, | |
| /// Strategy for allocating images across atlases. | |
| pub allocation_strategy: AllocationStrategy, | |
| } | |
| impl Default for AtlasConfig { | |
| fn default() -> Self { | |
| Self { | |
| initial_atlas_count: 0, | |
| max_atlases: 8, | |
| atlas_size: (4096, 4096), | |
| auto_grow: true, | |
| allocation_strategy: AllocationStrategy::FirstFit, | |
| } | |
| } | |
| } | |
| /// Strategy for allocating images across multiple atlases. | |
| #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] | |
| pub enum AllocationStrategy { | |
| /// Try atlases in order until one has space. | |
| #[default] | |
| FirstFit, | |
| /// Choose the atlas with the smallest remaining space that can fit the image. | |
| BestFit, | |
| /// Prefer the atlas with the lowest usage percentage. | |
| LeastUsed, | |
| /// Cycle through atlases in round-robin fashion. | |
| RoundRobin, | |
| } | |
| #[cfg(test)] | |
| mod tests { | |
| use super::*; | |
| #[test] | |
| fn test_atlas_creation() { | |
| let mut manager = MultiAtlasManager::new(AtlasConfig { | |
| initial_atlas_count: 0, | |
| ..Default::default() | |
| }); | |
| let atlas_id = manager.create_atlas().unwrap(); | |
| assert_eq!(atlas_id.as_u32(), 0); | |
| assert_eq!(manager.atlas_count(), 1); | |
| } | |
| #[test] | |
| fn test_default_lazily_creates_first_atlas() { | |
| let mut manager = MultiAtlasManager::new(AtlasConfig::default()); | |
| assert_eq!(manager.atlas_count(), 0); | |
| let allocation = manager.try_allocate(100, 100).unwrap(); | |
| assert_eq!(allocation.atlas_id.as_u32(), 0); | |
| assert_eq!(manager.atlas_count(), 1); | |
| } | |
| #[test] | |
| fn test_allocation_strategies() { | |
| let mut manager = MultiAtlasManager::new(AtlasConfig { | |
| initial_atlas_count: 1, | |
| max_atlases: 3, | |
| atlas_size: (256, 256), | |
| allocation_strategy: AllocationStrategy::FirstFit, | |
| auto_grow: true, | |
| }); | |
| // Should create atlas automatically | |
| let allocation = manager.try_allocate(100, 100).unwrap(); | |
| assert_eq!(allocation.atlas_id.as_u32(), 0); | |
| } | |
| #[test] | |
| fn test_atlas_limit() { | |
| let mut manager = MultiAtlasManager::new(AtlasConfig { | |
| initial_atlas_count: 1, | |
| max_atlases: 1, | |
| atlas_size: (256, 256), | |
| allocation_strategy: AllocationStrategy::FirstFit, | |
| auto_grow: false, | |
| }); | |
| assert!(matches!( | |
| manager.create_atlas(), | |
| Err(AtlasError::AtlasLimitReached { | |
| max_atlases: 1, | |
| diagnostics: AtlasSpaceDiagnostics::Unavailable, | |
| }) | |
| )); | |
| } | |
| #[test] | |
| fn test_no_space_diagnostics() { | |
| let mut manager = MultiAtlasManager::new(AtlasConfig { | |
| initial_atlas_count: 1, | |
| max_atlases: 1, | |
| atlas_size: (256, 256), | |
| auto_grow: false, | |
| ..Default::default() | |
| }); | |
| manager.try_allocate(128, 256).unwrap(); | |
| let Err(AtlasError::NoSpaceAvailable(AtlasSpaceDiagnostics::Allocation { | |
| width: 129, | |
| height: 256, | |
| atlas_width: 256, | |
| atlas_height: 256, | |
| max_atlases: 1, | |
| atlases, | |
| })) = manager.try_allocate(129, 256) | |
| else { | |
| panic!("expected no-space diagnostics"); | |
| }; | |
| assert_eq!(atlases.len(), 1); | |
| let atlas = &atlases[0]; | |
| assert_eq!(atlas.atlas_id, AtlasId::new(0)); | |
| assert_eq!(atlas.total_area, 65_536); | |
| assert_eq!(atlas.free_area, 32_768); | |
| assert_eq!(atlas.free_rectangle_count, 1); | |
| assert_eq!( | |
| (atlas.largest_free_width, atlas.largest_free_height), | |
| (128, 256) | |
| ); | |
| assert_eq!(atlas.fragmentation_percentage(), 0.0); | |
| } | |
| #[test] | |
| fn test_atlas_limit_diagnostics() { | |
| let mut manager = MultiAtlasManager::new(AtlasConfig { | |
| initial_atlas_count: 1, | |
| max_atlases: 1, | |
| atlas_size: (256, 256), | |
| auto_grow: true, | |
| ..Default::default() | |
| }); | |
| manager.try_allocate(128, 256).unwrap(); | |
| let Err(AtlasError::AtlasLimitReached { | |
| max_atlases: 1, | |
| diagnostics: | |
| AtlasSpaceDiagnostics::Allocation { | |
| width: 129, | |
| height: 256, | |
| atlas_width: 256, | |
| atlas_height: 256, | |
| max_atlases: 1, | |
| atlases, | |
| }, | |
| }) = manager.try_allocate(129, 256) | |
| else { | |
| panic!("expected atlas-limit diagnostics"); | |
| }; | |
| assert_eq!(atlases.len(), 1); | |
| } | |
| #[test] | |
| fn test_fragmentation_per_atlas_layer() { | |
| let atlases = [ | |
| AtlasLayerDiagnostics { | |
| atlas_id: AtlasId::new(0), | |
| total_area: 100, | |
| free_area: 100, | |
| free_rectangle_count: 1, | |
| largest_free_width: 10, | |
| largest_free_height: 10, | |
| }, | |
| AtlasLayerDiagnostics { | |
| atlas_id: AtlasId::new(1), | |
| total_area: 100, | |
| free_area: 100, | |
| free_rectangle_count: 2, | |
| largest_free_width: 5, | |
| largest_free_height: 10, | |
| }, | |
| ]; | |
| assert_eq!(atlases[0].fragmentation_percentage(), 0.0); | |
| assert_eq!(atlases[1].fragmentation_percentage(), 50.0); | |
| } | |
| #[test] | |
| fn test_texture_too_large() { | |
| let mut manager = MultiAtlasManager::new(AtlasConfig { | |
| atlas_size: (256, 256), | |
| ..Default::default() | |
| }); | |
| let result = manager.try_allocate(300, 300); | |
| assert!(matches!( | |
| result, | |
| Err(AtlasError::TextureTooLarge { | |
| width: 300, | |
| height: 300, | |
| max_width: 256, | |
| max_height: 256, | |
| }) | |
| )); | |
| } | |
| #[test] | |
| fn test_first_fit_allocation_strategy() { | |
| let mut manager = MultiAtlasManager::new(AtlasConfig { | |
| initial_atlas_count: 3, | |
| max_atlases: 3, | |
| atlas_size: (256, 256), | |
| allocation_strategy: AllocationStrategy::FirstFit, | |
| auto_grow: false, | |
| }); | |
| // First allocation should go to atlas 0 | |
| let allocation0 = manager.try_allocate(100, 100).unwrap(); | |
| assert_eq!(allocation0.atlas_id.as_u32(), 0); | |
| // Second allocation should also go to atlas 0 (first fit) | |
| let allocation1 = manager.try_allocate(50, 50).unwrap(); | |
| assert_eq!(allocation1.atlas_id.as_u32(), 0); | |
| // Third allocation should still go to atlas 0 (first fit continues to use same atlas) | |
| let allocation2 = manager.try_allocate(80, 80).unwrap(); | |
| assert_eq!(allocation2.atlas_id.as_u32(), 0); | |
| // Try to allocate something very large that definitely won't fit in atlas 0's remaining space | |
| // This should force it to go to atlas 1 | |
| let allocation3 = manager.try_allocate(200, 200).unwrap(); | |
| assert_eq!(allocation3.atlas_id.as_u32(), 1); | |
| // Next small allocation should go back to atlas 0 (first fit tries atlas 0 first) | |
| let allocation4 = manager.try_allocate(20, 20).unwrap(); | |
| assert_eq!(allocation4.atlas_id.as_u32(), 0); | |
| } | |
| #[test] | |
| fn test_best_fit_allocation_strategy() { | |
| let mut manager = MultiAtlasManager::new(AtlasConfig { | |
| initial_atlas_count: 3, | |
| max_atlases: 3, | |
| atlas_size: (256, 256), | |
| allocation_strategy: AllocationStrategy::BestFit, | |
| auto_grow: false, | |
| }); | |
| // All atlases start empty, so first allocation goes to atlas 0 (first available) | |
| let allocation0 = manager.try_allocate(150, 150).unwrap(); | |
| assert_eq!(allocation0.atlas_id.as_u32(), 0); | |
| // Second allocation should also go to atlas 0 since it still has the least remaining space | |
| // that can fit the image (all atlases have same remaining space, so it picks the first) | |
| let allocation1 = manager.try_allocate(100, 100).unwrap(); | |
| assert_eq!(allocation1.atlas_id.as_u32(), 0); | |
| // Now atlas 0 has less remaining space than atlases 1 and 2 | |
| // For a small allocation, it should still go to atlas 0 (best fit - least remaining space) | |
| let allocation2 = manager.try_allocate(100, 100).unwrap(); | |
| assert_eq!(allocation2.atlas_id.as_u32(), 0); | |
| // Now try to allocate something very large that won't fit in atlas 0's remaining space | |
| // This should force it to go to atlas 1 (which has the most remaining space) | |
| let allocation3 = manager.try_allocate(200, 200).unwrap(); | |
| assert_eq!(allocation3.atlas_id.as_u32(), 1); | |
| // Now atlas 1 has less remaining space | |
| // A small allocation should go to atlas 0 as it can | |
| let allocation4 = manager.try_allocate(80, 80).unwrap(); | |
| assert_eq!(allocation4.atlas_id.as_u32(), 0); | |
| // Now atlas 1 has less remaining space but it can't fit the allocation | |
| // It should go to atlas 2 (best fit - least remaining space) | |
| let allocation5 = manager.try_allocate(80, 80).unwrap(); | |
| assert_eq!(allocation5.atlas_id.as_u32(), 2); | |
| } | |
| #[test] | |
| fn test_least_used_allocation_strategy() { | |
| let mut manager = MultiAtlasManager::new(AtlasConfig { | |
| initial_atlas_count: 3, | |
| max_atlases: 3, | |
| atlas_size: (256, 256), | |
| allocation_strategy: AllocationStrategy::LeastUsed, | |
| auto_grow: false, | |
| }); | |
| // First allocation goes to atlas 0 (all atlases have 0% usage, picks first) | |
| let allocation0 = manager.try_allocate(100, 100).unwrap(); | |
| assert_eq!(allocation0.atlas_id.as_u32(), 0); | |
| // Second allocation should go to atlas 1 (least used among remaining) | |
| let allocation1 = manager.try_allocate(50, 50).unwrap(); | |
| assert_eq!(allocation1.atlas_id.as_u32(), 1); | |
| // Third allocation should go to atlas 2 (least used) | |
| let allocation2 = manager.try_allocate(30, 30).unwrap(); | |
| assert_eq!(allocation2.atlas_id.as_u32(), 2); | |
| // Fourth allocation should go to atlas 2 again (still least used) | |
| let allocation3 = manager.try_allocate(30, 30).unwrap(); | |
| assert_eq!(allocation3.atlas_id.as_u32(), 2); | |
| } | |
| #[test] | |
| fn test_round_robin_allocation_strategy() { | |
| let mut manager = MultiAtlasManager::new(AtlasConfig { | |
| initial_atlas_count: 3, | |
| max_atlases: 3, | |
| atlas_size: (256, 256), | |
| allocation_strategy: AllocationStrategy::RoundRobin, | |
| auto_grow: false, | |
| }); | |
| // Allocations should cycle through atlases in order | |
| let allocation0 = manager.try_allocate(50, 50).unwrap(); | |
| assert_eq!(allocation0.atlas_id.as_u32(), 0); | |
| let allocation1 = manager.try_allocate(50, 50).unwrap(); | |
| assert_eq!(allocation1.atlas_id.as_u32(), 1); | |
| let allocation2 = manager.try_allocate(50, 50).unwrap(); | |
| assert_eq!(allocation2.atlas_id.as_u32(), 2); | |
| // Should wrap back to atlas 0 | |
| let allocation3 = manager.try_allocate(50, 50).unwrap(); | |
| assert_eq!(allocation3.atlas_id.as_u32(), 0); | |
| // Continue the cycle | |
| let allocation4 = manager.try_allocate(50, 50).unwrap(); | |
| assert_eq!(allocation4.atlas_id.as_u32(), 1); | |
| } | |
| #[test] | |
| fn test_auto_grow() { | |
| let mut manager = MultiAtlasManager::new(AtlasConfig { | |
| initial_atlas_count: 1, | |
| max_atlases: 3, | |
| atlas_size: (256, 256), | |
| allocation_strategy: AllocationStrategy::FirstFit, | |
| auto_grow: true, | |
| }); | |
| let allocation0 = manager.try_allocate(256, 256).unwrap(); | |
| assert_eq!(allocation0.atlas_id.as_u32(), 0); | |
| let allocation1 = manager.try_allocate(256, 256).unwrap(); | |
| assert_eq!(allocation1.atlas_id.as_u32(), 1); | |
| let allocation2 = manager.try_allocate(256, 256).unwrap(); | |
| assert_eq!(allocation2.atlas_id.as_u32(), 2); | |
| } | |
| } |