Skip to main content

vello_common/
image_cache.rs

1// Copyright 2025 the Vello Authors
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Image resource caching with multi-atlas allocation.
5//!
6//! This module provides an [`ImageCache`] that manages image resources across multiple texture
7//! atlases, supporting allocation, deallocation, and slot reuse.
8
9use crate::multi_atlas::{
10    AllocId, AllocationStrategy, AtlasConfig, AtlasError, AtlasId, MultiAtlasManager,
11};
12use crate::paint::ImageId;
13use alloc::vec::Vec;
14
15/// Represents an image resource for rendering.
16#[derive(Debug)]
17pub struct ImageResource {
18    /// The width of the image.
19    pub width: u16,
20    /// The height of the image.
21    pub height: u16,
22    /// The Id of the atlas containing this image.
23    pub atlas_id: AtlasId,
24    /// The offset of the image within its atlas (does not include padding, i.e. it points to the
25    /// position of the first actual top-left pixel).
26    pub offset: [u16; 2],
27    /// The number of transparent padding pixels around the image in the atlas.
28    pub padding: u16,
29    /// The atlas allocation ID for deallocation.
30    atlas_alloc_id: AllocId,
31}
32
33impl ImageResource {
34    /// Returns the offset as `[u32; 2]`.
35    pub fn offsets(&self) -> [u32; 2] {
36        [self.offset[0] as u32, self.offset[1] as u32]
37    }
38
39    /// Returns the size as `[u32; 2]`.
40    pub fn size(&self) -> [u32; 2] {
41        [self.width as u32, self.height as u32]
42    }
43}
44
45/// Manages image resources for the renderer.
46pub struct ImageCache {
47    /// Multi-atlas manager for handling multiple texture atlases.
48    atlas_manager: MultiAtlasManager,
49    /// Vector of optional image resources (None = free slot).
50    slots: Vec<Option<ImageResource>>,
51    /// Stack of free indices.
52    free_idxs: Vec<usize>,
53}
54
55impl core::fmt::Debug for ImageCache {
56    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
57        let atlas_stats = self.atlas_manager.atlas_stats();
58
59        f.debug_struct("ImageCache")
60            .field("slots", &self.slots)
61            .field("free_idxs", &self.free_idxs)
62            .field("atlas_count", &self.atlas_manager.atlas_count())
63            .field("atlas_stats", &atlas_stats)
64            .finish()
65    }
66}
67
68impl ImageCache {
69    /// Create a new image cache with custom atlas configuration.
70    pub fn new_with_config(config: AtlasConfig) -> Self {
71        Self {
72            atlas_manager: MultiAtlasManager::new(config),
73            slots: Vec::new(),
74            free_idxs: Vec::new(),
75        }
76    }
77
78    /// Create a new dummy image atlas that is supposed to act as a stub.
79    pub fn new_dummy() -> Self {
80        Self::new_with_config(AtlasConfig {
81            initial_atlas_count: 1,
82            max_atlases: 1,
83            atlas_size: (1, 1),
84            auto_grow: false,
85            allocation_strategy: AllocationStrategy::FirstFit,
86        })
87    }
88
89    /// Get an image resource by its Id.
90    pub fn get(&self, id: ImageId) -> Option<&ImageResource> {
91        self.slots.get(id.as_u32() as usize)?.as_ref()
92    }
93
94    /// Allocate an image in the cache, with optional transparent padding.
95    pub fn allocate(
96        &mut self,
97        width: u32,
98        height: u32,
99        padding: u16,
100    ) -> Result<ImageId, AtlasError> {
101        self.allocate_excluding(width, height, padding, None)
102    }
103
104    /// Allocate an image in the cache, with optional transparency padding
105    /// and optionally excluding a specific atlas.
106    #[expect(
107        clippy::cast_possible_truncation,
108        reason = "u16 is enough for the offset and width/height"
109    )]
110    pub fn allocate_excluding(
111        &mut self,
112        width: u32,
113        height: u32,
114        padding: u16,
115        exclude_atlas_id: Option<AtlasId>,
116    ) -> Result<ImageId, AtlasError> {
117        let padded_width = width + u32::from(padding) * 2;
118        let padded_height = height + u32::from(padding) * 2;
119        let atlas_alloc = self.atlas_manager.try_allocate_excluding(
120            padded_width,
121            padded_height,
122            exclude_atlas_id,
123        )?;
124
125        let slot_idx = self.free_idxs.pop().unwrap_or_else(|| {
126            // No free slots, append to vector
127            let index = self.slots.len();
128            // Placeholder, will be replaced
129            self.slots.push(None);
130            index
131        });
132
133        let image_id = ImageId::new(slot_idx as u32);
134        let image_resource = ImageResource {
135            width: width as u16,
136            height: height as u16,
137            atlas_id: atlas_alloc.atlas_id,
138            offset: [
139                atlas_alloc.allocation.x as u16 + padding,
140                atlas_alloc.allocation.y as u16 + padding,
141            ],
142            padding,
143            atlas_alloc_id: atlas_alloc.allocation.id,
144        };
145        self.slots[slot_idx] = Some(image_resource);
146
147        Ok(image_id)
148    }
149
150    /// Deallocate an image from the cache, returning the image resource if it existed.
151    pub fn deallocate(&mut self, id: ImageId) -> Option<ImageResource> {
152        let index = id.as_u32() as usize;
153        if let Some(image_resource) = self.slots.get_mut(index).and_then(Option::take) {
154            // Deallocate from the appropriate atlas
155            let padded_width = image_resource.width as u32 + u32::from(image_resource.padding) * 2;
156            let padded_height =
157                image_resource.height as u32 + u32::from(image_resource.padding) * 2;
158            self.atlas_manager
159                .deallocate(
160                    image_resource.atlas_id,
161                    image_resource.atlas_alloc_id,
162                    padded_width,
163                    padded_height,
164                )
165                .unwrap();
166            self.free_idxs.push(index);
167            Some(image_resource)
168        } else {
169            None
170        }
171    }
172
173    /// Get access to the atlas manager.
174    pub fn atlas_manager(&self) -> &MultiAtlasManager {
175        &self.atlas_manager
176    }
177
178    /// Get the number of atlases.
179    pub fn atlas_count(&self) -> usize {
180        self.atlas_manager.atlas_count()
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    const ATLAS_SIZE: u32 = 1024;
189
190    #[test]
191    fn test_insert_single_image() {
192        let mut cache = ImageCache::new_with_config(AtlasConfig {
193            atlas_size: (ATLAS_SIZE, ATLAS_SIZE),
194            ..Default::default()
195        });
196
197        let id = cache.allocate(100, 100, 0).unwrap();
198
199        assert_eq!(id.as_u32(), 0);
200        let resource = cache.get(id).unwrap();
201        assert_eq!(resource.width, 100);
202        assert_eq!(resource.height, 100);
203        // First image should be at origin
204        assert_eq!(resource.offset, [0, 0]);
205    }
206
207    #[test]
208    fn test_insert_single_image_with_padding() {
209        let mut cache = ImageCache::new_with_config(AtlasConfig {
210            atlas_size: (ATLAS_SIZE, ATLAS_SIZE),
211            ..Default::default()
212        });
213
214        let id = cache.allocate(100, 100, 4).unwrap();
215
216        assert_eq!(id.as_u32(), 0);
217        let resource = cache.get(id).unwrap();
218        assert_eq!(resource.width, 100);
219        assert_eq!(resource.height, 100);
220        assert_eq!(resource.padding, 4);
221        // Offset should be shifted inward by padding.
222        assert_eq!(resource.offset, [4, 4]);
223    }
224
225    #[test]
226    fn test_insert_multiple_images() {
227        let mut cache = ImageCache::new_with_config(AtlasConfig {
228            atlas_size: (ATLAS_SIZE, ATLAS_SIZE),
229            ..Default::default()
230        });
231
232        let id1 = cache.allocate(50, 50, 0).unwrap();
233        let id2 = cache.allocate(75, 75, 0).unwrap();
234
235        assert_eq!(id1.as_u32(), 0);
236        assert_eq!(id2.as_u32(), 1);
237
238        let resource1 = cache.get(id1).unwrap();
239        let resource2 = cache.get(id2).unwrap();
240
241        assert_eq!(resource1.width, 50);
242        assert_eq!(resource2.width, 75);
243
244        // Second image should be placed adjacent to first
245        assert_ne!(resource1.offset, resource2.offset);
246    }
247
248    #[test]
249    fn test_get_nonexistent_image() {
250        let cache: ImageCache = ImageCache::new_with_config(AtlasConfig {
251            atlas_size: (ATLAS_SIZE, ATLAS_SIZE),
252            ..Default::default()
253        });
254
255        assert!(cache.get(ImageId::new(0)).is_none());
256        assert!(cache.get(ImageId::new(999)).is_none());
257    }
258
259    #[test]
260    fn test_remove_image() {
261        let mut cache = ImageCache::new_with_config(AtlasConfig {
262            atlas_size: (ATLAS_SIZE, ATLAS_SIZE),
263            ..Default::default()
264        });
265
266        let id = cache.allocate(100, 100, 0).unwrap();
267        assert!(cache.get(id).is_some());
268
269        cache.deallocate(id);
270        assert!(cache.get(id).is_none());
271    }
272
273    #[test]
274    fn test_remove_nonexistent_image() {
275        let mut cache: ImageCache = ImageCache::new_with_config(AtlasConfig {
276            atlas_size: (ATLAS_SIZE, ATLAS_SIZE),
277            ..Default::default()
278        });
279
280        // Should not panic when unregistering non-existent image
281        cache.deallocate(ImageId::new(0));
282        cache.deallocate(ImageId::new(999));
283    }
284
285    #[test]
286    fn test_slot_reuse_after_remove() {
287        let mut cache = ImageCache::new_with_config(AtlasConfig {
288            atlas_size: (ATLAS_SIZE, ATLAS_SIZE),
289            ..Default::default()
290        });
291
292        // Register three images
293        let id1 = cache.allocate(50, 50, 0).unwrap();
294        let id2 = cache.allocate(60, 60, 0).unwrap();
295        let id3 = cache.allocate(70, 70, 0).unwrap();
296
297        assert_eq!(id1.as_u32(), 0);
298        assert_eq!(id2.as_u32(), 1);
299        assert_eq!(id3.as_u32(), 2);
300
301        // Unregister the middle one
302        cache.deallocate(id2);
303        assert!(cache.get(id2).is_none());
304
305        // Register a new image - should reuse slot 1
306        let id4 = cache.allocate(80, 80, 0).unwrap();
307        // Reused slot 1
308        assert_eq!(id4.as_u32(), 1);
309
310        // Verify other images are still there
311        assert!(cache.get(id1).is_some());
312        assert!(cache.get(id3).is_some());
313        assert!(cache.get(id4).is_some());
314        assert_eq!(cache.get(id4).unwrap().width, 80);
315    }
316
317    #[test]
318    fn test_multiple_remove_and_reuse() {
319        let mut cache = ImageCache::new_with_config(AtlasConfig {
320            atlas_size: (ATLAS_SIZE, ATLAS_SIZE),
321            ..Default::default()
322        });
323
324        // Register several images
325        let ids: Vec<_> = (0..5)
326            .map(|i| cache.allocate(100 + i * 10, 100 + i * 10, 0).unwrap())
327            .collect();
328
329        // Unregister some in the middle
330        cache.deallocate(ids[1]);
331        cache.deallocate(ids[3]);
332
333        // Register new images - should reuse the freed slots
334        let new_id1 = cache.allocate(200, 200, 0).unwrap();
335        let new_id2 = cache.allocate(300, 300, 0).unwrap();
336
337        // Should have reused slots 3 and 1 (in reverse order due to stack behavior)
338        assert_eq!(new_id1.as_u32(), 3);
339        assert_eq!(new_id2.as_u32(), 1);
340        assert_ne!(new_id1.as_u32(), new_id2.as_u32());
341    }
342}