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}