Added packer
This commit is contained in:
parent
fb7721728c
commit
84a3e0f5d0
24
lib/packer/Cargo.toml
Normal file
24
lib/packer/Cargo.toml
Normal 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
334
lib/packer/src/atlasset.rs
Normal 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
99
lib/packer/src/lib.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user