Skip to main content

tex_packer_core/
runtime_atlas.rs

1use std::collections::HashMap;
2
3use image::{Rgba, RgbaImage};
4
5use crate::config::RuntimeConfig;
6use crate::error::{Result, TexPackerError};
7use crate::model::{Atlas, PageId, Rect};
8use crate::runtime::{AtlasSession, PreparedAppend, RuntimePlacement, RuntimeStats};
9
10/// Region that needs to be updated on a GPU texture.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub struct UpdateRegion {
13    pub page_id: PageId,
14    pub x: u32,
15    pub y: u32,
16    pub width: u32,
17    pub height: u32,
18}
19
20impl UpdateRegion {
21    pub const fn empty() -> Self {
22        Self {
23            page_id: PageId::new(0),
24            x: 0,
25            y: 0,
26            width: 0,
27            height: 0,
28        }
29    }
30
31    pub const fn is_empty(&self) -> bool {
32        self.width == 0 || self.height == 0
33    }
34
35    pub const fn area(&self) -> u64 {
36        self.width as u64 * self.height as u64
37    }
38}
39
40/// Result of uploading an image into a runtime atlas.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct RuntimeImageUpdate {
43    placement: RuntimePlacement,
44    dirty_region: UpdateRegion,
45}
46
47impl RuntimeImageUpdate {
48    pub const fn placement(&self) -> &RuntimePlacement {
49        &self.placement
50    }
51
52    pub const fn dirty_region(&self) -> UpdateRegion {
53        self.dirty_region
54    }
55}
56
57struct RuntimeImagePage {
58    id: PageId,
59    image: RgbaImage,
60}
61
62enum PreparedPixels {
63    Patch {
64        page_index: usize,
65        region: UpdateRegion,
66        pixels: RgbaImage,
67    },
68    New(RuntimeImagePage),
69}
70
71/// Runtime atlas with pixel data management.
72pub struct RuntimeAtlas {
73    session: AtlasSession,
74    pages: Vec<RuntimeImagePage>,
75    page_slots: HashMap<PageId, usize>,
76    background_color: Rgba<u8>,
77    outlines: bool,
78}
79
80impl RuntimeAtlas {
81    pub fn new(cfg: RuntimeConfig) -> Self {
82        Self {
83            session: AtlasSession::new(cfg),
84            pages: Vec::new(),
85            page_slots: HashMap::new(),
86            background_color: Rgba([0, 0, 0, 0]),
87            outlines: false,
88        }
89    }
90
91    pub fn with_background_color(mut self, color: Rgba<u8>) -> Self {
92        self.background_color = color;
93        self
94    }
95
96    pub fn with_outlines(mut self, enabled: bool) -> Self {
97        self.outlines = enabled;
98        self
99    }
100
101    pub fn append_with_image(
102        &mut self,
103        key: String,
104        image: &RgbaImage,
105    ) -> Result<RuntimeImageUpdate> {
106        let (width, height) = image.dimensions();
107        let prepared_append = self.session.prepare_append(key, width, height)?;
108        let (prepared_pixels, dirty_region) = self.prepare_pixels(&prepared_append, image)?;
109        let placement = self.commit_image_append(prepared_append, prepared_pixels);
110
111        Ok(RuntimeImageUpdate {
112            placement,
113            dirty_region,
114        })
115    }
116
117    pub fn append(&mut self, key: String, w: u32, h: u32) -> Result<RuntimePlacement> {
118        self.session.append(key, w, h)
119    }
120
121    pub fn evict_with_clear(
122        &mut self,
123        page_id: PageId,
124        key: &str,
125        clear: bool,
126    ) -> Option<UpdateRegion> {
127        let allocation = clear.then(|| self.session.get_reserved_slot(key)).flatten();
128
129        if !self.session.evict(page_id, key) {
130            return None;
131        }
132
133        if let Some((allocation_page_id, rect)) = allocation {
134            let region = update_region(allocation_page_id, rect);
135            self.clear_region(region);
136            Some(region)
137        } else {
138            Some(UpdateRegion::empty())
139        }
140    }
141
142    pub fn evict_by_key_with_clear(&mut self, key: &str, clear: bool) -> Option<UpdateRegion> {
143        let allocation = clear.then(|| self.session.get_reserved_slot(key)).flatten();
144
145        if !self.session.evict_by_key(key) {
146            return None;
147        }
148
149        if let Some((page_id, rect)) = allocation {
150            let region = update_region(page_id, rect);
151            self.clear_region(region);
152            Some(region)
153        } else {
154            Some(UpdateRegion::empty())
155        }
156    }
157
158    pub fn get_page_image(&self, page_id: PageId) -> Option<&RgbaImage> {
159        let page_slot = *self.page_slots.get(&page_id)?;
160        self.pages.get(page_slot).map(|page| &page.image)
161    }
162
163    pub fn get_page_image_mut(&mut self, page_id: PageId) -> Option<&mut RgbaImage> {
164        let page_slot = *self.page_slots.get(&page_id)?;
165        self.pages.get_mut(page_slot).map(|page| &mut page.image)
166    }
167
168    pub fn num_pages(&self) -> usize {
169        self.pages.len()
170    }
171
172    pub fn get_frame(&self, key: &str) -> Option<RuntimePlacement> {
173        self.session.get_frame(key)
174    }
175
176    pub fn contains(&self, key: &str) -> bool {
177        self.session.contains(key)
178    }
179
180    pub fn keys(&self) -> Vec<&str> {
181        self.session.keys()
182    }
183
184    pub fn texture_count(&self) -> usize {
185        self.session.texture_count()
186    }
187
188    pub fn stats(&self) -> RuntimeStats {
189        self.session.stats()
190    }
191
192    pub fn snapshot_atlas(&self) -> Result<Atlas> {
193        self.session.snapshot_atlas()
194    }
195
196    fn prepare_pixels(
197        &self,
198        prepared_append: &PreparedAppend,
199        image: &RgbaImage,
200    ) -> Result<(PreparedPixels, UpdateRegion)> {
201        let placement = prepared_append.placement();
202        let page_id = placement.page_id();
203        let extrusion = self.session.cfg.page_config().texture_extrusion();
204        let page_size = prepared_append.page_size();
205        let dirty_region = validate_blit(placement, image, page_size, extrusion)?;
206
207        if let Some(&page_index) = self.page_slots.get(&page_id) {
208            let page =
209                self.pages
210                    .get(page_index)
211                    .ok_or_else(|| TexPackerError::InvariantViolation {
212                        context: format!("runtime image page {page_id}"),
213                        reason: format!("page index references missing slot {page_index}"),
214                    })?;
215            if page.image.dimensions() != page_size {
216                return Err(TexPackerError::InvariantViolation {
217                    context: format!("runtime image page {page_id}"),
218                    reason: format!(
219                        "pixel buffer dimensions {:?} do not match geometry page {:?}",
220                        page.image.dimensions(),
221                        page_size
222                    ),
223                });
224            }
225
226            let mut pixels = RgbaImage::from_pixel(
227                dirty_region.width,
228                dirty_region.height,
229                self.background_color,
230            );
231            let content = placement.content();
232            blit_staged(
233                image,
234                &mut pixels,
235                Rect::new(
236                    content.x - dirty_region.x,
237                    content.y - dirty_region.y,
238                    content.w,
239                    content.h,
240                ),
241                placement.rotated(),
242                extrusion,
243                self.outlines,
244            );
245            Ok((
246                PreparedPixels::Patch {
247                    page_index,
248                    region: dirty_region,
249                    pixels,
250                },
251                dirty_region,
252            ))
253        } else {
254            let mut pixels = RgbaImage::from_pixel(page_size.0, page_size.1, self.background_color);
255            blit_staged(
256                image,
257                &mut pixels,
258                placement.content(),
259                placement.rotated(),
260                extrusion,
261                self.outlines,
262            );
263            Ok((
264                PreparedPixels::New(RuntimeImagePage {
265                    id: page_id,
266                    image: pixels,
267                }),
268                dirty_region,
269            ))
270        }
271    }
272
273    fn commit_pixels(&mut self, prepared: PreparedPixels) {
274        match prepared {
275            PreparedPixels::Patch {
276                page_index,
277                region,
278                pixels,
279            } => {
280                let page = &mut self.pages[page_index].image;
281                for (x, y, pixel) in pixels.enumerate_pixels() {
282                    page.put_pixel(region.x + x, region.y + y, *pixel);
283                }
284            }
285            PreparedPixels::New(page) => {
286                let page_id = page.id;
287                let page_slot = self.pages.len();
288                self.pages.push(page);
289                let replaced = self.page_slots.insert(page_id, page_slot);
290                debug_assert!(replaced.is_none(), "new pixel page identity must be unique");
291            }
292        }
293    }
294
295    fn commit_image_append(
296        &mut self,
297        prepared_append: PreparedAppend,
298        prepared_pixels: PreparedPixels,
299    ) -> RuntimePlacement {
300        let placement = self.session.commit_append(prepared_append);
301        self.commit_pixels(prepared_pixels);
302        placement
303    }
304
305    fn clear_region(&mut self, region: UpdateRegion) {
306        let background_color = self.background_color;
307        let Some(page) = self.get_page_image_mut(region.page_id) else {
308            return;
309        };
310
311        for y in region.y..region.y.saturating_add(region.height).min(page.height()) {
312            for x in region.x..region.x.saturating_add(region.width).min(page.width()) {
313                page.put_pixel(x, y, background_color);
314            }
315        }
316    }
317}
318
319fn validate_blit(
320    placement: &RuntimePlacement,
321    image: &RgbaImage,
322    page_size: (u32, u32),
323    extrusion: u32,
324) -> Result<UpdateRegion> {
325    let page_id = placement.page_id();
326    let content = placement.content();
327    let source_size = image.dimensions();
328    let expected_content_size = if placement.rotated() {
329        (source_size.1, source_size.0)
330    } else {
331        source_size
332    };
333    if content.is_empty() || (content.w, content.h) != expected_content_size {
334        return Err(blit_invariant(
335            page_id,
336            format!(
337                "content dimensions {:?} do not match source {:?} with rotated={}",
338                (content.w, content.h),
339                source_size,
340                placement.rotated()
341            ),
342        ));
343    }
344
345    let Some(start_x) = content.x.checked_sub(extrusion) else {
346        return Err(blit_invariant(page_id, "extrusion crosses the left edge"));
347    };
348    let Some(start_y) = content.y.checked_sub(extrusion) else {
349        return Err(blit_invariant(page_id, "extrusion crosses the top edge"));
350    };
351    let end_x = u64::from(content.x) + u64::from(content.w) + u64::from(extrusion);
352    let end_y = u64::from(content.y) + u64::from(content.h) + u64::from(extrusion);
353    if end_x > u64::from(page_size.0) || end_y > u64::from(page_size.1) {
354        return Err(blit_invariant(
355            page_id,
356            format!(
357                "destination including extrusion exceeds page dimensions {:?}",
358                page_size
359            ),
360        ));
361    }
362
363    let region = UpdateRegion {
364        page_id,
365        x: start_x,
366        y: start_y,
367        width: u32::try_from(end_x - u64::from(start_x))
368            .map_err(|_| blit_invariant(page_id, "dirty width exceeds u32"))?,
369        height: u32::try_from(end_y - u64::from(start_y))
370            .map_err(|_| blit_invariant(page_id, "dirty height exceeds u32"))?,
371    };
372    let dirty_rect = Rect::new(region.x, region.y, region.width, region.height);
373    if !placement.allocation().contains(&dirty_rect) {
374        return Err(blit_invariant(
375            page_id,
376            "dirty rectangle must lie inside the reserved allocation",
377        ));
378    }
379    Ok(region)
380}
381
382fn blit_staged(
383    image: &RgbaImage,
384    pixels: &mut RgbaImage,
385    content: Rect,
386    rotated: bool,
387    extrusion: u32,
388    outlines: bool,
389) {
390    let (source_width, source_height) = image.dimensions();
391    crate::compositing::blit_rgba(
392        image,
393        pixels,
394        crate::compositing::BlitRect::new(content.x, content.y, content.w, content.h),
395        crate::compositing::BlitRect::new(0, 0, source_width, source_height),
396        crate::compositing::BlitOptions {
397            rotated,
398            extrude: extrusion,
399            outlines,
400        },
401    );
402}
403
404fn blit_invariant(page_id: PageId, reason: impl Into<String>) -> TexPackerError {
405    TexPackerError::InvariantViolation {
406        context: format!("runtime image page {page_id}"),
407        reason: reason.into(),
408    }
409}
410
411fn update_region(page_id: PageId, rect: Rect) -> UpdateRegion {
412    UpdateRegion {
413        page_id,
414        x: rect.x,
415        y: rect.y,
416        width: rect.w,
417        height: rect.h,
418    }
419}