mirror of https://github.com/Leinnan/rpack.git
439 lines
14 KiB
Rust
439 lines
14 KiB
Rust
use bevy_rpack::{AtlasFrame, AtlasMetadata, SerializableRect};
|
|
use image::DynamicImage;
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::Value;
|
|
#[cfg(all(feature = "config_ext", not(target_arch = "wasm32")))]
|
|
use std::io::Write;
|
|
use std::{
|
|
ffi::OsStr,
|
|
fmt::Display,
|
|
path::{Path, PathBuf},
|
|
};
|
|
use texture_packer::{TexturePacker, TexturePackerConfig, importer::ImageImporter};
|
|
use thiserror::Error;
|
|
|
|
pub mod packer;
|
|
|
|
#[derive(Clone)]
|
|
pub struct Spritesheet {
|
|
pub image_data: DynamicImage,
|
|
pub atlas_asset: bevy_rpack::AtlasAsset,
|
|
pub atlas_asset_json: Value,
|
|
}
|
|
|
|
impl Spritesheet {
|
|
pub fn rebuild_json(&mut self) {
|
|
if let Ok(value) = serde_json::to_value(&self.atlas_asset) {
|
|
self.atlas_asset_json = value;
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, PartialEq)]
|
|
pub struct ImageFile {
|
|
pub id: String,
|
|
pub image: DynamicImage,
|
|
}
|
|
|
|
impl ImageFile {
|
|
pub fn at_path<P>(path: &Path, id: P) -> Option<ImageFile>
|
|
where
|
|
P: AsRef<str>,
|
|
{
|
|
let mut id = id.as_ref().to_owned().replace("\\", "/");
|
|
if let Some((before, _)) = id.split_once('.') {
|
|
id = before.to_string();
|
|
}
|
|
if let Ok(image) = ImageImporter::import_from_file(path) {
|
|
Some(ImageFile { image, id })
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn get_common_prefix<S>(paths: &[S]) -> String
|
|
where
|
|
S: AsRef<OsStr> + Sized,
|
|
{
|
|
if paths.is_empty() {
|
|
return String::new();
|
|
}
|
|
let path = Path::new(paths[0].as_ref())
|
|
.file_name()
|
|
.unwrap_or_default()
|
|
.to_str()
|
|
.unwrap_or_default();
|
|
|
|
let mut prefix = String::from(paths[0].as_ref().to_string_lossy())
|
|
.strip_suffix(&path)
|
|
.unwrap_or_default()
|
|
.to_owned();
|
|
|
|
for s in paths.iter().skip(1) {
|
|
let s = s.as_ref().to_string_lossy();
|
|
while !(s.starts_with(&prefix) || prefix.is_empty()) {
|
|
prefix.pop();
|
|
}
|
|
}
|
|
|
|
// Ensure the prefix ends at a directory boundary
|
|
if !prefix.is_empty() && !prefix.ends_with('/') && !prefix.ends_with('\\') {
|
|
if let Some(last_slash) = prefix.rfind('/') {
|
|
prefix.truncate(last_slash + 1);
|
|
} else if let Some(last_backslash) = prefix.rfind('\\') {
|
|
prefix.truncate(last_backslash + 1);
|
|
}
|
|
}
|
|
|
|
prefix
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default, Copy, Serialize, Deserialize, PartialEq)]
|
|
#[cfg_attr(
|
|
all(feature = "cli", not(target_arch = "wasm32")),
|
|
derive(clap::ValueEnum)
|
|
)]
|
|
pub enum SaveImageFormat {
|
|
#[default]
|
|
Png,
|
|
Dds,
|
|
#[cfg(feature = "basis")]
|
|
Basis,
|
|
}
|
|
|
|
impl Display for SaveImageFormat {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
SaveImageFormat::Png => f.write_str(".png"),
|
|
SaveImageFormat::Dds => f.write_str(".dds"),
|
|
#[cfg(feature = "basis")]
|
|
SaveImageFormat::Basis => f.write_str(".basis"),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Errors that can occur while building a `Spritesheet`.
|
|
#[non_exhaustive]
|
|
#[derive(Debug, Error, Clone)]
|
|
pub enum SpritesheetError {
|
|
#[error("Cannot pack image: {0}")]
|
|
CannotPackImage(String),
|
|
#[error("Failed to export tilemap image")]
|
|
FailedToExportImage,
|
|
#[error("could not parse asset: {0}")]
|
|
ParsingError(String),
|
|
#[error("Failed to pack image into tilemap, tilemap to small")]
|
|
FailedToPackImage,
|
|
}
|
|
|
|
/// Configuration for building a `Spritesheet`.
|
|
#[derive(Debug, Clone)]
|
|
pub struct SpritesheetBuildConfig {
|
|
/// Configuration for the texture packer.
|
|
pub packer_config: TexturePackerConfig,
|
|
/// Whether to skip metadata serialization.
|
|
pub skip_metadata_serialization: bool,
|
|
}
|
|
|
|
impl From<TexturePackerConfig> for SpritesheetBuildConfig {
|
|
fn from(config: TexturePackerConfig) -> Self {
|
|
Self {
|
|
packer_config: config,
|
|
skip_metadata_serialization: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Spritesheet {
|
|
pub fn build<P>(
|
|
config: impl Into<SpritesheetBuildConfig>,
|
|
images: &[ImageFile],
|
|
filename: P,
|
|
) -> Result<Self, SpritesheetError>
|
|
where
|
|
P: AsRef<str>,
|
|
{
|
|
let SpritesheetBuildConfig {
|
|
packer_config: config,
|
|
skip_metadata_serialization,
|
|
} = config.into();
|
|
let mut packer = TexturePacker::new_skyline(config);
|
|
for image in images.iter() {
|
|
if !packer.can_pack(&image.image) {
|
|
return Err(SpritesheetError::CannotPackImage(image.id.clone()));
|
|
}
|
|
if let Err(_err) = packer.pack_ref(&image.id, &image.image) {
|
|
return Err(SpritesheetError::FailedToPackImage);
|
|
}
|
|
}
|
|
let Ok(image_data) = texture_packer::exporter::ImageExporter::export(&packer, None) else {
|
|
return Err(SpritesheetError::FailedToExportImage);
|
|
};
|
|
|
|
let mut atlas_asset = bevy_rpack::AtlasAsset {
|
|
metadata: AtlasMetadata {
|
|
skip_serialization: skip_metadata_serialization,
|
|
..Default::default()
|
|
},
|
|
size: [image_data.width(), image_data.height()],
|
|
filename: filename.as_ref().to_owned(),
|
|
frames: packer
|
|
.get_frames()
|
|
.values()
|
|
.map(|v| -> AtlasFrame {
|
|
AtlasFrame {
|
|
key: v.key.clone(),
|
|
frame: SerializableRect {
|
|
x: v.frame.x,
|
|
y: v.frame.y,
|
|
w: v.frame.w,
|
|
h: v.frame.h,
|
|
},
|
|
}
|
|
})
|
|
.collect(),
|
|
};
|
|
atlas_asset.frames.sort_by(|a, b| a.key.cmp(&b.key));
|
|
let atlas_asset_json = serde_json::to_value(&atlas_asset)
|
|
.map_err(|e| SpritesheetError::ParsingError(e.to_string()))?;
|
|
|
|
Ok(Spritesheet {
|
|
image_data,
|
|
atlas_asset,
|
|
atlas_asset_json,
|
|
})
|
|
}
|
|
|
|
#[cfg(all(feature = "dds", not(target_arch = "wasm32")))]
|
|
pub fn save_as_dds<R>(&self, output_path: R)
|
|
where
|
|
R: AsRef<Path>,
|
|
{
|
|
let rgba_image = self.image_data.to_rgba8();
|
|
|
|
let dds = image_dds::dds_from_image(
|
|
&rgba_image,
|
|
image_dds::ImageFormat::Rgba8Unorm,
|
|
image_dds::Quality::Fast,
|
|
image_dds::Mipmaps::GeneratedAutomatic,
|
|
)
|
|
.unwrap();
|
|
|
|
let mut writer =
|
|
std::io::BufWriter::new(std::fs::File::create(output_path.as_ref()).unwrap());
|
|
dds.write(&mut writer).unwrap();
|
|
}
|
|
|
|
#[cfg(all(feature = "basis", not(target_arch = "wasm32")))]
|
|
pub fn save_as_basis<R>(&self, output_path: R) -> anyhow::Result<()>
|
|
where
|
|
R: AsRef<Path>,
|
|
{
|
|
use basis_universal::{BasisTextureFormat, Compressor, Transcoder};
|
|
use image::{EncodableLayout, GenericImageView};
|
|
|
|
let rgba_image = self.image_data.to_rgba8();
|
|
|
|
let channel_count = 4;
|
|
let (pixel_width, pixel_height) = self.image_data.dimensions();
|
|
let mut compressor_params = basis_universal::CompressorParams::new();
|
|
compressor_params.set_generate_mipmaps(true);
|
|
compressor_params.set_basis_format(BasisTextureFormat::ETC1S);
|
|
compressor_params.set_etc1s_quality_level(basis_universal::ETC1S_QUALITY_MAX);
|
|
compressor_params.set_print_status_to_stdout(false);
|
|
let mut compressor_image = compressor_params.source_image_mut(0);
|
|
compressor_image.init(
|
|
rgba_image.as_bytes(),
|
|
pixel_width,
|
|
pixel_height,
|
|
channel_count,
|
|
);
|
|
|
|
//
|
|
// Create the compressor and compress
|
|
//
|
|
let mut compressor = Compressor::default();
|
|
let compression_time = unsafe {
|
|
compressor.init(&compressor_params);
|
|
let t0 = std::time::Instant::now();
|
|
compressor.process().expect("Failed to compress the image.");
|
|
let t1 = std::time::Instant::now();
|
|
t1 - t0
|
|
};
|
|
|
|
// You could write it to disk like this
|
|
let basis_file = compressor.basis_file();
|
|
let transcoder = Transcoder::new();
|
|
let mip_level_count = transcoder.image_level_count(basis_file, 0);
|
|
println!(
|
|
"Compressed {} mip levels to {} total bytes in {} ms",
|
|
mip_level_count,
|
|
compressor.basis_file_size(),
|
|
compression_time.as_secs_f64() * 1000.0
|
|
);
|
|
std::fs::write(output_path.as_ref(), basis_file)?;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Serialize, Deserialize, Default, PartialEq)]
|
|
pub struct TilemapGenerationConfig {
|
|
pub asset_patterns: Vec<String>,
|
|
pub output_path: String,
|
|
/// Image format, png by default
|
|
#[serde(skip_serializing_if = "Option::is_none", default)]
|
|
pub format: Option<SaveImageFormat>,
|
|
/// Size of the tilemap texture. Default value is `2048`.
|
|
#[serde(skip_serializing_if = "Option::is_none", default)]
|
|
pub size: Option<u32>,
|
|
/// Size of the padding between frames in pixel. Default value is `2`
|
|
#[serde(skip_serializing_if = "Option::is_none", default)]
|
|
pub texture_padding: Option<u32>,
|
|
/// Size of the padding on the outer edge of the packed image in pixel. Default value is `0`.
|
|
#[serde(skip_serializing_if = "Option::is_none", default)]
|
|
pub border_padding: Option<u32>,
|
|
#[serde(skip_serializing_if = "Option::is_none", default)]
|
|
pub skip_serializing_metadata: Option<bool>,
|
|
#[serde(skip)]
|
|
pub working_dir: Option<PathBuf>,
|
|
}
|
|
|
|
impl From<&TilemapGenerationConfig> for TexturePackerConfig {
|
|
fn from(config: &TilemapGenerationConfig) -> Self {
|
|
texture_packer::TexturePackerConfig {
|
|
max_width: config.size.unwrap_or(2048),
|
|
max_height: config.size.unwrap_or(2048),
|
|
allow_rotation: false,
|
|
force_max_dimensions: true,
|
|
border_padding: config.border_padding.unwrap_or(0),
|
|
texture_padding: config.texture_padding.unwrap_or(2),
|
|
texture_extrusion: 0,
|
|
trim: false,
|
|
texture_outlines: false,
|
|
}
|
|
}
|
|
}
|
|
impl From<&TilemapGenerationConfig> for SpritesheetBuildConfig {
|
|
fn from(config: &TilemapGenerationConfig) -> Self {
|
|
SpritesheetBuildConfig {
|
|
packer_config: config.into(),
|
|
skip_metadata_serialization: config.skip_serializing_metadata.unwrap_or_default(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(all(feature = "config_ext", not(target_arch = "wasm32")))]
|
|
impl TilemapGenerationConfig {
|
|
pub fn read_from_file<P>(path: P) -> anyhow::Result<TilemapGenerationConfig>
|
|
where
|
|
P: AsRef<Path>,
|
|
{
|
|
let config_file = std::fs::read_to_string(path.as_ref())?;
|
|
let mut config: TilemapGenerationConfig = serde_json::from_str(&config_file)?;
|
|
config.working_dir = Path::new(path.as_ref()).parent().map(|p| p.to_path_buf());
|
|
Ok(config)
|
|
}
|
|
|
|
pub fn get_file_paths_and_prefix(&self) -> (Vec<PathBuf>, String) {
|
|
let working_dir = self.working_dir();
|
|
let lossy_working_dir = working_dir.to_string_lossy();
|
|
let mut file_paths: Vec<PathBuf> = self
|
|
.asset_patterns
|
|
.iter()
|
|
.flat_map(|pattern| {
|
|
let p = format!("{}/{}", lossy_working_dir, pattern);
|
|
glob::glob(&p).expect("Wrong pattern for assets").flatten()
|
|
})
|
|
.filter(|e| e.is_file())
|
|
.collect();
|
|
file_paths.sort();
|
|
let prefix = get_common_prefix(&file_paths);
|
|
(file_paths, prefix)
|
|
}
|
|
|
|
pub fn working_dir(&self) -> PathBuf {
|
|
let dir = match &self.working_dir {
|
|
None => std::env::current_dir().expect("msg"),
|
|
Some(p) => {
|
|
if p.as_os_str().is_empty() {
|
|
std::env::current_dir().expect("msg")
|
|
} else {
|
|
p.clone()
|
|
}
|
|
}
|
|
};
|
|
let working_dir = std::path::absolute(dir).unwrap_or_default();
|
|
|
|
working_dir
|
|
}
|
|
|
|
pub fn generate(&self) -> anyhow::Result<()> {
|
|
let working_dir = self.working_dir();
|
|
|
|
let (file_paths, prefix) = self.get_file_paths_and_prefix();
|
|
let images: Vec<ImageFile> = file_paths
|
|
.iter()
|
|
.flat_map(|f| {
|
|
let id = f
|
|
.to_str()
|
|
.unwrap_or_default()
|
|
.strip_prefix(&prefix)
|
|
.unwrap_or_default();
|
|
ImageFile::at_path(f, id)
|
|
})
|
|
.collect();
|
|
let atlas_image_path = working_dir.join(format!(
|
|
"{}{}",
|
|
self.output_path,
|
|
self.format.unwrap_or_default()
|
|
));
|
|
let atlas_filename = Path::new(&atlas_image_path)
|
|
.file_name()
|
|
.expect("D")
|
|
.to_string_lossy()
|
|
.to_string();
|
|
let atlas_config_path = working_dir.join(format!("{}.rpack.json", self.output_path));
|
|
let spritesheet = Spritesheet::build(self, &images, &atlas_filename)?;
|
|
|
|
if Path::new(&atlas_config_path).exists() {
|
|
std::fs::remove_file(&atlas_config_path).expect("Could not remove the old file");
|
|
}
|
|
if Path::new(&atlas_image_path).exists() {
|
|
std::fs::remove_file(&atlas_image_path).expect("Could not remove the old file");
|
|
}
|
|
match self.format.unwrap_or_default() {
|
|
SaveImageFormat::Dds => {
|
|
#[cfg(feature = "dds")]
|
|
spritesheet.save_as_dds(&atlas_image_path);
|
|
#[cfg(not(feature = "dds"))]
|
|
panic!(
|
|
"Program is compiled without support for dds. Compile it yourself with feature `dds` enabled."
|
|
);
|
|
}
|
|
SaveImageFormat::Png => {
|
|
spritesheet
|
|
.image_data
|
|
.save_with_format(&atlas_image_path, image::ImageFormat::Png)?;
|
|
}
|
|
#[cfg(feature = "basis")]
|
|
SaveImageFormat::Basis => {
|
|
spritesheet.save_as_basis(&atlas_image_path)?;
|
|
panic!(
|
|
"Program is compiled without support for basis. Compile it yourself with feature `basis` enabled."
|
|
);
|
|
}
|
|
}
|
|
let json = serde_json::to_string_pretty(&spritesheet.atlas_asset_json)?;
|
|
let mut file = std::fs::File::create(&atlas_config_path)?;
|
|
file.write_all(json.as_bytes())?;
|
|
println!(
|
|
"Atlas from {} images saved at: {}",
|
|
spritesheet.atlas_asset.frames.len(),
|
|
atlas_config_path.display()
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
}
|