1use alloc::format;
4use alloc::string::String;
5use alloc::vec::Vec;
6
7use crate::ecs::asset_id::{AssetId, de_opt_asset_ref};
8use crate::ecs::{SkinnedMeshHandle, de_opt_skinned_mesh_handle};
9use crate::gfx::anim_graph::{
10 Blend1D, Blend2D, ClipPlay, CmpOp, CompiledCondition, CompiledGraph, CompiledState,
11 CompiledTransition, ParamSpec, StatePlay,
12};
13
14#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
18#[serde(default)]
19pub struct AnimationParam {
20 pub name: String,
22 pub default: f32,
24}
25
26#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
28#[serde(default)]
29pub struct AnimationBlendPoint {
30 pub value: f32,
32 #[serde(deserialize_with = "de_opt_asset_ref")]
35 pub clip: Option<AssetId>,
36}
37
38#[derive(Debug, Clone)]
46pub enum AnimationBlend {
47 Blend1d {
51 parameter: String,
53 points: Vec<AnimationBlendPoint>,
55 sync: bool,
57 },
58 Blend2d {
62 parameter_x: String,
64 parameter_y: String,
66 x_values: Vec<f32>,
68 y_values: Vec<f32>,
70 rows: Vec<Vec<AssetId>>,
73 sync: bool,
75 },
76}
77
78#[derive(serde::Serialize, serde::Deserialize)]
84#[serde(tag = "kind", rename_all = "lowercase")]
85enum GraphBlendTagged {
86 Blend1d {
87 parameter: String,
88 points: Vec<AnimationBlendPoint>,
89 #[serde(default)]
90 sync: bool,
91 },
92 Blend2d {
93 parameter_x: String,
94 parameter_y: String,
95 x_values: Vec<f32>,
96 y_values: Vec<f32>,
97 rows: Vec<Vec<AssetId>>,
98 #[serde(default)]
99 sync: bool,
100 },
101}
102
103#[derive(serde::Serialize, serde::Deserialize)]
104enum GraphBlendPlain {
105 Blend1d {
106 parameter: String,
107 points: Vec<AnimationBlendPoint>,
108 sync: bool,
109 },
110 Blend2d {
111 parameter_x: String,
112 parameter_y: String,
113 x_values: Vec<f32>,
114 y_values: Vec<f32>,
115 rows: Vec<Vec<AssetId>>,
116 sync: bool,
117 },
118}
119
120macro_rules! graph_blend_from {
121 ($src:ident, $dst:ident, $value:expr) => {
122 match $value {
123 $src::Blend1d {
124 parameter,
125 points,
126 sync,
127 } => $dst::Blend1d {
128 parameter,
129 points,
130 sync,
131 },
132 $src::Blend2d {
133 parameter_x,
134 parameter_y,
135 x_values,
136 y_values,
137 rows,
138 sync,
139 } => $dst::Blend2d {
140 parameter_x,
141 parameter_y,
142 x_values,
143 y_values,
144 rows,
145 sync,
146 },
147 }
148 };
149}
150
151impl serde::Serialize for AnimationBlend {
152 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
153 let cloned = self.clone();
154 if s.is_human_readable() {
155 graph_blend_from!(AnimationBlend, GraphBlendTagged, cloned).serialize(s)
156 } else {
157 graph_blend_from!(AnimationBlend, GraphBlendPlain, cloned).serialize(s)
158 }
159 }
160}
161
162impl<'de> serde::Deserialize<'de> for AnimationBlend {
163 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
164 if d.is_human_readable() {
165 let b = GraphBlendTagged::deserialize(d)?;
166 Ok(graph_blend_from!(GraphBlendTagged, AnimationBlend, b))
167 } else {
168 let b = GraphBlendPlain::deserialize(d)?;
169 Ok(graph_blend_from!(GraphBlendPlain, AnimationBlend, b))
170 }
171 }
172}
173
174#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
178#[serde(default)]
179pub struct AnimationState {
180 pub name: String,
182 #[serde(deserialize_with = "de_opt_asset_ref")]
186 pub clip: Option<AssetId>,
187 pub blend: Option<AnimationBlend>,
189 pub rate: f32,
191 pub loop_override: Option<bool>,
194}
195
196impl Default for AnimationState {
197 fn default() -> Self {
198 Self {
199 name: String::new(),
200 clip: None,
201 blend: None,
202 rate: 1.0,
203 loop_override: None,
204 }
205 }
206}
207
208#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
218#[serde(default)]
219pub struct AnimationIkChain {
220 pub joints: Vec<String>,
223 pub pole: [f32; 3],
226 pub weight_parameter: String,
229 pub foot_height: f32,
232}
233
234impl Default for AnimationIkChain {
235 fn default() -> Self {
236 Self {
237 joints: Vec::new(),
238 pole: [0.0, 0.0, 1.0],
239 weight_parameter: String::new(),
240 foot_height: 0.0,
241 }
242 }
243}
244
245#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
248#[serde(default)]
249pub struct AnimationCondition {
250 pub parameter: String,
252 pub op: CmpOp,
254 pub value: f32,
256}
257
258#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
260#[serde(default)]
261pub struct AnimationTransition {
262 pub from: String,
264 pub to: String,
266 pub duration_secs: f32,
269 pub exit_time: Option<f32>,
274 pub conditions: Vec<AnimationCondition>,
277}
278
279#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
306#[serde(default)]
307pub struct AnimationGraph {
308 #[serde(skip)]
310 pub asset_id: AssetId,
311 #[serde(deserialize_with = "de_opt_skinned_mesh_handle")]
313 pub target: Option<SkinnedMeshHandle>,
314 pub parameters: Vec<AnimationParam>,
316 pub initial: String,
318 pub states: Vec<AnimationState>,
320 pub transitions: Vec<AnimationTransition>,
322 pub ik_chains: Vec<AnimationIkChain>,
325}
326
327impl AnimationGraph {
328 pub fn compile(
336 &self,
337 resolve_clip: impl Fn(AssetId) -> Option<(usize, f32, bool)>,
338 ) -> Result<CompiledGraph, String> {
339 let ctx = |detail: String| format!("AnimationGraph {}: {detail}", self.asset_id);
340 if self.states.is_empty() {
341 return Err(ctx("graph has no states".into()));
342 }
343
344 let params: Vec<ParamSpec> = self
345 .parameters
346 .iter()
347 .map(|p| ParamSpec {
348 name: p.name.clone(),
349 default: p.default,
350 })
351 .collect();
352 let param_index = |name: &str| params.iter().position(|p| p.name == name);
353 let state_index = |name: &str| self.states.iter().position(|s| s.name == name);
354
355 let mut states: Vec<CompiledState> = Vec::with_capacity(self.states.len());
356 for s in &self.states {
357 if s.rate <= 0.0 {
358 return Err(ctx(format!("state '{}': rate must be positive", s.name)));
359 }
360 let play_for = |clip_id: AssetId| -> Result<(ClipPlay, bool), String> {
363 let Some((clip, duration_secs, clip_looping)) = resolve_clip(clip_id) else {
364 return Err(ctx(format!(
365 "state '{}': clip {clip_id} is not a clip on the graph's target",
366 s.name
367 )));
368 };
369 Ok((
370 ClipPlay {
371 clip,
372 duration_secs,
373 },
374 clip_looping,
375 ))
376 };
377 let (play, default_looping) = match (&s.clip, &s.blend) {
378 (Some(_), Some(_)) => {
379 return Err(ctx(format!(
380 "state '{}' sets both `clip` and `blend`; pick one",
381 s.name
382 )));
383 }
384 (None, None) => {
385 return Err(ctx(format!("state '{}' has no `clip` or `blend`", s.name)));
386 }
387 (Some(clip_id), None) => {
388 let (clip_play, clip_looping) = play_for(*clip_id)?;
389 (StatePlay::Clip(clip_play), clip_looping)
390 }
391 (None, Some(blend)) => (compile_blend(s, blend, ¶m_index, &play_for)?, true),
394 };
395 states.push(CompiledState {
396 name: s.name.clone(),
397 rate: s.rate,
398 looping: s.loop_override.unwrap_or(default_looping),
399 play,
400 transitions: Vec::new(),
401 });
402 }
403
404 for t in &self.transitions {
405 let Some(from) = state_index(&t.from) else {
406 return Err(ctx(format!("transition from unknown state '{}'", t.from)));
407 };
408 let Some(to) = state_index(&t.to) else {
409 return Err(ctx(format!("transition to unknown state '{}'", t.to)));
410 };
411 let mut conditions = Vec::with_capacity(t.conditions.len());
412 for c in &t.conditions {
413 let Some(param) = param_index(&c.parameter) else {
414 return Err(ctx(format!(
415 "transition '{}' -> '{}' references undeclared parameter '{}'",
416 t.from, t.to, c.parameter
417 )));
418 };
419 conditions.push(CompiledCondition {
420 param,
421 op: c.op,
422 value: c.value,
423 });
424 }
425 states[from].transitions.push(CompiledTransition {
426 to,
427 duration_secs: t.duration_secs.max(0.0),
428 exit_time: t.exit_time,
429 conditions,
430 });
431 }
432
433 let initial = if self.initial.is_empty() {
434 0
435 } else {
436 state_index(&self.initial)
437 .ok_or_else(|| ctx(format!("initial state '{}' not found", self.initial)))?
438 };
439
440 Ok(CompiledGraph {
441 params,
442 states,
443 initial,
444 })
445 }
446}
447
448fn compile_blend(
451 state: &AnimationState,
452 blend: &AnimationBlend,
453 param_index: &impl Fn(&str) -> Option<usize>,
454 play_for: &impl Fn(AssetId) -> Result<(ClipPlay, bool), String>,
455) -> Result<StatePlay, String> {
456 let err = |detail: String| format!("state '{}': {detail}", state.name);
457 let param = |name: &str, axis: &str| {
458 param_index(name)
459 .ok_or_else(|| err(format!("blend {axis} '{name}' is not a declared parameter")))
460 };
461 let strictly_ascending = |v: &[f32]| v.windows(2).all(|w| w[0] < w[1]);
462 let member = |clip: Option<AssetId>| -> Result<ClipPlay, String> {
463 let id = clip.ok_or_else(|| err("blend member has no `clip`".into()))?;
464 Ok(play_for(id)?.0)
465 };
466
467 match blend {
468 AnimationBlend::Blend1d {
469 parameter,
470 points,
471 sync,
472 } => {
473 if points.is_empty() {
474 return Err(err("blend has no `points`".into()));
475 }
476 let thresholds: Vec<f32> = points.iter().map(|p| p.value).collect();
477 if !strictly_ascending(&thresholds) {
478 return Err(err("blend point `value`s must be strictly ascending".into()));
479 }
480 let plays = points
481 .iter()
482 .map(|p| member(p.clip))
483 .collect::<Result<Vec<_>, _>>()?;
484 Ok(StatePlay::Blend1D(Blend1D {
485 param: param(parameter, "parameter")?,
486 thresholds,
487 plays,
488 sync: *sync,
489 }))
490 }
491 AnimationBlend::Blend2d {
492 parameter_x,
493 parameter_y,
494 x_values,
495 y_values,
496 rows,
497 sync,
498 } => {
499 if x_values.is_empty() || y_values.is_empty() {
500 return Err(err("blend `x_values` / `y_values` must not be empty".into()));
501 }
502 if !strictly_ascending(x_values) || !strictly_ascending(y_values) {
503 return Err(err(
504 "blend `x_values` and `y_values` must be strictly ascending".into(),
505 ));
506 }
507 if rows.len() != y_values.len() || rows.iter().any(|r| r.len() != x_values.len()) {
508 return Err(err(format!(
509 "blend `rows` must be {} row(s) of {} clip(s) to match the grid",
510 y_values.len(),
511 x_values.len()
512 )));
513 }
514 let plays = rows
515 .iter()
516 .flatten()
517 .map(|&clip| member(Some(clip)))
518 .collect::<Result<Vec<_>, _>>()?;
519 Ok(StatePlay::Blend2D(Blend2D {
520 param_x: param(parameter_x, "parameter_x")?,
521 param_y: param(parameter_y, "parameter_y")?,
522 x_values: x_values.clone(),
523 y_values: y_values.clone(),
524 plays,
525 sync: *sync,
526 }))
527 }
528 }
529}
530
531#[cfg(test)]
532mod tests {
533 use super::*;
534 use alloc::vec;
535
536 fn graph_json() -> serde_json::Value {
537 serde_json::json!({
538 "target": "hero",
539 "parameters": [{"name": "speed", "default": 0.5}],
540 "initial": "idle",
541 "states": [
542 {"name": "idle", "clip": "hero_idle"},
543 {"name": "run", "clip": "hero_run", "rate": 1.5, "loop_override": false}
544 ],
545 "transitions": [
546 {"from": "idle", "to": "run", "duration_secs": 0.2, "exit_time": 0.5,
547 "conditions": [{"parameter": "speed", "op": "gt", "value": 1.0}]}
548 ]
549 })
550 }
551
552 fn any_clip(_: AssetId) -> Option<(usize, f32, bool)> {
554 Some((0, 1.0, true))
555 }
556
557 #[test]
558 fn deserialises_full_graph() {
559 crate::test_support::reset_interner();
560 let g: AnimationGraph = serde_json::from_value(graph_json()).unwrap();
561 assert!(g.target.is_some());
562 assert_eq!(g.parameters.len(), 1);
563 assert_eq!(g.states.len(), 2);
564 assert_eq!(g.states[1].rate, 1.5);
565 assert_eq!(g.states[1].loop_override, Some(false));
566 assert_eq!(g.transitions.len(), 1);
567 assert_eq!(g.transitions[0].exit_time, Some(0.5));
568 assert_eq!(g.transitions[0].conditions[0].op, CmpOp::Gt);
569 }
570
571 #[test]
572 fn deserialises_with_defaults() {
573 let g: AnimationGraph = serde_json::from_str("{}").unwrap();
574 assert!(g.target.is_none());
575 assert!(g.states.is_empty());
576 assert!(g.initial.is_empty());
577 }
578
579 #[test]
580 fn compiles_names_to_indices() {
581 crate::test_support::reset_interner();
582 let g: AnimationGraph = serde_json::from_value(graph_json()).unwrap();
583 let compiled = g.compile(any_clip).unwrap();
584 assert_eq!(compiled.initial, 0);
585 assert_eq!(compiled.states[0].transitions.len(), 1);
586 let tr = &compiled.states[0].transitions[0];
587 assert_eq!(tr.to, 1);
588 assert_eq!(tr.conditions[0].param, 0);
589 assert!(!compiled.states[1].looping);
591 assert!(compiled.states[0].looping);
592 }
593
594 #[test]
595 fn compile_empty_initial_defaults_to_first_state() {
596 crate::test_support::reset_interner();
597 let mut v = graph_json();
598 v["initial"] = serde_json::json!("");
599 let g: AnimationGraph = serde_json::from_value(v).unwrap();
600 assert_eq!(g.compile(any_clip).unwrap().initial, 0);
601 }
602
603 #[test]
604 fn compile_rejects_unknown_names() {
605 crate::test_support::reset_interner();
606 let mut v = graph_json();
607 v["transitions"][0]["to"] = serde_json::json!("ghost");
608 let g: AnimationGraph = serde_json::from_value(v).unwrap();
609 assert!(g.compile(any_clip).unwrap_err().contains("ghost"));
610
611 let mut v = graph_json();
612 v["transitions"][0]["conditions"][0]["parameter"] = serde_json::json!("nope");
613 let g: AnimationGraph = serde_json::from_value(v).unwrap();
614 assert!(g.compile(any_clip).unwrap_err().contains("nope"));
615
616 let mut v = graph_json();
617 v["initial"] = serde_json::json!("ghost");
618 let g: AnimationGraph = serde_json::from_value(v).unwrap();
619 assert!(g.compile(any_clip).unwrap_err().contains("ghost"));
620 }
621
622 #[test]
623 fn compile_rejects_unresolvable_clip_and_bad_rate() {
624 crate::test_support::reset_interner();
625 let g: AnimationGraph = serde_json::from_value(graph_json()).unwrap();
626 assert!(g.compile(|_| None).unwrap_err().contains("clip"));
627
628 let mut v = graph_json();
629 v["states"][0]["rate"] = serde_json::json!(0.0);
630 let g: AnimationGraph = serde_json::from_value(v).unwrap();
631 assert!(g.compile(any_clip).unwrap_err().contains("rate"));
632 }
633
634 #[test]
635 fn compile_rejects_empty_graph() {
636 let g = AnimationGraph::default();
637 assert!(g.compile(any_clip).unwrap_err().contains("no states"));
638 }
639
640 fn blend1d_graph_json() -> serde_json::Value {
641 serde_json::json!({
642 "target": "hero",
643 "parameters": [{"name": "speed", "default": 0.0}],
644 "states": [
645 {"name": "locomotion", "blend": {"kind": "blend1d", "parameter": "speed",
646 "sync": true,
647 "points": [
648 {"value": 0.0, "clip": "idle"},
649 {"value": 1.6, "clip": "walk"},
650 {"value": 5.0, "clip": "run"}
651 ]}}
652 ]
653 })
654 }
655
656 fn blend2d_graph_json() -> serde_json::Value {
657 serde_json::json!({
658 "target": "hero",
659 "parameters": [{"name": "speed"}, {"name": "strafe"}],
660 "states": [
661 {"name": "locomotion", "blend": {"kind": "blend2d",
662 "parameter_x": "speed", "parameter_y": "strafe",
663 "x_values": [0.0, 5.0], "y_values": [-1.0, 1.0],
664 "rows": [["run_l", "run_l"], ["run_r", "run_r"]]}}
665 ]
666 })
667 }
668
669 #[test]
670 fn compiles_blend1d_state() {
671 crate::test_support::reset_interner();
672 let g: AnimationGraph = serde_json::from_value(blend1d_graph_json()).unwrap();
673 let compiled = g.compile(any_clip).unwrap();
674 let StatePlay::Blend1D(b) = &compiled.states[0].play else {
675 panic!("expected a 1D blendspace");
676 };
677 assert_eq!(b.param, 0);
678 assert_eq!(b.thresholds, vec![0.0, 1.6, 5.0]);
679 assert_eq!(b.plays.len(), 3);
680 assert!(b.sync);
681 assert!(compiled.states[0].looping, "blendspaces default to looping");
682 }
683
684 #[test]
685 fn compiles_blend2d_state() {
686 crate::test_support::reset_interner();
687 let g: AnimationGraph = serde_json::from_value(blend2d_graph_json()).unwrap();
688 let compiled = g.compile(any_clip).unwrap();
689 let StatePlay::Blend2D(b) = &compiled.states[0].play else {
690 panic!("expected a 2D blendspace");
691 };
692 assert_eq!((b.param_x, b.param_y), (0, 1));
693 assert_eq!(b.plays.len(), 4);
694 assert!(!b.sync);
695 }
696
697 #[test]
700 fn graph_blend_keeps_the_tagged_json_shape_and_round_trips_through_postcard() {
701 crate::test_support::reset_interner();
702 let g: AnimationGraph = serde_json::from_value(blend1d_graph_json()).unwrap();
703 let json = serde_json::to_value(&g).unwrap();
704 assert_eq!(
705 json["states"][0]["blend"]["kind"],
706 serde_json::json!("blend1d"),
707 "authored JSON stays kind-tagged"
708 );
709
710 let bytes = postcard::to_allocvec(&g).unwrap();
711 let back: AnimationGraph = postcard::from_bytes(&bytes).unwrap();
712 let Some(AnimationBlend::Blend1d {
713 parameter,
714 points,
715 sync,
716 }) = &back.states[0].blend
717 else {
718 panic!("expected a 1D blendspace after the round trip");
719 };
720 assert_eq!(parameter, "speed");
721 assert_eq!(points.len(), 3);
722 assert!(sync);
723
724 let g2: AnimationGraph = serde_json::from_value(blend2d_graph_json()).unwrap();
725 let bytes = postcard::to_allocvec(&g2).unwrap();
726 let back: AnimationGraph = postcard::from_bytes(&bytes).unwrap();
727 let Some(AnimationBlend::Blend2d { rows, .. }) = &back.states[0].blend else {
728 panic!("expected a 2D blendspace after the round trip");
729 };
730 assert_eq!(rows.len(), 2);
731 }
732
733 #[test]
734 fn compile_rejects_clip_and_blend_together_or_neither() {
735 crate::test_support::reset_interner();
736 let mut v = blend1d_graph_json();
737 v["states"][0]["clip"] = serde_json::json!("idle");
738 let g: AnimationGraph = serde_json::from_value(v).unwrap();
739 assert!(g.compile(any_clip).unwrap_err().contains("pick one"));
740
741 let v = serde_json::json!({"target":"hero","states":[{"name":"empty"}]});
742 let g: AnimationGraph = serde_json::from_value(v).unwrap();
743 assert!(
744 g.compile(any_clip)
745 .unwrap_err()
746 .contains("no `clip` or `blend`")
747 );
748 }
749
750 #[test]
751 fn compile_rejects_unsorted_blend_points() {
752 crate::test_support::reset_interner();
753 let mut v = blend1d_graph_json();
754 v["states"][0]["blend"]["points"][2]["value"] = serde_json::json!(1.0);
755 let g: AnimationGraph = serde_json::from_value(v).unwrap();
756 assert!(g.compile(any_clip).unwrap_err().contains("ascending"));
757 }
758
759 #[test]
760 fn compile_rejects_undeclared_blend_parameter() {
761 crate::test_support::reset_interner();
762 let mut v = blend1d_graph_json();
763 v["states"][0]["blend"]["parameter"] = serde_json::json!("nope");
764 let g: AnimationGraph = serde_json::from_value(v).unwrap();
765 assert!(g.compile(any_clip).unwrap_err().contains("nope"));
766 }
767
768 #[test]
769 fn compile_rejects_mismatched_grid_rows() {
770 crate::test_support::reset_interner();
771 let mut v = blend2d_graph_json();
772 v["states"][0]["blend"]["rows"] = serde_json::json!([["a", "b"]]);
773 let g: AnimationGraph = serde_json::from_value(v).unwrap();
774 assert!(g.compile(any_clip).unwrap_err().contains("rows"));
775 }
776}