Skip to main content

kvas_engine/
skeletal.rs

1//! Skeletal animation: skinned models with bones and clips, played by the [`Animator`] component.
2//!
3//! * **Import.** FBX (ufbx) and glTF/GLB skins are read with their skeleton, per-vertex bone
4//!   weights (up to four) and every animation clip. Clips are sampled at 30 frames per second
5//!   into model-space bone poses; walking clips are made "in place" (the forward drift of the
6//!   hips is removed, the sway stays) because game code moves the object itself.
7//! * **Humanoid clips without animation files.** A rig with recognisable bone names (hips or
8//!   pelvis, spine, chest, neck, head, upper arm, forearm, thigh, shin, foot — `.L/.R`,
9//!   `_left/_right` and similar) gets procedural clips: `Idle`, `Walk`, `Run`, `Talk`, `Wave`,
10//!   `Sit`, `Wipe` (wiping a counter) and `Look` (glancing around). Clips from the file keep
11//!   their names; the procedural ones are added only when no clip of that name exists.
12//! * **Playback.** [`Animator`] picks the clip, speed and looping; changing `clip` cross-fades
13//!   over `fade` seconds. The pose is skinned on the CPU into a [`DynamicMesh`], so any shader,
14//!   texture and shadow work as for a static model (a few thousand vertices cost microseconds).
15//!
16//! ```no_run
17//! # use kvas_engine::prelude::*;
18//! # fn f(ctx: &mut Ctx, monster: Entity) {
19//! // In a script: walk while moving, idle otherwise.
20//! if let Some(a) = ctx.get_mut_of::<Animator>(monster) {
21//!     a.clip = "Walk".into();
22//! }
23//! # }
24//! ```
25
26use crate::assets::Assets;
27use crate::components::{DynamicMesh, MeshRenderer};
28use crate::mesh::MeshData;
29use glam::{Mat4, Quat, Vec3};
30use kvas_ecs::{Entity, World};
31use serde::{Deserialize, Serialize};
32use std::collections::HashMap;
33use std::sync::{Arc, Mutex};
34
35/// Translation, rotation and scale of one bone in model space.
36#[derive(Clone, Copy, Debug, PartialEq)]
37pub struct Trs {
38    /// Position.
39    pub t: Vec3,
40    /// Orientation.
41    pub r: Quat,
42    /// Scale per axis.
43    pub s: Vec3,
44}
45
46impl Default for Trs {
47    fn default() -> Self {
48        Self { t: Vec3::ZERO, r: Quat::IDENTITY, s: Vec3::ONE }
49    }
50}
51
52impl Trs {
53    /// Splits a matrix into translation, rotation and scale.
54    pub fn from_mat(m: Mat4) -> Self {
55        let (s, r, t) = m.to_scale_rotation_translation();
56        Self { t, r: r.normalize(), s }
57    }
58    /// The matrix of this transform.
59    pub fn mat(&self) -> Mat4 {
60        Mat4::from_scale_rotation_translation(self.s, self.r, self.t)
61    }
62    /// Blend towards `o` by `k` (0..1), shortest-arc for the rotation.
63    pub fn lerp(&self, o: &Trs, k: f32) -> Trs {
64        Trs { t: self.t.lerp(o.t, k), r: self.r.slerp(o.r, k).normalize(), s: self.s.lerp(o.s, k) }
65    }
66}
67
68/// A node of the skeleton (a bone, or another node the bones hang from).
69#[derive(Clone, Debug)]
70pub struct SkelNode {
71    /// Bone or node name from the file.
72    pub name: String,
73    /// Index of the parent node in [`SkinnedModel::nodes`].
74    pub parent: Option<usize>,
75    /// Model-space transform in the rest (bind) pose.
76    pub rest: Mat4,
77}
78
79/// What a vertex weight refers to: a skeleton node and the matrix from the mesh's bind space
80/// to that node.
81#[derive(Clone, Debug)]
82pub struct Joint {
83    /// Index in [`SkinnedModel::nodes`].
84    pub node: usize,
85    /// From the mesh's bind space to the node's space in the bind pose.
86    pub inverse_bind: Mat4,
87}
88
89/// An animation sampled at a fixed rate: `frames[f][node]` is a model-space pose.
90#[derive(Clone, Debug)]
91pub struct Clip {
92    /// Clip name (`Walk`, `Idle_Watchful`...).
93    pub name: String,
94    /// Length in seconds.
95    pub duration: f32,
96    /// Sampling rate of `frames`.
97    pub fps: f32,
98    /// `frames[frame][node]`: model-space pose of each node.
99    pub frames: Vec<Vec<Trs>>,
100}
101
102/// A skinned model: its bind-pose mesh, the weights, the skeleton and the clips.
103#[derive(Clone)]
104pub struct SkinnedModel {
105    /// The mesh in its rest pose (model space), with textures and material runs.
106    pub mesh: MeshData,
107    /// Per vertex: position and normal in the mesh's bind space (what joints transform).
108    pub bind: Vec<(Vec3, Vec3)>,
109    /// Per vertex: up to four joints and their weights (summing to one).
110    pub joints: Vec<[u16; 4]>,
111    /// Per vertex: the weights of `joints`.
112    pub weights: Vec<[f32; 4]>,
113    /// The skeleton (parents before children for FBX).
114    pub nodes: Vec<SkelNode>,
115    /// What `joints` refer to.
116    pub skin: Vec<Joint>,
117    /// Clips from the file and the procedural humanoid ones.
118    pub clips: Vec<Clip>,
119    /// The humanoid parts, when the bones have recognisable names (IK, ragdolls).
120    pub humanoid: Option<crate::humanoid::HumanoidBones>,
121    /// `children[i]`: nodes whose parent is `i`.
122    pub children: Vec<Vec<usize>>,
123}
124
125fn norm_name(s: &str) -> String {
126    s.chars().filter(|c| c.is_alphanumeric()).collect::<String>().to_lowercase()
127}
128
129impl SkinnedModel {
130    /// Clip by name: exact, then case-insensitive, then the part after `|` (`Rig|Walk` = `Walk`).
131    pub fn clip(&self, name: &str) -> Option<&Clip> {
132        let n = norm_name(name);
133        self.clips
134            .iter()
135            .find(|c| c.name == name)
136            .or_else(|| self.clips.iter().find(|c| norm_name(&c.name) == n))
137            .or_else(|| self.clips.iter().find(|c| norm_name(c.name.rsplit('|').next().unwrap_or("")) == n))
138    }
139
140    /// The rest pose.
141    pub fn rest_pose(&self) -> Vec<Trs> {
142        self.nodes.iter().map(|n| Trs::from_mat(n.rest)).collect()
143    }
144
145    /// Pose of `clip` at `time` seconds (wrapped when looping, held at the end otherwise).
146    pub fn sample(&self, clip: &Clip, time: f32, looping: bool, out: &mut Vec<Trs>) {
147        out.clear();
148        let n = clip.frames.len();
149        if n == 0 {
150            out.extend(self.rest_pose());
151            return;
152        }
153        let t = if looping && clip.duration > 0.0 { time.rem_euclid(clip.duration) } else { time.clamp(0.0, clip.duration) };
154        let f = t * clip.fps;
155        let a = (f.floor() as usize).min(n - 1);
156        let b = if a + 1 < n { a + 1 } else if looping { 0 } else { a };
157        let k = (f - f.floor()).clamp(0.0, 1.0);
158        out.extend(clip.frames[a].iter().zip(&clip.frames[b]).map(|(x, y)| x.lerp(y, k)));
159    }
160
161    /// Deforms the vertices of `out` (a copy of `self.mesh`) into `pose`.
162    pub fn skin_into(&self, pose: &[Trs], out: &mut MeshData) {
163        let mats: Vec<Mat4> = self.skin.iter().map(|j| pose.get(j.node).map_or(Mat4::IDENTITY, Trs::mat) * j.inverse_bind).collect();
164        for (i, v) in out.vertices.iter_mut().enumerate() {
165            let (Some(&(p, n)), Some(js), Some(ws)) = (self.bind.get(i), self.joints.get(i), self.weights.get(i)) else { continue };
166            let (mut pos, mut nrm) = (Vec3::ZERO, Vec3::ZERO);
167            for k in 0..4 {
168                let w = ws[k];
169                if w <= 0.0 {
170                    continue;
171                }
172                let m = mats.get(js[k] as usize).copied().unwrap_or(Mat4::IDENTITY);
173                pos += m.transform_point3(p) * w;
174                nrm += m.transform_vector3(n) * w;
175            }
176            v.pos = pos.into();
177            v.normal = nrm.normalize_or_zero().into();
178        }
179    }
180
181    fn node_named(&self, names: &[&str]) -> Option<usize> {
182        names.iter().find_map(|want| self.nodes.iter().position(|n| norm_name(&n.name) == *want))
183    }
184
185    /// Makes clips play in place: removes the hips' straight-line drift over the clip.
186    fn in_place(&mut self) {
187        let Some(hips) = self.node_named(&["hips", "pelvis", "hip", "torso"]) else { return };
188        for clip in &mut self.clips {
189            let (Some(first), Some(last)) = (clip.frames.first(), clip.frames.last()) else { continue };
190            let drift = (last[hips].t - first[hips].t) * Vec3::new(1.0, 0.0, 1.0);
191            if drift.length() < 0.05 {
192                continue;
193            }
194            let n = (clip.frames.len() - 1).max(1) as f32;
195            for (f, frame) in clip.frames.iter_mut().enumerate() {
196                let off = drift * (f as f32 / n);
197                for trs in frame.iter_mut() {
198                    trs.t -= off;
199                }
200            }
201        }
202    }
203}
204
205fn top4(mut list: Vec<(u16, f32)>) -> ([u16; 4], [f32; 4]) {
206    list.sort_by(|a, b| b.1.total_cmp(&a.1));
207    list.truncate(4);
208    let sum: f32 = list.iter().map(|w| w.1).sum();
209    let (mut j, mut w) = ([0u16; 4], [0f32; 4]);
210    for (i, (jj, ww)) in list.iter().enumerate() {
211        j[i] = *jj;
212        w[i] = if sum > 0.0 { ww / sum } else { 0.0 };
213    }
214    (j, w)
215}
216
217// ---------------------------------------------------------------- FBX
218
219fn fbx_mat(g: &ufbx::Matrix) -> Mat4 {
220    Mat4::from_cols_array(&[
221        g.m00 as f32, g.m10 as f32, g.m20 as f32, 0.0, g.m01 as f32, g.m11 as f32, g.m21 as f32, 0.0, g.m02 as f32, g.m12 as f32, g.m22 as f32, 0.0, g.m03 as f32,
222        g.m13 as f32, g.m23 as f32, 1.0,
223    ])
224}
225
226fn load_fbx(assets: &Assets, path: &str, bytes: &[u8]) -> Result<SkinnedModel, String> {
227    use ufbx::{CoordinateAxes, CoordinateAxis};
228    // The same options as the static loader, so the vertices line up one to one.
229    let opts = ufbx::LoadOpts {
230        target_axes: CoordinateAxes { right: CoordinateAxis::PositiveX, up: CoordinateAxis::PositiveY, front: CoordinateAxis::PositiveZ },
231        target_unit_meters: 1.0,
232        generate_missing_normals: true,
233        ..Default::default()
234    };
235    let scene = ufbx::load_memory(bytes, opts).map_err(|e| format!("{e:?}"))?;
236    let mesh = MeshData::load(assets, path)?;
237    // Skeleton: every node a weight refers to, with all its ancestors.
238    let mut index_of: HashMap<u32, usize> = HashMap::new();
239    let mut nodes: Vec<SkelNode> = Vec::new();
240    fn add(node: &ufbx::Node, index_of: &mut HashMap<u32, usize>, nodes: &mut Vec<SkelNode>) -> usize {
241        if let Some(&i) = index_of.get(&node.element.typed_id) {
242            return i;
243        }
244        let parent = node.parent.as_ref().map(|p| add(p, index_of, nodes));
245        nodes.push(SkelNode { name: node.element.name.to_string(), parent, rest: fbx_mat(&node.node_to_world) });
246        index_of.insert(node.element.typed_id, nodes.len() - 1);
247        nodes.len() - 1
248    }
249    let mut skin = Vec::new();
250    let (mut bind, mut joints, mut weights) = (Vec::new(), Vec::new(), Vec::new());
251    for node in scene.nodes.iter() {
252        let Some(m) = &node.mesh else { continue };
253        let deformer = m.skin_deformers.first();
254        // Which joint each skin cluster is; an unskinned mesh follows its own node.
255        let cluster_joint: Vec<u16> = match deformer {
256            Some(d) => d
257                .clusters
258                .iter()
259                .map(|c| {
260                    let bone = c.bone_node.as_ref().map(|b| add(b, &mut index_of, &mut nodes)).unwrap_or_else(|| add(node, &mut index_of, &mut nodes));
261                    // The rest pose of a bone is where the skin was bound, not the file's
262                    // current pose (they differ when the file was saved mid-animation).
263                    nodes[bone].rest = fbx_mat(&c.bind_to_world);
264                    skin.push(Joint { node: bone, inverse_bind: fbx_mat(&c.geometry_to_bone) });
265                    (skin.len() - 1) as u16
266                })
267                .collect(),
268            None => {
269                let own = add(node, &mut index_of, &mut nodes);
270                skin.push(Joint { node: own, inverse_bind: fbx_mat(&node.geometry_to_node) });
271                vec![(skin.len() - 1) as u16]
272            }
273        };
274        for i in 0..m.num_indices {
275            let p = m.vertex_position[i];
276            let n = if m.vertex_normal.exists { m.vertex_normal[i] } else { ufbx::Vec3 { x: 0.0, y: 1.0, z: 0.0 } };
277            bind.push((Vec3::new(p.x as f32, p.y as f32, p.z as f32), Vec3::new(n.x as f32, n.y as f32, n.z as f32)));
278            let (j, w) = match deformer {
279                Some(d) => {
280                    let vi = m.vertex_indices[i] as usize;
281                    let list: Vec<(u16, f32)> = d
282                        .vertices
283                        .get(vi)
284                        .map(|sv| {
285                            (sv.weight_begin..sv.weight_begin + sv.num_weights)
286                                .filter_map(|k| d.weights.get(k as usize))
287                                .filter_map(|w| Some((*cluster_joint.get(w.cluster_index as usize)?, w.weight as f32)))
288                                .collect()
289                        })
290                        .unwrap_or_default();
291                    if list.is_empty() {
292                        ([0; 4], [0.0; 4])
293                    } else {
294                        top4(list)
295                    }
296                }
297                None => ([cluster_joint[0], 0, 0, 0], [1.0, 0.0, 0.0, 0.0]),
298            };
299            joints.push(j);
300            weights.push(w);
301        }
302    }
303    if bind.len() != mesh.vertices.len() {
304        return Err(format!("{path}: skinned vertex count {} differs from the mesh ({})", bind.len(), mesh.vertices.len()));
305    }
306    // Clips: every node's model-space pose, 30 times a second.
307    let mut clips = Vec::new();
308    for stack in scene.anim_stacks.iter() {
309        let duration = (stack.time_end - stack.time_begin).max(0.0) as f32;
310        let fps = 30.0;
311        let count = (duration * fps).ceil() as usize + 1;
312        let mut frames = Vec::with_capacity(count);
313        for f in 0..count {
314            let t = stack.time_begin + (f as f64 / fps as f64).min(duration as f64);
315            let eval = ufbx::evaluate_scene(&scene, &stack.anim, t, ufbx::EvaluateOpts::default()).map_err(|e| format!("{e:?}"))?;
316            let mut pose = vec![Trs::default(); nodes.len()];
317            for (&typed, &i) in &index_of {
318                if let Some(n) = eval.nodes.get(typed as usize) {
319                    pose[i] = Trs::from_mat(fbx_mat(&n.node_to_world));
320                }
321            }
322            frames.push(pose);
323        }
324        let name = stack.element.name.to_string();
325        clips.push(Clip { name: name.rsplit('|').next().unwrap_or(&name).to_string(), duration, fps, frames });
326    }
327    Ok(SkinnedModel { mesh, bind, joints, weights, nodes, skin, clips, humanoid: None, children: Vec::new() })
328}
329
330// ---------------------------------------------------------------- glTF
331
332fn load_gltf(assets: &Assets, path: &str, bytes: &[u8]) -> Result<SkinnedModel, String> {
333    let crate::mesh::ImportedGltf { document: doc, buffers, images, .. } = crate::mesh::import_gltf(assets, path, bytes)?;
334    let get = |b: gltf::Buffer| buffers.get(b.index()).map(|d| d.0.as_slice());
335    // Rest pose of every node (local), parents, model-space rest.
336    let count = doc.nodes().len();
337    let mut parent = vec![None; count];
338    for n in doc.nodes() {
339        for c in n.children() {
340            parent[c.index()] = Some(n.index());
341        }
342    }
343    let local_rest: Vec<(Vec3, Quat, Vec3)> = doc
344        .nodes()
345        .map(|n| {
346            let (t, r, s) = n.transform().decomposed();
347            (Vec3::from(t), Quat::from_array(r).normalize(), Vec3::from(s))
348        })
349        .collect();
350    let world_of = |local: &[(Vec3, Quat, Vec3)]| -> Vec<Mat4> {
351        let mut world: Vec<Option<Mat4>> = vec![None; count];
352        fn resolve(i: usize, parent: &[Option<usize>], local: &[(Vec3, Quat, Vec3)], world: &mut Vec<Option<Mat4>>, depth: usize) -> Mat4 {
353            if let Some(m) = world[i] {
354                return m;
355            }
356            let (t, r, s) = local[i];
357            let own = Mat4::from_scale_rotation_translation(s, r, t);
358            let m = match parent[i] {
359                Some(p) if depth < 64 => resolve(p, parent, local, world, depth + 1) * own,
360                _ => own,
361            };
362            world[i] = Some(m);
363            m
364        }
365        (0..count).map(|i| resolve(i, &parent, local, &mut world, 0)).collect()
366    };
367    let rest_world = world_of(&local_rest);
368    let nodes: Vec<SkelNode> = doc.nodes().map(|n| SkelNode { name: n.name().unwrap_or("").to_string(), parent: parent[n.index()], rest: rest_world[n.index()] }).collect();
369
370    let mut mesh = MeshData::default();
371    let (mut bind, mut joints, mut weights, mut skin) = (Vec::new(), Vec::new(), Vec::new(), Vec::<Joint>::new());
372    let mut parts = Vec::new();
373    for node in doc.nodes() {
374        let Some(gm) = node.mesh() else { continue };
375        // Joints of this node's skin (or the node itself for a rigid mesh).
376        let joint_base = skin.len() as u16;
377        match node.skin() {
378            Some(s) => {
379                let ibms: Vec<Mat4> = s.reader(get).read_inverse_bind_matrices().map(|it| it.map(|m| Mat4::from_cols_array_2d(&m)).collect()).unwrap_or_default();
380                for (k, j) in s.joints().enumerate() {
381                    skin.push(Joint { node: j.index(), inverse_bind: ibms.get(k).copied().unwrap_or(Mat4::IDENTITY) });
382                }
383            }
384            None => skin.push(Joint { node: node.index(), inverse_bind: Mat4::IDENTITY }),
385        }
386        let skinned = node.skin().is_some();
387        for prim in gm.primitives() {
388            if prim.mode() != gltf::mesh::Mode::Triangles {
389                continue;
390            }
391            let r = prim.reader(get);
392            let Some(pos) = r.read_positions() else { continue };
393            let pos: Vec<Vec3> = pos.map(Vec3::from).collect();
394            let nrm: Vec<Vec3> = r.read_normals().map(|n| n.map(Vec3::from).collect()).unwrap_or_else(|| vec![Vec3::Y; pos.len()]);
395            let uvs: Vec<[f32; 2]> = r.read_tex_coords(0).map(|t| t.into_f32().collect()).unwrap_or_else(|| vec![[0.0; 2]; pos.len()]);
396            let js: Vec<[u16; 4]> = r.read_joints(0).map(|j| j.into_u16().collect()).unwrap_or_default();
397            let ws: Vec<[f32; 4]> = r.read_weights(0).map(|w| w.into_f32().collect()).unwrap_or_default();
398            let base = mesh.vertices.len() as u32;
399            let rest_rigid = rest_world[node.index()];
400            for (i, p) in pos.iter().enumerate() {
401                let n = nrm.get(i).copied().unwrap_or(Vec3::Y);
402                let (j, w) = if skinned && i < js.len() && i < ws.len() {
403                    let list: Vec<(u16, f32)> = (0..4).filter(|k| ws[i][*k] > 0.0).map(|k| (joint_base + js[i][k], ws[i][k])).collect();
404                    top4(list)
405                } else {
406                    ([joint_base, 0, 0, 0], [1.0, 0.0, 0.0, 0.0])
407                };
408                // The rest-pose vertex in model space, for the static look and the bounds.
409                let m: Mat4 = (0..4).filter(|k| w[*k] > 0.0).map(|k| {
410                    let jt = &skin[j[k] as usize];
411                    (rest_world[jt.node] * jt.inverse_bind) * w[k]
412                }).fold(Mat4::ZERO, |a, b| a + b);
413                let m = if skinned { m } else { rest_rigid };
414                mesh.vertices.push(crate::mesh::Vertex { pos: m.transform_point3(*p).into(), normal: m.transform_vector3(n).normalize_or_zero().into(), uv: uvs.get(i).copied().unwrap_or([0.0; 2]) });
415                bind.push((*p, n));
416                joints.push(j);
417                weights.push(w);
418            }
419            let first = mesh.indices.len() as u32;
420            match r.read_indices() {
421                Some(ix) => mesh.indices.extend(ix.into_u32().map(|i| base + i)),
422                None => mesh.indices.extend((0..pos.len() as u32).map(|i| base + i)),
423            }
424            let albedo = crate::mesh::material_albedo(&images, &prim.material().pbr_metallic_roughness());
425            if mesh.albedo.is_none() {
426                mesh.albedo = albedo.clone();
427            }
428            parts.push(crate::mesh::MeshPart { first_index: first, index_count: mesh.indices.len() as u32 - first, albedo, normal: None, orm: None });
429        }
430    }
431    if parts.len() > 1 {
432        mesh.parts = parts;
433    }
434    if mesh.indices.is_empty() {
435        return Err(format!("{path}: no triangles"));
436    }
437    // Clips.
438    let mut clips = Vec::new();
439    for anim in doc.animations() {
440        let mut tracks = Vec::new();
441        let mut duration = 0.0f32;
442        for ch in anim.channels() {
443            let r = ch.reader(get);
444            let times: Vec<f32> = r.read_inputs().map(|t| t.collect()).unwrap_or_default();
445            duration = duration.max(times.last().copied().unwrap_or(0.0));
446            let cubic = ch.sampler().interpolation() == gltf::animation::Interpolation::CubicSpline;
447            use gltf::animation::util::ReadOutputs;
448            let values: Vec<[f32; 4]> = match r.read_outputs() {
449                Some(ReadOutputs::Translations(v)) => v.map(|x| [x[0], x[1], x[2], 0.0]).collect(),
450                Some(ReadOutputs::Scales(v)) => v.map(|x| [x[0], x[1], x[2], 0.0]).collect(),
451                Some(ReadOutputs::Rotations(v)) => v.into_f32().collect(),
452                _ => continue,
453            };
454            // Cubic splines store (in-tangent, value, out-tangent); keep the values.
455            let values: Vec<[f32; 4]> = if cubic { values.chunks(3).filter_map(|c| c.get(1).copied()).collect() } else { values };
456            tracks.push((ch.target().node().index(), ch.target().property(), times, values));
457        }
458        let fps = 30.0;
459        let frames_n = (duration * fps).ceil() as usize + 1;
460        let mut frames = Vec::with_capacity(frames_n);
461        for f in 0..frames_n {
462            let t = f as f32 / fps;
463            let mut local = local_rest.clone();
464            for (node, prop, times, values) in &tracks {
465                if times.is_empty() || values.is_empty() {
466                    continue;
467                }
468                let k = times.partition_point(|x| *x <= t).saturating_sub(1).min(values.len() - 1);
469                let k2 = (k + 1).min(values.len() - 1).min(times.len() - 1);
470                let span = times[k2] - times[k];
471                let a = if span > 0.0 { ((t - times[k]) / span).clamp(0.0, 1.0) } else { 0.0 };
472                let (v0, v1) = (values[k], values[k2]);
473                use gltf::animation::Property;
474                match prop {
475                    Property::Translation => local[*node].0 = Vec3::new(v0[0], v0[1], v0[2]).lerp(Vec3::new(v1[0], v1[1], v1[2]), a),
476                    Property::Scale => local[*node].2 = Vec3::new(v0[0], v0[1], v0[2]).lerp(Vec3::new(v1[0], v1[1], v1[2]), a),
477                    Property::Rotation => local[*node].1 = Quat::from_array(v0).normalize().slerp(Quat::from_array(v1).normalize(), a),
478                    _ => {}
479                }
480            }
481            frames.push(world_of(&local).into_iter().map(Trs::from_mat).collect());
482        }
483        clips.push(Clip { name: anim.name().unwrap_or("Clip").to_string(), duration, fps, frames });
484    }
485    Ok(SkinnedModel { mesh, bind, joints, weights, nodes, skin, clips, humanoid: None, children: Vec::new() })
486}
487
488/// Loads a skinned model (FBX, glTF, GLB) with its clips, plus procedural humanoid clips.
489pub fn load(assets: &Assets, path: &str) -> Result<SkinnedModel, String> {
490    let bytes = assets.read(path).map_err(|e| format!("{path}: {e}"))?;
491    let lower = path.to_ascii_lowercase();
492    let mut model = if lower.ends_with(".fbx") {
493        load_fbx(assets, path, &bytes)?
494    } else if lower.ends_with(".glb") || lower.ends_with(".gltf") {
495        load_gltf(assets, path, &bytes)?
496    } else {
497        return Err(format!("{path}: skinning needs an FBX or glTF model"));
498    };
499    model.in_place();
500    crate::humanoid::add_clips(&mut model);
501    model.humanoid = crate::humanoid::find_bones(&model);
502    model.children = vec![Vec::new(); model.nodes.len()];
503    for (i, n) in model.nodes.iter().enumerate() {
504        if let Some(p) = n.parent {
505            model.children[p].push(i);
506        }
507    }
508    Ok(model)
509}
510
511/// Skinned models by path, loaded once (the asset source is the one given to
512/// [`crate::meshmod::set_assets`]). `None` when the file is missing or not skinned.
513pub fn model(path: &str) -> Option<Arc<SkinnedModel>> {
514    static CACHE: Mutex<Option<HashMap<String, Option<Arc<SkinnedModel>>>>> = Mutex::new(None);
515    let mut cache = CACHE.lock().ok()?;
516    cache
517        .get_or_insert_with(HashMap::new)
518        .entry(path.to_string())
519        .or_insert_with(|| {
520            let assets = crate::meshmod::assets().unwrap_or(Assets::Dir(std::path::PathBuf::new()));
521            load(&assets, path).ok().map(Arc::new)
522        })
523        .clone()
524}
525
526// ---------------------------------------------------------------- the component
527
528/// Plays the clips of a skinned model (`MeshRenderer.mesh`). Changing `clip` cross-fades.
529#[derive(Clone, Serialize, Deserialize)]
530#[serde(default)]
531pub struct Animator {
532    /// Clip to play (`Idle`, `Walk`, or a name from the file). Empty: the first clip.
533    pub clip: String,
534    /// Playback rate; 1 is the clip's own speed.
535    pub speed: f32,
536    /// Wrap at the end (walk cycles) or hold the last frame (a lunge, a fall).
537    pub looping: bool,
538    /// Paused when false (the pose stays).
539    pub playing: bool,
540    /// Seconds to blend from the previous clip into a new one.
541    pub fade: f32,
542    /// Clips the model offers (filled in by the engine; read only).
543    #[serde(skip)]
544    pub clips: Vec<String>,
545    /// Seconds into the current clip (read only; set `clip` to restart).
546    #[serde(skip)]
547    pub time: f32,
548}
549
550impl Default for Animator {
551    fn default() -> Self {
552        Self { clip: String::new(), speed: 1.0, looping: true, playing: true, fade: 0.25, clips: Vec::new(), time: 0.0 }
553    }
554}
555
556impl crate::components::Component for Animator {
557    fn fields(&mut self) -> Vec<crate::reflect::Field<'_>> {
558        use crate::reflect::Field;
559        vec![Field::text("clip", &mut self.clip), Field::f32("speed", &mut self.speed), Field::bool("looping", &mut self.looping), Field::bool("playing", &mut self.playing), Field::f32("fade", &mut self.fade)]
560    }
561}
562
563/// Run-time playback state next to an [`Animator`] (not saved).
564#[derive(Clone, Default)]
565pub struct AnimatorState {
566    current: String,
567    time: f32,
568    previous: Option<(String, f32)>,
569    fade_t: f32,
570    fade_len: f32,
571    wall: f32,
572    version: u64,
573    pose: Vec<Trs>,
574    blend: Vec<Trs>,
575    ik: crate::ik::IkState,
576    ragdoll: crate::ragdoll::RagdollState,
577}
578
579/// Traces down for foot IK: `(from, length, the character)` → height of the hit (world space).
580pub type GroundQuery<'a> = &'a dyn Fn(Vec3, f32, Entity) -> Option<f32>;
581
582/// Advances every animator and skins its model. `simulate` is false in the editor outside Play:
583/// the animation then runs on wall-clock `time` as a preview. Without a ground query the
584/// feet are not planted (see [`update_with`]).
585pub fn update(world: &mut World, time: f32, dt: f32, simulate: bool) {
586    update_with(world, time, dt, simulate, None);
587}
588
589/// [`update`] with physics: `ground` plants the feet ([`crate::ik::CharacterIk`]); ragdolls
590/// ([`crate::ragdoll::Ragdoll`]) run only when `simulate` is true. Called by the runtime after
591/// the physics steps of a frame.
592pub fn update_with(world: &mut World, time: f32, dt: f32, simulate: bool, ground: Option<GroundQuery>) {
593    // Animators that were removed leave their skinned mesh (and ragdoll bodies) behind; drop them.
594    let stale: Vec<Entity> = world.iter::<AnimatorState>().map(|(e, _)| e).filter(|e| !world.has::<Animator>(*e)).collect();
595    for e in stale {
596        if let Some(mut st) = world.remove::<AnimatorState>(e) {
597            crate::ragdoll::deactivate(world, &mut st.ragdoll);
598        }
599        world.remove::<DynamicMesh>(e);
600    }
601    let list: Vec<(Entity, Animator, String)> = world.iter::<Animator>().filter_map(|(e, a)| Some((e, a.clone(), world.get::<MeshRenderer>(e)?.mesh.clone()))).collect();
602    for (e, mut anim, mesh) in list {
603        let Some(model) = model(&mesh) else { continue };
604        let mut st = world.get::<AnimatorState>(e).cloned().unwrap_or_default();
605        let step = if simulate { dt } else { (time - st.wall).clamp(0.0, 0.1) };
606        st.wall = time;
607        let wanted = if anim.clip.is_empty() { model.clips.first().map(|c| c.name.clone()).unwrap_or_default() } else { anim.clip.clone() };
608        if wanted != st.current {
609            if !st.current.is_empty() && anim.fade > 0.0 {
610                st.previous = Some((st.current.clone(), st.time));
611                st.fade_t = 0.0;
612                st.fade_len = anim.fade;
613            }
614            st.current = wanted;
615            st.time = 0.0;
616        }
617        let advance = if anim.playing { step * anim.speed } else { 0.0 };
618        st.time += advance;
619        let mut pose = std::mem::take(&mut st.pose);
620        match model.clip(&st.current) {
621            Some(c) => model.sample(c, st.time, anim.looping, &mut pose),
622            None => pose = model.rest_pose(),
623        }
624        if let Some((prev, t)) = st.previous.clone() {
625            st.fade_t += step;
626            let k = (st.fade_t / st.fade_len.max(1e-3)).clamp(0.0, 1.0);
627            let k = k * k * (3.0 - 2.0 * k);
628            if k >= 1.0 {
629                st.previous = None;
630            } else if let Some(c) = model.clip(&prev) {
631                let mut from = std::mem::take(&mut st.blend);
632                model.sample(c, t + st.fade_t * anim.speed, true, &mut from);
633                for (p, f) in pose.iter_mut().zip(&from) {
634                    *p = f.lerp(p, k);
635                }
636                st.blend = from;
637            }
638        }
639        // Ragdoll or IK on top of the clip.
640        let owner_world = world.get::<crate::components::GlobalTransform>(e).map_or_else(|| crate::hierarchy::global_matrix(world, e), |g| g.0);
641        let rd = world.get::<crate::ragdoll::Ragdoll>(e).cloned();
642        let want_rag = simulate && model.humanoid.is_some() && rd.as_ref().is_some_and(|r| r.active);
643        if want_rag && !st.ragdoll.is_active() {
644            let velocity = world.get::<crate::components::RigidBody>(e).map_or(Vec3::ZERO, |r| r.velocity);
645            st.ragdoll = crate::ragdoll::activate(world, e, &model, &pose, owner_world, rd.as_ref().unwrap(), velocity);
646        } else if !want_rag && st.ragdoll.is_active() {
647            crate::ragdoll::deactivate(world, &mut st.ragdoll);
648        }
649        if st.ragdoll.is_active() {
650            crate::ragdoll::pose(world, &model, &st.ragdoll, owner_world, &mut pose);
651        } else if let Some(ik) = world.get::<crate::ik::CharacterIk>(e).cloned() {
652            let target = ik.look_point.or_else(|| {
653                let t = (!ik.look_at.is_empty()).then(|| crate::hierarchy::find_by_name(world, &ik.look_at)).flatten()?;
654                Some(world.get::<crate::components::GlobalTransform>(t).map_or_else(|| crate::hierarchy::global_matrix(world, t), |g| g.0).w_axis.truncate())
655            });
656            let probe = ground.map(|g| move |from: Vec3, len: f32| g(from, len, e));
657            let probe_ref = probe.as_ref().map(|p| p as &dyn Fn(Vec3, f32) -> Option<f32>);
658            crate::ik::apply(&model, &mut pose, owner_world, &ik, target, probe_ref, &mut st.ik, if simulate { dt } else { step });
659        }
660        let mut data = model.mesh.clone();
661        model.skin_into(&pose, &mut data);
662        st.pose = pose;
663        st.version += 1;
664        let version = st.version;
665        anim.time = st.time;
666        if anim.clips.len() != model.clips.len() {
667            anim.clips = model.clips.iter().map(|c| c.name.clone()).collect();
668        }
669        if let Some(a) = world.get_mut::<Animator>(e) {
670            a.time = anim.time;
671            if a.clips.len() != anim.clips.len() {
672                a.clips = anim.clips;
673            }
674        }
675        world.insert(e, st);
676        world.insert(e, DynamicMesh { data: Arc::new(data), version });
677    }
678}
679
680#[cfg(test)]
681mod tests {
682    use super::*;
683
684    #[test]
685    fn trs_round_trip_and_blend() {
686        let m = Mat4::from_scale_rotation_translation(Vec3::splat(2.0), Quat::from_rotation_y(0.7), Vec3::new(1.0, 2.0, 3.0));
687        let t = Trs::from_mat(m);
688        assert!(t.mat().abs_diff_eq(m, 1e-4));
689        let half = Trs::default().lerp(&t, 0.5);
690        assert!((half.t - Vec3::new(0.5, 1.0, 1.5)).length() < 1e-5);
691    }
692}