use alloc::format;
use alloc::string::String;
use alloc::vec::Vec;
use crate::ecs::asset_id::{AssetId, de_opt_asset_ref};
use crate::ecs::{SkinnedMeshHandle, de_opt_skinned_mesh_handle};
use crate::gfx::anim_graph::{
Blend1D, Blend2D, ClipPlay, CmpOp, CompiledCondition, CompiledGraph, CompiledState,
CompiledTransition, ParamSpec, StatePlay,
};
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct AnimationParam {
pub name: String,
pub default: f32,
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct AnimationBlendPoint {
pub value: f32,
#[serde(deserialize_with = "de_opt_asset_ref")]
pub clip: Option<AssetId>,
}
#[derive(Debug, Clone)]
pub enum AnimationBlend {
Blend1d {
parameter: String,
points: Vec<AnimationBlendPoint>,
sync: bool,
},
Blend2d {
parameter_x: String,
parameter_y: String,
x_values: Vec<f32>,
y_values: Vec<f32>,
rows: Vec<Vec<AssetId>>,
sync: bool,
},
}
#[derive(serde::Serialize, serde::Deserialize)]
#[serde(tag = "kind", rename_all = "lowercase")]
enum GraphBlendTagged {
Blend1d {
parameter: String,
points: Vec<AnimationBlendPoint>,
#[serde(default)]
sync: bool,
},
Blend2d {
parameter_x: String,
parameter_y: String,
x_values: Vec<f32>,
y_values: Vec<f32>,
rows: Vec<Vec<AssetId>>,
#[serde(default)]
sync: bool,
},
}
#[derive(serde::Serialize, serde::Deserialize)]
enum GraphBlendPlain {
Blend1d {
parameter: String,
points: Vec<AnimationBlendPoint>,
sync: bool,
},
Blend2d {
parameter_x: String,
parameter_y: String,
x_values: Vec<f32>,
y_values: Vec<f32>,
rows: Vec<Vec<AssetId>>,
sync: bool,
},
}
macro_rules! graph_blend_from {
($src:ident, $dst:ident, $value:expr) => {
match $value {
$src::Blend1d {
parameter,
points,
sync,
} => $dst::Blend1d {
parameter,
points,
sync,
},
$src::Blend2d {
parameter_x,
parameter_y,
x_values,
y_values,
rows,
sync,
} => $dst::Blend2d {
parameter_x,
parameter_y,
x_values,
y_values,
rows,
sync,
},
}
};
}
impl serde::Serialize for AnimationBlend {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
let cloned = self.clone();
if s.is_human_readable() {
graph_blend_from!(AnimationBlend, GraphBlendTagged, cloned).serialize(s)
} else {
graph_blend_from!(AnimationBlend, GraphBlendPlain, cloned).serialize(s)
}
}
}
impl<'de> serde::Deserialize<'de> for AnimationBlend {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
if d.is_human_readable() {
let b = GraphBlendTagged::deserialize(d)?;
Ok(graph_blend_from!(GraphBlendTagged, AnimationBlend, b))
} else {
let b = GraphBlendPlain::deserialize(d)?;
Ok(graph_blend_from!(GraphBlendPlain, AnimationBlend, b))
}
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct AnimationState {
pub name: String,
#[serde(deserialize_with = "de_opt_asset_ref")]
pub clip: Option<AssetId>,
pub blend: Option<AnimationBlend>,
pub rate: f32,
pub loop_override: Option<bool>,
}
impl Default for AnimationState {
fn default() -> Self {
Self {
name: String::new(),
clip: None,
blend: None,
rate: 1.0,
loop_override: None,
}
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct AnimationIkChain {
pub joints: Vec<String>,
pub pole: [f32; 3],
pub weight_parameter: String,
pub foot_height: f32,
}
impl Default for AnimationIkChain {
fn default() -> Self {
Self {
joints: Vec::new(),
pole: [0.0, 0.0, 1.0],
weight_parameter: String::new(),
foot_height: 0.0,
}
}
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct AnimationCondition {
pub parameter: String,
pub op: CmpOp,
pub value: f32,
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct AnimationTransition {
pub from: String,
pub to: String,
pub duration_secs: f32,
pub exit_time: Option<f32>,
pub conditions: Vec<AnimationCondition>,
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct AnimationGraph {
#[serde(skip)]
pub asset_id: AssetId,
#[serde(deserialize_with = "de_opt_skinned_mesh_handle")]
pub target: Option<SkinnedMeshHandle>,
pub parameters: Vec<AnimationParam>,
pub initial: String,
pub states: Vec<AnimationState>,
pub transitions: Vec<AnimationTransition>,
pub ik_chains: Vec<AnimationIkChain>,
}
impl AnimationGraph {
pub fn compile(
&self,
resolve_clip: impl Fn(AssetId) -> Option<(usize, f32, bool)>,
) -> Result<CompiledGraph, String> {
let ctx = |detail: String| format!("AnimationGraph {}: {detail}", self.asset_id);
if self.states.is_empty() {
return Err(ctx("graph has no states".into()));
}
let params: Vec<ParamSpec> = self
.parameters
.iter()
.map(|p| ParamSpec {
name: p.name.clone(),
default: p.default,
})
.collect();
let param_index = |name: &str| params.iter().position(|p| p.name == name);
let state_index = |name: &str| self.states.iter().position(|s| s.name == name);
let mut states: Vec<CompiledState> = Vec::with_capacity(self.states.len());
for s in &self.states {
if s.rate <= 0.0 {
return Err(ctx(format!("state '{}': rate must be positive", s.name)));
}
let play_for = |clip_id: AssetId| -> Result<(ClipPlay, bool), String> {
let Some((clip, duration_secs, clip_looping)) = resolve_clip(clip_id) else {
return Err(ctx(format!(
"state '{}': clip {clip_id} is not a clip on the graph's target",
s.name
)));
};
Ok((
ClipPlay {
clip,
duration_secs,
},
clip_looping,
))
};
let (play, default_looping) = match (&s.clip, &s.blend) {
(Some(_), Some(_)) => {
return Err(ctx(format!(
"state '{}' sets both `clip` and `blend`; pick one",
s.name
)));
}
(None, None) => {
return Err(ctx(format!("state '{}' has no `clip` or `blend`", s.name)));
}
(Some(clip_id), None) => {
let (clip_play, clip_looping) = play_for(*clip_id)?;
(StatePlay::Clip(clip_play), clip_looping)
}
(None, Some(blend)) => (compile_blend(s, blend, ¶m_index, &play_for)?, true),
};
states.push(CompiledState {
name: s.name.clone(),
rate: s.rate,
looping: s.loop_override.unwrap_or(default_looping),
play,
transitions: Vec::new(),
});
}
for t in &self.transitions {
let Some(from) = state_index(&t.from) else {
return Err(ctx(format!("transition from unknown state '{}'", t.from)));
};
let Some(to) = state_index(&t.to) else {
return Err(ctx(format!("transition to unknown state '{}'", t.to)));
};
let mut conditions = Vec::with_capacity(t.conditions.len());
for c in &t.conditions {
let Some(param) = param_index(&c.parameter) else {
return Err(ctx(format!(
"transition '{}' -> '{}' references undeclared parameter '{}'",
t.from, t.to, c.parameter
)));
};
conditions.push(CompiledCondition {
param,
op: c.op,
value: c.value,
});
}
states[from].transitions.push(CompiledTransition {
to,
duration_secs: t.duration_secs.max(0.0),
exit_time: t.exit_time,
conditions,
});
}
let initial = if self.initial.is_empty() {
0
} else {
state_index(&self.initial)
.ok_or_else(|| ctx(format!("initial state '{}' not found", self.initial)))?
};
Ok(CompiledGraph {
params,
states,
initial,
})
}
}
fn compile_blend(
state: &AnimationState,
blend: &AnimationBlend,
param_index: &impl Fn(&str) -> Option<usize>,
play_for: &impl Fn(AssetId) -> Result<(ClipPlay, bool), String>,
) -> Result<StatePlay, String> {
let err = |detail: String| format!("state '{}': {detail}", state.name);
let param = |name: &str, axis: &str| {
param_index(name)
.ok_or_else(|| err(format!("blend {axis} '{name}' is not a declared parameter")))
};
let strictly_ascending = |v: &[f32]| v.windows(2).all(|w| w[0] < w[1]);
let member = |clip: Option<AssetId>| -> Result<ClipPlay, String> {
let id = clip.ok_or_else(|| err("blend member has no `clip`".into()))?;
Ok(play_for(id)?.0)
};
match blend {
AnimationBlend::Blend1d {
parameter,
points,
sync,
} => {
if points.is_empty() {
return Err(err("blend has no `points`".into()));
}
let thresholds: Vec<f32> = points.iter().map(|p| p.value).collect();
if !strictly_ascending(&thresholds) {
return Err(err("blend point `value`s must be strictly ascending".into()));
}
let plays = points
.iter()
.map(|p| member(p.clip))
.collect::<Result<Vec<_>, _>>()?;
Ok(StatePlay::Blend1D(Blend1D {
param: param(parameter, "parameter")?,
thresholds,
plays,
sync: *sync,
}))
}
AnimationBlend::Blend2d {
parameter_x,
parameter_y,
x_values,
y_values,
rows,
sync,
} => {
if x_values.is_empty() || y_values.is_empty() {
return Err(err("blend `x_values` / `y_values` must not be empty".into()));
}
if !strictly_ascending(x_values) || !strictly_ascending(y_values) {
return Err(err(
"blend `x_values` and `y_values` must be strictly ascending".into(),
));
}
if rows.len() != y_values.len() || rows.iter().any(|r| r.len() != x_values.len()) {
return Err(err(format!(
"blend `rows` must be {} row(s) of {} clip(s) to match the grid",
y_values.len(),
x_values.len()
)));
}
let plays = rows
.iter()
.flatten()
.map(|&clip| member(Some(clip)))
.collect::<Result<Vec<_>, _>>()?;
Ok(StatePlay::Blend2D(Blend2D {
param_x: param(parameter_x, "parameter_x")?,
param_y: param(parameter_y, "parameter_y")?,
x_values: x_values.clone(),
y_values: y_values.clone(),
plays,
sync: *sync,
}))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use alloc::vec;
fn graph_json() -> serde_json::Value {
serde_json::json!({
"target": "hero",
"parameters": [{"name": "speed", "default": 0.5}],
"initial": "idle",
"states": [
{"name": "idle", "clip": "hero_idle"},
{"name": "run", "clip": "hero_run", "rate": 1.5, "loop_override": false}
],
"transitions": [
{"from": "idle", "to": "run", "duration_secs": 0.2, "exit_time": 0.5,
"conditions": [{"parameter": "speed", "op": "gt", "value": 1.0}]}
]
})
}
fn any_clip(_: AssetId) -> Option<(usize, f32, bool)> {
Some((0, 1.0, true))
}
#[test]
fn deserialises_full_graph() {
crate::test_support::reset_interner();
let g: AnimationGraph = serde_json::from_value(graph_json()).unwrap();
assert!(g.target.is_some());
assert_eq!(g.parameters.len(), 1);
assert_eq!(g.states.len(), 2);
assert_eq!(g.states[1].rate, 1.5);
assert_eq!(g.states[1].loop_override, Some(false));
assert_eq!(g.transitions.len(), 1);
assert_eq!(g.transitions[0].exit_time, Some(0.5));
assert_eq!(g.transitions[0].conditions[0].op, CmpOp::Gt);
}
#[test]
fn deserialises_with_defaults() {
let g: AnimationGraph = serde_json::from_str("{}").unwrap();
assert!(g.target.is_none());
assert!(g.states.is_empty());
assert!(g.initial.is_empty());
}
#[test]
fn compiles_names_to_indices() {
crate::test_support::reset_interner();
let g: AnimationGraph = serde_json::from_value(graph_json()).unwrap();
let compiled = g.compile(any_clip).unwrap();
assert_eq!(compiled.initial, 0);
assert_eq!(compiled.states[0].transitions.len(), 1);
let tr = &compiled.states[0].transitions[0];
assert_eq!(tr.to, 1);
assert_eq!(tr.conditions[0].param, 0);
assert!(!compiled.states[1].looping);
assert!(compiled.states[0].looping);
}
#[test]
fn compile_empty_initial_defaults_to_first_state() {
crate::test_support::reset_interner();
let mut v = graph_json();
v["initial"] = serde_json::json!("");
let g: AnimationGraph = serde_json::from_value(v).unwrap();
assert_eq!(g.compile(any_clip).unwrap().initial, 0);
}
#[test]
fn compile_rejects_unknown_names() {
crate::test_support::reset_interner();
let mut v = graph_json();
v["transitions"][0]["to"] = serde_json::json!("ghost");
let g: AnimationGraph = serde_json::from_value(v).unwrap();
assert!(g.compile(any_clip).unwrap_err().contains("ghost"));
let mut v = graph_json();
v["transitions"][0]["conditions"][0]["parameter"] = serde_json::json!("nope");
let g: AnimationGraph = serde_json::from_value(v).unwrap();
assert!(g.compile(any_clip).unwrap_err().contains("nope"));
let mut v = graph_json();
v["initial"] = serde_json::json!("ghost");
let g: AnimationGraph = serde_json::from_value(v).unwrap();
assert!(g.compile(any_clip).unwrap_err().contains("ghost"));
}
#[test]
fn compile_rejects_unresolvable_clip_and_bad_rate() {
crate::test_support::reset_interner();
let g: AnimationGraph = serde_json::from_value(graph_json()).unwrap();
assert!(g.compile(|_| None).unwrap_err().contains("clip"));
let mut v = graph_json();
v["states"][0]["rate"] = serde_json::json!(0.0);
let g: AnimationGraph = serde_json::from_value(v).unwrap();
assert!(g.compile(any_clip).unwrap_err().contains("rate"));
}
#[test]
fn compile_rejects_empty_graph() {
let g = AnimationGraph::default();
assert!(g.compile(any_clip).unwrap_err().contains("no states"));
}
fn blend1d_graph_json() -> serde_json::Value {
serde_json::json!({
"target": "hero",
"parameters": [{"name": "speed", "default": 0.0}],
"states": [
{"name": "locomotion", "blend": {"kind": "blend1d", "parameter": "speed",
"sync": true,
"points": [
{"value": 0.0, "clip": "idle"},
{"value": 1.6, "clip": "walk"},
{"value": 5.0, "clip": "run"}
]}}
]
})
}
fn blend2d_graph_json() -> serde_json::Value {
serde_json::json!({
"target": "hero",
"parameters": [{"name": "speed"}, {"name": "strafe"}],
"states": [
{"name": "locomotion", "blend": {"kind": "blend2d",
"parameter_x": "speed", "parameter_y": "strafe",
"x_values": [0.0, 5.0], "y_values": [-1.0, 1.0],
"rows": [["run_l", "run_l"], ["run_r", "run_r"]]}}
]
})
}
#[test]
fn compiles_blend1d_state() {
crate::test_support::reset_interner();
let g: AnimationGraph = serde_json::from_value(blend1d_graph_json()).unwrap();
let compiled = g.compile(any_clip).unwrap();
let StatePlay::Blend1D(b) = &compiled.states[0].play else {
panic!("expected a 1D blendspace");
};
assert_eq!(b.param, 0);
assert_eq!(b.thresholds, vec![0.0, 1.6, 5.0]);
assert_eq!(b.plays.len(), 3);
assert!(b.sync);
assert!(compiled.states[0].looping, "blendspaces default to looping");
}
#[test]
fn compiles_blend2d_state() {
crate::test_support::reset_interner();
let g: AnimationGraph = serde_json::from_value(blend2d_graph_json()).unwrap();
let compiled = g.compile(any_clip).unwrap();
let StatePlay::Blend2D(b) = &compiled.states[0].play else {
panic!("expected a 2D blendspace");
};
assert_eq!((b.param_x, b.param_y), (0, 1));
assert_eq!(b.plays.len(), 4);
assert!(!b.sync);
}
#[test]
fn graph_blend_keeps_the_tagged_json_shape_and_round_trips_through_postcard() {
crate::test_support::reset_interner();
let g: AnimationGraph = serde_json::from_value(blend1d_graph_json()).unwrap();
let json = serde_json::to_value(&g).unwrap();
assert_eq!(
json["states"][0]["blend"]["kind"],
serde_json::json!("blend1d"),
"authored JSON stays kind-tagged"
);
let bytes = postcard::to_allocvec(&g).unwrap();
let back: AnimationGraph = postcard::from_bytes(&bytes).unwrap();
let Some(AnimationBlend::Blend1d {
parameter,
points,
sync,
}) = &back.states[0].blend
else {
panic!("expected a 1D blendspace after the round trip");
};
assert_eq!(parameter, "speed");
assert_eq!(points.len(), 3);
assert!(sync);
let g2: AnimationGraph = serde_json::from_value(blend2d_graph_json()).unwrap();
let bytes = postcard::to_allocvec(&g2).unwrap();
let back: AnimationGraph = postcard::from_bytes(&bytes).unwrap();
let Some(AnimationBlend::Blend2d { rows, .. }) = &back.states[0].blend else {
panic!("expected a 2D blendspace after the round trip");
};
assert_eq!(rows.len(), 2);
}
#[test]
fn compile_rejects_clip_and_blend_together_or_neither() {
crate::test_support::reset_interner();
let mut v = blend1d_graph_json();
v["states"][0]["clip"] = serde_json::json!("idle");
let g: AnimationGraph = serde_json::from_value(v).unwrap();
assert!(g.compile(any_clip).unwrap_err().contains("pick one"));
let v = serde_json::json!({"target":"hero","states":[{"name":"empty"}]});
let g: AnimationGraph = serde_json::from_value(v).unwrap();
assert!(
g.compile(any_clip)
.unwrap_err()
.contains("no `clip` or `blend`")
);
}
#[test]
fn compile_rejects_unsorted_blend_points() {
crate::test_support::reset_interner();
let mut v = blend1d_graph_json();
v["states"][0]["blend"]["points"][2]["value"] = serde_json::json!(1.0);
let g: AnimationGraph = serde_json::from_value(v).unwrap();
assert!(g.compile(any_clip).unwrap_err().contains("ascending"));
}
#[test]
fn compile_rejects_undeclared_blend_parameter() {
crate::test_support::reset_interner();
let mut v = blend1d_graph_json();
v["states"][0]["blend"]["parameter"] = serde_json::json!("nope");
let g: AnimationGraph = serde_json::from_value(v).unwrap();
assert!(g.compile(any_clip).unwrap_err().contains("nope"));
}
#[test]
fn compile_rejects_mismatched_grid_rows() {
crate::test_support::reset_interner();
let mut v = blend2d_graph_json();
v["states"][0]["blend"]["rows"] = serde_json::json!([["a", "b"]]);
let g: AnimationGraph = serde_json::from_value(v).unwrap();
assert!(g.compile(any_clip).unwrap_err().contains("rows"));
}
}