diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index fdb5b2b..caa34e7 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -63,6 +63,7 @@ jobs: libxkbcommon-dev \ libxkbcommon-x11-0 \ mesa-vulkan-drivers \ + blender \ mold \ pkg-config @@ -135,6 +136,7 @@ jobs: llvm-19 \ mesa-vulkan-drivers \ mold \ + blender \ pkg-config sudo update-alternatives --install /usr/bin/clang clang /usr/bin/clang-19 100 sudo update-alternatives --install /usr/bin/clang++ clang++ /usr/bin/clang++-19 100 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/assets/.lore/config.toml b/assets/.lore/config.toml new file mode 100644 index 0000000..90b69c8 --- /dev/null +++ b/assets/.lore/config.toml @@ -0,0 +1,15 @@ +remote_url = "lore://lore.eirexe.moe" +identity = "2" + +[store] +max_capacity = 2000000 +eviction_delay = 30 +max_size = 10737418240 +compaction_delay = 50 + +[file] +direct_write = false +flush_write = false + +[vfs] +vfs_type = "None" diff --git a/assets/catalog.bsn b/assets/catalog.bsn new file mode 100644 index 0000000..16a6693 --- /dev/null +++ b/assets/catalog.bsn @@ -0,0 +1 @@ +// no catalog entries diff --git a/src/blend_asset.rs b/src/blend_asset.rs new file mode 100644 index 0000000..5d45aa1 --- /dev/null +++ b/src/blend_asset.rs @@ -0,0 +1,376 @@ +//! 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::{ + collections::HashMap, + fs, io, + path::{Path, PathBuf}, + process::Command, + time::{SystemTime, UNIX_EPOCH}, +}; + +use crate::fighter::animation::AnimKind; + +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, +} + +#[derive(Debug, Deserialize)] +struct AnimationManifest { + animations: HashMap, +} + +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"); + let actions = referenced_actions(load_context, &asset_root()).await?; + let actions_path = temp.path().join("actions.json"); + fs::write( + &actions_path, + serde_json::to_vec(&actions).map_err(io::Error::other)?, + )?; + 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) + .arg(&actions_path) + .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"] + } +} + +async fn referenced_actions( + load_context: &mut LoadContext<'_>, + root: &Path, +) -> io::Result> { + let model_path = load_context.path().path(); + let Some(stem) = model_path.file_stem().and_then(|stem| stem.to_str()) else { + return Ok(Vec::new()); + }; + let manifest_path = model_path + .parent() + .unwrap_or_else(|| Path::new("")) + .join(format!("{stem}.fighter.ron")); + if !root.join(&manifest_path).is_file() { + return Ok(Vec::new()); + } + let bytes = load_context + .read_asset_bytes(manifest_path) + .await + .map_err(|error| io::Error::other(error.to_string()))?; + let manifest: AnimationManifest = ron::de::from_bytes(&bytes) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + Ok(manifest.animations.into_values().collect()) +} + +#[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().map_or(0, Vec::len); + if image_count > 0 && !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..61efb59 --- /dev/null +++ b/src/blend_asset/export.py @@ -0,0 +1,71 @@ +import bpy +import json +import os +import sys + + +def main(): + output = sys.argv[sys.argv.index("--") + 1] + actions_path = sys.argv[sys.argv.index("--") + 2] + with open(actions_path) as file: + actions_to_keep = set(json.load(file)) + + for obj in bpy.data.objects: + if not obj.animation_data: + continue + for track in list(obj.animation_data.nla_tracks): + if track.name.startswith("[Action Stash]"): + obj.animation_data.nla_tracks.remove(track) + + nla_tracks = [ + track + for obj in bpy.data.objects + if obj.animation_data + for track in obj.animation_data.nla_tracks + ] + use_nla_tracks = bool(nla_tracks) + + # For blends without NLA tracks, use the actions referenced by the fighter + # manifest instead of sampling every source action. + if not use_nla_tracks and actions_to_keep: + from io_scene_gltf2 import GLTF2_filter_action + + if not hasattr(bpy.types.Scene, "gltf_action_filter"): + bpy.types.Scene.gltf_action_filter = bpy.props.CollectionProperty( + type=GLTF2_filter_action + ) + scene = bpy.context.scene + scene.gltf_action_filter.clear() + for action in bpy.data.actions: + item = scene.gltf_action_filter.add() + item.action = action + item.keep = action.name in actions_to_keep + + 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, + export_def_bones=True, + export_action_filter=False, + export_animation_mode="NLA_TRACKS" if use_nla_tracks else "ACTIONS", + ) + + 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/fighter/baked_animation/mod.rs b/src/fighter/baked_animation/mod.rs index a0b4663..d5b6213 100644 --- a/src/fighter/baked_animation/mod.rs +++ b/src/fighter/baked_animation/mod.rs @@ -21,6 +21,7 @@ mod tests { use asset_baking::{ Channel, ChannelValues, bake_gltf_document, baked_frame_count, sample_vec3, }; + use serde_json::json; use std::collections::HashMap; #[test] @@ -29,11 +30,7 @@ mod tests { "../../../assets/fighters/test/test.fighter.ron" )) .unwrap(); - let (document, buffers, _) = gltf::import(concat!( - env!("CARGO_MANIFEST_DIR"), - "/assets/fighters/test/test_fighter.gltf" - )) - .unwrap(); + let (document, buffers) = test_gltf(&manifest.animations); let baked = bake_gltf_document(document, buffers, &manifest.animations).unwrap(); assert_eq!(baked.frame_rate, BAKED_ANIMATION_FPS); @@ -51,6 +48,104 @@ mod tests { assert_eq!(baked.parents.len(), baked.bone_names.len()); } + /// Builds the smallest glTF document that still exercises the complete + /// animation-baking path. Keeping this fixture in memory avoids making + /// unit tests depend on the production Blender asset or Blender itself. + fn test_gltf( + animations: &HashMap, + ) -> (gltf::Document, Vec) { + let mut names: Vec<_> = animations.values().cloned().collect(); + names.sort(); + names.dedup(); + + let mut data = Vec::new(); + let mut buffer_views = Vec::new(); + let mut accessors = Vec::new(); + let mut gltf_animations = Vec::new(); + + for name in names { + let duration: f32 = match name.as_str() { + "Idle_Loop_Game" => 2.0, + "Walk_Loop_IK_Game" | "Run_Game" => 0.533333361, + "Dash_Game" => 0.333333343, + "Jump_Land_Game" => 0.133333340, + "JumpB_Game" => 56.0 / 60.0, + _ => 0.0, + }; + let input_offset = data.len(); + data.extend_from_slice(&0.0_f32.to_le_bytes()); + data.extend_from_slice(&duration.to_le_bytes()); + let output_offset = data.len(); + data.extend_from_slice( + &[0.0_f32, 0.0, 0.0] + .into_iter() + .flat_map(f32::to_le_bytes) + .collect::>(), + ); + data.extend_from_slice( + &[1.0_f32, 0.0, 0.0] + .into_iter() + .flat_map(f32::to_le_bytes) + .collect::>(), + ); + + let input_view = buffer_views.len(); + buffer_views.push(json!({ + "buffer": 0, + "byteOffset": input_offset, + "byteLength": 8 + })); + let output_view = buffer_views.len(); + buffer_views.push(json!({ + "buffer": 0, + "byteOffset": output_offset, + "byteLength": 24 + })); + let input_accessor = accessors.len(); + accessors.push(json!({ + "bufferView": input_view, + "componentType": 5126, + "count": 2, + "type": "SCALAR", + "min": [0.0], + "max": [duration] + })); + let output_accessor = accessors.len(); + accessors.push(json!({ + "bufferView": output_view, + "componentType": 5126, + "count": 2, + "type": "VEC3" + })); + gltf_animations.push(json!({ + "name": name, + "samplers": [{ + "input": input_accessor, + "output": output_accessor, + "interpolation": "LINEAR" + }], + "channels": [{ + "sampler": 0, + "target": { "node": 0, "path": "translation" } + }] + })); + } + + let document = json!({ + "asset": { "version": "2.0" }, + "scene": 0, + "scenes": [{ "nodes": [0] }], + "nodes": [{ "name": "root", "children": [1] }, { "name": "hand" }], + "buffers": [{ "byteLength": data.len() }], + "bufferViews": buffer_views, + "accessors": accessors, + "animations": gltf_animations + }); + let bytes = serde_json::to_vec(&document).unwrap(); + let gltf = gltf::Gltf::from_slice(&bytes).unwrap(); + (gltf.document, vec![gltf::buffer::Data(data)]) + } + #[test] fn pose_sampling_wraps_repeating_frames_and_clamps_finished_frames() { let first = FixedMat4::IDENTITY; diff --git a/src/lib.rs b/src/lib.rs index 9430fcf..3d1a9b1 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; @@ -89,6 +90,7 @@ pub struct GameAssetPlugin; impl Plugin for GameAssetPlugin { fn build(&self, app: &mut App) { app.add_plugins(( + blend_asset::BlendAssetProcessingPlugin, RonAssetPlugin::::create("fighter.ron"), RonAssetPlugin::::create("stage.ron"), RonAssetPlugin::::create("ron"), diff --git a/src/main.rs b/src/main.rs index 100c9d8..8138e87 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ use bevy::asset::AssetMode::Processed; #[cfg(feature = "debug_features")] use bevy::camera_controller::free_camera::FreeCameraPlugin; +use bevy::gltf::GltfPlugin; use bevy::image::{ImageAddressMode, ImagePlugin, ImageSamplerDescriptor}; use bevy::prelude::*; #[cfg(feature = "debug_features")] @@ -12,14 +13,18 @@ fn main() -> AppExit { // A `Repeat` sampler so the tileable materials authored in the // editor render the same way at runtime; Bevy's default // `ClampToEdge` smears texture edges on brush geometry. + let default_sampler = ImageSamplerDescriptor::linear() + .set_address_mode(ImageAddressMode::Repeat) + .set_anisotropic_filter(16) + .clone(); + let default_plugins = DefaultPlugins .set(ImagePlugin { - default_sampler: ImageSamplerDescriptor { - address_mode_u: ImageAddressMode::Repeat, - address_mode_v: ImageAddressMode::Repeat, - address_mode_w: ImageAddressMode::Repeat, - ..ImageSamplerDescriptor::linear() - }, + default_sampler: default_sampler.clone(), + }) + .set(GltfPlugin { + default_sampler, + ..default() }) .set(AssetPlugin { mode: Processed,