279 lines
7.1 KiB
Rust
279 lines
7.1 KiB
Rust
use crate::{SpriteAtlas, SpriteAtlasImage};
|
|
use anyhow::{bail, Context, Result};
|
|
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
|
|
|
|
/// 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> {
|
|
let mut f = File::open(&path)?;
|
|
let mut bytes = Vec::new();
|
|
f.read_to_end(&mut bytes)
|
|
.with_context(|| format!("While reading file `{}`", path.display()))?;
|
|
let img = image::load_from_memory(&bytes)
|
|
.with_context(|| format!("While loading file `{}`", path.display()))?;
|
|
|
|
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 {
|
|
println!("[WARNING] {} 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 => bail!("textures didn't fit!"),
|
|
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).with_context(|| {
|
|
format!(
|
|
"path `{}` is not relative to asset root `{}`",
|
|
path.display(),
|
|
self.asset_root.display()
|
|
)
|
|
})?;
|
|
|
|
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<()>
|
|
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)
|
|
}
|
|
}
|