| // Copyright 2025 the Vello Authors |
| // SPDX-License-Identifier: Apache-2.0 OR MIT |
| |
| //! Image resource caching with multi-atlas allocation. |
| //! |
| //! This module provides an [`ImageCache`] that manages image resources across multiple texture |
| //! atlases, supporting allocation, deallocation, and slot reuse. |
| |
| use crate::multi_atlas::{AtlasConfig, AtlasError, AtlasId, MultiAtlasManager}; |
| use crate::paint::ImageId; |
| use alloc::vec::Vec; |
| use guillotiere::AllocId; |
| |
| /// Represents an image resource for rendering. |
| #[derive(Debug)] |
| pub struct ImageResource { |
| /// The width of the image. |
| pub width: u16, |
| /// The height of the image. |
| pub height: u16, |
| /// The Id of the atlas containing this image. |
| pub atlas_id: AtlasId, |
| /// The offset of the image within its atlas (does not include padding, i.e. it points to the |
| /// position of the first actual top-left pixel). |
| pub offset: [u16; 2], |
| /// The number of transparent padding pixels around the image in the atlas. |
| pub padding: u16, |
| /// The atlas allocation ID for deallocation. |
| atlas_alloc_id: AllocId, |
| } |
| |
| /// Manages image resources for the renderer. |
| pub struct ImageCache { |
| /// Multi-atlas manager for handling multiple texture atlases. |
| atlas_manager: MultiAtlasManager, |
| /// Vector of optional image resources (None = free slot). |
| slots: Vec<Option<ImageResource>>, |
| /// Stack of free indices. |
| free_idxs: Vec<usize>, |
| } |
| |
| impl core::fmt::Debug for ImageCache { |
| fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { |
| let atlas_stats = self.atlas_manager.atlas_stats(); |
| |
| f.debug_struct("ImageCache") |
| .field("slots", &self.slots) |
| .field("free_idxs", &self.free_idxs) |
| .field("atlas_count", &self.atlas_manager.atlas_count()) |
| .field("atlas_stats", &atlas_stats) |
| .finish() |
| } |
| } |
| |
| impl ImageCache { |
| /// Create a new image cache with custom atlas configuration. |
| pub fn new_with_config(config: AtlasConfig) -> Self { |
| Self { |
| atlas_manager: MultiAtlasManager::new(config), |
| slots: Vec::new(), |
| free_idxs: Vec::new(), |
| } |
| } |
| |
| /// Get an image resource by its Id. |
| pub fn get(&self, id: ImageId) -> Option<&ImageResource> { |
| self.slots.get(id.as_u32() as usize)?.as_ref() |
| } |
| |
| /// Allocate an image in the cache, with optional transparent padding. |
| #[expect( |
| clippy::cast_possible_truncation, |
| reason = "u16 is enough for the offset and width/height" |
| )] |
| pub fn allocate( |
| &mut self, |
| width: u32, |
| height: u32, |
| padding: u16, |
| ) -> Result<ImageId, AtlasError> { |
| let padded_width = width + u32::from(padding) * 2; |
| let padded_height = height + u32::from(padding) * 2; |
| let atlas_alloc = self |
| .atlas_manager |
| .try_allocate(padded_width, padded_height)?; |
| |
| let slot_idx = self.free_idxs.pop().unwrap_or_else(|| { |
| // No free slots, append to vector |
| let index = self.slots.len(); |
| // Placeholder, will be replaced |
| self.slots.push(None); |
| index |
| }); |
| |
| let image_id = ImageId::new(slot_idx as u32); |
| let image_resource = ImageResource { |
| width: width as u16, |
| height: height as u16, |
| atlas_id: atlas_alloc.atlas_id, |
| offset: [ |
| atlas_alloc.allocation.rectangle.min.x as u16 + padding, |
| atlas_alloc.allocation.rectangle.min.y as u16 + padding, |
| ], |
| padding, |
| atlas_alloc_id: atlas_alloc.allocation.id, |
| }; |
| self.slots[slot_idx] = Some(image_resource); |
| |
| Ok(image_id) |
| } |
| |
| /// Deallocate an image from the cache, returning the image resource if it existed. |
| pub fn deallocate(&mut self, id: ImageId) -> Option<ImageResource> { |
| let index = id.as_u32() as usize; |
| if let Some(image_resource) = self.slots.get_mut(index).and_then(Option::take) { |
| // Deallocate from the appropriate atlas |
| let padded_width = image_resource.width as u32 + u32::from(image_resource.padding) * 2; |
| let padded_height = |
| image_resource.height as u32 + u32::from(image_resource.padding) * 2; |
| self.atlas_manager |
| .deallocate( |
| image_resource.atlas_id, |
| image_resource.atlas_alloc_id, |
| padded_width, |
| padded_height, |
| ) |
| .unwrap(); |
| self.free_idxs.push(index); |
| Some(image_resource) |
| } else { |
| None |
| } |
| } |
| |
| /// Get access to the atlas manager. |
| pub fn atlas_manager(&self) -> &MultiAtlasManager { |
| &self.atlas_manager |
| } |
| |
| /// Get the number of atlases. |
| pub fn atlas_count(&self) -> usize { |
| self.atlas_manager.atlas_count() |
| } |
| } |
| |
| #[cfg(test)] |
| mod tests { |
| use super::*; |
| |
| const ATLAS_SIZE: u32 = 1024; |
| |
| #[test] |
| fn test_insert_single_image() { |
| let mut cache = ImageCache::new_with_config(AtlasConfig { |
| atlas_size: (ATLAS_SIZE, ATLAS_SIZE), |
| ..Default::default() |
| }); |
| |
| let id = cache.allocate(100, 100, 0).unwrap(); |
| |
| assert_eq!(id.as_u32(), 0); |
| let resource = cache.get(id).unwrap(); |
| assert_eq!(resource.width, 100); |
| assert_eq!(resource.height, 100); |
| // First image should be at origin |
| assert_eq!(resource.offset, [0, 0]); |
| } |
| |
| #[test] |
| fn test_insert_single_image_with_padding() { |
| let mut cache = ImageCache::new_with_config(AtlasConfig { |
| atlas_size: (ATLAS_SIZE, ATLAS_SIZE), |
| ..Default::default() |
| }); |
| |
| let id = cache.allocate(100, 100, 4).unwrap(); |
| |
| assert_eq!(id.as_u32(), 0); |
| let resource = cache.get(id).unwrap(); |
| assert_eq!(resource.width, 100); |
| assert_eq!(resource.height, 100); |
| assert_eq!(resource.padding, 4); |
| // Offset should be shifted inward by padding. |
| assert_eq!(resource.offset, [4, 4]); |
| } |
| |
| #[test] |
| fn test_insert_multiple_images() { |
| let mut cache = ImageCache::new_with_config(AtlasConfig { |
| atlas_size: (ATLAS_SIZE, ATLAS_SIZE), |
| ..Default::default() |
| }); |
| |
| let id1 = cache.allocate(50, 50, 0).unwrap(); |
| let id2 = cache.allocate(75, 75, 0).unwrap(); |
| |
| assert_eq!(id1.as_u32(), 0); |
| assert_eq!(id2.as_u32(), 1); |
| |
| let resource1 = cache.get(id1).unwrap(); |
| let resource2 = cache.get(id2).unwrap(); |
| |
| assert_eq!(resource1.width, 50); |
| assert_eq!(resource2.width, 75); |
| |
| // Second image should be placed adjacent to first |
| assert_ne!(resource1.offset, resource2.offset); |
| } |
| |
| #[test] |
| fn test_get_nonexistent_image() { |
| let cache: ImageCache = ImageCache::new_with_config(AtlasConfig { |
| atlas_size: (ATLAS_SIZE, ATLAS_SIZE), |
| ..Default::default() |
| }); |
| |
| assert!(cache.get(ImageId::new(0)).is_none()); |
| assert!(cache.get(ImageId::new(999)).is_none()); |
| } |
| |
| #[test] |
| fn test_remove_image() { |
| let mut cache = ImageCache::new_with_config(AtlasConfig { |
| atlas_size: (ATLAS_SIZE, ATLAS_SIZE), |
| ..Default::default() |
| }); |
| |
| let id = cache.allocate(100, 100, 0).unwrap(); |
| assert!(cache.get(id).is_some()); |
| |
| cache.deallocate(id); |
| assert!(cache.get(id).is_none()); |
| } |
| |
| #[test] |
| fn test_remove_nonexistent_image() { |
| let mut cache: ImageCache = ImageCache::new_with_config(AtlasConfig { |
| atlas_size: (ATLAS_SIZE, ATLAS_SIZE), |
| ..Default::default() |
| }); |
| |
| // Should not panic when unregistering non-existent image |
| cache.deallocate(ImageId::new(0)); |
| cache.deallocate(ImageId::new(999)); |
| } |
| |
| #[test] |
| fn test_slot_reuse_after_remove() { |
| let mut cache = ImageCache::new_with_config(AtlasConfig { |
| atlas_size: (ATLAS_SIZE, ATLAS_SIZE), |
| ..Default::default() |
| }); |
| |
| // Register three images |
| let id1 = cache.allocate(50, 50, 0).unwrap(); |
| let id2 = cache.allocate(60, 60, 0).unwrap(); |
| let id3 = cache.allocate(70, 70, 0).unwrap(); |
| |
| assert_eq!(id1.as_u32(), 0); |
| assert_eq!(id2.as_u32(), 1); |
| assert_eq!(id3.as_u32(), 2); |
| |
| // Unregister the middle one |
| cache.deallocate(id2); |
| assert!(cache.get(id2).is_none()); |
| |
| // Register a new image - should reuse slot 1 |
| let id4 = cache.allocate(80, 80, 0).unwrap(); |
| // Reused slot 1 |
| assert_eq!(id4.as_u32(), 1); |
| |
| // Verify other images are still there |
| assert!(cache.get(id1).is_some()); |
| assert!(cache.get(id3).is_some()); |
| assert!(cache.get(id4).is_some()); |
| assert_eq!(cache.get(id4).unwrap().width, 80); |
| } |
| |
| #[test] |
| fn test_multiple_remove_and_reuse() { |
| let mut cache = ImageCache::new_with_config(AtlasConfig { |
| atlas_size: (ATLAS_SIZE, ATLAS_SIZE), |
| ..Default::default() |
| }); |
| |
| // Register several images |
| let ids: Vec<_> = (0..5) |
| .map(|i| cache.allocate(100 + i * 10, 100 + i * 10, 0).unwrap()) |
| .collect(); |
| |
| // Unregister some in the middle |
| cache.deallocate(ids[1]); |
| cache.deallocate(ids[3]); |
| |
| // Register new images - should reuse the freed slots |
| let new_id1 = cache.allocate(200, 200, 0).unwrap(); |
| let new_id2 = cache.allocate(300, 300, 0).unwrap(); |
| |
| // Should have reused slots 3 and 1 (in reverse order due to stack behavior) |
| assert_eq!(new_id1.as_u32(), 3); |
| assert_eq!(new_id2.as_u32(), 1); |
| assert_ne!(new_id1.as_u32(), new_id2.as_u32()); |
| } |
| } |