diff --git a/.gitignore b/.gitignore
index 6b39d31a96291e28ad5c033dc1267db1ff1b278d..673431564e4776f2ddc92de759bf02c814cb8b8c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,3 @@
 /target
-.idea/
\ No newline at end of file
+.idea/
+exmp/
\ No newline at end of file
diff --git a/Cargo.lock b/Cargo.lock
index 8ccd4838bcddea3f432af2cba51cba72cf36c490..177243163b414f4ad45b06e51fa04835682bc4b7 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -233,12 +233,13 @@ dependencies = [
 
 [[package]]
 name = "crunch-cli"
-version = "0.5.3"
+version = "0.6.0"
 dependencies = [
  "anyhow",
  "clap",
  "deltae",
  "env_logger",
+ "etagere",
  "glam",
  "glob",
  "image",
@@ -310,6 +311,25 @@ dependencies = [
  "libc",
 ]
 
+[[package]]
+name = "etagere"
+version = "0.2.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fcf22f748754352918e082e0039335ee92454a5d62bcaf69b5e8daf5907d9644"
+dependencies = [
+ "euclid",
+ "svg_fmt",
+]
+
+[[package]]
+name = "euclid"
+version = "0.22.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "87f253bc5c813ca05792837a0ff4b3a580336b224512d48f7eda1d7dd9210787"
+dependencies = [
+ "num-traits",
+]
+
 [[package]]
 name = "exr"
 version = "1.7.0"
@@ -825,6 +845,12 @@ version = "0.10.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"
 
+[[package]]
+name = "svg_fmt"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fb1df15f412ee2e9dfc1c504260fa695c1c3f10fe9f4a6ee2d2184d7d6450e2"
+
 [[package]]
 name = "syn"
 version = "2.0.27"
diff --git a/Cargo.toml b/Cargo.toml
index 702df2958eb714858cc64b3c50b8b17a2c4a790a..a18b1f041807a145dc0b3cad205f218702afb3fd 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
 [package]
 name = "crunch-cli"
-version = "0.5.3"
+version = "0.6.0"
 edition = "2021"
 
 homepage = "https://microhacks.lcr.app/crunch/"
@@ -35,3 +35,4 @@ toml = "0.7.3"
 serde = { version = "1.0.156", features = ["derive"] }
 serde_json = "1.0.94"
 glob = "0.3.1"
+etagere = "0.2.8"
diff --git a/src/cli_args.rs b/src/cli_args.rs
index 6bb2d464f6509bdebeec64aa5a95dc25ac12d53b..8e3db8198eba9484b4816c95f53afc32715a272f 100644
--- a/src/cli_args.rs
+++ b/src/cli_args.rs
@@ -2,8 +2,9 @@ use clap::Parser;
 use image::ImageFormat;
 use serde::{Deserialize, Serialize};
 
-// use crate::commands::{calculate_mapping, execute_pipeline, extrude, flip, palette, remap_image, rescale, rotate, write_palette, FlipDirection, RotateDegree, Rotate};
-use crate::commands::{Extrude, Flip, Palette, Pipeline, Reduce, Remap, Rotate, Scale, Split};
+use crate::commands::{
+	Atlas, Extrude, Flip, Palette, Pipeline, Reduce, Remap, Rotate, Scale, Split,
+};
 
 use crate::load_image;
 
@@ -43,6 +44,9 @@ pub enum Args {
 	#[clap(name = "split")]
 	#[serde(alias = "split")]
 	Split(Split),
+	#[clap(name = "atlas")]
+	#[serde(alias = "atlas")]
+	Atlas(Atlas),
 }
 
 impl Args {
@@ -108,7 +112,7 @@ impl Args {
 				split.run(&image)
 			}
 			Args::Pipeline(pipeline) => pipeline.execute(),
-			_ => Ok(()),
+			Args::Atlas(atlas) => atlas.run(),
 		}
 	}
 }
diff --git a/src/commands/atlas.rs b/src/commands/atlas.rs
new file mode 100644
index 0000000000000000000000000000000000000000..d867dbcc07edec7f33674ff69e2ac34bfa50ddfa
--- /dev/null
+++ b/src/commands/atlas.rs
@@ -0,0 +1,209 @@
+use clap::Parser;
+use std::collections::HashMap;
+use std::fmt::Display;
+use std::fs::File;
+
+use image::{
+	image_dimensions, GenericImage, GenericImageView, ImageFormat, Pixel, Rgba, RgbaImage,
+};
+
+use num_traits::AsPrimitive;
+
+use serde::{Deserialize, Serialize};
+
+use crate::format::load_image;
+use etagere::{AllocId, AtlasAllocator, Rectangle, Size};
+use glob::MatchOptions;
+use rayon::prelude::{IntoParallelIterator, ParallelBridge, ParallelIterator};
+use std::path::{Path, PathBuf};
+use thiserror::__private::DisplayAsDisplay;
+
+#[inline(always)]
+fn tile_size() -> u32 {
+	32
+}
+
+fn default_max_size() -> usize {
+	2048
+}
+
+/// Given a set of images, create a single atlas image and metadata file containing all of the original
+/// set
+#[derive(Parser, Serialize, Deserialize, Clone, Debug)]
+#[clap(author, version = "0.7.0")]
+pub struct Atlas {
+	/// A pattern evaluating to one or more image files
+	#[serde(default)]
+	pub glob: String,
+	/// The path to use when writing the texture atlas
+	#[serde(default)]
+	pub output: String,
+	/// The maximum width of the output texture
+	#[serde(default = "default_max_size")]
+	#[clap(short = 'w', long = "max_width")]
+	pub max_frame_width: usize,
+	/// The maximum height of the output texture
+	#[serde(default = "default_max_size")]
+	#[clap(short = 'h', long = "max_height")]
+	pub max_frame_height: usize,
+}
+
+#[derive(Copy, Clone, Hash, Eq, PartialEq)]
+struct AtlasIdent {
+	page: usize,
+	id: u32,
+}
+
+struct SliceData {
+	path: PathBuf,
+}
+
+struct AtlasBuilder {
+	wh: (usize, usize),
+	pages: Vec<AtlasAllocator>,
+}
+
+impl AtlasBuilder {
+	pub fn new(wh: (usize, usize)) -> Self {
+		Self {
+			wh,
+			pages: Vec::with_capacity(1),
+		}
+	}
+
+	pub fn insert(
+		&mut self,
+		width: u32,
+		height: u32,
+		path: PathBuf,
+	) -> Option<(AtlasIdent, SliceData)> {
+		for (idx, page) in self.pages.iter_mut().enumerate() {
+			if let Some(alloc) = page.allocate(Size::new(width as i32, height as i32)) {
+				return Some((
+					AtlasIdent {
+						page: idx,
+						id: alloc.id.serialize(),
+					},
+					SliceData { path },
+				));
+			}
+		}
+
+		let mut new_page = AtlasAllocator::new(Size::new(self.wh.0 as i32, self.wh.1 as i32));
+		if let Some(alloc) = new_page.allocate(Size::new(width as i32, height as i32)) {
+			let idx = self.pages.len();
+			self.pages.push(new_page);
+
+			Some((
+				AtlasIdent {
+					page: idx,
+					id: alloc.id.serialize(),
+				},
+				SliceData { path },
+			))
+		} else {
+			None
+		}
+	}
+
+	pub fn get(&self, ident: AtlasIdent) -> Option<Rectangle> {
+		self.pages
+			.get(ident.page)
+			.map(|page| page.get(AllocId::deserialize(ident.id)))
+	}
+}
+
+impl Atlas {
+	pub fn run(&self) -> anyhow::Result<()> {
+		let pattern = glob::glob(self.glob.as_str())?;
+		let mut builder = AtlasBuilder::new((self.max_frame_width, self.max_frame_height));
+
+		let page_content_map: HashMap<usize, Vec<(PathBuf, Rectangle)>> = pattern
+			.into_iter()
+			.filter_map(Result::ok)
+			.flat_map(|path| image_dimensions(&path).map(|(w, h)| (w, h, path)))
+			.flat_map(|(w, h, path)| builder.insert(w, h, path))
+			.collect::<Vec<(AtlasIdent, SliceData)>>()
+			.into_iter()
+			.flat_map(|(ident, slice)| {
+				builder
+					.get(ident)
+					.map(|rect| (ident.page, slice.path, rect))
+			})
+			.fold(HashMap::default(), |mut map, (page, path, rect)| {
+				let mut entry = map.entry(page).or_default();
+				entry.push((path, rect));
+				map
+			});
+
+		page_content_map.into_par_iter().for_each(|(page, items)| {
+			if let Err(err) = write_atlas(
+				self.output.clone(),
+				(self.max_frame_width as u32, self.max_frame_height as u32),
+				page,
+				items,
+			) {
+				log::error!("{}", err);
+			}
+		});
+
+		Ok(())
+	}
+}
+
+#[derive(Serialize, Deserialize)]
+struct IndexArea {
+	left: usize,
+	right: usize,
+	top: usize,
+	bottom: usize,
+}
+
+#[derive(Serialize, Deserialize)]
+struct IndexEntry {
+	name: String,
+	area: IndexArea,
+}
+
+fn write_atlas(
+	output_path: impl Display,
+	image_wh: (u32, u32),
+	page_num: usize,
+	items: Vec<(PathBuf, Rectangle)>,
+) -> anyhow::Result<()> {
+	let mut new_image = RgbaImage::from_pixel(image_wh.0, image_wh.1, Rgba::from([0, 0, 0, 0]));
+	let mut metadata_index = Vec::with_capacity(items.len());
+	for (source, spacing) in items {
+		let base_name = source
+			.file_stem()
+			.ok_or(anyhow::Error::msg("Path had no stem"))?
+			.to_str()
+			.ok_or(anyhow::Error::msg("Path was not unicode"))?
+			.to_string();
+
+		let source_image = load_image(
+			source
+				.to_str()
+				.ok_or(anyhow::Error::msg("Path was wrong"))?,
+			None,
+		)?;
+
+		metadata_index.push(IndexEntry {
+			name: base_name,
+			area: IndexArea {
+				left: spacing.min.x as usize,
+				right: spacing.max.x as usize,
+				bottom: spacing.max.y as usize,
+				top: spacing.min.y as usize,
+			},
+		});
+
+		new_image.copy_from(&source_image, spacing.min.x as u32, spacing.min.y as u32)?;
+	}
+
+	new_image.save(format!("{}_{}.png", output_path, page_num))?;
+	let new_file = File::create(format!("{}_{}.json", output_path, page_num))?;
+	serde_json::to_writer_pretty(new_file, &metadata_index)?;
+
+	Ok(())
+}
diff --git a/src/commands/mod.rs b/src/commands/mod.rs
index c2538e25d4e4d57d742b0db79c879eb96989837b..d39c70b421e64788e03364af810702d7f92d3d3d 100644
--- a/src/commands/mod.rs
+++ b/src/commands/mod.rs
@@ -1,3 +1,4 @@
+mod atlas;
 mod extrude;
 mod flip;
 mod palette;
@@ -8,6 +9,7 @@ mod rotate;
 mod scale;
 mod split;
 
+pub use atlas::Atlas;
 pub use extrude::Extrude;
 pub use flip::Flip;
 pub use palette::Palette;