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