Skip to main content

ranim_core/animation/
node.rs

1//! Runtime animation nodes and their interpreters.
2//!
3//! This is the closed core of the animation tree: [`NodeContent`](crate::animation::node::NodeContent) is the
4//! runtime vocabulary (sequence, stack, leaf, static, audio), [`AnimNode`](crate::animation::node::AnimNode)
5//! wraps it with the timing shell, and all structural traversals — visual
6//! evaluation, seal-time audio baking, and preview introspection — live here.
7//!
8//! Authoring sugar (`AnimSequence`, `AnimStack`, `AnimLagged`) is layered on
9//! top in [`crate::animation::compose`]; user leaf protocols live in
10//! [`crate::animation::eval`].
11
12use std::ops::Range;
13
14use crate::{audio::AudioTrack, core_item::DynItem, utils::rate_functions::linear};
15
16use super::eval::EvalDyn;
17
18/// Runtime animation content category used by preview tooling.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum AnimationInfoKind {
21    /// A typed evaluator without animation children.
22    Eval,
23    /// A sequential animation container.
24    Sequence,
25    /// An overlay animation container.
26    Stack,
27    /// A captured, type-erased static output batch.
28    Static,
29    /// An audio leaf: resolved into the audio plane at seal time.
30    Audio,
31}
32
33/// Hierarchical runtime animation information used by preview tooling.
34#[derive(Clone)]
35pub struct AnimationInfo {
36    /// Concrete evaluator or container type name.
37    pub anim_name: String,
38    /// Runtime content category.
39    pub kind: AnimationInfoKind,
40    /// Time range in the parent content's local coordinates.
41    pub range: Range<f64>,
42    /// Duration of this node's inner content before outer timing is applied.
43    pub content_duration_secs: f64,
44    /// Time remapping function applied by this node.
45    pub rate_func: fn(f64) -> f64,
46    /// Whether this node contributes values during evaluation.
47    pub enabled: bool,
48    /// The content step of iterative segments (`1/N` progress per integration
49    /// step, `N` declared via `with_steps`); `None` for other nodes.
50    pub sim_step: Option<f64>,
51    /// Direct animation children in this node's content coordinates.
52    pub children: Vec<AnimationInfo>,
53}
54
55/// What a runtime node IS — the closed vocabulary of the tree.
56///
57/// Each variant earns its seat by having non-desugarable evaluation
58/// semantics (a combinator expressible as pure window placement belongs in
59/// [`IntoAnimNode::into_anim_node`](crate::animation::build::IntoAnimNode::into_anim_node), not here):
60///
61/// - [`NodeContent::Sequence`] — exclusive selection: the LAST child containing
62///   the content time evaluates (children placed with `.at()` may overlap);
63/// - [`NodeContent::Stack`] — overlay: EVERY child containing the content time
64///   evaluates and the outputs sum;
65/// - [`NodeContent::Leaf`] — the open world: any user [`Eval`](crate::animation::eval::Eval) implementation,
66///   the only type-erased box in the tree;
67/// - [`NodeContent::Static`] — a captured output batch replayed over a window;
68/// - [`NodeContent::Audio`] — a sound leaf, consumed by the seal-time bake.
69pub(in crate::animation) enum NodeContent {
70    /// Exclusive succession: the LAST child containing the content time
71    /// evaluates.
72    Sequence(Vec<AnimNode>),
73    /// Overlay: EVERY child containing the content time evaluates.
74    Stack(Vec<AnimNode>),
75    /// A typed evaluator (`Eval` implementation) — no children.
76    Leaf(Box<dyn EvalDyn>),
77    /// A captured, type-erased static output batch.
78    Static(Vec<DynItem>),
79    /// An audio leaf: its track resolves through the seal-time bake.
80    ///
81    /// Boxed: a track is the fattest payload in the tree, and sibling
82    /// scanning (window checks over many nodes) is cache-bound — nodes stay
83    /// slim, the track pays one indirection only when audible.
84    Audio(Box<AudioTrack>),
85}
86
87impl NodeContent {
88    fn info_kind(&self) -> AnimationInfoKind {
89        match self {
90            NodeContent::Sequence(_) => AnimationInfoKind::Sequence,
91            NodeContent::Stack(_) => AnimationInfoKind::Stack,
92            NodeContent::Leaf(_) => AnimationInfoKind::Eval,
93            NodeContent::Static(_) => AnimationInfoKind::Static,
94            NodeContent::Audio(_) => AnimationInfoKind::Audio,
95        }
96    }
97}
98
99/// A single runtime animation node: one closed [`NodeContent`] plus everything
100/// every kind shares — the subtree, the content axis, and the timing shell
101/// (window, rate, enable) in this node's parent's coordinates.
102pub struct AnimNode {
103    pub(in crate::animation) content: NodeContent,
104    /// Length of this node's content time axis, in seconds: the sequence
105    /// cursor or stack extent for containers, a sound's play window, `1.0`
106    /// for bare visual leaves, `0.0` for statics.
107    pub(in crate::animation) internal_time_secs: f64,
108    pub(in crate::animation) rate_func: Option<fn(f64) -> f64>,
109    pub(in crate::animation) time_range: Range<f64>,
110    pub(in crate::animation) enabled: bool,
111    pub(in crate::animation) anim_name: &'static str,
112}
113
114/// An affine map from global seconds to content seconds: `t = a + b·x`.
115///
116/// Composed by the seal-time audio bake ([`bake_audio`]) while every rate
117/// function along the path is linear; a non-linear rate drops it.
118#[derive(Clone, Copy)]
119pub(crate) struct MixAff {
120    a: f64,
121    b: f64,
122}
123
124impl MixAff {
125    pub(crate) const IDENTITY: Self = Self { a: 0.0, b: 1.0 };
126
127    /// Compose `y = m_a + m_b·x` after `self`: `y = m_a + m_b·(a + b·x)`.
128    fn then(self, m_a: f64, m_b: f64) -> Self {
129        Self {
130            a: m_a + m_b * self.a,
131            b: m_b * self.b,
132        }
133    }
134
135    /// The global time whose image is `y` (`b > 0` on every audio path).
136    fn inv_y(&self, y: f64) -> f64 {
137        (y - self.a) / self.b
138    }
139}
140
141impl AnimNode {
142    /// This node's children — only the container kinds have any; leaves
143    /// read as the empty slice, so traversals stay uniform without storing
144    /// an impossible empty `Vec` per leaf.
145    pub(crate) fn children(&self) -> &[AnimNode] {
146        match &self.content {
147            NodeContent::Sequence(children) | NodeContent::Stack(children) => children,
148            _ => &[],
149        }
150    }
151
152    /// Global or parent-relative time range, depending on its containing plan.
153    pub fn time_range(&self) -> Range<f64> {
154        self.time_range.clone()
155    }
156
157    /// Duration in seconds.
158    pub fn duration_secs(&self) -> f64 {
159        self.time_range.end - self.time_range.start
160    }
161
162    pub(in crate::animation) fn shift_by(&mut self, offset_sec: f64) {
163        self.time_range.start += offset_sec;
164        self.time_range.end += offset_sec;
165    }
166
167    /// Whether this clip contributes a value.
168    pub fn enabled(&self) -> bool {
169        self.enabled
170    }
171
172    /// Concrete evaluator type name captured before erasure.
173    pub fn anim_name(&self) -> &str {
174        self.anim_name
175    }
176
177    /// Whether the given scene time is inside this clip's inclusive range.
178    pub fn active_at(&self, sec: f64) -> bool {
179        sec >= self.time_range.start && sec <= self.time_range.end
180    }
181
182    /// Compute the rate-warped progress in this cell's local coordinates.
183    ///
184    /// The cell owns the time configuration (start, duration, rate function)
185    /// and turns it into a reading; evaluators never see the configuration
186    /// itself. A zero-duration cell reports `alpha == 1.0`.
187    fn local_alpha(&self, sec: f64) -> f64 {
188        let duration = self.duration_secs();
189        let raw = if duration == 0.0 {
190            1.0
191        } else {
192            (sec - self.time_range.start) / duration
193        };
194        self.rate_func.map_or(raw, |rate| rate(raw))
195    }
196
197    /// Evaluate this node at a time point — the ONLY time-management entry.
198    ///
199    /// Remaps the scene time to this node's local `alpha` (via its rate
200    /// function), then evaluates the content at that progress (a pure query
201    /// on `&self`). Direction management (forward vs backward reset+replay,
202    /// how many `sim_step`s to integrate) is INTERNAL to each stateful node.
203    /// Audio nodes evaluate to nothing here: their content resolves through
204    /// the seal-time audio bake ([`bake_audio`]) instead of the frame
205    /// pipeline.
206    pub(crate) fn eval_at(&self, sec: f64, output: &mut Vec<DynItem>) {
207        if !self.enabled || !self.active_at(sec) {
208            return;
209        }
210        let alpha = self.local_alpha(sec);
211        match &self.content {
212            NodeContent::Sequence(children) => {
213                let content = self.internal_time_secs * alpha;
214                eval_sequence(children, content, self.internal_time_secs, output)
215            }
216            NodeContent::Stack(children) => {
217                let content = self.internal_time_secs * alpha;
218                eval_stack(children, content, self.internal_time_secs, output)
219            }
220            NodeContent::Leaf(eval) => eval.eval_into(alpha, output),
221            NodeContent::Static(items) => output.extend(items.iter().cloned()),
222            NodeContent::Audio(_) => {}
223        }
224    }
225
226    /// Bake this subtree's audio into `pcm` (the whole timeline's interleaved
227    /// stereo buffer, absolute frame indices), collecting what cannot be
228    /// pre-mixed.
229    ///
230    /// The descent composes the affine global→content map (`aff`) while
231    /// every rate along the path is linear; a linear sound leaf then knows
232    /// its exact audible frame range and mixes it in one tight loop
233    /// ([`AudioTrack::mix_span_into`]). A leaf behind any non-linear rate has
234    /// no such map — its root-to-leaf path is pushed to `warp_paths` for the
235    /// residual pass instead. The tree is never mutated.
236    pub(crate) fn bake_into<'a>(
237        &'a self,
238        span: (f64, f64),
239        aff: Option<MixAff>,
240        path: &mut Vec<&'a Self>,
241        warp_paths: &mut Vec<Vec<&'a Self>>,
242        pcm: &mut [f32],
243        sample_rate: f64,
244    ) {
245        if !self.enabled {
246            return;
247        }
248        let lo = span.0.max(self.time_range.start);
249        let hi = span.1.min(self.time_range.end);
250        if lo >= hi {
251            return;
252        }
253        let internal = self.internal_time_secs;
254        let linear = self.rate_func.is_none();
255        // This node's map in parent coordinates: t = m_a + m_b·x.
256        let (m_a, m_b) = if linear {
257            let m_b = internal / self.duration_secs();
258            (-self.time_range.start * m_b, m_b)
259        } else {
260            (0.0, f64::NAN)
261        };
262        let new_span = if linear {
263            (m_a + m_b * lo, m_a + m_b * hi)
264        } else {
265            (0.0, internal)
266        };
267        path.push(self);
268        match &self.content {
269            NodeContent::Audio(track) => {
270                if let Some(aff) = aff.filter(|_| linear) {
271                    // Audible where content time ∈ [0, play window): the
272                    // affine inverts that range into global frames exactly.
273                    // (ceil is the exclusive upper edge; the lower edge
274                    // takes an epsilon against the seconds↔frames round
275                    // trip — both conventions match `sample_at`'s half-open
276                    // play window.)
277                    let clo = new_span.0.max(0.0);
278                    let chi = new_span.1.min(internal);
279                    if clo < chi {
280                        let total = aff.then(m_a, m_b);
281                        let g_lo =
282                            ((total.inv_y(clo) * sample_rate - 1e-9).ceil() as i64).max(0) as usize;
283                        let g_hi = ((total.inv_y(chi) * sample_rate).ceil() as i64)
284                            .min((pcm.len() / 2) as i64)
285                            .max(0) as usize;
286                        if g_lo < g_hi {
287                            track.mix_span_into(total.a, total.b, g_lo, g_hi, sample_rate, pcm);
288                        }
289                    }
290                } else {
291                    // Warped path: keep it for the residual pass.
292                    warp_paths.push(path.clone());
293                }
294            }
295            // Containers recurse; leaves read as no children.
296            _ => {
297                let new_aff = if linear {
298                    aff.map(|aff| aff.then(m_a, m_b))
299                } else {
300                    None
301                };
302                for child in self.children() {
303                    child.bake_into(new_span, new_aff, path, warp_paths, pcm, sample_rate);
304                }
305            }
306        }
307        path.pop();
308    }
309
310    /// Whether any sound leaf lives in this subtree (enabled or not).
311    pub(crate) fn has_audio(&self) -> bool {
312        match &self.content {
313            NodeContent::Audio(_) => true,
314            _ => self.children().iter().any(AnimNode::has_audio),
315        }
316    }
317
318    pub(in crate::animation) fn contains_sec(&self, sec: f64, parent_duration: f64) -> bool {
319        self.time_range.contains(&sec) || (sec == parent_duration && sec == self.time_range.end)
320    }
321
322    pub(crate) fn animation_info(&self) -> AnimationInfo {
323        AnimationInfo {
324            anim_name: self.anim_name.to_string(),
325            kind: self.content.info_kind(),
326            range: self.time_range.clone(),
327            content_duration_secs: self.internal_time_secs,
328            rate_func: self.rate_func.unwrap_or(linear),
329            enabled: self.enabled,
330            sim_step: match &self.content {
331                NodeContent::Leaf(eval) => eval.sim_step(),
332                _ => None,
333            },
334            children: self
335                .children()
336                .iter()
337                .map(AnimNode::animation_info)
338                .collect(),
339        }
340    }
341}
342
343/// Sequence evaluation: the LAST child containing the content time runs
344/// (children placed with `.at()` inside a sequence may overlap; the later
345/// one wins — this exclusivity is what makes a sequence more than sugar for
346/// a stack of placements).
347fn eval_sequence(children: &[AnimNode], content_sec: f64, extent: f64, output: &mut Vec<DynItem>) {
348    if let Some(child) = children
349        .iter()
350        .rev()
351        .find(|child| child.contains_sec(content_sec, extent))
352    {
353        child.eval_at(content_sec, output);
354    }
355}
356
357/// Stack evaluation: EVERY child containing the content time runs, outputs
358/// sum.
359fn eval_stack(children: &[AnimNode], content_sec: f64, extent: f64, output: &mut Vec<DynItem>) {
360    for child in children {
361        if child.contains_sec(content_sec, extent) {
362            child.eval_at(content_sec, output);
363        }
364    }
365}
366
367/// The non-linear remainder of the audio plane, as a compact tree.
368///
369/// Built at bake time from the collected root-to-leaf paths of sound leaves
370/// behind non-linear rates, sharing common prefixes so ancestors evaluate
371/// once per sample. Every node carries only what the per-sample walk reads
372/// — the timing shell and the track reference, ~40 bytes — because sibling
373/// scanning is cache-bound: a warp container forces its children to be
374/// window-checked per sample, and fat nodes would stream ~2× the bytes.
375/// (Every residual node is enabled by construction — the bake skips
376/// disabled subtrees.)
377enum Residual<'a> {
378    Leaf {
379        window: Range<f64>,
380        internal: f64,
381        rate: Option<fn(f64) -> f64>,
382        track: &'a AudioTrack,
383    },
384    Node {
385        window: Range<f64>,
386        internal: f64,
387        rate: Option<fn(f64) -> f64>,
388        children: Vec<Residual<'a>>,
389    },
390}
391
392/// Construction-time residual: paths keyed by node identity, so shared
393/// prefixes merge soundly; [`ResidualLink::compact`] then flattens it into
394/// the walked form.
395enum ResidualLink<'a> {
396    Leaf(&'a AnimNode),
397    Node {
398        node: &'a AnimNode,
399        children: Vec<ResidualLink<'a>>,
400    },
401}
402
403impl<'a> ResidualLink<'a> {
404    fn node(&self) -> &'a AnimNode {
405        match self {
406            ResidualLink::Leaf(node) | ResidualLink::Node { node, .. } => node,
407        }
408    }
409
410    /// Insert a root-to-leaf `path` into the forest, reusing shared prefixes.
411    fn insert(forest: &mut Vec<ResidualLink<'a>>, path: &[&'a AnimNode]) {
412        let Some((&head, rest)) = path.split_first() else {
413            return;
414        };
415        let slot = match forest
416            .iter_mut()
417            .find(|link| std::ptr::eq(link.node(), head))
418        {
419            Some(slot) => slot,
420            None => {
421                forest.push(ResidualLink::Node {
422                    node: head,
423                    children: Vec::new(),
424                });
425                forest.last_mut().expect("just pushed")
426            }
427        };
428        match slot {
429            ResidualLink::Leaf(_) => {}
430            ResidualLink::Node { children, .. } => {
431                if rest.is_empty() {
432                    // The path's head is its only node: an audio leaf.
433                    *slot = ResidualLink::Leaf(head);
434                } else {
435                    ResidualLink::insert(children, rest);
436                }
437            }
438        }
439    }
440
441    /// Flatten into the compact walked form.
442    fn compact(&self) -> Residual<'a> {
443        let node = self.node();
444        let window = node.time_range.clone();
445        let internal = node.internal_time_secs;
446        let rate = node.rate_func;
447        match self {
448            ResidualLink::Leaf(_) => match &node.content {
449                NodeContent::Audio(track) => Residual::Leaf {
450                    window,
451                    internal,
452                    rate,
453                    track,
454                },
455                _ => unreachable!("warp paths only end at audio leaves"),
456            },
457            ResidualLink::Node { children, .. } => Residual::Node {
458                window,
459                internal,
460                rate,
461                children: children.iter().map(|c| c.compact()).collect(),
462            },
463        }
464    }
465}
466
467impl Residual<'_> {
468    /// This residual branch's stereo sample at scene time `x` — point
469    /// semantics, identical to a full-tree walk restricted to warp paths.
470    fn sample_at(&self, x: f64) -> [f32; 2] {
471        let (window, internal, rate) = match self {
472            Residual::Leaf {
473                window,
474                internal,
475                rate,
476                ..
477            }
478            | Residual::Node {
479                window,
480                internal,
481                rate,
482                ..
483            } => (window, internal, rate),
484        };
485        if x < window.start || x >= window.end {
486            return [0.0; 2];
487        }
488        let raw = (x - window.start) / (window.end - window.start);
489        let own = internal * rate.map_or(raw, |rate| rate(raw));
490        match self {
491            Residual::Leaf { track, .. } => track.sample_at(own),
492            Residual::Node { children, .. } => {
493                let mut acc = [0.0; 2];
494                for child in children {
495                    let [l, r] = child.sample_at(own);
496                    acc[0] += l;
497                    acc[1] += r;
498                }
499                acc
500            }
501        }
502    }
503}
504
505/// Bake the tree's whole audio plane over `[0, total_secs]` into one
506/// interleaved stereo buffer at `sample_rate`.
507///
508/// Two passes, neither mutating the tree: (1) a single descent pre-mixes
509/// every sound leaf whose path is entirely linear into `pcm`; (2) the
510/// collected paths of everything behind non-linear rates are folded into a
511/// [`Residual`] forest and walked once per output sample — shared ancestors
512/// evaluate once, linear and silent branches are gone entirely.
513pub(crate) fn bake_audio(animations: &[AnimNode], total_secs: f64, sample_rate: f64) -> Vec<f32> {
514    if !animations.iter().any(AnimNode::has_audio) {
515        return Vec::new();
516    }
517    let out_frames = (total_secs * sample_rate).ceil() as usize;
518    let mut pcm = vec![0.0f32; out_frames * 2];
519
520    let mut warp_paths: Vec<Vec<&AnimNode>> = Vec::new();
521    let mut path = Vec::new();
522    for cell in animations {
523        cell.bake_into(
524            (0.0, total_secs),
525            Some(MixAff::IDENTITY),
526            &mut path,
527            &mut warp_paths,
528            &mut pcm,
529            sample_rate,
530        );
531    }
532
533    let mut links: Vec<ResidualLink> = Vec::new();
534    for cells in &warp_paths {
535        ResidualLink::insert(&mut links, cells);
536    }
537    let forest: Vec<Residual> = links.iter().map(|l| l.compact()).collect();
538    if !forest.is_empty() {
539        for frame in 0..out_frames {
540            let x = frame as f64 / sample_rate;
541            let mut acc = [0.0f32; 2];
542            for root in &forest {
543                let [l, r] = root.sample_at(x);
544                acc[0] += l;
545                acc[1] += r;
546            }
547            pcm[frame * 2] += acc[0];
548            pcm[frame * 2 + 1] += acc[1];
549        }
550    }
551    pcm
552}
553
554/// Build a static cell replaying already-sampled items over `time_range`.
555pub(in crate::animation) fn static_cell(state: Vec<DynItem>, time_range: Range<f64>) -> AnimNode {
556    AnimNode {
557        content: NodeContent::Static(state),
558        internal_time_secs: 0.0,
559        rate_func: None,
560        time_range,
561        enabled: true,
562        anim_name: "Static",
563    }
564}