Added packer

This commit is contained in:
Mark 2025-01-04 17:30:24 -08:00
parent fb7721728c
commit 84a3e0f5d0
Signed by: Mark
GPG Key ID: C6D63995FE72FD80
3 changed files with 457 additions and 0 deletions

24
lib/packer/Cargo.toml Normal file
View File

@ -0,0 +1,24 @@
[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]
tracing = { workspace = true }
image = { workspace = true }
toml = { workspace = true }
serde = { workspace = true }
thiserror = { workspace = true }

334
lib/packer/src/atlasset.rs Normal file
View File

@ -0,0 +1,334 @@
use crate::{SpriteAtlas, SpriteAtlasImage};
use image::{imageops, GenericImageView, ImageBuffer, Rgba, RgbaImage};
use std::{
fs::File,
io::{Read, Write},
num::NonZeroU32,
path::{Path, PathBuf},
};
// TODO: article
// TODO: parallelize
// TODO: consistent naming (document)
// spriteatlas: the big images
// texture: the same, what we load to wgpu
// image: a single file
// sprite: a possibly animated texture
use thiserror::Error;
use tracing::{error, info};
/// An error we can encounter when adding an image to an [`AtlasSet`]
#[derive(Debug, Error)]
pub enum WriteImageError {
/// A generic I/O error
#[error("I/O error: {error}")]
IoError {
#[from]
error: std::io::Error,
},
/// We couldn't load an image
#[error("Could not load image from `{path}`")]
ImageLoadError { path: PathBuf },
/// We could not fit this image into this atlas
#[error("Could not fit image `{path}` into atlas")]
DidntFit { path: PathBuf },
/// We tried to add a path that isn't inside the
/// root asset directory
#[error("Image `{path}` is not inside asset root `{root}`")]
NotInAssetRoot { path: PathBuf, root: PathBuf },
}
/// An error we can encounter when saving an [`AtlasSet`]
#[derive(Debug, Error)]
pub enum SaveAtlasError {
/// A generic I/O error
#[error("I/O error: {error}")]
IoError {
#[from]
error: std::io::Error,
},
/// We couldn't load an image
#[error("TOML serialization error: {error}")]
TomlError {
#[from]
error: toml::ser::Error,
},
/// We couldn't load an image
#[error("Image error: {error}")]
ImagelError {
#[from]
error: image::error::ImageError,
},
}
/// Builds a set of atlas textures from input image files
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: SpriteAtlas,
/// Array of textures, grows as needed
texture_list: Vec<ImageBuffer<Rgba<u8>, Vec<u8>>>,
/// The size of the smallest image that didn't fit in each texture
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])
/// in pixels, with (0, 0) at top left
used_regions: Vec<Vec<([u32; 2], [u32; 2])>>,
/// 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,
/// Leave an empty border this many pixels wide around each image
image_margin: u32,
}
impl AtlasSet {
/// Make a new AtlasSet
pub fn new(
texture_width: u32,
texture_height: u32,
texture_limit: usize,
asset_root: &Path,
image_margin: u32,
) -> 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: SpriteAtlas::new(),
used_area: 0f64,
image_y_start: Vec::new(),
image_margin,
}
}
/// Add a sprite to this atlas set
pub fn write_image(&mut self, path: &Path) -> Result<usize, WriteImageError> {
let mut f = File::open(&path)?;
let mut bytes = Vec::new();
info!(message = format!("Loading image from {path:?}"));
f.read_to_end(&mut bytes)?;
let img = image::load_from_memory(&bytes).map_err(|_| WriteImageError::ImageLoadError {
path: path.to_path_buf(),
})?;
let image_dim = img.dimensions();
/*
TODO: seperate CLI argument to check these.
don't check this every run,because sometimes it's necessary. (for animated sprites!)
let mut transparent_border = true;
for (x, y, c) in img.pixels() {
if (x == 0 || y == 0 || x == img.width() - 1 || y == img.height() - 1) && c[3] != 0 {
transparent_border = false;
break;
}
}
if transparent_border {
warn!("{} wastes space with a transmparent border",);
}
*/
let dim = [
image_dim.0 + 2 * self.image_margin,
image_dim.1 + 2 * self.image_margin,
];
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() {
// 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, u32::MAX]);
self.image_y_start.push((0, [u32::MAX, u32::MAX]));
}
// 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;
}
}
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 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;
}
}
let atlas_idx = match final_atlas_idx {
None => {
return Err(WriteImageError::DidntFit {
path: path.to_path_buf(),
})
}
Some(s) => s,
};
// We found a spot for this image, write it.
imageops::overlay(
&mut self.texture_list[atlas_idx],
&img,
(x + self.image_margin).into(),
(y + self.image_margin).into(),
);
self.used_regions[atlas_idx].push(([x, y], dim));
self.used_area += dim[0] as f64 * dim[1] as f64;
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)
.map_err(|_| WriteImageError::NotInAssetRoot {
path: path.to_path_buf(),
root: self.asset_root.to_path_buf(),
})?;
self.index.push(
p,
SpriteAtlasImage {
true_size: image_dim,
// Add one to account for hidden texture
idx: NonZeroU32::new(self.index.len() as u32 + 1).unwrap(),
atlas: atlas_idx as u32,
x: (x + self.image_margin) as f32 / self.texture_width as f32,
y: (y + self.image_margin) as f32 / self.texture_height as f32,
w: image_dim.0 as f32 / self.texture_width as f32,
h: image_dim.1 as f32 / self.texture_height as f32,
},
);
return Ok(atlas_idx);
}
/// Save the index and all atlas files
pub fn save_files<F>(mut self, atlas_path: F, index_path: &Path) -> Result<(), SaveAtlasError>
where
F: Fn(usize) -> PathBuf,
{
// Save atlases
for i in 0..self.texture_list.len() {
let path = atlas_path(i);
self.texture_list[i].save(&path)?;
self.index
.atlas_list
.push(path.file_name().unwrap().to_str().unwrap().to_owned());
}
// 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()
}
*/
/// Get the current packing efficiency of this set, over all atlases.
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)
}
}

99
lib/packer/src/lib.rs Normal file
View File

@ -0,0 +1,99 @@
//! This crate creates texture atlases from an asset tree.
mod atlasset;
pub use atlasset::*;
use std::{
collections::HashMap,
num::NonZeroU32,
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
/// This is an index in SpriteAtlas.atlas_list
pub atlas: u32,
/// A globally unique, consecutively numbered index for this sprite.
/// This is nonzero because index zero is reserved for the "hidden" texture.
pub idx: NonZeroU32,
/// The size of this image, in pixels
pub true_size: (u32, u32),
/// 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 SpriteAtlas {
/// The images in this atlas
pub(crate) index: Vec<SpriteAtlasImage>,
/// Map paths to image indices
path_map: HashMap<PathBuf, NonZeroU32>,
/// The file names of the atlas textures we've generated
pub atlas_list: Vec<String>,
}
impl SpriteAtlas {
/// Make an empty [`SpriteAtlasIndex`]
pub fn new() -> Self {
Self {
path_map: HashMap::new(),
index: Vec::new(),
atlas_list: Vec::new(),
}
}
/// Get a SpriteAtlasImage by index
pub fn get_by_idx(&self, idx: NonZeroU32) -> &SpriteAtlasImage {
&self.index[idx.get() as usize - 1]
}
/// Get an image index from its path
/// returns None if this path isn't in this index
pub fn get_idx_by_path(&self, path: &Path) -> Option<NonZeroU32> {
self.path_map.get(path).map(|x| *x)
}
/// Iterate all images in this atlas
pub fn iter_images(&self) -> impl Iterator<Item = &SpriteAtlasImage> {
self.index.iter()
}
/// Get the number of images in this atlas
pub fn len(&self) -> u32 {
self.index.len() as u32
}
/// Add an image with the given path to this index
pub fn push(&mut self, p: &Path, i: SpriteAtlasImage) {
self.path_map.insert(
p.to_path_buf(),
NonZeroU32::new(self.index.len() as u32 + 1).unwrap(),
);
self.index.push(i);
}
}