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