Compare commits

...

5 Commits

Author SHA1 Message Date
Mark 1001b8ba4a
Added a particle 2024-01-03 19:49:37 -08:00
Mark 797aa92374
Minor fix 2024-01-03 19:49:18 -08:00
Mark 4def821503
Updated crates 2024-01-03 19:49:06 -08:00
Mark b20ed2bfba
Updated TODO 2024-01-03 19:48:53 -08:00
Mark 769c74a22c
Added basic texture packer 2024-01-03 19:48:46 -08:00
10 changed files with 418 additions and 5 deletions

14
Cargo.lock generated
View File

@ -610,6 +610,7 @@ version = "0.0.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"cgmath", "cgmath",
"galactica-packer",
"image", "image",
"nalgebra", "nalgebra",
"serde", "serde",
@ -627,6 +628,18 @@ dependencies = [
"rand", "rand",
] ]
[[package]]
name = "galactica-packer"
version = "0.0.0"
dependencies = [
"anyhow",
"galactica-constants",
"image",
"serde",
"toml",
"walkdir",
]
[[package]] [[package]]
name = "galactica-render" name = "galactica-render"
version = "0.0.0" version = "0.0.0"
@ -636,6 +649,7 @@ dependencies = [
"cgmath", "cgmath",
"galactica-constants", "galactica-constants",
"galactica-content", "galactica-content",
"galactica-packer",
"image", "image",
"rand", "rand",
"wgpu", "wgpu",

View File

@ -49,6 +49,7 @@ galactica-world = { path = "crates/world" }
galactica-behavior = { path = "crates/behavior" } galactica-behavior = { path = "crates/behavior" }
galactica-gameobject = { path = "crates/gameobject" } galactica-gameobject = { path = "crates/gameobject" }
galactica-ui = { path = "crates/ui" } galactica-ui = { path = "crates/ui" }
galactica-packer = { path = "crates/packer" }
galactica = { path = "crates/galactica" } galactica = { path = "crates/galactica" }
image = { version = "0.24", features = ["png"] } image = { version = "0.24", features = ["png"] }

View File

@ -80,6 +80,7 @@
- Engine flares shouldn't be centered - Engine flares shouldn't be centered
- Sprite optimization: do we need to allocate a new `Vec` every frame? Probably not. - Sprite optimization: do we need to allocate a new `Vec` every frame? Probably not.
- Better error when run outside of directory - Better error when run outside of directory
- Documentation site & front page
## Content ## Content
- Angled engines - Angled engines
@ -117,7 +118,7 @@
- Nova dust parallax - Nova dust parallax
- Ship outlines in radar - Ship outlines in radar
- Engine flare ease in/out - Engine flare ease in/out
- Lens flare
## Write and Document ## Write and Document
- Parallax - Parallax
@ -132,6 +133,7 @@
- Ship AI - Ship AI
- Handles - Handles
- Content specification and pipeline - Content specification and pipeline
- How packer and optimizations work, and why
## Ideas ## Ideas

View File

@ -38,8 +38,24 @@ file = "ui/center-arrow.png"
duration = 0.15 duration = 0.15
repeat = "once" repeat = "once"
frames = [ frames = [
"particle/blaster-01.png", "particle/blaster/01.png",
"particle/blaster-02.png", "particle/blaster/02.png",
"particle/blaster-03.png", "particle/blaster/03.png",
"particle/blaster-04.png", "particle/blaster/04.png",
]
[texture."particle::explosion"]
duration = 0.4
repeat = "once"
frames = [
"particle/explosion-large/01.png",
"particle/explosion-large/02.png",
"particle/explosion-large/03.png",
"particle/explosion-large/04.png",
"particle/explosion-large/05.png",
"particle/explosion-large/06.png",
"particle/explosion-large/07.png",
"particle/explosion-large/08.png",
"particle/explosion-large/09.png",
] ]

View File

@ -18,6 +18,7 @@ fn main() -> Result<()> {
let content = content::Content::load_dir( let content = content::Content::load_dir(
PathBuf::from(galactica_constants::CONTENT_ROOT), PathBuf::from(galactica_constants::CONTENT_ROOT),
PathBuf::from(galactica_constants::TEXTURE_ROOT), PathBuf::from(galactica_constants::TEXTURE_ROOT),
PathBuf::from("spriteatlas.toml"),
galactica_constants::STARFIELD_TEXTURE_NAME.to_owned(), galactica_constants::STARFIELD_TEXTURE_NAME.to_owned(),
)?; )?;

30
crates/packer/Cargo.toml Normal file
View File

@ -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 }

View File

@ -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<ImageBuffer<Rgba<u8>, Vec<u8>>>,
/// The size of the smallest image that didn't fit in each texture
image_max_sizes: Vec<u32>,
/// 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,
}
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<usize> {
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<F>(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)
}
}

61
crates/packer/src/lib.rs Normal file
View File

@ -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<PathBuf, SpriteAtlasImage>,
}
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)
}
}

86
crates/packer/src/main.rs Normal file
View File

@ -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(());
}

View File

@ -19,6 +19,7 @@ workspace = true
[dependencies] [dependencies]
galactica-content = { workspace = true } galactica-content = { workspace = true }
galactica-constants = { workspace = true } galactica-constants = { workspace = true }
galactica-packer = { workspace = true }
anyhow = { workspace = true } anyhow = { workspace = true }
cgmath = { workspace = true } cgmath = { workspace = true }