diff --git a/perro_source/core/perro_animation/src/lib.rs b/perro_source/core/perro_animation/src/lib.rs index 00aae9925..eba8336ae 100644 --- a/perro_source/core/perro_animation/src/lib.rs +++ b/perro_source/core/perro_animation/src/lib.rs @@ -1,7 +1,9 @@ use perro_scene::{Node3DField, NodeField}; use std::borrow::Cow; mod panim; +mod panim_tree; pub use panim::parse_panim; +pub use panim_tree::{AnimationBlendTreeDef, parse_panimtree}; #[derive(Clone, Debug, Default)] pub struct AnimationClip { diff --git a/perro_source/core/perro_animation/src/panim_tree.rs b/perro_source/core/perro_animation/src/panim_tree.rs new file mode 100644 index 000000000..452bc52e7 --- /dev/null +++ b/perro_source/core/perro_animation/src/panim_tree.rs @@ -0,0 +1,39 @@ +use std::borrow::Cow; + +#[derive(Clone, Debug, Default)] +pub struct AnimationBlendTreeDef { + pub slots: Cow<'static, [Cow<'static, str>]>, +} + +pub fn parse_panimtree(source: &str) -> Result { + let mut slots = Vec::new(); + for line in source.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + if let Some((k, v)) = line.split_once('=') + && k.trim() == "slot" + { + slots.push(Cow::Owned(v.trim().to_string())); + } + } + if slots.is_empty() { + return Err("panimtree contains no slots".to_string()); + } + Ok(AnimationBlendTreeDef { + slots: Cow::Owned(slots), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_slots() { + let src = "slot = Idle\nslot = Walk\nslot = Run"; + let def = parse_panimtree(src).unwrap(); + assert_eq!(def.slots.len(), 3); + } +} diff --git a/perro_source/core/perro_ids/src/ids.rs b/perro_source/core/perro_ids/src/ids.rs index 16258706d..3d16b03fa 100644 --- a/perro_source/core/perro_ids/src/ids.rs +++ b/perro_source/core/perro_ids/src/ids.rs @@ -171,6 +171,10 @@ define_generational!( AnimationID, "Animation ID - allocated by animation system. Index + generation." ); +define_generational!( + AnimationMixClipID, + "Animation mix clip ID - allocated by animation mix system. Index + generation." +); define_generational!( LightID, "Light ID — allocated by light system. Index + generation." diff --git a/perro_source/core/perro_nodes/src/nodes/animation_tree.rs b/perro_source/core/perro_nodes/src/nodes/animation_tree.rs new file mode 100644 index 000000000..5df4d51d9 --- /dev/null +++ b/perro_source/core/perro_nodes/src/nodes/animation_tree.rs @@ -0,0 +1,285 @@ +use perro_ids::{AnimationID, AnimationMixClipID}; +use std::collections::HashMap; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum AnimationMixerSlotState { + Playing, + Paused, + #[default] + Stopped, +} + +#[derive(Clone, Debug, Default)] +pub struct AnimationMixerSlot { + pub animation: AnimationID, + pub time_seconds: f32, + pub speed: f32, + pub state: AnimationMixerSlotState, + pub looping: bool, + pub weight: f32, +} + +#[derive(Clone, Debug, Default)] +pub struct AnimationMixerGraph { + pub nodes: Vec, + pub output: Option, +} + +#[derive(Clone, Debug)] +pub struct AnimationMixerNode { + pub kind: AnimationMixerNodeKind, +} + +#[derive(Clone, Debug)] +pub enum AnimationMixerNodeKind { + SlotRef { + slot_index: usize, + }, + BlendN { + inputs: Vec, + weights: Vec, + }, + AddN { + inputs: Vec, + weights: Vec, + }, + Invert { + input: usize, + }, + Output { + input: usize, + }, +} + +#[derive(Clone, Debug, Default)] +pub struct AnimationMixerInternalData { + pub last_resolved_animation: AnimationID, +} + +#[derive(Clone, Debug)] +pub struct AnimationMixer { + pub slots: Vec, + pub graph: AnimationMixerGraph, + pub blend_tree: AnimationMixClipID, + pub reverse_slot_lookup: HashMap>, + pub internal: AnimationMixerInternalData, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AnimationMixerError { + InvalidSlot(usize), + InvalidNode(usize), + InvalidNodeInput { node_id: usize, input_index: usize }, +} + +impl AnimationMixer { + pub fn new(slot_count: usize) -> Self { + Self { + slots: (0..slot_count) + .map(|_| AnimationMixerSlot { + speed: 1.0, + looping: true, + weight: 1.0, + ..AnimationMixerSlot::default() + }) + .collect(), + graph: AnimationMixerGraph::default(), + blend_tree: AnimationMixClipID::nil(), + reverse_slot_lookup: HashMap::new(), + internal: AnimationMixerInternalData::default(), + } + } + + pub fn set_mix_clip(&mut self, mix_clip: AnimationMixClipID) { + self.blend_tree = mix_clip; + } + + pub fn set_slot_animation( + &mut self, + slot_index: usize, + animation: AnimationID, + ) -> Result<(), AnimationMixerError> { + let Some(slot) = self.slots.get_mut(slot_index) else { + return Err(AnimationMixerError::InvalidSlot(slot_index)); + }; + if !slot.animation.is_nil() { + if let Some(v) = self.reverse_slot_lookup.get_mut(&slot.animation) { + v.retain(|idx| *idx != slot_index); + } + } + slot.animation = animation; + self.reverse_slot_lookup + .entry(animation) + .or_default() + .push(slot_index); + Ok(()) + } + + pub fn slot_indices_for_animation(&self, animation: AnimationID) -> &[usize] { + self.reverse_slot_lookup + .get(&animation) + .map_or(&[], |v| v.as_slice()) + } + + pub fn play_slot(&mut self, slot_index: usize) -> Result<(), AnimationMixerError> { + self.set_slot_state(slot_index, AnimationMixerSlotState::Playing) + } + pub fn pause_slot(&mut self, slot_index: usize) -> Result<(), AnimationMixerError> { + self.set_slot_state(slot_index, AnimationMixerSlotState::Paused) + } + pub fn stop_slot(&mut self, slot_index: usize) -> Result<(), AnimationMixerError> { + self.set_slot_state(slot_index, AnimationMixerSlotState::Stopped)?; + self.seek_slot(slot_index, 0.0) + } + pub fn seek_slot( + &mut self, + slot_index: usize, + time_seconds: f32, + ) -> Result<(), AnimationMixerError> { + let Some(slot) = self.slots.get_mut(slot_index) else { + return Err(AnimationMixerError::InvalidSlot(slot_index)); + }; + slot.time_seconds = time_seconds.max(0.0); + Ok(()) + } + + pub fn play_all(&mut self) { + for slot in &mut self.slots { + slot.state = AnimationMixerSlotState::Playing; + } + } + pub fn pause_all(&mut self) { + for slot in &mut self.slots { + slot.state = AnimationMixerSlotState::Paused; + } + } + pub fn stop_all(&mut self) { + for slot in &mut self.slots { + slot.state = AnimationMixerSlotState::Stopped; + slot.time_seconds = 0.0; + } + } + + pub fn play_animation(&mut self, animation: AnimationID) { + for idx in self.slot_indices_for_animation(animation).to_vec() { + let _ = self.play_slot(idx); + } + } + pub fn pause_animation(&mut self, animation: AnimationID) { + for idx in self.slot_indices_for_animation(animation).to_vec() { + let _ = self.pause_slot(idx); + } + } + pub fn seek_animation(&mut self, animation: AnimationID, time_seconds: f32) { + for idx in self.slot_indices_for_animation(animation).to_vec() { + let _ = self.seek_slot(idx, time_seconds); + } + } + + pub fn set_blend_weight( + &mut self, + node_id: usize, + input_index: usize, + weight: f32, + ) -> Result<(), AnimationMixerError> { + let Some(node) = self.graph.nodes.get_mut(node_id) else { + return Err(AnimationMixerError::InvalidNode(node_id)); + }; + match &mut node.kind { + AnimationMixerNodeKind::BlendN { weights, .. } + | AnimationMixerNodeKind::AddN { weights, .. } => { + let Some(input) = weights.get_mut(input_index) else { + return Err(AnimationMixerError::InvalidNodeInput { + node_id, + input_index, + }); + }; + *input = weight; + Ok(()) + } + _ => Err(AnimationMixerError::InvalidNodeInput { + node_id, + input_index, + }), + } + } + + pub fn evaluate_output_animation(&self) -> Result, AnimationMixerError> { + let Some(output) = self.graph.output else { + return Ok(None); + }; + self.eval_node(output) + } + + fn eval_node(&self, node_id: usize) -> Result, AnimationMixerError> { + let Some(node) = self.graph.nodes.get(node_id) else { + return Err(AnimationMixerError::InvalidNode(node_id)); + }; + match &node.kind { + AnimationMixerNodeKind::SlotRef { slot_index } => Ok(self + .slots + .get(*slot_index) + .map(|s| s.animation) + .filter(|id| !id.is_nil())), + AnimationMixerNodeKind::BlendN { inputs, weights } + | AnimationMixerNodeKind::AddN { inputs, weights } => { + if inputs.is_empty() { + return Ok(None); + } + let mut best: Option<(AnimationID, f32)> = None; + for (i, input) in inputs.iter().enumerate() { + let Some(anim) = self.eval_node(*input)? else { + continue; + }; + let w = *weights.get(i).unwrap_or(&1.0); + if best.as_ref().is_none_or(|(_, bw)| w > *bw) { + best = Some((anim, w)); + } + } + Ok(best.map(|v| v.0)) + } + AnimationMixerNodeKind::Invert { input } | AnimationMixerNodeKind::Output { input } => { + self.eval_node(*input) + } + } + } + + fn set_slot_state( + &mut self, + slot_index: usize, + state: AnimationMixerSlotState, + ) -> Result<(), AnimationMixerError> { + let Some(slot) = self.slots.get_mut(slot_index) else { + return Err(AnimationMixerError::InvalidSlot(slot_index)); + }; + slot.state = state; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn animation_tree_core_api() { + let mut tree = AnimationMixer::new(3); + assert_eq!(tree.slots.len(), 3); + tree.set_slot_animation(0, AnimationID::from_u32(10)) + .unwrap(); + tree.set_slot_animation(1, AnimationID::from_u32(11)) + .unwrap(); + tree.set_slot_animation(2, AnimationID::from_u32(11)) + .unwrap(); + assert_eq!( + tree.slot_indices_for_animation(AnimationID::from_u32(11)), + &[1, 2] + ); + tree.play_slot(1).unwrap(); + tree.pause_slot(1).unwrap(); + tree.seek_slot(1, 2.5).unwrap(); + tree.stop_slot(1).unwrap(); + } +} + +pub type AnimationTree = AnimationMixer; diff --git a/perro_source/core/perro_nodes/src/nodes/mod.rs b/perro_source/core/perro_nodes/src/nodes/mod.rs index 221100af5..936f78ce8 100644 --- a/perro_source/core/perro_nodes/src/nodes/mod.rs +++ b/perro_source/core/perro_nodes/src/nodes/mod.rs @@ -1,9 +1,11 @@ pub mod animation_player; +pub mod animation_tree; pub mod node_2d; pub mod node_3d; pub mod node_registry; pub use animation_player::*; +pub use animation_tree::*; pub use node_2d::*; pub use node_3d::*; pub use node_registry::*; diff --git a/perro_source/core/perro_nodes/src/nodes/node_registry.rs b/perro_source/core/perro_nodes/src/nodes/node_registry.rs index f18d0cc3e..bd4867615 100644 --- a/perro_source/core/perro_nodes/src/nodes/node_registry.rs +++ b/perro_source/core/perro_nodes/src/nodes/node_registry.rs @@ -1,5 +1,6 @@ use crate::ambient_light_3d::AmbientLight3D; use crate::animation_player::AnimationPlayer; +use crate::animation_tree::AnimationMixer; use crate::camera_2d::Camera2D; use crate::camera_3d::Camera3D; use crate::mesh_instance_3d::MeshInstance3D; @@ -844,6 +845,7 @@ define_scene_nodes! { UiTreeList => (UiBox, UiTreeList, Renderable::False, InternalUpdate::False, InternalFixedUpdate::False) } resource: { - AnimationPlayer => (None, AnimationPlayer, Renderable::False, InternalUpdate::True, InternalFixedUpdate::False) + AnimationPlayer => (None, AnimationPlayer, Renderable::False, InternalUpdate::True, InternalFixedUpdate::False), + AnimationMixer => (None, AnimationMixer, Renderable::False, InternalUpdate::True, InternalFixedUpdate::False) } } diff --git a/perro_source/runtime_project/perro_internal_updates/src/lib.rs b/perro_source/runtime_project/perro_internal_updates/src/lib.rs index d2a36f080..e6c08fa9c 100644 --- a/perro_source/runtime_project/perro_internal_updates/src/lib.rs +++ b/perro_source/runtime_project/perro_internal_updates/src/lib.rs @@ -13,6 +13,7 @@ pub fn internal_update_node( IP: InputAPI + ?Sized, { nodes::animation_player::internal_update(ctx, res, ipt, id); + nodes::animation_tree::internal_update(ctx, res, ipt, id); nodes::particle_emitter_3d::internal_update(ctx, res, ipt, id); } @@ -27,5 +28,6 @@ pub fn internal_fixed_update_node( IP: InputAPI + ?Sized, { nodes::animation_player::internal_fixed_update(ctx, res, ipt, id); + nodes::animation_tree::internal_fixed_update(ctx, res, ipt, id); nodes::particle_emitter_3d::internal_fixed_update(ctx, res, ipt, id); } diff --git a/perro_source/runtime_project/perro_internal_updates/src/nodes/animation_tree.rs b/perro_source/runtime_project/perro_internal_updates/src/nodes/animation_tree.rs new file mode 100644 index 000000000..a2d8953ba --- /dev/null +++ b/perro_source/runtime_project/perro_internal_updates/src/nodes/animation_tree.rs @@ -0,0 +1,39 @@ +use crate::prelude::*; +use perro_nodes::{AnimationMixer, AnimationMixerSlotState}; + +type SelfNodeType = AnimationMixer; + +pub fn internal_update( + ctx: &mut RuntimeWindow<'_, RT>, + _res: &ResourceWindow<'_, R>, + _ipt_w: &InputWindow<'_, IP>, + id: NodeID, +) where + RT: RuntimeAPI + ?Sized, + R: ResourceAPI + ?Sized, + IP: InputAPI + ?Sized, +{ + let delta = delta_time!(ctx).max(0.0); + with_node_mut!(ctx, SelfNodeType, id, |tree| { + for slot in &mut tree.slots { + if slot.state == AnimationMixerSlotState::Playing { + slot.time_seconds += delta * slot.speed; + } + } + if let Ok(Some(animation)) = tree.evaluate_output_animation() { + tree.internal.last_resolved_animation = animation; + } + }); +} + +pub fn internal_fixed_update( + _run: &mut RuntimeWindow<'_, RT>, + _res_w: &ResourceWindow<'_, R>, + _ipt_w: &InputWindow<'_, IP>, + _id: NodeID, +) where + RT: RuntimeAPI + ?Sized, + R: ResourceAPI + ?Sized, + IP: InputAPI + ?Sized, +{ +} diff --git a/perro_source/runtime_project/perro_internal_updates/src/nodes/mod.rs b/perro_source/runtime_project/perro_internal_updates/src/nodes/mod.rs index 07057c0dd..d33ec2eca 100644 --- a/perro_source/runtime_project/perro_internal_updates/src/nodes/mod.rs +++ b/perro_source/runtime_project/perro_internal_updates/src/nodes/mod.rs @@ -1,2 +1,4 @@ pub mod animation_player; pub mod particle_emitter_3d; + +pub mod animation_tree; diff --git a/perro_source/runtime_project/perro_runtime/src/runtime/scene_loader/prepare/nodes/three_d/animation.rs b/perro_source/runtime_project/perro_runtime/src/runtime/scene_loader/prepare/nodes/three_d/animation.rs index e0700dc3f..499c66815 100644 --- a/perro_source/runtime_project/perro_runtime/src/runtime/scene_loader/prepare/nodes/three_d/animation.rs +++ b/perro_source/runtime_project/perro_runtime/src/runtime/scene_loader/prepare/nodes/three_d/animation.rs @@ -93,3 +93,22 @@ fn parse_animation_bindings(value: &SceneValue) -> Option> Some(out) } + + +fn build_animation_mixer(data: &SceneDefNodeData) -> AnimationMixer { + let mut node = AnimationMixer::new(0); + if let Some(source) = extract_animation_mixer_source(data) { + node.set_mix_clip(perro_ids::AnimationMixClipID::from_u64(perro_ids::string_to_u64(&source))); + } + node +} + +fn extract_animation_mixer_source(data: &SceneDefNodeData) -> Option { + if data.ty != "AnimationMixer" { return None; } + data.fields.iter().find_map(|(name, value)| { + (resolve_node_field("AnimationMixer", name) + == Some(NodeField::AnimationMixer(AnimationMixerField::Mixer))) + .then(|| as_asset_source(value)) + .flatten() + }) +} diff --git a/perro_source/runtime_project/perro_scene/src/node_fields.rs b/perro_source/runtime_project/perro_scene/src/node_fields.rs index a071690ab..078a017b9 100644 --- a/perro_source/runtime_project/perro_scene/src/node_fields.rs +++ b/perro_source/runtime_project/perro_scene/src/node_fields.rs @@ -16,6 +16,7 @@ pub enum NodeField { Camera3D(Camera3DField), ParticleEmitter3D(ParticleEmitter3DField), AnimationPlayer(AnimationPlayerField), + AnimationMixer(AnimationMixerField), Light3D(Light3DField), Sky3D(Sky3DField), RayLight3D(RayLight3DField), @@ -147,6 +148,11 @@ pub enum AnimationPlayerField { Playback, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AnimationMixerField { + Mixer, +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Light3DField { Color, @@ -362,6 +368,10 @@ pub fn resolve_node_field(node_type_name: &str, field: &str) -> Option Some(NodeField::AnimationPlayer(AnimationPlayerField::Playback)), _ => None, }, + NodeType::AnimationMixer => match field { + "mixer" => Some(NodeField::AnimationMixer(AnimationMixerField::Mixer)), + _ => None, + }, NodeType::AmbientLight3D => resolve_light3d_common(field).map(NodeField::Light3D), NodeType::Sky3D => resolve_sky3d_field(field).map(NodeField::Sky3D), NodeType::RayLight3D => match field {