Skip to main content

concinnity_engine/gfx/animation/
mod.rs

1// src/gfx/animation/mod.rs
2//
3// Skeletal animation playback. An internal system (not a declarable asset):
4// `World::start` constructs one whenever the world contains any `Animation`
5// or `AnimationGraph` component, then it produces fresh skinning matrices for each
6// `SkeletonPose` every frame.
7//
8// Each target `SkinnedMesh` gets a bucket of clips driven in one of two
9// modes: `Flat` blends every clip by a live weight vector (startup fade-in +
10// runtime crossfades; see `flat`), while `Graph` walks a compiled animation
11// state machine whose transitions are driven by the target's `AnimationParams`
12// component (see `graph`). Runtime debug commands for both modes are drained
13// in `commands`.
14
15mod commands;
16mod flat;
17mod graph;
18mod ik;
19mod morph;
20mod root;
21#[cfg(test)]
22mod tests;
23
24use std::collections::{BTreeMap, HashMap};
25use std::time::Instant;
26
27use crate::components::{Animation, SkeletonPose};
28use crate::ecs::asset_id::AssetId;
29use crate::ecs::{PipelineContext, SkinnedMeshHandle, StepResult, System};
30use crate::gfx::pose_blend::PoseBlend;
31use crate::gfx::skeleton::AnimationClip;
32use crate::jobs;
33
34use flat::{ClipEntry, FlatState, Transition};
35use graph::GraphTarget;
36
37// Per-`SkinnedMesh` bucket: the clips targeting it plus the mode that drives
38// them. Clip storage is mode-independent so hot-reload can swap a clip in
39// place either way.
40struct TargetState {
41    clips: Vec<ClipEntry>,
42    mode: TargetMode,
43}
44
45// How a bucket's clips are driven each frame.
46enum TargetMode {
47    // Weighted blend of every clip (the default).
48    Flat(FlatState),
49    // A compiled `AnimationGraph` state machine owns the bucket.
50    Graph(GraphTarget),
51}
52
53/// One hot-reload entry for a file-backed `Animation`. Captured at init
54/// alongside the runtime clip; consulted by the per-step reload pass when the
55/// shared `PENDING_ANIMATIONS` flag fires (see
56/// [`crate::app::dev_flags::take_pending_animations`]). Inline-authored
57/// animations (no `source`) carry no entry; there's no file to watch and
58/// the build pipeline never expanded one.
59///
60/// `pub` (with public fields) because the editor crate's hot-reload drive reads
61/// these to re-import the clip from source, then pushes the result back through
62/// `AnimationSystem::apply_reloaded_clip`. The GLB decode itself lives in the
63/// editor crate; the runtime crate only stores the catalogue.
64#[derive(Debug, Clone)]
65pub struct AnimationReloadEntry {
66    /// EntityTarget `SkinnedMesh` handle, also the key into
67    /// `AnimationSystem::targets` where this clip lives.
68    pub target: SkinnedMeshHandle,
69    /// Position in the target bucket's `clips`. Set at init when the clip is
70    /// first pushed; stable for the process lifetime since the Vec is
71    /// neither rebuilt nor trimmed.
72    pub clip_index: usize,
73    /// `.glb` source path verbatim from the asset declaration; used as-is by
74    /// the GLB parser at reload time.
75    pub source: String,
76    /// The target mesh's `skin_index`: the clip re-imports
77    /// against the same skeleton the build cooked it against.
78    pub skin_index: u32,
79    /// Mirrors [`Animation::animation_index`].
80    pub animation_index: u32,
81    /// Mirrors [`Animation::animation_name`] (precedence over index when
82    /// non-empty).
83    pub animation_name: String,
84    /// Mirrors [`Animation::sample_rate`]; the FBX reload path bakes at the
85    /// same rate the build used.
86    pub sample_rate: f32,
87    /// Mirrors [`Animation::weight`]; the .glb has nothing equivalent, so
88    /// it's carried through the reload unchanged.
89    pub weight: f32,
90    /// Mirrors [`Animation::looping`]; same rationale as `weight`.
91    pub looping: bool,
92}
93
94/// Skeletal animation playback behavior. Constructed internally by
95/// `World::start` when the world declares any `Animation` or `AnimationGraph`;
96/// never a world-declared asset, so it carries no config.
97pub struct AnimationSystem {
98    // Per-target clip buckets keyed by the `SkinnedMesh` handle they animate.
99    // Ordered so per-frame iteration (and the RootMotionEvent events it emits) is
100    // deterministic across runs.
101    targets: BTreeMap<SkinnedMeshHandle, TargetState>,
102    // Interned-name -> handle index snapshotted at init, so the debug WS
103    // animation commands (which address a mesh by name) can find the bucket.
104    name_index: crate::gfx::skinned_mesh_map::SkinnedMeshNameIndex,
105    // Wall-clock origin, captured on the first step.
106    start: Option<Instant>,
107    // Clip time `t` of the previous step, for the graph clocks' delta time.
108    last_step_secs: Option<f32>,
109    // When a menu opened (and froze playback), if currently paused. On resume
110    // the origin `start` is shifted forward by the paused span so clip time `t`
111    // is continuous across the pause: the animation freezes on its current pose
112    // and resumes from it, with no jump.
113    pause_anchor: Option<Instant>,
114    // One entry per file-backed Animation, captured at init under
115    // `cn debug`. Empty when hot-reload is off or every clip is inline.
116    reload_entries: Vec<AnimationReloadEntry>,
117    // Per-target IK solve inputs, refreshed in place each frame so the pin
118    // buffers persist across frames.
119    ik_frames: std::collections::HashMap<SkinnedMeshHandle, ik::IkFrame>,
120    // Foot-position scratch for the probe-ray refresh, reused across targets.
121    ik_feet_scratch: Vec<[f32; 3]>,
122}
123
124impl std::fmt::Debug for AnimationSystem {
125    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
126        f.debug_struct("AnimationSystem")
127            .field("targets", &self.targets.len())
128            .field("reload_entries", &self.reload_entries.len())
129            .finish()
130    }
131}
132
133impl Default for AnimationSystem {
134    fn default() -> Self {
135        Self::new()
136    }
137}
138
139impl AnimationSystem {
140    /// Fresh playback state with no clips. Clips and graphs are drained from
141    /// the world's components in [`System::init`].
142    pub fn new() -> Self {
143        Self {
144            targets: BTreeMap::new(),
145            name_index: Default::default(),
146            start: None,
147            last_step_secs: None,
148            pause_anchor: None,
149            reload_entries: Vec::new(),
150            ik_frames: std::collections::HashMap::new(),
151            ik_feet_scratch: Vec::new(),
152        }
153    }
154
155    /// The file-backed clips captured at init under `cn debug`. The editor
156    /// crate's hot-reload drive reads these to re-import each clip from source.
157    /// Empty when hot-reload is off or every clip is inline.
158    pub fn reload_entries(&self) -> &[AnimationReloadEntry] {
159        &self.reload_entries
160    }
161
162    /// Swap a freshly re-imported `clip` into the bucket slot identified by
163    /// `target` + `clip_index`, restoring its declared `weight`. Returns false
164    /// if the target bucket disappeared or the slot index is out of range
165    /// (a half-applied reload is impossible: nothing is mutated on miss). The
166    /// editor crate calls this after decoding the source GLB; the runtime crate
167    /// does no decoding of its own.
168    pub fn apply_reloaded_clip(
169        &mut self,
170        target: SkinnedMeshHandle,
171        clip_index: usize,
172        clip: AnimationClip,
173        weight: f32,
174    ) -> bool {
175        let Some(bucket) = self.targets.get_mut(&target) else {
176            return false;
177        };
178        let Some(slot) = bucket.clips.get_mut(clip_index) else {
179            return false;
180        };
181        slot.clip = clip;
182        slot.declared_weight = weight;
183        // A graph compiled this clip's duration into any member playing it;
184        // keep those in sync so wrap / phase / exit-time math tracks the new
185        // clip. The compiled loop mode is left as resolved at compile time.
186        if let TargetMode::Graph(g) = &mut bucket.mode {
187            let duration = bucket.clips[clip_index].clip.duration;
188            g.graph.refresh_clip_duration(clip_index, duration);
189        }
190        true
191    }
192}
193
194// The animation origin to use on the frame a pause ends. Shifting the original
195// origin forward by the paused span (now - anchor) holds clip time
196// `t = now - origin` exactly where it was when the pause began, so playback
197// resumes from the frozen pose with no jump. Split out so the continuity
198// property is unit-testable without a live system.
199fn resumed_origin(start: Instant, anchor: Instant, now: Instant) -> Instant {
200    start + now.saturating_duration_since(anchor)
201}
202
203impl System for AnimationSystem {
204    fn access(&self) -> crate::ecs::Access {
205        crate::ecs::Access::new()
206            .reads_components(crate::component_mask![crate::components::CharacterRig])
207            .writes_components(crate::component_mask![
208                crate::components::SkeletonPose,
209                crate::components::AnimationParams,
210                crate::components::GroundProbes,
211            ])
212            .reads_resources(crate::resource_mask![crate::ecs::MenuActive])
213            .writes_resources(crate::resource_mask![crate::components::RootMotionEvent])
214    }
215
216    fn init(&mut self, ctx: &mut PipelineContext) {
217        // Clips accumulate per target mesh; how a bucket's clips combine is
218        // decided below (graph if the world declares one, weighted blend
219        // otherwise).
220        let capture_sources = crate::app::dev_flags::enabled();
221        // Interned-name -> handle index published by GraphicsSystem (which
222        // loaded the SkinnedMesh table before this system inits), kept for the
223        // debug WS animation commands. The correlation web itself is keyed by
224        // the authored `target` handles directly.
225        self.name_index = ctx
226            .resource::<crate::gfx::skinned_mesh_map::SkinnedMeshNameIndex>()
227            .cloned()
228            .unwrap_or_default();
229        let skin_index = ctx
230            .resource::<crate::gfx::skinned_mesh_map::SkinnedMeshSkinIndex>()
231            .cloned()
232            .unwrap_or_default();
233        // Animation asset id -> (target bucket, clip slot), for resolving
234        // graph clip references onto bucket indices.
235        let mut clip_slots: HashMap<AssetId, (SkinnedMeshHandle, usize)> = HashMap::new();
236        let mut count = 0usize;
237        for anim in ctx.drain::<Animation>() {
238            let Some(target) = anim.target else {
239                tracing::warn!("AnimationSystem: Animation has no target SkinnedMesh, ignored");
240                continue;
241            };
242            let weight = anim.weight;
243            let fade_in_secs = anim.fade_in_secs.max(0.0);
244            let state = self.targets.entry(target).or_insert_with(|| TargetState {
245                clips: Vec::new(),
246                mode: TargetMode::Flat(FlatState::default()),
247            });
248            let clip_index = state.clips.len();
249            state.clips.push(ClipEntry {
250                clip: anim.to_clip(),
251                declared_weight: weight,
252                fade_in_secs,
253            });
254            clip_slots.insert(anim.asset_id, (target, clip_index));
255            // Each new clip starts at full declared weight unless it requests
256            // a fade-in, in which case it begins at zero and ramps up.
257            let initial = if fade_in_secs > 0.0 { 0.0 } else { weight };
258            if let TargetMode::Flat(flat) = &mut state.mode {
259                flat.current_weights.push(initial);
260            }
261            if capture_sources && !anim.source.is_empty() {
262                self.reload_entries.push(AnimationReloadEntry {
263                    target,
264                    clip_index,
265                    source: anim.source.clone(),
266                    skin_index: skin_index.get(target),
267                    animation_index: anim.animation_index,
268                    animation_name: anim.animation_name.clone(),
269                    sample_rate: anim.sample_rate,
270                    weight,
271                    looping: anim.looping,
272                });
273            }
274            count += 1;
275        }
276
277        // Graphs take ownership of their target's bucket; each publishes an
278        // `AnimationParams` component seeded with its declared defaults.
279        let graph_count = graph::install_graphs(&mut self.targets, ctx, &clip_slots);
280
281        // Build a startup transition for any flat bucket whose clips requested
282        // a fade-in. The transition runs from zero to the declared weights over
283        // the bucket's longest fade-in; clips with `fade_in_secs == 0` start
284        // already at their declared weight via `current_weights`, so the lerp
285        // leaves them alone. Graph buckets ignore fade-in (the graph owns
286        // weights outright).
287        for state in self.targets.values_mut() {
288            let TargetMode::Flat(flat) = &mut state.mode else {
289                continue;
290            };
291            let max_fade = state
292                .clips
293                .iter()
294                .fold(0.0f32, |m, c| m.max(c.fade_in_secs));
295            if max_fade > 0.0 {
296                let source = flat.current_weights.clone();
297                let target: Vec<f32> = state.clips.iter().map(|c| c.declared_weight).collect();
298                flat.transition = Some(Transition {
299                    source_weights: source,
300                    target_weights: target,
301                    // Start the ramp on the first step (negative until then,
302                    // overwritten in `step`).
303                    start_secs: 0.0,
304                    duration_secs: max_fade,
305                });
306            }
307        }
308        tracing::info!(
309            "AnimationSystem: {} clip(s) across {} target mesh(es); {} graph(s); {} \
310             file-backed clip(s) captured for hot-reload",
311            count,
312            self.targets.len(),
313            graph_count,
314            self.reload_entries.len()
315        );
316    }
317
318    fn step(&mut self, ctx: &mut PipelineContext) -> StepResult {
319        // Asset hot-reload of file-backed clips (`cn debug` only) is driven
320        // from the binary's `DebugHook::tick` via `reload_clips_if_pending`,
321        // not here. `cn run` has no debug hook, so this step is reload-free.
322
323        let now = Instant::now();
324
325        // Freeze while a menu is open: skip all sampling so animation stops
326        // consuming CPU/GPU behind the menu, recording when the pause began.
327        // The flag is published by OverlaySystem, which runs first this tick.
328        let paused = ctx
329            .resource::<crate::ecs::MenuActive>()
330            .is_some_and(|m| m.0);
331        if paused {
332            self.pause_anchor.get_or_insert(now);
333            return StepResult::Continue;
334        }
335        // Resuming: advance the origin by the paused span so clip time `t` stays
336        // continuous -- the animation resumes from the exact pose it froze on,
337        // with no jump. (A pause before the first step has no origin yet, so it
338        // just defers the capture below.)
339        if let Some(anchor) = self.pause_anchor.take()
340            && let Some(start) = self.start.as_mut()
341        {
342            *start = resumed_origin(*start, anchor, now);
343        }
344
345        let start = *self.start.get_or_insert(now);
346        let t = (now - start).as_secs_f32();
347        // Graph clocks advance by delta time; the origin shift above keeps
348        // `t` continuous across a pause, so the first post-pause delta stays
349        // one frame long.
350        let dt = t - self.last_step_secs.replace(t).unwrap_or(t);
351
352        // First-frame fix-up: the startup transition built in `init` has
353        // `start_secs == 0.0`. We don't know the wall-clock origin until the
354        // first step, so re-anchor any in-flight transition that hasn't yet
355        // started elapsing.
356        for state in self.targets.values_mut() {
357            if let TargetMode::Flat(flat) = &mut state.mode
358                && let Some(tr) = flat.transition.as_mut()
359                && tr.start_secs == 0.0
360            {
361                tr.start_secs = t;
362            }
363        }
364
365        // Runtime commands (`cn debug` WS `anim-crossfade` / `anim-param` /
366        // `anim-state`) are drained from the binary's `DebugHook::tick` via
367        // `apply_runtime_commands`, not here.
368
369        // Advance each bucket's driver before sampling: flat buckets move
370        // their weight transitions, graph buckets sync `AnimationParams` and step
371        // their cursor. Each advance also yields the frame's root-motion
372        // displacement (mesh-local), published as one `RootMotionEvent` event per
373        // target that actually moved; the rig drive in PhysicsSystem
374        // consumes them next frame.
375        for (target, state) in &mut self.targets {
376            let TargetState { clips, mode } = state;
377            let delta = match mode {
378                TargetMode::Flat(flat) => {
379                    flat::advance_weights(flat, t);
380                    root::flat_root_delta(clips, &flat.current_weights, t - dt, t)
381                }
382                TargetMode::Graph(g) => {
383                    let before = g.cursor.clone();
384                    graph::step_target(g, *target, ctx, dt);
385                    crate::gfx::anim_graph::cursor_root_delta(
386                        &g.graph,
387                        &before,
388                        &g.cursor,
389                        &g.params,
390                        &|i| &clips[i].clip,
391                    )
392                }
393            };
394            if delta != [0.0; 3] {
395                ctx.events_mut::<crate::components::RootMotionEvent>().send(
396                    crate::components::RootMotionEvent {
397                        target: *target,
398                        delta,
399                    },
400                );
401            }
402        }
403
404        // Foot-pinning inputs for this frame: per graph target with IK
405        // chains, the rig transform and each chain's ground pin (probe hits
406        // answered by PhysicsSystem earlier this tick).
407        ik::frame_inputs(&self.targets, ctx, &mut self.ik_frames);
408        let ik_frames = &self.ik_frames;
409
410        // Each `SkeletonPose` is sampled and skinned independently, so the
411        // per-pose work fans across the job pool and joins before returning.
412        let targets = &self.targets;
413        let poses = ctx.query_slice_mut::<SkeletonPose>();
414        jobs::pool().parallel_for(poses, |pose| {
415            let Some(state) = targets.get(&pose.mesh_id) else {
416                return;
417            };
418            // Split borrows: the scratch buffers and outputs are written
419            // while the skeleton is read.
420            let crate::components::SkeletonPose {
421                skeleton,
422                scratch,
423                joint_matrices,
424                morph_weights,
425                morph_base,
426                proportions,
427                updated,
428                ..
429            } = pose;
430            match &state.mode {
431                TargetMode::Flat(flat) => match state.clips.as_slice() {
432                    [] => return,
433                    [single] => {
434                        // One-clip buckets ignore weight and play at full
435                        // strength; the blend would be a no-op anyway.
436                        single.clip.sample_into(t, skeleton, &mut scratch.locals)
437                    }
438                    many => {
439                        // Incremental normalized fold: the first clip seeds
440                        // the accumulator (regardless of weight, so an
441                        // all-zero bucket falls back to it), later clips at
442                        // weight 0 are skipped without sampling.
443                        let mut fold = PoseBlend::new(&mut scratch.locals);
444                        for (i, entry) in many.iter().enumerate() {
445                            let w = flat.current_weights.get(i).copied().unwrap_or(1.0);
446                            if fold.seeded() && w <= 0.0 {
447                                continue;
448                            }
449                            entry.clip.sample_into(t, skeleton, &mut scratch.clip);
450                            fold.add(&scratch.clip, w);
451                        }
452                    }
453                },
454                TargetMode::Graph(g) => crate::gfx::anim_graph::sample_graph_pose_into(
455                    &g.graph,
456                    &g.cursor,
457                    &g.params,
458                    |i| &state.clips[i].clip,
459                    skeleton,
460                    scratch,
461                ),
462            }
463            if let TargetMode::Graph(g) = &state.mode
464                && let Some(frame) = ik_frames.get(&pose.mesh_id)
465            {
466                ik::apply_chains(skeleton, scratch, &g.chains, frame);
467            }
468            // The shape's proportion layer re-shapes the posed locals; the
469            // inverse bind matrices stay as authored.
470            proportions.apply(&mut scratch.locals);
471            skeleton.skinning_matrices_into(&scratch.locals, joint_matrices);
472            *updated = true;
473
474            // Morph weights follow the same flat blend as the pose, added onto
475            // the shape's base layer. Graph-driven targets do not sample
476            // morph tracks.
477            if let TargetMode::Flat(flat) = &state.mode {
478                morph::update_weights(&state.clips, flat, t, morph_base, scratch, morph_weights);
479            }
480        });
481
482        // Refresh the ground-probe rays from the posed foot positions for
483        // PhysicsSystem to answer next frame.
484        ik::refresh_rays(&self.targets, ctx, &mut self.ik_feet_scratch);
485
486        StepResult::Continue
487    }
488}