use std::collections::HashMap;
use crate::gfx::animation::AnimationSystem;
use crate::gfx::skeleton::{AnimationClip, JointTrack, Keyframe};
pub(crate) fn reload_clips_if_pending(anim: &mut AnimationSystem) {
if anim.reload_entries().is_empty()
|| !concinnity_engine::app::dev_flags::take_pending_animations()
{
return;
}
reload_clips(anim);
}
fn reload_clips(anim: &mut AnimationSystem) {
let mut parsed_cache: HashMap<String, concinnity_cook::import::gltf_source::GltfDoc> =
HashMap::new();
let assets_dir = crate::project::assets_dir();
let mut reloaded = 0usize;
let mut failed = 0usize;
let entries = anim.reload_entries().to_vec();
for entry in &entries {
let imported = if entry.source.to_lowercase().ends_with(".fbx") {
concinnity_cook::import::fbx::import_fbx_animation(
&entry.source,
entry.animation_index,
&entry.animation_name,
entry.sample_rate,
entry.skin_index,
)
} else {
match parsed_cache.get(&entry.source) {
Some(d) => Ok(d),
None => match concinnity_cook::import::glb::parse_glb(
&entry.source,
assets_dir.as_deref(),
) {
Ok(d) => Ok(&*parsed_cache.entry(entry.source.clone()).or_insert(d)),
Err(e) => Err(e),
},
}
.and_then(|doc| {
concinnity_cook::import::glb::import_glb_animation_from_doc(
doc,
&entry.source,
entry.skin_index,
entry.animation_index,
&entry.animation_name,
)
})
};
let imported = match imported {
Ok(a) => a,
Err(e) => {
tracing::error!(
"animation hot-reload: failed to import animation from '{}': {} \
(clip slot {:?}:{} kept its old keyframes)",
entry.source,
e,
entry.target,
entry.clip_index
);
failed += 1;
continue;
}
};
let clip = imported_to_clip(&imported, entry.looping);
if anim.apply_reloaded_clip(entry.target, entry.clip_index, clip, entry.weight) {
reloaded += 1;
} else {
tracing::error!(
"animation hot-reload: target {:?} clip {} no longer present (skipped)",
entry.target,
entry.clip_index
);
failed += 1;
}
}
tracing::info!(
"animation hot-reload: reloaded {} clip(s) ({} failed)",
reloaded,
failed
);
}
fn imported_to_clip(
imported: &concinnity_cook::import::glb::ImportedAnimation,
looping: bool,
) -> AnimationClip {
AnimationClip {
root: None,
duration: imported.duration.max(1e-3),
looping,
tracks: imported
.tracks
.iter()
.map(|t| JointTrack {
joint: t.joint,
keys: t
.keys
.iter()
.map(|k| Keyframe {
time: k.time,
pose: k.pose,
})
.collect(),
})
.collect(),
morph_keys: imported
.morph_track
.iter()
.map(|k| (k.time, k.weights.clone()))
.collect(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ecs::asset_id::intern;
use crate::test_support;
fn f32s(vals: &[f32]) -> Vec<u8> {
concinnity_testing::fixtures::glb::f32_bytes(vals)
}
fn u16s(vals: &[u16]) -> Vec<u8> {
concinnity_testing::fixtures::glb::u16_bytes(vals)
}
fn make_glb(json: &serde_json::Value, bin: &[u8]) -> Vec<u8> {
concinnity_testing::fixtures::glb::container(&json.to_string(), Some(bin))
}
fn skinned_glb() -> Vec<u8> {
let mut bin = f32s(&[0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0]); bin.extend(u16s(&[0, 1, 2])); bin.extend([0u8; 2]); bin.extend([0u8; 12]); bin.extend(f32s(&[
1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0,
])); bin.extend(f32s(&[0.0, 1.0])); bin.extend(f32s(&[0.0, 0.0, 0.0, 0.0, 2.0, 0.0]));
let json = serde_json::json!({
"asset": {"version": "2.0"},
"buffers": [{"byteLength": 136}],
"bufferViews": [
{"buffer": 0, "byteOffset": 0, "byteLength": 36},
{"buffer": 0, "byteOffset": 36, "byteLength": 6},
{"buffer": 0, "byteOffset": 44, "byteLength": 12},
{"buffer": 0, "byteOffset": 56, "byteLength": 48},
{"buffer": 0, "byteOffset": 104, "byteLength": 8},
{"buffer": 0, "byteOffset": 112, "byteLength": 24}
],
"accessors": [
{"bufferView": 0, "componentType": 5126, "count": 3, "type": "VEC3",
"min": [0.0, 0.0, 0.0], "max": [1.0, 1.0, 0.0]},
{"bufferView": 1, "componentType": 5123, "count": 3, "type": "SCALAR"},
{"bufferView": 2, "componentType": 5121, "count": 3, "type": "VEC4"},
{"bufferView": 3, "componentType": 5126, "count": 3, "type": "VEC4"},
{"bufferView": 4, "componentType": 5126, "count": 2, "type": "SCALAR",
"min": [0.0], "max": [1.0]},
{"bufferView": 5, "componentType": 5126, "count": 2, "type": "VEC3"}
],
"meshes": [{"primitives": [
{"attributes": {"POSITION": 0, "JOINTS_0": 2, "WEIGHTS_0": 3}, "indices": 1}
]}],
"skins": [{"joints": [2, 1]}],
"nodes": [
{"mesh": 0, "skin": 0},
{"name": "root", "children": [2], "translation": [0.0, 1.0, 0.0]},
{"name": "tip", "translation": [0.0, 0.5, 0.0]}
],
"scenes": [{"nodes": [0, 1]}],
"scene": 0,
"animations": [{
"name": "wave",
"channels": [
{"sampler": 0, "target": {"node": 2, "path": "translation"}}
],
"samplers": [
{"input": 4, "output": 5, "interpolation": "LINEAR"}
]
}]
});
make_glb(&json, &bin)
}
fn write_fixture(dir: &tempfile::TempDir) -> String {
concinnity_testing::utf8(&concinnity_testing::write_into(
dir.path(),
"hero.glb",
skinned_glb(),
))
}
fn file_backed_animation(source: &str, animation_name: &str) -> crate::components::Animation {
crate::components::Animation {
asset_id: intern("reload_clip"),
target: Some(crate::ecs::SkinnedMeshHandle(intern("reload_hero").0)),
source: source.to_string(),
animation_name: animation_name.to_string(),
..Default::default()
}
}
fn world_with_reload_entry(source: &str, animation_name: &str) -> crate::ecs::World {
concinnity_engine::app::dev_flags::set_enabled(true);
let mut world = crate::ecs::World::new();
world.add_component(file_backed_animation(source, animation_name));
let started = world.start(concinnity_engine::ecs::SYSTEMS);
concinnity_engine::app::dev_flags::set_enabled(false);
started.unwrap();
world
}
fn with_anim<R>(world: &mut crate::ecs::World, f: impl FnOnce(&mut AnimationSystem) -> R) -> R {
for system in world.systems_mut() {
if let Some(anim) = system.downcast_mut::<crate::gfx::animation::AnimationSystem>() {
return f(anim);
}
}
panic!("AnimationSystem not constructed");
}
#[test]
fn reload_rebuilds_a_clip_from_its_source() {
let _guard = test_support::lock();
let dir = tempfile::tempdir().unwrap();
let source = write_fixture(&dir);
let mut world = world_with_reload_entry(&source, "wave");
with_anim(&mut world, |anim| {
let entries = anim.reload_entries().to_vec();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].source, source);
assert_eq!(entries[0].clip_index, 0);
assert_eq!(entries[0].animation_name, "wave");
reload_clips(anim);
assert_eq!(anim.reload_entries().len(), 1);
});
}
#[test]
fn reload_with_a_missing_source_keeps_the_old_clip() {
let _guard = test_support::lock();
let dir = tempfile::tempdir().unwrap();
let source = dir
.path()
.join("missing.glb")
.to_string_lossy()
.into_owned();
let mut world = world_with_reload_entry(&source, "wave");
with_anim(&mut world, |anim| {
assert_eq!(anim.reload_entries().len(), 1);
reload_clips(anim);
assert_eq!(anim.reload_entries().len(), 1);
});
}
#[test]
fn reload_with_an_unknown_animation_name_keeps_the_old_clip() {
let _guard = test_support::lock();
let dir = tempfile::tempdir().unwrap();
let source = write_fixture(&dir);
let mut world = world_with_reload_entry(&source, "sprint");
with_anim(&mut world, |anim| {
reload_clips(anim);
assert_eq!(anim.reload_entries().len(), 1);
});
}
#[test]
fn reload_if_pending_gates_on_entries_and_the_flag() {
use concinnity_engine::app::dev_flags;
let _guard = test_support::lock();
let mut empty = AnimationSystem::new();
dev_flags::set_pending_animations();
reload_clips_if_pending(&mut empty);
assert!(
dev_flags::take_pending_animations(),
"empty catalogue must not consume the pending flag"
);
let dir = tempfile::tempdir().unwrap();
let source = write_fixture(&dir);
let mut world = world_with_reload_entry(&source, "wave");
with_anim(&mut world, |anim| {
reload_clips_if_pending(anim);
});
dev_flags::set_pending_animations();
with_anim(&mut world, |anim| {
reload_clips_if_pending(anim);
});
assert!(
!dev_flags::take_pending_animations(),
"a reload pass must consume the pending flag"
);
}
#[test]
fn imported_to_clip_maps_tracks_and_clamps_duration() {
let doc = concinnity_cook::import::gltf_source::GltfDoc::from_slice(
&skinned_glb(),
None,
"hero.glb",
)
.expect("fixture parses");
let imported = concinnity_cook::import::glb::import_glb_animation_from_doc(
&doc, "hero.glb", 0, 0, "wave",
)
.expect("fixture animation imports");
let clip = imported_to_clip(&imported, true);
assert!(clip.looping);
assert!((clip.duration - 1.0).abs() < 1e-6);
assert_eq!(clip.tracks.len(), 1);
assert_eq!(clip.tracks[0].keys.len(), 2);
assert_eq!(clip.tracks[0].keys[1].time, 1.0);
let zero = concinnity_cook::import::glb::ImportedAnimation {
morph_track: Vec::new(),
name: "flat".to_string(),
duration: 0.0,
tracks: Vec::new(),
};
let clip = imported_to_clip(&zero, false);
assert!(!clip.looping);
assert_eq!(clip.duration, 1e-3);
assert!(clip.tracks.is_empty());
}
}