动画系统
Caution
ai 生成,可能叙事逻辑和表述并不是很好,仅供参考。
Ranim 的一个场景就是一棵动画树。这棵树分成「定义期」和「运行期」两个层面:
- 定义期(开放):叶子是任何实现了
Eval的类型——FadeIn、Morph、Pure、Iterative或自定义 struct,Eval::Output是该动画产出的 item 类型T。容器和作者侧组合子位于animation::compose:AnimSequence/AnimStack/AnimLagged。 - 运行期(封闭核心):所有定义都会通过
IntoAnimNodelower 成一个AnimNode。AnimNode的内容是封闭的NodeContent:Sequence/Stack/Leaf/Static/Audio。视觉求值、seal 时音频 烘焙、preview introspection 都是这棵运行时树上的 interpreter。
本章自底向上整理这条链路:
trait Eval<Output = T> 叶子协议:alpha -> T 的纯函数
│ 具体叶子 struct:FadeIn / Morph / Pure / Iterative / 自定义 …
│ blanket IntoAnimNode(默认 linear、1 秒、enabled)
▼
struct Paramed<A> / At<A> PlaybackExt / Unplaced::at
▼
struct AnimSequence / AnimStack / AnimLagged 顺序 / 并行 / 交错容器
│ IntoAnimNode::into_anim_node
▼
struct AnimNode { timing shell, NodeContent }
NodeContent:
Sequence(Vec<AnimNode>)
Stack(Vec<AnimNode>)
Leaf(Box<dyn EvalDyn>)
Static(Vec<DynItem>)
Audio(Box<AudioTrack>)
▼
RanimScene 根 AnimStack → SceneEvaluator 采样
Eval:叶子求值协议
Ranim 的叶子动画核心是一个统一的求值协议。动画内容一旦定义就不可变:它是自身
归一化进度 alpha ∈ [0, 1] 的纯函数。
pub trait Eval {
type Output;
/// 在归一化进度 alpha 处求值。
fn eval_alpha(&self, alpha: f64) -> Self::Output;
}
- 协议只有一个入口:
eval_alpha(&self, alpha); - 它是
&self上的纯查询:无论调用顺序和次数,同一个alpha得到同一个Output; - evaluator 看不到秒、场景时钟或
logic_fps。AnimNode负责把场景时间 映射成进度后才调用它; - 有状态(迭代)区段在内部记忆化自己的积分快照;纯区段就是闭式。
运行期的 NodeContent::Leaf 持有 Box<dyn EvalDyn>,它是 Eval 的擦除
对应物:Eval 带有关联类型 Output,不能直接作为 trait object,因此
animation::eval 为所有满足 Output: AnyExtractCoreItem 的 E: Eval
自动实现了 EvalDyn。只有用户叶子会进入 Leaf;内置容器是 core 自己的
variant。
EvalExt 提供两个 build 期便捷方法:
pub trait EvalExt: Eval + Sized {
fn apply_alpha_to(self, item: &mut Self::Output, alpha: f64) -> Self;
fn apply_to(self, item: &mut Self::Output) -> Self; // alpha = 1.0
}
内置动画的工具方法(fade_in() 等)正是靠 apply_to 在创建动画的同时把 item
置为动画末态。
进度是唯一坐标
ranim::core::time 只有两个类型别名:
pub type Alpha = f64; // 归一化进度
pub type DeltaAlpha = f64; // 均匀进度步长
「内容即序列」:迭代动画的内容是作者声明的进度点序列 x₀…x_N。N 是定义而
不是采样精度;rate_func、with_duration、placement 都只是「哪个进度何时可
见」的采样重映射。
内容的两种来源:Pure 与 Iterative
两者都是 Eval 的实现:Pure 适配闭式求值,Iterative 适配逐步积分。它们与
具名动画(FadeIn、Morph 等直接 impl Eval 的类型)地位相同,只是内容的
产生方式不同。
纯闭包:Pure
闭包是匿名类型,不能按名字实现 Eval,所以用 Pure 包一层:
use ranim::core::animation::eval::pure::Pure;
let animation = Pure::new(|alpha| Square::new(alpha)).with_duration(2.0);
具名纯动画(FadeIn、Morph、Create 等)直接实现 Eval,不需要这个
wrapper。
迭代区段:IterativeEval + Iterative
物理模拟、混沌系统等没有闭式的内容,用逐步推进的方式定义:
pub trait IterativeEval {
type Output;
/// 推进一个内容步。alpha 是当前进度,delta_alpha = 1/N。
fn step(&self, output: &mut Self::Output, alpha: f64, delta_alpha: f64);
}
Iterative::new(initial, evaluator) 持有不可变的定义(初始状态、sim_step、
step 逻辑),把积分快照放在内部 RefCell<Snapshot> 中:
let sim_secs = 4.0;
let animation = Iterative::from_fn(
SpringState { x: 1.0, v: 0.0 },
move |state: &mut SpringState, _alpha, delta_alpha| {
let dt = sim_secs * delta_alpha; // 内容自己的物理秒
let acc = -K * state.x - C * state.v;
state.v += acc * dt;
state.x += state.v * dt;
},
)
.with_steps(240)
.with_duration(sim_secs);
- 逻辑时长用过程中的局部变量(例如
sim_secs)捕获,并同时传给with_duration,不要使用全局const; - 迭代逻辑较复杂时,实现命名
IterativeEval结构体,把sim_secs等参数放在self上; with_steps(N)声明内容自己的步数,默认1/120;eval_alpha(target)前进时逐sim_step积分,回退时从初始状态重置重放, 重复查询同一个alpha是 O(1);- 可变状态全部住在
Output里; - 闭包的状态类型位于
Fn输入位置,无法从闭包类型反推出关联Output,所以Iterative::from_fn通过IterativeFn<S, F>显式绑定二者。
Iterative 实现的 Eval::sim_step() 返回 Some(1/N),供 ranim inspect tree
等工具内省;它不影响求值本身。
从 Eval 到可播放的动画
以一行最常见的代码为例,自顶向下拆开它经过的每一层:
let animation = square.fade_in().with_duration(1.0);
第 1 层:fade_in()。 它来自 ranim-anims 的 FadingAnim trait(对满足
Opacity + Interpolatable + Clone 的类型自动实现)。它做两件事:构造具名
evaluator FadeIn<T>,并通过 EvalExt::apply_to 把 square 就地置为动画末
态——所以动画创建完成时,item 本身已经是「播完」的样子,后续 build 出的新
状态都从这个末态出发:
fn fade_in(&mut self) -> FadeIn<Self> {
FadeIn::new(self.clone()).apply_to(self)
}
第 2 层:FadeIn<T>。 它就是一个普通的 Eval 实现——持有初末两个状态,
按 alpha 插值:
pub struct FadeIn<T: FadingRequirement> {
src: T,
dst: T,
}
impl<T: FadingRequirement> Eval for FadeIn<T> {
type Output = T;
fn eval_alpha(&self, alpha: f64) -> Self::Output {
self.src.lerp(&self.dst, alpha)
}
}
第 3 层:blanket impl。 任何 Eval 实现,只要 Output 可提取为场景元素
(AnyExtractCoreItem),就自动实现 IntoAnimNode,默认参数为 linear、时长
1 秒、enabled:
impl<E> IntoAnimNode for E
where
E: Eval + 'static,
E::Output: AnyExtractCoreItem,
{
fn into_anim_node(self) -> AnimNode {
AnimNode {
content: NodeContent::Leaf(Box::new(self)),
internal_time_secs: 1.0,
// rate_func = none(linear), time_range = 0.0..1.0, enabled = true
...
}
}
}
第 4 层:with_duration(1.0)。 来自 PlaybackExt,把动画包成
Paramed<A> 携带播放参数(见下节)。
ranim-anims 只包含这类具名叶子动画家族,通用适配器(Pure / Iterative
/ Static)与 lowering 协议在 ranim_core::animation 中:
ranim::anims
├── camera (Orbit、CameraFrameAnim)
├── creation (Create/UnCreate/Write/Unwrite)
├── fading (FadeIn/FadeOut)
├── morph (Morph)
└── rotating (RotatingAnimation)
Unplaced、PlaybackExt、Paramed 与 At
所有尚未固定父时间坐标的 Unplaced 动画通过 PlaybackExt 获得统一的播放
参数 API:
animation
.with_duration(2.0)
.with_rate_func(smooth)
.with_enabled(true)
At<A> 表示已经固定在父时间坐标中的 entry,不再实现 Unplaced,因此参数
必须在 placement 之前设置:
animation.with_duration(2.0).at(3.0); // At<Paramed<A>>
这些方法定义在 animation::build,返回的 Paramed<A> / At<A> 也都是
IntoAnimNode,所以可以继续被容器或 Scene 接纳。
animation::compose:顺序、并行、交错
顺序容器:AnimSequence
AnimSequence::push 先将动画 lower 为局部 AnimNode,再把它移动到当前
cursor,并按 node duration 推进 cursor:
let mut intro = AnimSequence::new();
intro
.push(square.clone().fade_in())
.hold(1.0)
.push(square.fade_out());
r.play(intro);
Sequence 是动态类型擦除边界,但不会展开传入动画的组合树。每次 push 只将
直接子动画转换为一个 AnimNode;如果子动画是 Stack 或 Sequence,其内部层级
会继续保留。AnimSequence::into_anim_node 最终产生
NodeContent::Sequence(Vec<AnimNode>)。
Sequence 自己通过 cursor 决定子动画的位置,因此 push 只接受尚未显式放置的
Unplaced。At<A> 已经固定父时间坐标,不能进入 Sequence。
Sequence 本身仍实现 IntoAnimNode 与 Unplaced,所以可以先独立构造,再整体
使用 at 放置或加入另一个组合:
r.play(intro.at(2.0));
forward 与 hold
两者都会推进 Sequence cursor,但输出语义不同:
forward(secs)只推进 cursor,产生的空白区间没有输出。hold(secs)取得 cursor 处的 Sequence 状态,将它保存为持续secs的静态 运行时节点(NodeContent::Static)。forward_to(target)和hold_to(target)是对应的绝对 cursor 版本。
hold 没有额外的状态协议,它直接采用 Sequence 在 cursor 处的正常求值结果。
Sequence 在同一时刻只求值最后一个适用的直接子动画;如果这个子动画是 Stack,
则由 Stack 求值其中所有仍然适用的子动画。已经提前结束的 Stack 子动画不会被
自动延长。
child A: [0, 1)
child B: [0, 2)
cursor: 2
hold at 2 -> 只保持 B 的左侧终态
连续 hold 会分别保存每次调用时的求值结果,形成相邻的静态区间。
show、hide 与最终求值
show() 和 hide() 都是普通的零时长动画:
show()是 enabled 的静态动画,求值时输出对应物件;hide()是 disabled 的静态动画,求值时不输出内容。
它们不需要 hold 特判。因为 Sequence 在边界上选择最后一个适用的直接子动画,
末尾的 show() 会成为最终求值结果,末尾的 hide() 则自然得到空结果;hold
只负责把这个结果保存为静态动画。
let mut content = AnimSequence::new();
content
.push(square.show())
.hold(1.0)
.push(square.hide())
.hold(1.0);
这里 hide 只改变 content 这条 Sequence 的状态。它不会查找或影响根 Stack
中另一个独立动画。
如果两个物件需要独立生命周期,应分别使用两个 Sequence:
r.play(square_sequence);
r.play(circle_sequence);
如果两个物件需要在同一时刻一起求值,应直接 push 一个 stack![...] 组合。
并行容器:AnimStack 与根场景
AnimStack::push 不推进其他子动画;Stack duration 是所有子动画 duration 的
最大值:
let animation = stack![
background.show().with_duration(5.0),
content.at(1.0),
camera.show().with_duration(5.0),
];
r.play(animation);
Stack 接受普通 Unplaced 动画和已经放置的 At<A>。普通动画从 Stack 局部 0
开始,At<A> 使用自己的显式 offset。参数必须在调用 at 之前设置。
RanimScene 自带一个根 AnimStack:
pub fn play<A: IntoAnimNode + 'static>(&mut self, animation: A) -> &mut Self {
self.root.push(animation);
self
}
因此,多次根级 play 默认都从 0 秒开始。它们是并行动画,不存在后一次调用
覆盖前一次调用的隐含对象语义。
运行时数量不固定时可以直接构造 AnimStack:
let mut layers = AnimStack::new();
for animation in animations {
layers.push(animation);
}
r.play(layers);
场景时长与显式生命周期
Scene 总时长是根 Stack 中最长子动画的 duration。新模型不会像旧 Timeline 那样 在 seal 时自动把静态物件和相机延长到 Scene 结束。
需要全程存在的内容应显式指定生命周期:
let total_secs = content.cursor_sec();
let mut camera = AnimSequence::new();
camera
.push(CameraFrame::default().show())
.hold_to(total_secs);
r.play(camera);
r.play(content);
这种写法使空白和保持区间成为动画定义的一部分。
交错容器:AnimLagged
AnimLagged 把一组未放置(Unplaced)的子动画按 stagger 规则相继排布:
第 i 个子动画的起点是 start_{i-1} + lag_ratio · d_{i-1}。lag_ratio 插值
在两种容器语义之间:
0.0—— 所有子动画同时开始(类似AnimStack);1.0—— 首尾相接(类似AnimSequence);- 中间值 —— 重叠相继。
let animation = lagged![0.2;
square.fade_in(),
circle.fade_in(),
text.write(),
];
r.play(animation);
子动画窗口之外的时间默认由真实的静态动画填充:每个元素在 build 时被物化
为一条 [前填充][动画][后填充] 的 per-item AnimSequence 轨道(前=初态,
后=末态,采样自窗口边缘,空的填充会被跳过),因此 preview 时间线看到的就是
实际渲染的内容,没有隐藏的求值规则。每端的行为可以用
with_leading/with_trailing 配置(LaggedFill::{Hold, Empty},默认都是
Hold);若希望元素在窗口结束后消失,让它的动画以 hide 结尾即可(如
seq![item.fade_in(), item.hide()])。
填充在 build 时采样,因此子动画应当是纯(闭式)动画——迭代式子动画的末态 填充会得到其初态。
对一组元素施加同一个动画时,用迭代器收集(core 的 AnimIterExt):
let animation = group
.iter_mut()
.map(|item| item.fade_in().with_rate_func(smooth))
.into_lagged(0.2);
迭代器还可以收集为另外两个容器:into_stack()/into_seq(),或直接
collect::<AnimStack>()/collect::<AnimSequence>()。
seq!、stack! 与 lagged!
固定写法可以使用宏简化:
let intro = seq![
square.clone().fade_in(),
square.fade_out(),
];
let scene = stack![intro, camera];
r.play(scene);
seq! 返回 AnimSequence,stack! 返回 AnimStack。二者都只是构造辅助,
最终 lower 为保留子节点层级的运行时动画树。lagged![0.2; a, b, c] 以 0.2 的
stagger ratio 返回 AnimLagged(见上文)。
运行期:AnimNode、NodeContent 与 SceneEvaluator
Sequence、Stack 和 Scene 需要保存异构动画,因此每个直接子动画会 lower 成一个
AnimNode:
AnimNode
├─ content: NodeContent
├─ time_range: Range<f64> 在父坐标中的窗口
├─ internal_time_secs: f64 content 轴长度
├─ rate_func: Option<fn(f64) -> f64>
├─ enabled: bool
└─ anim_name: &'static str
NodeContent 是封闭的运行时语言:
NodeContent
├─ Sequence(Vec<AnimNode>) 最后一个命中的子节点求值
├─ Stack(Vec<AnimNode>) 所有命中的子节点叠加求值
├─ Leaf(Box<dyn EvalDyn>) 用户 Eval 叶子的类型擦除
├─ Static(Vec<DynItem>) 已经采样好的输出批次
└─ Audio(Box<AudioTrack>) seal 时烘焙的音频叶子
AnimNode::eval_at(sec, out) 是唯一的时间管理入口:node 先检查
enabled / active,再用自己的 time_range 和 rate_func 把 sec 映射成局部
alpha,最后交给 NodeContent:
Sequence选择最后一个包含 content 时间的子节点;Stack求值所有包含 content 时间的子节点;Leaf调用擦除后的EvalDyn::eval_into(alpha, out);Static克隆保存的输出;Audio不参与逐帧视觉求值,由 seal 时的音频 interpreter 处理。
这里的关键是:运行时核心是封闭的,因此不同 consumer 可以各自遍历同一棵树, 互不污染:
eval_at:逐帧视觉求值;bake_audio:seal 时对音频叶子做一次性混音;has_audio:判断树里是否存在音频叶子;animation_info:为 preview 生成层级 introspection 树。
叶子仍然通过 Eval 保持开放;如果一个组合子能用核心构造子和窗口 placement
表达,就在 build 时 desugar,而不是增加新的 runtime variant。AnimLagged
就是这种 sugar:它最终 lower 成 Stack + Sequence。
SceneEvaluator::sample_at(render_secs, out) 是唯一的 session 交互:
- 对每个顶层
AnimNode调用eval_at(render_secs); - 前进 / 回退 / 原地求值的判断在
Iterative等 stateful 节点内部完成; - preview 拖拽和 render 采样共用同一条路径。
logic_fps 参数仅为 API 兼容保留,不再驱动步进;步进尺度由每个迭代区段自己
的 sim_step 决定。
音频叶子
Sound 是音频平面的叶子 atom,使用方式与视觉动画一致(from_file 需要
audio-decode feature):
let mut scene = RanimScene::new();
scene.play(seq![
square.fade_in(),
Sound::new(AudioClip::from_file("narration.wav")?)
.with_fade_in(0.25),
square.write(),
]);
Sound::into_anim_node 产生 NodeContent::Audio(Box<AudioTrack>)。它同样参与
seq! / stack! / lagged!、.at()、.with_duration()、
.with_rate_func() 和 .with_enabled()。音频不进入逐帧视觉管线,而是在
RanimScene::seal 时通过 bake_audio 一次性混音;见 ranim-core 的音频模块
文档。