diff --git a/crates/bevy_image/src/texture_atlas_builder.rs b/crates/bevy_image/src/texture_atlas_builder.rs index a4a6da79024e0..0c63e0ce863bc 100644 --- a/crates/bevy_image/src/texture_atlas_builder.rs +++ b/crates/bevy_image/src/texture_atlas_builder.rs @@ -3,7 +3,7 @@ use bevy_math::{URect, UVec2}; use bevy_platform::collections::HashMap; use rectangle_pack::{ contains_smallest_box, pack_rects, volume_heuristic, GroupedRectsToPlace, PackedLocation, - RectToInsert, TargetBin, + RectToInsert, RectanglePackOk, TargetBin, }; use thiserror::Error; use tracing::{debug, error, warn}; @@ -67,6 +67,28 @@ impl Default for TextureAtlasBuilder<'_> { /// The [`Result`] type used by [`TextureAtlasBuilder`]. pub type TextureAtlasBuilderResult = Result; +/// A texture added to a [`TextureAtlasBuilder`]. +#[derive(Debug, Clone, Copy)] +pub struct UnplacedAtlasTexture<'a> { + /// The optional asset id for the texture. + pub image_id: Option>, + /// The source image to add to the texture atlas. + pub texture: &'a Image, +} + +/// The result of building a texture atlas while allowing unplaced textures. +#[derive(Debug)] +pub struct TextureAtlasPartialBuildResult<'a> { + /// The atlas layout for the successfully placed textures. + pub layout: TextureAtlasLayout, + /// Sources for the successfully placed textures. + pub sources: TextureAtlasSources, + /// The atlas texture containing the successfully placed textures. + pub texture: Image, + /// Textures that could not be placed into the atlas. + pub unplaced_textures: Vec>, +} + impl<'a> TextureAtlasBuilder<'a> { /// Sets the initial size of the atlas in pixels. pub fn initial_size(&mut self, size: UVec2) -> &mut Self { @@ -170,50 +192,11 @@ impl<'a> TextureAtlasBuilder<'a> { Ok(()) } - /// Consumes the builder, and returns the newly created texture atlas and - /// the associated atlas layout. - /// - /// Assigns indices to the textures based on the insertion order. - /// Internally it copies all rectangles from the textures and copies them - /// into a new texture. - /// - /// # Usage - /// - /// ```rust - /// # use bevy_ecs::prelude::*; - /// # use bevy_asset::*; - /// # use bevy_image::prelude::*; - /// - /// fn my_system(mut textures: ResMut>, mut layouts: ResMut>) { - /// // Declare your builder - /// let mut builder = TextureAtlasBuilder::default(); - /// // Customize it - /// // ... - /// // Build your texture and the atlas layout - /// let (atlas_layout, atlas_sources, texture) = builder.build().unwrap(); - /// let texture = textures.add(texture); - /// let layout = layouts.add(atlas_layout); - /// } - /// ``` - /// - /// # Errors - /// - /// If there is not enough space in the atlas texture, an error will - /// be returned. It is then recommended to make a larger sprite sheet. - pub fn build( - &mut self, - ) -> TextureAtlasBuilderResult<(TextureAtlasLayout, TextureAtlasSources, Image)> { - let max_width = self.max_size.x; - let max_height = self.max_size.y; - - let mut current_width = self.initial_size.x; - let mut current_height = self.initial_size.y; - let mut rect_placements = None; - let mut atlas_texture = Image::default(); + fn create_rects_to_place(&self, texture_indices: &[usize]) -> GroupedRectsToPlace { let mut rects_to_place = GroupedRectsToPlace::::new(); - // Adds textures to rectangle group packer - for (index, (_, texture)) in self.textures_to_place.iter().enumerate() { + for &index in texture_indices { + let (_, texture) = self.textures_to_place[index]; rects_to_place.push_rect( index, None, @@ -225,63 +208,85 @@ impl<'a> TextureAtlasBuilder<'a> { ); } - while rect_placements.is_none() { + rects_to_place + } + + fn pack_textures( + &self, + texture_indices: &[usize], + ) -> Option<(RectanglePackOk, UVec2)> { + let max_width = self.max_size.x; + let max_height = self.max_size.y; + let mut current_width = self.initial_size.x; + let mut current_height = self.initial_size.y; + let rects_to_place = self.create_rects_to_place(texture_indices); + + loop { if current_width > max_width || current_height > max_height { - break; + return None; } let last_attempt = current_height == max_height && current_width == max_width; let mut target_bins = alloc::collections::BTreeMap::new(); target_bins.insert(0, TargetBin::new(current_width, current_height, 1)); - rect_placements = match pack_rects( + + match pack_rects( &rects_to_place, &mut target_bins, &volume_heuristic, &contains_smallest_box, ) { Ok(rect_placements) => { - atlas_texture = Image::new( - Extent3d { - width: current_width, - height: current_height, - depth_or_array_layers: 1, - }, - TextureDimension::D2, - vec![ - 0; - self.format.pixel_size()? * (current_width * current_height) as usize - ], - self.format, - RenderAssetUsages::MAIN_WORLD | RenderAssetUsages::RENDER_WORLD, - ); - Some(rect_placements) + return Some((rect_placements, UVec2::new(current_width, current_height))); + } + Err(rectangle_pack::RectanglePackError::NotEnoughBinSpace) if last_attempt => { + return None; } Err(rectangle_pack::RectanglePackError::NotEnoughBinSpace) => { current_height = (current_height * 2).clamp(0, max_height); current_width = (current_width * 2).clamp(0, max_width); - None } - }; - - if last_attempt { - break; } } + } - let rect_placements = rect_placements.ok_or(TextureAtlasBuilderError::NotEnoughSpace)?; + fn create_atlas_texture(&self, size: UVec2) -> TextureAtlasBuilderResult { + Ok(Image::new( + Extent3d { + width: size.x, + height: size.y, + depth_or_array_layers: 1, + }, + TextureDimension::D2, + vec![0; self.format.pixel_size()? * (size.x * size.y) as usize], + self.format, + RenderAssetUsages::MAIN_WORLD | RenderAssetUsages::RENDER_WORLD, + )) + } - let mut texture_rects = Vec::with_capacity(rect_placements.packed_locations().len()); + fn build_texture_atlas_from_placements( + &self, + texture_indices: &[usize], + rect_placements: &RectanglePackOk, + mut atlas_texture: Image, + ) -> TextureAtlasBuilderResult<(TextureAtlasLayout, TextureAtlasSources, Image)> { + let mut texture_rects = Vec::with_capacity(texture_indices.len()); let mut texture_ids = >::default(); + // We iterate through the textures to place to respect the insertion order for the texture indices - for (index, (image_id, texture)) in self.textures_to_place.iter().enumerate() { - let (_, packed_location) = rect_placements.packed_locations().get(&index).unwrap(); + for (atlas_index, &source_index) in texture_indices.iter().enumerate() { + let (image_id, texture) = self.textures_to_place[source_index]; + let (_, packed_location) = rect_placements + .packed_locations() + .get(&source_index) + .unwrap(); let min = UVec2::new(packed_location.x(), packed_location.y()); let max = min + UVec2::new(packed_location.width(), packed_location.height()) - self.padding; if let Some(image_id) = image_id { - texture_ids.insert(*image_id, index); + texture_ids.insert(image_id, atlas_index); } texture_rects.push(URect { min, max }); if texture.texture_descriptor.format != self.format && !self.auto_format_conversion { @@ -303,4 +308,313 @@ impl<'a> TextureAtlasBuilder<'a> { atlas_texture, )) } + + /// Consumes the builder, and returns the newly created texture atlas and + /// the associated atlas layout. + /// + /// Assigns indices to the textures based on the insertion order. + /// Internally it copies all rectangles from the textures and copies them + /// into a new texture. + /// + /// # Usage + /// + /// ```rust + /// # use bevy_ecs::prelude::*; + /// # use bevy_asset::*; + /// # use bevy_image::prelude::*; + /// + /// fn my_system(mut textures: ResMut>, mut layouts: ResMut>) { + /// // Declare your builder + /// let mut builder = TextureAtlasBuilder::default(); + /// // Customize it + /// // ... + /// // Build your texture and the atlas layout + /// let (atlas_layout, atlas_sources, texture) = builder.build().unwrap(); + /// let texture = textures.add(texture); + /// let layout = layouts.add(atlas_layout); + /// } + /// ``` + /// + /// # Errors + /// + /// If there is not enough space in the atlas texture, an error will + /// be returned. It is then recommended to make a larger sprite sheet. + pub fn build( + &mut self, + ) -> TextureAtlasBuilderResult<(TextureAtlasLayout, TextureAtlasSources, Image)> { + let texture_indices = (0..self.textures_to_place.len()).collect::>(); + let (rect_placements, atlas_size) = self + .pack_textures(&texture_indices) + .ok_or(TextureAtlasBuilderError::NotEnoughSpace)?; + let atlas_texture = self.create_atlas_texture(atlas_size)?; + self.build_texture_atlas_from_placements(&texture_indices, &rect_placements, atlas_texture) + } + + /// Consumes the builder and returns a texture atlas along with any textures that could not be + /// placed. + /// + /// If all textures fit, this behaves like `build()`. + /// Otherwise, textures are greedily selected in insertion order. + /// Errors unrelated to available space are still returned as hard errors. + pub fn build_partial( + &mut self, + ) -> TextureAtlasBuilderResult> { + let texture_indices = (0..self.textures_to_place.len()).collect::>(); + + if let Some((rect_placements, atlas_size)) = self.pack_textures(&texture_indices) { + let atlas_texture = self.create_atlas_texture(atlas_size)?; + let (layout, sources, texture) = self.build_texture_atlas_from_placements( + &texture_indices, + &rect_placements, + atlas_texture, + )?; + return Ok(TextureAtlasPartialBuildResult { + layout, + sources, + texture, + unplaced_textures: Vec::new(), + }); + } + + let mut placed_texture_indices = Vec::new(); + let mut unplaced_textures = Vec::new(); + + for index in texture_indices { + let mut candidate_indices = placed_texture_indices.clone(); + candidate_indices.push(index); + + if self.pack_textures(&candidate_indices).is_some() { + placed_texture_indices.push(index); + } else { + let (image_id, texture) = self.textures_to_place[index]; + unplaced_textures.push(UnplacedAtlasTexture { image_id, texture }); + } + } + + if placed_texture_indices.is_empty() { + return Err(TextureAtlasBuilderError::NotEnoughSpace); + } + + let (rect_placements, atlas_size) = self + .pack_textures(&placed_texture_indices) + .ok_or(TextureAtlasBuilderError::NotEnoughSpace)?; + let atlas_texture = self.create_atlas_texture(atlas_size)?; + let (layout, sources, texture) = self.build_texture_atlas_from_placements( + &placed_texture_indices, + &rect_placements, + atlas_texture, + )?; + + Ok(TextureAtlasPartialBuildResult { + layout, + sources, + texture, + unplaced_textures, + }) + } +} + +#[cfg(test)] +mod tests { + use core::marker::PhantomData; + + use bevy_asset::{AssetId, AssetIndex, RenderAssetUsages}; + use bevy_math::{URect, UVec2}; + use wgpu_types::{Extent3d, TextureDimension, TextureFormat}; + + use crate::{Image, TextureAtlasBuilder, TextureAtlasBuilderError}; + + fn make_filled_image(size: UVec2, pixel_rgba_bytes: [u8; 4]) -> Image { + Image::new_fill( + Extent3d { + width: size.x, + height: size.y, + depth_or_array_layers: 1, + }, + TextureDimension::D2, + &pixel_rgba_bytes, + TextureFormat::Rgba8Unorm, + RenderAssetUsages::all(), + ) + } + + fn make_image_id(index: u64) -> AssetId { + AssetId::Index { + index: AssetIndex::from_bits(index), + marker: PhantomData, + } + } + + fn rect_contains_value(image: &Image, rect: URect, pixel_rgba_bytes: [u8; 4]) -> bool { + let image_data = image.data.as_ref().unwrap(); + for y in rect.min.y..rect.max.y { + for x in rect.min.x..rect.max.x { + let byte_start = ((x + y * image.width()) * 4) as usize; + if image_data[byte_start..(byte_start + 4)] != pixel_rgba_bytes { + return false; + } + } + } + + true + } + + #[test] + fn build_partial_returns_unplaced_textures() { + let mut builder = TextureAtlasBuilder::default(); + builder + .initial_size(UVec2::new(16, 8)) + .max_size(UVec2::new(16, 8)) + .format(TextureFormat::Rgba8Unorm); + + let colors = [[255, 0, 0, 255], [0, 255, 0, 255], [0, 0, 255, 255]]; + let textures = [ + make_filled_image(UVec2::new(8, 8), colors[0]), + make_filled_image(UVec2::new(8, 8), colors[1]), + make_filled_image(UVec2::new(8, 8), colors[2]), + ]; + let ids = [make_image_id(1), make_image_id(2), make_image_id(3)]; + + for (id, texture) in ids.into_iter().zip(textures.iter()) { + builder.add_texture(Some(id), texture); + } + + let result = builder.build_partial().unwrap(); + + assert_eq!(result.layout.len(), 2); + assert_eq!(result.unplaced_textures.len(), 1); + assert_eq!(result.unplaced_textures[0].image_id, Some(make_image_id(3))); + assert_eq!(result.sources.texture_index(make_image_id(1)), Some(0)); + assert_eq!(result.sources.texture_index(make_image_id(2)), Some(1)); + assert_eq!(result.sources.texture_index(make_image_id(3)), None); + + let first_rect = result + .sources + .texture_rect(&result.layout, make_image_id(1)) + .unwrap(); + let second_rect = result + .sources + .texture_rect(&result.layout, make_image_id(2)) + .unwrap(); + assert!(rect_contains_value(&result.texture, first_rect, colors[0])); + assert!(rect_contains_value(&result.texture, second_rect, colors[1])); + } + + #[test] + fn build_partial_errors_when_nothing_fits() { + let texture = make_filled_image(UVec2::new(16, 16), [255, 0, 0, 255]); + + let mut builder = TextureAtlasBuilder::default(); + builder + .initial_size(UVec2::new(8, 8)) + .max_size(UVec2::new(8, 8)) + .format(TextureFormat::Rgba8Unorm) + .add_texture(None, &texture); + + assert!(matches!( + builder.build_partial(), + Err(TextureAtlasBuilderError::NotEnoughSpace) + )); + } + + #[test] + fn build_partial_has_no_unplaced_textures_when_everything_fits() { + let mut builder = TextureAtlasBuilder::default(); + builder + .initial_size(UVec2::new(16, 8)) + .max_size(UVec2::new(16, 8)) + .format(TextureFormat::Rgba8Unorm); + + let textures = [ + make_filled_image(UVec2::new(8, 8), [255, 0, 0, 255]), + make_filled_image(UVec2::new(8, 8), [0, 255, 0, 255]), + ]; + + for texture in &textures { + builder.add_texture(None, texture); + } + + let result = builder.build_partial().unwrap(); + + assert!(result.unplaced_textures.is_empty()); + assert_eq!(result.layout.len(), 2); + } + + #[test] + fn build_still_returns_not_enough_space_when_all_textures_do_not_fit() { + let mut builder = TextureAtlasBuilder::default(); + builder + .initial_size(UVec2::new(16, 8)) + .max_size(UVec2::new(16, 8)) + .format(TextureFormat::Rgba8Unorm); + + let textures = [ + make_filled_image(UVec2::new(8, 8), [255, 0, 0, 255]), + make_filled_image(UVec2::new(8, 8), [0, 255, 0, 255]), + make_filled_image(UVec2::new(8, 8), [0, 0, 255, 255]), + ]; + + for texture in &textures { + builder.add_texture(None, texture); + } + + assert!(matches!( + builder.build(), + Err(TextureAtlasBuilderError::NotEnoughSpace) + )); + } + + #[test] + fn build_partial_reindexes_texture_ids_to_match_atlas_indices() { + let mut builder = TextureAtlasBuilder::default(); + builder + .initial_size(UVec2::new(16, 8)) + .max_size(UVec2::new(16, 8)) + .format(TextureFormat::Rgba8Unorm); + + let textures = [ + make_filled_image(UVec2::new(8, 8), [255, 0, 0, 255]), + make_filled_image(UVec2::new(8, 8), [0, 255, 0, 255]), + make_filled_image(UVec2::new(8, 8), [0, 0, 255, 255]), + ]; + let ids = [make_image_id(11), make_image_id(12), make_image_id(13)]; + + for (id, texture) in ids.into_iter().zip(textures.iter()) { + builder.add_texture(Some(id), texture); + } + + let result = builder.build_partial().unwrap(); + + assert_eq!(result.sources.texture_index(make_image_id(11)), Some(0)); + assert_eq!(result.sources.texture_index(make_image_id(12)), Some(1)); + assert_eq!(result.sources.texture_index(make_image_id(13)), None); + } + + #[test] + fn build_partial_returns_wrong_format_as_a_hard_error() { + let texture = Image::new_fill( + Extent3d { + width: 8, + height: 8, + depth_or_array_layers: 1, + }, + TextureDimension::D2, + &[255, 0, 0, 255], + TextureFormat::Rgba8UnormSrgb, + RenderAssetUsages::all(), + ); + + let mut builder = TextureAtlasBuilder::default(); + builder + .initial_size(UVec2::new(8, 8)) + .max_size(UVec2::new(8, 8)) + .format(TextureFormat::Rgba8Unorm) + .auto_format_conversion(false) + .add_texture(None, &texture); + + assert!(matches!( + builder.build_partial(), + Err(TextureAtlasBuilderError::WrongFormat) + )); + } } diff --git a/release-content/release-notes/texture_atlas_partial_build.md b/release-content/release-notes/texture_atlas_partial_build.md new file mode 100644 index 0000000000000..a02e8fa961148 --- /dev/null +++ b/release-content/release-notes/texture_atlas_partial_build.md @@ -0,0 +1,15 @@ +--- +title: Partial texture atlas builds +authors: ["@masamori0083"] +pull_requests: [23467] +--- + +`TextureAtlasBuilder` now supports partial builds via `build_partial()`. + +Previously, `build()` would fail with `TextureAtlasBuilderError::NotEnoughSpace` if all textures could not fit into the atlas, producing no result. + +With `build_partial()`, the builder instead returns a texture atlas containing the successfully placed textures, along with a list of textures that could not be placed. + +This allows users to gracefully handle atlas size limits without losing all results, making it easier to work with large or variable sets of textures. + +The existing `build()` method remains unchanged and continues to return an error if not all textures fit.