From 10f9776108d36ecd763f496fcd994f3ab496c951 Mon Sep 17 00:00:00 2001 From: Mark Date: Thu, 4 Jan 2024 17:15:32 -0800 Subject: [PATCH] Improved image packer --- crates/packer/src/atlasset.rs | 162 +++++++++++++++++++++------------- crates/packer/src/lib.rs | 23 ++--- crates/packer/src/main.rs | 38 +++++--- 3 files changed, 131 insertions(+), 92 deletions(-) diff --git a/crates/packer/src/atlasset.rs b/crates/packer/src/atlasset.rs index f641940..ac404f6 100644 --- a/crates/packer/src/atlasset.rs +++ b/crates/packer/src/atlasset.rs @@ -1,5 +1,5 @@ -use anyhow::{bail, Result}; -use galactica_packer::{SpriteAtlasImage, SpriteAtlasIndex}; +use anyhow::{bail, Context, Result}; +use galactica_packer::{SpriteAtlas, SpriteAtlasImage}; use image::{imageops, ImageBuffer, Rgba, RgbaImage}; use std::{ fs::File, @@ -11,6 +11,7 @@ use std::{ // TODO: rework texturearray // TODO: reasonable sprite sizes // TODO: consistent naming +// TODO: parallelize // spriteatlas: the big images // texture: the same, what we load to wgpu // image: a single file @@ -28,13 +29,16 @@ pub struct AtlasSet { texture_limit: usize, /// Keeps track of image files - index: SpriteAtlasIndex, + index: SpriteAtlas, /// Array of textures, grows as needed texture_list: Vec, Vec>>, /// The size of the smallest image that didn't fit in each texture - image_max_sizes: Vec, + image_max_sizes: Vec<[u32; 2]>, + + /// (y-value, image size) + image_y_start: Vec<(u32, [u32; 2])>, /// A list of used regions in each texture /// Format: ([xpos, ypos], [width, height]) @@ -43,105 +47,137 @@ pub struct AtlasSet { /// Used to calculate packing efficiency used_area: f64, + + /// The root directory that contains all image files. + /// Files outside this directory will not be packed. + asset_root: PathBuf, } impl AtlasSet { - pub fn new(texture_width: u32, texture_height: u32, texture_limit: usize) -> Self { + pub fn new( + texture_width: u32, + texture_height: u32, + texture_limit: usize, + asset_root: &Path, + ) -> Self { Self { + asset_root: asset_root.to_path_buf(), texture_width, texture_height, texture_limit, texture_list: Vec::new(), image_max_sizes: Vec::new(), used_regions: Vec::new(), - index: SpriteAtlasIndex::new(), + index: SpriteAtlas::new(), used_area: 0f64, + image_y_start: Vec::new(), } } - /// Returns true if new and fixed overlap, - /// or if new exits the atlas. - /// Parameters: ([xpos, ypos], [width, height]) - pub fn boxes_overlap(&self, fixed: ([u32; 2], [u32; 2]), new: ([u32; 2], [u32; 2])) -> bool { - if new.0[0] + new.1[0] >= self.texture_width || new.0[1] + new.1[1] >= self.texture_height { - return true; - } - - return fixed.0[0] <= new.0[0] + new.1[0] - && fixed.0[0] + fixed.1[0] >= new.0[0] - && fixed.0[1] <= new.0[1] + new.1[1] - && fixed.0[1] + fixed.1[1] >= new.0[1]; - } - /// Add a sprite to this atlas set pub fn write_image(&mut self, path: &Path, dim: [u32; 2]) -> Result { let mut f = File::open(&path)?; let mut bytes = Vec::new(); f.read_to_end(&mut bytes)?; let img = image::load_from_memory(&bytes)?; - let mut pixel_idx = 0; - let mut atlas_idx = 0; - // Find first available region, starting at top-left of atlas 0. - // Includes a few speed optimizations - loop { + let mut x = 0; + let mut y = 0; + let mut final_atlas_idx = None; + + // Loop over atlas textures + 'outer: for atlas_idx in 0..self.texture_limit { if atlas_idx >= self.texture_list.len() { - // We can't start another atlas, we're at the limit - if atlas_idx >= self.texture_limit { - // TODO: how does a user resolve this? - bail!("Sprites didn't fit into atlas"); - } - // Start a new atlas self.texture_list .push(RgbaImage::new(self.texture_width, self.texture_height)); self.used_regions.push(Vec::new()); - self.image_max_sizes.push(u32::MAX) + self.image_max_sizes.push([u32::MAX, u32::MAX]); + self.image_y_start.push((0, [u32::MAX, u32::MAX])); } - let x = pixel_idx % self.texture_width; - let y = pixel_idx / self.texture_height; - let new = ([x, y], dim); - let mut used = false; - for r in &self.used_regions[atlas_idx] { - if self.boxes_overlap(*r, new) { - // Speed boost: skip the whole box - pixel_idx += new.1[0] - 1; - used = true; - break; + // Optimization: save the smallest sprite that didn't fit in each atlas, + // and don't try to add similarly-sized sprites. + if dim[0] >= self.image_max_sizes[atlas_idx][0] + && dim[1] >= self.image_max_sizes[atlas_idx][1] + { + continue 'outer; + } + + x = 0; + y = 0; + if self.image_y_start.len() != 0 { + let (sy, sd) = self.image_y_start[atlas_idx]; + if dim[0] >= sd[0] || dim[1] >= sd[1] { + y = sy; + } + } else { + self.image_y_start.push((0, [u32::MAX, u32::MAX])); + } + + let mut free = false; + let mut new; + 'inner: while y < self.texture_height && !free { + new = ([x, y], dim); + free = true; + for r in &self.used_regions[atlas_idx] { + // If boxes overlap... + if r.0[0] < new.0[0] + new.1[0] + && r.0[0] + r.1[0] > new.0[0] + && r.0[1] < new.0[1] + new.1[1] + && r.0[1] + r.1[1] > new.0[1] + { + // Skip the whole occupied area + x = r.1[0] + r.0[0]; + if x + dim[0] >= self.texture_width { + y += 1; + x = 0; + } + free = false; + continue 'inner; + } } } - if !used { + if y + dim[1] >= self.texture_height { + // This sprite didn't fit, move on to the next atlas + self.image_max_sizes[atlas_idx] = [dim[0] / 2, dim[1] / 2]; + } else if free { + final_atlas_idx = Some(atlas_idx); break; } - - pixel_idx += 1; - - // Speed boost: save the smallest sprite that didn't fit in each atlas, - // and don't even try to add bigger sprite. - if dim[0] * dim[1] >= self.image_max_sizes[atlas_idx] { - atlas_idx += 1; - pixel_idx = 0; - } - - // This sprite didn't fit, move on to the next atlas - if pixel_idx >= self.texture_width * self.texture_height { - self.image_max_sizes[atlas_idx] = dim[0] * dim[1]; - atlas_idx += 1; - pixel_idx = 0; - } } + let atlas_idx = match final_atlas_idx { + None => bail!("textures didn't fit!"), + Some(s) => s, + }; + // We found a spot for this image, write it. - let x = pixel_idx % self.texture_width; - let y = pixel_idx / self.texture_height; + //let img = RgbaImage::from_pixel(dim[0], dim[1], Rgba([0, 0, 0, 255])); imageops::overlay(&mut self.texture_list[atlas_idx], &img, x.into(), y.into()); self.used_regions[atlas_idx].push(([x, y], dim)); self.used_area += dim[0] as f64 * dim[1] as f64; - self.index.insert( - path.to_path_buf(), + let (sy, sd) = self.image_y_start[atlas_idx]; + if dim[0] <= sd[0] && dim[1] <= sd[1] { + // Reset start y if both dimensions of this texture are smaller than the previous smallest texture + // We check for both, because that ensures that the smaller texture can tile the previous largest one. + self.image_y_start[atlas_idx] = (0, [dim[0] / 2, dim[1] / 2]); + } else { + self.image_y_start[atlas_idx] = (y.max(sy), sd); + } + + let p = path.strip_prefix(&self.asset_root).with_context(|| { + format!( + "path `{}` is not relative to asset root `{}`", + path.display(), + self.asset_root.display() + ) + })?; + + self.index.index.insert( + p.to_path_buf(), SpriteAtlasImage { atlas: atlas_idx, x: x as f32 / self.texture_width as f32, diff --git a/crates/packer/src/lib.rs b/crates/packer/src/lib.rs index 1314536..bcc9c2e 100644 --- a/crates/packer/src/lib.rs +++ b/crates/packer/src/lib.rs @@ -3,10 +3,7 @@ //! This crate creates texture atlases from an asset tree. //! The main interface for this crate is ... TODO -use std::{ - collections::HashMap, - path::{Path, PathBuf}, -}; +use std::{collections::HashMap, path::PathBuf}; use serde::{Deserialize, Serialize}; @@ -36,26 +33,16 @@ pub struct SpriteAtlasImage { /// A map between file paths (relative to the root asset dir) /// and [`AtlasTexture`]s. #[derive(Debug, Serialize, Deserialize, Clone)] -pub struct SpriteAtlasIndex { - pub(crate) index: HashMap, +pub struct SpriteAtlas { + /// The images in this atlas + pub index: HashMap, } -impl SpriteAtlasIndex { +impl SpriteAtlas { /// Make an empty [`SpriteAtlasIndex`] pub fn new() -> Self { Self { index: HashMap::new(), } } - - /// Make an empty [`SpriteAtlasIndex`] - pub fn insert(&mut self, path: PathBuf, atlasimage: SpriteAtlasImage) { - self.index.insert(path, atlasimage); - } - - /// Get an [`AtlasImage`] for a file `p`. - /// Paths must be relative to the root of the asset directory. - pub fn get(&self, p: &Path) -> Option<&SpriteAtlasImage> { - self.index.get(p) - } } diff --git a/crates/packer/src/main.rs b/crates/packer/src/main.rs index 29afbd3..fce16ef 100644 --- a/crates/packer/src/main.rs +++ b/crates/packer/src/main.rs @@ -2,13 +2,14 @@ mod atlasset; use atlasset::AtlasSet; -use anyhow::Result; +use anyhow::{bail, Result}; use image::io::Reader; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use walkdir::WalkDir; // TODO: procedural sun coloring -// TODO: transparency buffer +// TODO: gap between sprites? +// TODO: warning when images have extra transparency // TODO: don't re-encode. Direct to gpu? // (maybe not, tiling is slow. Make it work with files first.) // TODO: path for atlas files @@ -24,15 +25,17 @@ use walkdir::WalkDir; fn main() -> Result<()> { let mut files = Vec::new(); - for e in WalkDir::new("./assets/render") - .into_iter() - .filter_map(|e| e.ok()) - { + let asset_root = Path::new("./assets/render"); + + // Total number of pixels we want to add + let mut total_dim = 0f64; + + for e in WalkDir::new(&asset_root).into_iter().filter_map(|e| e.ok()) { if e.metadata().unwrap().is_file() { // TODO: better warnings match e.path().extension() { Some(t) => { - if t.to_str() != Some("png") { + if t.to_str() != Some("png") && t.to_str() != Some("jpg") { println!("[WARNING] {e:#?} is not a png file, skipping."); continue; } @@ -46,7 +49,8 @@ fn main() -> Result<()> { let path = e.path().to_path_buf(); let reader = Reader::open(&path)?; let dim = reader.into_dimensions()?; - files.push((path, [dim.0, dim.1])) + files.push((path, [dim.0, dim.1])); + total_dim += dim.0 as f64 * dim.1 as f64; } } @@ -57,10 +61,19 @@ fn main() -> Result<()> { b.cmp(&a) }); + // Make sure we have enough pixels. + // This check is conservative and imperfect: + // Our tiling algorithm usually has efficiency better than 80% (~90%, as of writing) + // We need room for error, though, since this check doesn't guarante success. + if total_dim / 0.80 >= (8192.0 * 8192.0 * 16.0) { + bail!("Texture atlas is too small") + } + // Create atlas set - let mut atlas_set = AtlasSet::new(8192, 8192, 16); + let mut atlas_set = AtlasSet::new(8192, 8192, 16, &asset_root); let total = files.len(); let mut i = 0; + let mut peak_efficiency = 0f64; for (path, dim) in files { i += 1; let atlas_idx = atlas_set.write_image(&path, dim)?; @@ -69,6 +82,7 @@ fn main() -> Result<()> { 100.0 * atlas_set.get_efficiency(), path.display() ); + peak_efficiency = peak_efficiency.max(atlas_set.get_efficiency()); } println!( @@ -76,9 +90,11 @@ fn main() -> Result<()> { 100.0 * atlas_set.get_efficiency() ); + println!("Peak efficiency: {:.02}%", 100.0 * peak_efficiency); + println!("Saving files..."); atlas_set.save_files( - |x| PathBuf::from(format!("atlas-{x:0.2}.png")), + |x| PathBuf::from(format!("atlas-{x:0.2}.bmp")), &PathBuf::from("spriteatlas.toml"), )?;