diff --git a/Cargo.toml b/Cargo.toml index f128cdb..1c6328b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ development = ["bevy/dynamic_linking", "bevy/file_watcher"] debug_features = ["bevy/free_camera"] [dependencies] -bevy = { version = "0.19", features = ["asset_processor", "basis-universal", "bevy_settings"] } +bevy = { version = "0.19", features = ["asset_processor", "basis-universal", "compressed_image_saver", "bevy_settings"] } # Loads the scenes you author in the editor (assets/*.bsn) and builds # physics colliders from them. An ordinary dependency; remove it if you # stop using authored scenes. diff --git a/src/blend_asset.rs b/src/blend_asset.rs new file mode 100644 index 0000000..035160a --- /dev/null +++ b/src/blend_asset.rs @@ -0,0 +1,340 @@ +//! Blender `.blend` source processing. +//! +//! Blender is used as an offline importer. The processed result is a normal +//! GLB, so the runtime continues to use Bevy's built-in glTF loader. + +use bevy::{ + asset::{ + AssetLoader, AssetPath, AsyncWriteExt, LoadContext, + io::{Reader, Writer}, + processor::LoadTransformAndSave, + saver::{AssetSaver, SavedAsset}, + transformer::{AssetTransformer, TransformedAsset}, + }, + gltf::GltfLoader, + prelude::*, + reflect::TypePath, +}; +use serde::Deserialize; +use std::{ + fs, io, + path::{Path, PathBuf}, + process::Command, + time::{SystemTime, UNIX_EPOCH}, +}; + +const EXPORT_SCRIPT: &str = include_str!("blend_asset/export.py"); + +#[derive(Asset, TypePath)] +struct BlendSource { + glb: Vec, +} + +#[derive(Default, TypePath)] +struct BlendSourceLoader; + +#[derive(Debug, Deserialize)] +struct ExportManifest { + dependencies: Vec, +} + +impl AssetLoader for BlendSourceLoader { + type Asset = BlendSource; + type Settings = (); + type Error = io::Error; + + async fn load( + &self, + reader: &mut dyn Reader, + _: &Self::Settings, + load_context: &mut LoadContext<'_>, + ) -> Result { + let mut source_bytes = Vec::new(); + reader.read_to_end(&mut source_bytes).await?; + + let temp = TempDir::new()?; + let source_path = locate_source(load_context.path().path()); + let blend_path = if let Some(path) = source_path.as_deref() { + path.to_path_buf() + } else { + let path = temp.path().join("source.blend"); + fs::write(&path, source_bytes)?; + path + }; + let output = temp.path().join("export.gltf"); + let script = temp.path().join("export.py"); + fs::write(&script, EXPORT_SCRIPT)?; + + let result = Command::new(std::env::var_os("BLENDER").unwrap_or_else(|| "blender".into())) + .arg("--background") + .arg(&blend_path) + .arg("--python") + .arg(&script) + .arg("--") + .arg(&output) + .output() + .map_err(|error| io::Error::other(format!("failed to start Blender: {error}")))?; + if !result.status.success() { + return Err(io::Error::other(format!( + "Blender export failed with {}:\n{}\n{}", + result.status, + String::from_utf8_lossy(&result.stdout), + String::from_utf8_lossy(&result.stderr) + ))); + } + + let manifest: ExportManifest = + serde_json::from_slice(&fs::read(temp.path().join("export-manifest.json"))?) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + + let asset_root = asset_root(); + let mut dependencies = manifest.dependencies; + let document: serde_json::Value = serde_json::from_slice(&fs::read(&output)?) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + if let Some(images) = document.get("images").and_then(serde_json::Value::as_array) { + for image in images { + if let Some(uri) = image.get("uri").and_then(serde_json::Value::as_str) { + let path = temp.path().join(uri); + dependencies.push(path.to_string_lossy().into_owned()); + } + } + } + + let mut seen = std::collections::HashSet::new(); + for dependency in dependencies { + let physical = PathBuf::from(&dependency); + let Some(asset_path) = physical_to_asset_path(&physical, &asset_root) else { + continue; + }; + if seen.insert(asset_path.clone()) { + // This read is intentionally discarded: it registers the file and + // its processed hash as a processor dependency. + load_context + .read_asset_bytes(asset_path) + .await + .map_err(|error| { + io::Error::other(format!("failed to read blend dependency: {error}")) + })?; + } + } + + let glb = make_glb(&output, &temp, load_context.path().path(), &asset_root)?; + Ok(BlendSource { glb }) + } + + fn extensions(&self) -> &[&str] { + &["blend"] + } +} + +#[derive(Asset, TypePath)] +struct ProcessedBlend { + bytes: Vec, +} + +#[derive(Default, TypePath)] +struct BlendTransformer; + +impl AssetTransformer for BlendTransformer { + type AssetInput = BlendSource; + type AssetOutput = ProcessedBlend; + type Settings = (); + type Error = io::Error; + + async fn transform<'a>( + &'a self, + source: TransformedAsset, + _: &'a Self::Settings, + ) -> Result, Self::Error> { + let bytes = source.glb.clone(); + Ok(source.replace_asset(ProcessedBlend { bytes })) + } +} + +#[derive(Default, TypePath)] +struct BlendSaver; + +impl AssetSaver for BlendSaver { + type Asset = ProcessedBlend; + type Settings = (); + type OutputLoader = GltfLoader; + type Error = io::Error; + + async fn save( + &self, + writer: &mut Writer, + asset: SavedAsset<'_, '_, Self::Asset>, + _: &Self::Settings, + _: AssetPath<'_>, + ) -> Result<::Settings, Self::Error> { + writer.write_all(&asset.bytes).await?; + Ok(default()) + } +} + +type BlendProcessor = LoadTransformAndSave; + +pub struct BlendAssetProcessingPlugin; + +impl Plugin for BlendAssetProcessingPlugin { + fn build(&self, app: &mut App) { + app.init_asset::() + .init_asset::() + .register_asset_loader(BlendSourceLoader) + .register_asset_processor(BlendProcessor::new(BlendTransformer, BlendSaver)) + .set_default_asset_processor::("blend"); + } +} + +struct TempDir(PathBuf); + +impl TempDir { + fn new() -> io::Result { + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let path = + std::env::temp_dir().join(format!("shinespark-blend-{}-{stamp}", std::process::id())); + fs::create_dir_all(&path)?; + Ok(Self(path)) + } + fn path(&self) -> &Path { + &self.0 + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +fn asset_root() -> PathBuf { + std::env::var_os("BEVY_ASSET_ROOT") + .map(PathBuf::from) + .unwrap_or_else(|| std::env::current_dir().unwrap_or_default().join("assets")) +} + +fn locate_source(path: &Path) -> Option { + let root = asset_root(); + [root.join(path), PathBuf::from(path)] + .into_iter() + .find(|candidate| candidate.is_file()) +} + +fn physical_to_asset_path(path: &Path, root: &Path) -> Option { + let canonical = fs::canonicalize(path).ok()?; + let root = fs::canonicalize(root).ok()?; + let relative = canonical.strip_prefix(root).ok()?; + Some(relative.to_string_lossy().replace('\\', "/")) +} + +fn make_glb( + gltf_path: &Path, + temp: &TempDir, + model_asset_path: &Path, + asset_root: &Path, +) -> io::Result> { + let mut json: serde_json::Value = serde_json::from_slice(&fs::read(gltf_path)?) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + let bin_name = json["buffers"][0]["uri"].as_str().ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidData, "glTF has no binary buffer URI") + })?; + let mut bin = fs::read(temp.path().join(bin_name))?; + let model_parent = model_asset_path.parent().unwrap_or_else(|| Path::new("")); + let image_count = json["images"] + .as_array() + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "glTF images are not an array"))? + .len(); + if !json["bufferViews"].is_array() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "glTF bufferViews are not an array", + )); + } + for index in 0..image_count { + let uri = json["images"][index] + .get("uri") + .and_then(serde_json::Value::as_str) + .map(str::to_owned); + let Some(uri) = uri else { continue }; + if uri.starts_with("data:") { + continue; + } + let uri_path = temp.path().join(uri); + if let Some(asset_path) = physical_to_asset_path(&uri_path, asset_root) { + json["images"][index]["uri"] = + serde_json::Value::String(relative_uri(model_parent, Path::new(&asset_path))); + } else { + let bytes = fs::read(&uri_path)?; + while bin.len() % 4 != 0 { + bin.push(0); + } + let offset = bin.len(); + bin.extend_from_slice(&bytes); + let mime = mime_type(&uri_path); + let view = json["bufferViews"].as_array().unwrap().len(); + json["bufferViews"] + .as_array_mut() + .unwrap() + .push(serde_json::json!({ + "buffer": 0, + "byteOffset": offset, + "byteLength": bytes.len(), + })); + json["images"][index].as_object_mut().unwrap().remove("uri"); + json["images"][index]["bufferView"] = serde_json::Value::from(view); + json["images"][index]["mimeType"] = serde_json::Value::String(mime.to_owned()); + } + } + json["buffers"][0]["byteLength"] = serde_json::Value::from(bin.len()); + json["buffers"][0].as_object_mut().unwrap().remove("uri"); + let mut json_bytes = serde_json::to_vec(&json).map_err(io::Error::other)?; + while json_bytes.len() % 4 != 0 { + json_bytes.push(b' '); + } + while bin.len() % 4 != 0 { + bin.push(0); + } + let length = 12 + 8 + json_bytes.len() + 8 + bin.len(); + let mut glb = Vec::with_capacity(length); + glb.extend_from_slice(b"glTF"); + glb.extend_from_slice(&2u32.to_le_bytes()); + glb.extend_from_slice(&(length as u32).to_le_bytes()); + glb.extend_from_slice(&(json_bytes.len() as u32).to_le_bytes()); + glb.extend_from_slice(b"JSON"); + glb.extend_from_slice(&json_bytes); + glb.extend_from_slice(&(bin.len() as u32).to_le_bytes()); + glb.extend_from_slice(b"BIN\0"); + glb.extend_from_slice(&bin); + Ok(glb) +} + +fn relative_uri(from: &Path, to: &Path) -> String { + let from: Vec<_> = from.components().collect(); + let to: Vec<_> = to.components().collect(); + let common = from.iter().zip(&to).take_while(|(a, b)| a == b).count(); + let mut result = PathBuf::new(); + for _ in common..from.len() { + result.push(".."); + } + for component in &to[common..] { + result.push(component.as_os_str()); + } + result.to_string_lossy().replace('\\', "/") +} + +fn mime_type(path: &Path) -> &'static str { + match path + .extension() + .and_then(|e| e.to_str()) + .unwrap_or_default() + .to_ascii_lowercase() + .as_str() + { + "jpg" | "jpeg" => "image/jpeg", + "webp" => "image/webp", + _ => "image/png", + } +} diff --git a/src/blend_asset/export.py b/src/blend_asset/export.py new file mode 100644 index 0000000..703fad7 --- /dev/null +++ b/src/blend_asset/export.py @@ -0,0 +1,33 @@ +import bpy +import json +import os +import sys + + +def main(): + output = sys.argv[sys.argv.index("--") + 1] + bpy.ops.export_scene.gltf( + filepath=output, + export_format="GLTF_SEPARATE", + export_keep_originals=True, + export_extras=True, + export_animations=True, + export_skins=True, + export_morph=True, + export_yup=True, + ) + + dependencies = [] + for image in bpy.data.images: + if image.source not in {"FILE", "TILED"}: + continue + path = image.filepath_raw or image.filepath + if path and not image.packed_file: + dependencies.append(bpy.path.abspath(path)) + + with open(os.path.join(os.path.dirname(output), "export-manifest.json"), "w") as file: + json.dump({"dependencies": sorted(set(dependencies))}, file) + + +if __name__ == "__main__": + main() diff --git a/src/fighter/baked_animation/asset_baking.rs b/src/fighter/baked_animation/asset_baking.rs index 1e8cbd4..37fe861 100644 --- a/src/fighter/baked_animation/asset_baking.rs +++ b/src/fighter/baked_animation/asset_baking.rs @@ -2,8 +2,8 @@ use super::{ BAKED_ANIMATION_FPS, runtime::{BakedAnimationClip, BakedFighterAnimations}, }; -use crate::math::fixed_transform::FixedMat4; use crate::fighter::{animation::AnimKind, manifest::FighterManifest}; +use crate::math::fixed_transform::FixedMat4; use bevy::{ asset::{ AssetLoader, AssetPath, AsyncWriteExt, LoadContext, @@ -51,6 +51,9 @@ impl AssetLoader for FighterBakeSourceLoader { .map_err(|error| io::Error::other(error.to_string()))?; let manifest: FighterManifest = ron::de::from_bytes(&manifest_bytes) .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + // `read_asset_bytes` uses the processor's processed asset source. This + // deliberately allows `model_path` to point at a `.blend`: the blend + // processor has already converted it to a hybrid GLB by this point. let model_bytes = load_context .read_asset_bytes(&manifest.model_path) .await diff --git a/src/lib.rs b/src/lib.rs index 9430fcf..994b87a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -38,6 +38,7 @@ use crate::{ }; mod args; +pub mod blend_asset; pub mod camera; pub mod debug_tools; pub mod fighter; @@ -179,6 +180,7 @@ impl Plugin for AssetProcessingPlugin { fn build(&self, app: &mut App) { app.add_plugins(( GameAssetPlugin, + blend_asset::BlendAssetProcessingPlugin, scripting::importer::FighterScriptImportPlugin, fighter::baked_animation::BakedAnimationAssetProcessingPlugin, ));