From 769c74a22cb15e03477927a1efe9990c9129985f Mon Sep 17 00:00:00 2001 From: Mark Date: Wed, 3 Jan 2024 19:48:46 -0800 Subject: [PATCH] Added basic texture packer --- crates/packer/Cargo.toml | 30 +++++ crates/packer/src/atlasset.rs | 201 ++++++++++++++++++++++++++++++++++ crates/packer/src/lib.rs | 61 +++++++++++ crates/packer/src/main.rs | 86 +++++++++++++++ crates/render/Cargo.toml | 1 + 5 files changed, 379 insertions(+) create mode 100644 crates/packer/Cargo.toml create mode 100644 crates/packer/src/atlasset.rs create mode 100644 crates/packer/src/lib.rs create mode 100644 crates/packer/src/main.rs diff --git a/crates/packer/Cargo.toml b/crates/packer/Cargo.toml new file mode 100644 index 0000000..0462360 --- /dev/null +++ b/crates/packer/Cargo.toml @@ -0,0 +1,30 @@ +[[bin]] +name = "galactica-packer" +path = "src/main.rs" + +[package] +name = "galactica-packer" +description = "Galactica's sprite packer" +categories = { workspace = true } +keywords = { workspace = true } +version = { workspace = true } +rust-version = { workspace = true } +authors = { workspace = true } +edition = { workspace = true } +homepage = { workspace = true } +repository = { workspace = true } +license = { workspace = true } +documentation = { workspace = true } +readme = { workspace = true } + +[lints] +workspace = true + +[dependencies] +galactica-constants = { workspace = true } + +image = { workspace = true } +toml = { workspace = true } +serde = { workspace = true } +anyhow = { workspace = true } +walkdir = { workspace = true } diff --git a/crates/packer/src/atlasset.rs b/crates/packer/src/atlasset.rs new file mode 100644 index 0000000..f641940 --- /dev/null +++ b/crates/packer/src/atlasset.rs @@ -0,0 +1,201 @@ +use anyhow::{bail, Result}; +use galactica_packer::{SpriteAtlasImage, SpriteAtlasIndex}; +use image::{imageops, ImageBuffer, Rgba, RgbaImage}; +use std::{ + fs::File, + io::{Read, Write}, + path::{Path, PathBuf}, +}; + +// TODO: article +// TODO: rework texturearray +// TODO: reasonable sprite sizes +// TODO: consistent naming +// spriteatlas: the big images +// texture: the same, what we load to wgpu +// image: a single file +// sprite: a possibly animated texture + +pub struct AtlasSet { + /// The width of each atlas + texture_width: u32, + + /// The height of each atlas + texture_height: u32, + + /// Make at most this many atlases + /// Probably determined by the GPU + texture_limit: usize, + + /// Keeps track of image files + index: SpriteAtlasIndex, + + /// 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, + + /// A list of used regions in each texture + /// Format: ([xpos, ypos], [width, height]) + /// in pixels, with (0, 0) at top left + used_regions: Vec>, + + /// Used to calculate packing efficiency + used_area: f64, +} + +impl AtlasSet { + pub fn new(texture_width: u32, texture_height: u32, texture_limit: usize) -> Self { + Self { + texture_width, + texture_height, + texture_limit, + texture_list: Vec::new(), + image_max_sizes: Vec::new(), + used_regions: Vec::new(), + index: SpriteAtlasIndex::new(), + used_area: 0f64, + } + } + + /// 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 { + 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) + } + + 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; + } + } + + if !used { + 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; + } + } + + // We found a spot for this image, write it. + let x = pixel_idx % self.texture_width; + let y = pixel_idx / self.texture_height; + 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(), + SpriteAtlasImage { + atlas: atlas_idx, + x: x as f32 / self.texture_width as f32, + y: y as f32 / self.texture_height as f32, + w: dim[0] as f32 / self.texture_width as f32, + h: dim[1] as f32 / self.texture_height as f32, + }, + ); + + return Ok(atlas_idx); + } + + pub fn save_files(self, atlas_path: F, index_path: &Path) -> Result<()> + where + F: Fn(usize) -> PathBuf, + { + // Save atlases + for i in 0..self.texture_list.len() { + self.texture_list[i].save(atlas_path(i))?; + } + + // Save index + let toml = toml::to_string(&self.index)?; + let mut f = File::create(index_path)?; + f.write_all(toml.as_bytes())?; + return Ok(()); + } + + /* + pub fn get_width(&self) -> u32 { + self.texture_width + } + + pub fn get_height(&self) -> u32 { + self.texture_height + } + + pub fn get_limit(&self) -> usize { + self.texture_limit + } + + pub fn get_used_area(&self) -> f64 { + self.used_area + } + + pub fn num_textures(&self) -> usize { + self.texture_list.len() + } + */ + + pub fn get_efficiency(&self) -> f64 { + self.used_area + / (self.texture_height as f64 + * self.texture_width as f64 + * self.texture_list.len() as f64) + } +} diff --git a/crates/packer/src/lib.rs b/crates/packer/src/lib.rs new file mode 100644 index 0000000..1314536 --- /dev/null +++ b/crates/packer/src/lib.rs @@ -0,0 +1,61 @@ +#![warn(missing_docs)] + +//! 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 serde::{Deserialize, Serialize}; + +/// The location of a single image in a sprite atlas +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct SpriteAtlasImage { + /// The index of the atlas this image is in + pub atlas: usize, + + /// x-position of this image + /// (between 0 and 1, using wgpu texture coordinates) + pub x: f32, + + /// y-position of this image + /// (between 0 and 1, using wgpu texture coordinates) + pub y: f32, + + /// Width of this image + /// (between 0 and 1, using wgpu texture coordinates) + pub w: f32, + + /// Height of this image + /// (between 0 and 1, using wgpu texture coordinates) + pub h: f32, +} + +/// 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, +} + +impl SpriteAtlasIndex { + /// 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 new file mode 100644 index 0000000..29afbd3 --- /dev/null +++ b/crates/packer/src/main.rs @@ -0,0 +1,86 @@ +mod atlasset; + +use atlasset::AtlasSet; + +use anyhow::Result; +use image::io::Reader; +use std::path::PathBuf; +use walkdir::WalkDir; + +// TODO: procedural sun coloring +// TODO: transparency buffer +// TODO: don't re-encode. Direct to gpu? +// (maybe not, tiling is slow. Make it work with files first.) +// TODO: path for atlas files +// TODO: rework texturearray +// TODO: reasonable sprite sizes (especially ui, document rules) +// TODO: consistent naming +// TODO: dynamic packing (for plugins) +// spriteatlas: the set of textures +// texture: a single plane of many images, what we load to wgpu +// image: a single file +// sprite: a possibly animated texture + +fn main() -> Result<()> { + let mut files = Vec::new(); + + for e in WalkDir::new("./assets/render") + .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") { + println!("[WARNING] {e:#?} is not a png file, skipping."); + continue; + } + } + None => { + println!("[WARNING] {e:#?} is not a png file, skipping."); + continue; + } + } + + let path = e.path().to_path_buf(); + let reader = Reader::open(&path)?; + let dim = reader.into_dimensions()?; + files.push((path, [dim.0, dim.1])) + } + } + + // Sort by image size + files.sort_by(|a, b| { + let a = a.1[0] * a.1[1]; + let b = b.1[0] * b.1[1]; + b.cmp(&a) + }); + + // Create atlas set + let mut atlas_set = AtlasSet::new(8192, 8192, 16); + let total = files.len(); + let mut i = 0; + for (path, dim) in files { + i += 1; + let atlas_idx = atlas_set.write_image(&path, dim)?; + println!( + "({i} of {total}, atlas {atlas_idx}, efficiency {:.02}%) Added {}", + 100.0 * atlas_set.get_efficiency(), + path.display() + ); + } + + println!( + "Packing efficiency: {:.02}%", + 100.0 * atlas_set.get_efficiency() + ); + + println!("Saving files..."); + atlas_set.save_files( + |x| PathBuf::from(format!("atlas-{x:0.2}.png")), + &PathBuf::from("spriteatlas.toml"), + )?; + + return Ok(()); +} diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml index c06dcf0..15fcfa6 100644 --- a/crates/render/Cargo.toml +++ b/crates/render/Cargo.toml @@ -19,6 +19,7 @@ workspace = true [dependencies] galactica-content = { workspace = true } galactica-constants = { workspace = true } +galactica-packer = { workspace = true } anyhow = { workspace = true } cgmath = { workspace = true }