Add animation store for pre-made clips
aac.AnimationStore(folder) seeds a clip from an existing .anim asset and returns a normal ClipBuilder, so pre-made clips work anywhere generated clips do and can still be edited (looping, keyframes, toggles, blend shapes). The Unity side clones the asset into the container and applies the script's changes to the clone, leaving the original untouched. - ClipData gains optional source; looping becomes Option<bool> so an untouched store clip keeps the asset's own looping setting. - Add rust/examples/animation_store.rhai, a worked store example.
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
// Pre-made animation clips: a store seeds a clip from an `.anim` asset in the project.
|
||||
//
|
||||
// cargo run --bin aac-dump -- rust/examples/animation_store.rhai
|
||||
//
|
||||
// A store clip is a starting point. Requesting `Assets/.../<name>.anim` produces a clip that is
|
||||
// usable anywhere a generated clip is; anything written in the script (looping, keyframes, toggles,
|
||||
// blend shapes) is applied to a clone, so the pre-made asset itself is never modified. A clip the
|
||||
// script does not touch keeps the asset's own settings.
|
||||
//
|
||||
// Combine conditions with `when_all([...])`: Rhai's `&&` cannot be overloaded.
|
||||
|
||||
let aac = AnimatorAsCode();
|
||||
aac.system_name("AnimationStore");
|
||||
aac.asset_key("AAC_STORE_");
|
||||
|
||||
let speed = aac.float_param("Speed", 0.0);
|
||||
let vertical = aac.float_param("Vertical", 0.0);
|
||||
let grip = aac.float_param("Grip", 0.0);
|
||||
let crouching = aac.bool_param("Crouching", false);
|
||||
let gesture = aac.int_param("Gesture", 0);
|
||||
|
||||
let ctrl = aac.new_controller();
|
||||
let locomotion_layer = ctrl.layer("Locomotion");
|
||||
let props_layer = ctrl.layer("Props");
|
||||
|
||||
// A store per folder. The name is joined as written, so it may contain subfolders, and the `.anim`
|
||||
// extension is optional.
|
||||
let locomotion = aac.AnimationStore("Assets/Doloro/Clips/Locomotion");
|
||||
let props = aac.AnimationStore("Assets/Doloro/Clips/Props/");
|
||||
let directions = aac.AnimationStore("Assets/Doloro/Clips/Directions");
|
||||
|
||||
let idle_clip = locomotion.clip("Idle");
|
||||
let walk_clip = locomotion.clip("Walk");
|
||||
let run_clip = locomotion.clip("Run");
|
||||
|
||||
// Requesting the same asset again -- with the extension this time -- returns the same clip, so it is
|
||||
// only declared once and only one clone is generated.
|
||||
let walk_again = locomotion.clip("Walk.anim");
|
||||
|
||||
// Untouched: `Idle` is used as authored, with no looping override and no curves added here.
|
||||
let idle = locomotion_layer.state("Idle", 0, 0);
|
||||
idle.set_clip(idle_clip);
|
||||
|
||||
// Edited: `Walk` loops and toggles a prop on top of whatever the asset already animates.
|
||||
walk_clip.looping(true);
|
||||
walk_clip.toggle("Body/Props/Umbrella", true);
|
||||
let walk = locomotion_layer.state("Walk", 1, 0);
|
||||
walk.set_clip(walk_clip);
|
||||
|
||||
// Edited: `Run` forces non-looping and blends a smile in over one second.
|
||||
run_clip.looping(false);
|
||||
run_clip.blend_shape("Body", "Smile", 0.0, 0.0);
|
||||
run_clip.blend_shape("Body", "Smile", 1.0, 1.0);
|
||||
let run = locomotion_layer.state("Run", 2, 0);
|
||||
run.set_clip(run_clip);
|
||||
|
||||
// --- Blend trees over store clips
|
||||
|
||||
// A 1D tree over three store clips. `idle_clip` is shared with the `Idle` state above.
|
||||
let movement = aac.blend_tree("movement");
|
||||
movement.simple_1d(speed);
|
||||
movement.add_motion(idle_clip, 0.0);
|
||||
movement.add_motion(walk_clip, 2.0);
|
||||
movement.add_motion(run_clip, 5.0);
|
||||
|
||||
// A 2D tree over four directional assets.
|
||||
let forward = directions.clip("Forward");
|
||||
let back = directions.clip("Back");
|
||||
let left = directions.clip("Left");
|
||||
let right = directions.clip("Right");
|
||||
let strafe = aac.blend_tree("strafe");
|
||||
strafe.freeform_directional_2d(speed, vertical);
|
||||
strafe.add_motion(forward, 0.0, 1.0);
|
||||
strafe.add_motion(back, 0.0, -1.0);
|
||||
strafe.add_motion(left, -1.0, 0.0);
|
||||
strafe.add_motion(right, 1.0, 0.0);
|
||||
|
||||
// A tree may nest trees declared before it, so the two above blend into one full-body motion.
|
||||
let full_body = aac.blend_tree("full_body");
|
||||
full_body.simple_1d(speed);
|
||||
full_body.add_motion(movement, 0.0);
|
||||
full_body.add_motion(strafe, 5.0);
|
||||
|
||||
let move = locomotion_layer.state("Move", 3, 0);
|
||||
move.set_motion(full_body);
|
||||
|
||||
// A direct tree addresses a store clip by parameter, here a clip nested in a store subfolder.
|
||||
let umbrella = props.clip("Weapons/Umbrella");
|
||||
umbrella.looping(true);
|
||||
let hold = aac.blend_tree("hold");
|
||||
hold.direct();
|
||||
hold.add_motion_direct(umbrella, grip);
|
||||
|
||||
let holding = props_layer.state("Hold", 0, 0);
|
||||
holding.set_motion(hold);
|
||||
|
||||
// --- Transitions, any-state, and a sub-state machine
|
||||
|
||||
idle.transition_to(walk).when(speed > 0.1).no_exit_time().duration(0.2);
|
||||
walk.transition_to(run).when(speed > 4.0).no_exit_time().duration(0.2);
|
||||
run.transition_to(walk).when(speed < 4.0).no_exit_time().duration(0.2);
|
||||
walk.transition_to(idle).when_all([speed < 0.05, crouching == false]).duration(0.2);
|
||||
|
||||
let sit_clip = locomotion.clip("Sit");
|
||||
let sit = locomotion_layer.state("Sit", 4, 0);
|
||||
sit.set_clip(sit_clip);
|
||||
locomotion_layer.any_state().transition_to(sit).when(crouching == true).no_exit_time().duration(0.15);
|
||||
|
||||
let gestures = locomotion_layer.sub_machine("Gestures", 5, 0);
|
||||
let wave = gestures.state("Wave", 0, 0);
|
||||
let point = gestures.state("Point", 1, 0);
|
||||
wave.set_clip(props.clip("Gestures/Wave"));
|
||||
point.set_clip(props.clip("Gestures/Point"));
|
||||
wave.transition_to(point).when(gesture == 1).duration(0.1);
|
||||
point.transition_to(wave).when(gesture == 0).duration(0.1);
|
||||
@@ -35,6 +35,14 @@ let run_clip = aac.clip("run_anim");
|
||||
run_clip.looping(true);
|
||||
run_clip.toggle("Body/Props", false);
|
||||
|
||||
// A pre-made clip: it is cloned into the container at generation time, so it can be edited here
|
||||
// without touching the asset stored in Assets/Doloro/Clips.
|
||||
let store = aac.AnimationStore("Assets/Doloro/Clips");
|
||||
let crouch_clip = store.clip("Crouch");
|
||||
crouch_clip.looping(true);
|
||||
crouch_clip.toggle("Body/Props", true);
|
||||
sit.set_clip(crouch_clip);
|
||||
|
||||
idle.set_clip(idle_clip);
|
||||
walk.set_clip(walk_clip);
|
||||
|
||||
|
||||
@@ -87,6 +87,13 @@ pub struct ClipBuilder {
|
||||
pub(crate) name: String,
|
||||
}
|
||||
|
||||
/// A folder of pre-made `AnimationClip` assets, resolved on the Unity side at generation time.
|
||||
#[derive(Clone)]
|
||||
pub struct AnimationStore {
|
||||
pub(crate) aac: Aac,
|
||||
pub(crate) folder: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct BlendTreeBuilder {
|
||||
pub(crate) aac: Aac,
|
||||
@@ -197,6 +204,12 @@ impl ClipBuilder {
|
||||
self.aac.write(|graph| clip_mut(graph, &name).map(f))
|
||||
}
|
||||
|
||||
/// Override the clip's looping setting. A store clip with no override keeps the source asset's own setting.
|
||||
pub fn set_looping(&self, value: bool) -> Result<(), String> {
|
||||
self.data_mut(|data| data.looping = Some(value));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Append one keyframe to the curve for (path, property), creating the curve if needed.
|
||||
pub fn push_key(&self, path: &str, property: &str, key: Keyframe) -> Result<(), String> {
|
||||
let target = infer_target(property).ok_or_else(|| {
|
||||
@@ -212,6 +225,38 @@ impl ClipBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
impl AnimationStore {
|
||||
/// Resolve `folder/<name>.anim` and declare it as a clip the first time it is requested. The clip
|
||||
/// name is the asset path, so the same asset seen through different names is still one clip.
|
||||
pub fn clip(&self, name: &str) -> Result<ClipBuilder, String> {
|
||||
if name.trim().is_empty() {
|
||||
return Err("animation store clip name is empty".to_string());
|
||||
}
|
||||
let mut path = format!(
|
||||
"{}/{}",
|
||||
self.folder.trim_end_matches('/'),
|
||||
name.trim_start_matches('/')
|
||||
);
|
||||
if !path.ends_with(".anim") {
|
||||
path.push_str(".anim");
|
||||
}
|
||||
self.aac.write(|graph| {
|
||||
if !graph.clips.iter().any(|clip| clip.source.as_deref() == Some(path.as_str())) {
|
||||
graph.clips.push(ClipData {
|
||||
name: path.clone(),
|
||||
looping: None,
|
||||
curves: Vec::new(),
|
||||
source: Some(path.clone()),
|
||||
});
|
||||
}
|
||||
});
|
||||
Ok(ClipBuilder {
|
||||
aac: self.aac.clone(),
|
||||
name: path,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl BlendTreeBuilder {
|
||||
pub fn data_mut<R>(&self, f: impl FnOnce(&mut BlendTreeData) -> R) -> Option<R> {
|
||||
let name = self.name.clone();
|
||||
|
||||
+7
-1
@@ -111,8 +111,14 @@ pub enum CondMode {
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ClipData {
|
||||
pub name: String,
|
||||
pub looping: bool,
|
||||
/// `None` for a store clip that leaves the source asset's looping setting alone.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub looping: Option<bool>,
|
||||
pub curves: Vec<Curve>,
|
||||
/// Set when the clip is seeded from an asset (see `AnimationStore`): the project-relative asset
|
||||
/// path. Omitted for generated clips so their JSON stays unchanged.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub source: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
|
||||
+28
-4
@@ -5,6 +5,7 @@ use rhai::{Engine, EvalAltResult};
|
||||
|
||||
pub fn register(engine: &mut Engine) {
|
||||
register_clips(engine);
|
||||
register_animation_store(engine);
|
||||
register_blend_trees(engine);
|
||||
register_state_motion(engine);
|
||||
}
|
||||
@@ -32,8 +33,9 @@ fn register_clips(engine: &mut Engine) {
|
||||
a.write(|graph| {
|
||||
graph.clips.push(ClipData {
|
||||
name: name.to_string(),
|
||||
looping: false,
|
||||
looping: Some(false),
|
||||
curves: Vec::new(),
|
||||
source: None,
|
||||
})
|
||||
});
|
||||
Ok(ClipBuilder {
|
||||
@@ -42,9 +44,9 @@ fn register_clips(engine: &mut Engine) {
|
||||
})
|
||||
});
|
||||
|
||||
engine.register_fn("looping", |clip: ClipBuilder, value: bool| {
|
||||
clip.data_mut(|data| data.looping = value);
|
||||
clip
|
||||
engine.register_fn("looping", |clip: ClipBuilder, value: bool| -> Result<ClipBuilder, Box<EvalAltResult>> {
|
||||
clip.set_looping(value).map_err(err)?;
|
||||
Ok(clip)
|
||||
});
|
||||
|
||||
engine.register_fn(
|
||||
@@ -77,6 +79,28 @@ fn register_clips(engine: &mut Engine) {
|
||||
);
|
||||
}
|
||||
|
||||
fn register_animation_store(engine: &mut Engine) {
|
||||
engine.register_fn(
|
||||
"AnimationStore",
|
||||
|a: &mut Aac, folder: &str| -> Result<AnimationStore, Box<EvalAltResult>> {
|
||||
if folder.trim().is_empty() {
|
||||
return Err(err("animation store folder is empty".to_string()));
|
||||
}
|
||||
Ok(AnimationStore {
|
||||
aac: a.clone(),
|
||||
folder: folder.to_string(),
|
||||
})
|
||||
},
|
||||
);
|
||||
|
||||
engine.register_fn(
|
||||
"clip",
|
||||
|store: AnimationStore, name: &str| -> Result<ClipBuilder, Box<EvalAltResult>> {
|
||||
store.clip(name).map_err(err)
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn register_blend_trees(engine: &mut Engine) {
|
||||
engine.register_fn("blend_tree", |a: &mut Aac, name: &str| -> Result<BlendTreeBuilder, Box<EvalAltResult>> {
|
||||
if name.trim().is_empty() {
|
||||
|
||||
@@ -89,6 +89,7 @@ pub fn engine(aac: Aac) -> Engine {
|
||||
engine.register_type_with_name::<StateRef>("State");
|
||||
engine.register_type_with_name::<TransitionRef>("Transition");
|
||||
engine.register_type_with_name::<ClipBuilder>("Clip");
|
||||
engine.register_type_with_name::<AnimationStore>("AnimationStore");
|
||||
engine.register_type_with_name::<BlendTreeBuilder>("BlendTree");
|
||||
|
||||
register_conditions(&mut engine);
|
||||
|
||||
+118
-1
@@ -2,6 +2,7 @@ use aac::graph::*;
|
||||
use serde_json::Value;
|
||||
|
||||
const AVATAR: &str = include_str!("../examples/avatar.rhai");
|
||||
const ANIMATION_STORE: &str = include_str!("../examples/animation_store.rhai");
|
||||
|
||||
fn json(script: &str) -> Value {
|
||||
serde_json::from_str(&aac::evaluate_to_json(script).expect("script should evaluate")).expect("valid json")
|
||||
@@ -37,7 +38,7 @@ fn example_script_describes_the_whole_controller() {
|
||||
assert_eq!(value["system_name"], "MyAvatar");
|
||||
assert_eq!(value["asset_key"], "AAC_");
|
||||
assert_eq!(value["parameters"].as_array().unwrap().len(), 3);
|
||||
assert_eq!(value["clips"].as_array().unwrap().len(), 3);
|
||||
assert_eq!(value["clips"].as_array().unwrap().len(), 4);
|
||||
assert_eq!(value["blend_trees"].as_array().unwrap().len(), 1);
|
||||
|
||||
let layers = value["controller"]["layers"].as_array().unwrap();
|
||||
@@ -107,6 +108,122 @@ fn curve_of<'a>(clip: &'a Value, property: &str) -> &'a Value {
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn animation_store_clips_are_referenced_not_generated() {
|
||||
let value = json(
|
||||
r#"
|
||||
let aac = AnimatorAsCode();
|
||||
aac.system_name("S");
|
||||
aac.asset_key("K");
|
||||
let store = aac.AnimationStore("Assets/Doloro/Clips/");
|
||||
let walk = store.clip("Walk");
|
||||
let ctrl = aac.new_controller();
|
||||
let base = ctrl.layer("Base");
|
||||
let s = base.state("S", 0, 0);
|
||||
s.set_clip(walk);
|
||||
"#,
|
||||
);
|
||||
let clips = value["clips"].as_array().unwrap();
|
||||
assert_eq!(clips.len(), 1);
|
||||
assert_eq!(clips[0]["name"], "Assets/Doloro/Clips/Walk.anim");
|
||||
assert_eq!(clips[0]["source"], "Assets/Doloro/Clips/Walk.anim");
|
||||
assert!(clips[0]["curves"].as_array().unwrap().is_empty());
|
||||
// Untouched, it keeps the source asset's looping setting.
|
||||
assert!(clips[0].get("looping").is_none());
|
||||
|
||||
// A generated clip keeps its old shape: no `source` key at all.
|
||||
let generated = json(
|
||||
r#"
|
||||
let aac = AnimatorAsCode();
|
||||
aac.system_name("S");
|
||||
aac.asset_key("K");
|
||||
let c = aac.clip("c");
|
||||
"#,
|
||||
);
|
||||
assert!(generated["clips"][0].get("source").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn animation_store_resolves_one_clip_per_asset() {
|
||||
// The same asset asked for twice, with and without the extension, stays a single clip.
|
||||
let value = json(
|
||||
r#"
|
||||
let aac = AnimatorAsCode();
|
||||
aac.system_name("S");
|
||||
aac.asset_key("K");
|
||||
let store = aac.AnimationStore("Assets/Doloro/Clips");
|
||||
let x = store.clip("Walk");
|
||||
let y = store.clip("Walk.anim");
|
||||
let ctrl = aac.new_controller();
|
||||
let base = ctrl.layer("Base");
|
||||
let s = base.state("S", 0, 0);
|
||||
s.set_clip(x);
|
||||
"#,
|
||||
);
|
||||
assert_eq!(value["clips"].as_array().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn animation_store_clips_can_be_edited_on_a_clone() {
|
||||
let value = json(
|
||||
r#"
|
||||
let aac = AnimatorAsCode();
|
||||
aac.system_name("S");
|
||||
aac.asset_key("K");
|
||||
let store = aac.AnimationStore("Assets/Doloro/Clips");
|
||||
let walk = store.clip("Walk");
|
||||
walk.looping(true);
|
||||
walk.keyframe("Body", "m_IsActive", 0.0, 1.0);
|
||||
let ctrl = aac.new_controller();
|
||||
let base = ctrl.layer("Base");
|
||||
let s = base.state("S", 0, 0);
|
||||
s.set_clip(walk);
|
||||
"#,
|
||||
);
|
||||
let clip = &value["clips"][0];
|
||||
// The source is still carried, so the Unity side knows which asset to clone.
|
||||
assert_eq!(clip["source"], "Assets/Doloro/Clips/Walk.anim");
|
||||
assert_eq!(clip["looping"], true);
|
||||
assert_eq!(clip["curves"][0]["property"], "m_IsActive");
|
||||
}
|
||||
|
||||
/// The store example is a whole controller, so evaluating it exercises every store path end to end.
|
||||
#[test]
|
||||
fn animation_store_example_sources_every_clip() {
|
||||
let value = json(ANIMATION_STORE);
|
||||
|
||||
assert_eq!(value["system_name"], "AnimationStore");
|
||||
assert_eq!(value["parameters"].as_array().unwrap().len(), 5);
|
||||
assert_eq!(value["controller"]["layers"].as_array().unwrap().len(), 2);
|
||||
|
||||
// Every clip comes from an asset, and each asset is declared exactly once.
|
||||
let clips = value["clips"].as_array().unwrap();
|
||||
let sources: Vec<&str> = clips
|
||||
.iter()
|
||||
.map(|clip| clip["source"].as_str().expect("every clip has a source"))
|
||||
.collect();
|
||||
let unique: std::collections::HashSet<&str> = sources.iter().copied().collect();
|
||||
assert_eq!(unique.len(), sources.len(), "the same asset must not be declared twice");
|
||||
assert_eq!(clips.len(), 11);
|
||||
|
||||
// An edited clip carries its override and curves; an untouched one carries neither.
|
||||
let walk = find_clip(&value, "Assets/Doloro/Clips/Locomotion/Walk.anim");
|
||||
assert_eq!(walk["looping"], true);
|
||||
assert_eq!(curve_of(walk, "m_IsActive")["target"], "game_object");
|
||||
let idle = find_clip(&value, "Assets/Doloro/Clips/Locomotion/Idle.anim");
|
||||
assert!(idle.get("looping").is_none());
|
||||
assert!(idle["curves"].as_array().unwrap().is_empty());
|
||||
|
||||
// A store clip feeds a state and a blend tree, and a tree may nest a tree declared before it.
|
||||
let trees = value["blend_trees"].as_array().unwrap();
|
||||
assert_eq!(trees.len(), 4);
|
||||
let movement = trees.iter().find(|t| t["name"] == "movement").unwrap();
|
||||
assert_eq!(movement["children"].as_array().unwrap().len(), 3);
|
||||
let full_body = trees.iter().find(|t| t["name"] == "full_body").unwrap();
|
||||
assert_eq!(full_body["children"][0]["motion"]["type"], "blend_tree");
|
||||
assert_eq!(full_body["children"][0]["motion"]["name"], "movement");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyframes_are_sorted_by_time() {
|
||||
let value = json(
|
||||
|
||||
Reference in New Issue
Block a user