Getting Started
Ranim 的场景由一个 fn(&mut RanimScene) 函数构造。场景函数只负责定义动画;预览、渲染和输出配置由 #[scene]、#[output] 与 ranim CLI 处理。
准备项目
使用 CLI 热加载 lib target 时,crate 需要生成动态库:
[lib]
crate-type = ["rlib", "cdylib"]
动画代码通常从 prelude、item 类型和对应的动画扩展 trait 中导入 API:
use ranim::{
anims::fading::FadingAnim,
color::palettes::manim,
items::vitem::geometry::Square,
prelude::*,
};
第一个场景
下面的场景让一个蓝色正方形淡入、保持一秒,再淡出。相机作为独立 Sequence 与内容并行播放:
use ranim::{
anims::fading::FadingAnim,
color::palettes::manim,
items::vitem::geometry::Square,
prelude::*,
};
#[scene(clear_color = "#000000")]
#[output(width = 1280, height = 720, fps = 30, format = "mp4")]
fn hello(r: &mut RanimScene) {
let square = Square::new(2.0).with(|square| {
square.set_color(manim::BLUE_C);
});
let mut content = AnimSequence::new();
content
.push(square.clone().fade_in())
.hold(1.0)
.push(square.fade_out());
let mut camera = AnimSequence::new();
camera
.push(CameraFrame::default().show())
.hold_to(content.cursor_sec());
r.play(camera);
r.play(content);
}
#[scene] 会保留场景函数,并生成、注册对应的静态 Scene 描述。通常不需要手工创建 Scene 或编写 main。
Scene 的根是并行 Stack
RanimScene::play 等价于向根 AnimStack 执行 push:
r.play(camera);
r.play(content);
这两个动画共享局部 0 秒并行播放,互不覆盖。play 不维护全局 cursor,也不会根据 item 值查找并修改之前加入的动画。
需要顺序播放时,先使用 AnimSequence 组织一条完整状态序列,再将 Sequence 加入 Scene。
使用 AnimSequence
AnimSequence 维护自己的 cursor:
| 方法 | 行为 |
|---|---|
push(animation) | 在当前 cursor 加入动画,并按其 duration 推进 cursor |
forward(secs) | 只推进 cursor,空白区间没有输出 |
forward_to(sec) | 将 cursor 推进到指定绝对时间 |
hold(secs) | 保持 cursor 处的 Sequence 状态并推进 cursor |
hold_to(sec) | 将当前状态保持到指定绝对时间 |
cursor_sec() | 返回当前 Sequence 时长/cursor |
完整的 show/hide 示例可以直接查看:
use ranim::{
anims::fading::FadingAnim, color::palettes::manim, items::vitem::geometry::Square, prelude::*,
utils::rate_functions::smooth,
};
#[scene]
#[output(dir = "./output/getting_started0")]
fn getting_started0(r: &mut RanimScene) {
// A Square with size 2.0 and color blue
let square = Square::new(2.0).with(|square| {
square.set_color(manim::BLUE_C);
});
let mut content = seq![square.clone().fade_in().with_rate_func(smooth)];
content
.hold(1.0)
.push(square.hide())
.forward(1.0)
.push(square.show())
.hold(1.0)
.push(square.clone().fade_out().with_rate_func(smooth));
r.play(
CameraFrame::default()
.show()
.with_duration(content.cursor_sec()),
);
r.play(content);
}
hold 与 forward
forward 表示明确的空白时间;hold 表示把当前状态延长一段时间。新模型不会隐式认为一个动画结束后物件仍然存在。
sequence
.push(square.fade_in())
.hold(1.0) // 保持淡入后的状态
.forward(0.5) // 接下来 0.5 秒没有该 Sequence 的输出
.push(circle.fade_in());
show 与 hide
show() 和 hide() 是用于 Sequence 状态切换的零时长事件。cursor 上出现状态事件时,后续 hold 使用这些事件组成新的完整状态快照,不再继承左侧状态。
sequence
.push(square.show())
.hold(1.0)
.push(square.hide())
.hold(1.0);
hide 不会跨 Sequence 查找同一个 item。需要独立显示/隐藏的内容应放在独立 Sequence 中,再通过根 Stack 并行组合。
并行组合
固定数量的并行动画可以使用 stack!:
let scene = stack![
background.show().with_duration(total_secs),
content,
camera.show().with_duration(total_secs),
];
r.play(scene);
运行时动态生成的动画使用 AnimStack:
let mut layers = AnimStack::new();
for animation in animations {
layers.push(animation);
}
r.play(layers);
AnimStack 的 duration 是最长子动画的 duration。较短子动画结束后不会自动保持到 Stack 结束。
类型转换与动画扩展 trait
动画方法由 requirement/extension trait 提供,使用前需要导入对应 trait。例如 fade_in 来自 FadingAnim,morph_to 来自 MorphAnim,write/unwrite 来自 WritingAnim。
有些动画只对更底层的 VItem 实现。几何 item 可以通过 VItem::from 或 .into() 转换:
use ranim::{
anims::{creation::WritingAnim, morph::MorphAnim},
color::palettes::manim,
items::vitem::{
VItem,
geometry::{Circle, Square},
},
prelude::*,
utils::rate_functions::smooth,
};
#[scene]
#[output(dir = "./output/getting_started1")]
fn getting_started1(r: &mut RanimScene) {
// A Square with size 2.0 and color blue
let square = Square::new(2.0).with(|square| {
square.set_color(manim::BLUE_C);
});
let circle = Circle::new(2.0).with(|circle| {
circle.set_color(manim::RED_C);
});
let content = seq![
VItem::from(square)
.morph_to(VItem::from(circle.clone()))
.with_rate_func(smooth),
VItem::from(circle).unwrite().with_rate_func(smooth),
];
r.play(
CameraFrame::default()
.show()
.with_duration(content.cursor_sec()),
);
r.play(content);
}
多个独立 Sequence 的组合示例:
use ranim::{
anims::{
creation::{CreationAnim, WritingAnim},
morph::MorphAnim,
},
color::palettes::manim,
items::vitem::{
VItem,
geometry::{Circle, Rectangle, Square},
},
prelude::*,
utils::rate_functions::{linear, smooth},
};
#[scene]
#[output(dir = "./output/getting_started2")]
fn getting_started2(r: &mut RanimScene) {
let rect = Rectangle::new(4.0, 9.0 / 4.0).with(|rect| {
rect.set_stroke_color(manim::GREEN_C);
});
let square: VItem = Square::new(2.0)
.with(|square| {
square.set_color(manim::BLUE_C);
})
.into();
let circle: VItem = Circle::new(2.0)
.with(|circle| {
circle.set_color(manim::RED_C);
})
.into();
let mut rect_sequence = seq![rect.clone().show()];
rect_sequence
.hold(1.0)
.push(VItem::from(rect).uncreate().with_rate_func(smooth));
let mut item_sequence = seq![square.clone().show()];
item_sequence
.hold(1.0)
.push(square.clone().create().with_rate_func(smooth))
.push(
square
.clone()
.morph_to(circle.clone())
.with_rate_func(linear),
)
.push(circle.clone().unwrite().with_rate_func(smooth));
let total_secs = item_sequence.cursor_sec().max(rect_sequence.cursor_sec());
r.play(CameraFrame::default().show().with_duration(total_secs));
r.play(stack![rect_sequence, item_sequence]);
}
Scene 与 Output 属性
#[scene] 支持:
name = "...":设置注册的场景名称,默认使用函数名。clear_color = "...":设置 CSS 格式的清屏颜色,默认#333333ff。
每个 #[output] 定义一个输出;一个 Scene 可以声明多个 output:
width、height:输出像素尺寸,默认 1920x1080。fps:帧率,默认 60。format:mp4、webm、mov或gif。dir:输出目录,默认./output。name:输出文件名前缀;未设置时使用 Scene 名称。save_frames:是否保存逐帧图片,默认false。
没有写 #[output] 时会使用默认输出配置。
预览与渲染
安装 CLI:
cargo install ranim-cli
预览或渲染当前 package 的 lib target:
ranim preview
ranim render
ranim render hello
指定 workspace package 或 example target:
ranim preview -p package_name --example example_name
ranim render -p package_name --example example_name
preview 可以接收一个可选 Scene 名称;render 可以接收零个或多个 Scene 名称。额外的 Cargo 构建参数放在 -- 后,例如:
ranim render hello -- --release
在本仓库中可以直接运行 CLI package:
cargo run -p ranim-cli --release -- preview --example getting_started0
cargo run -p ranim-cli --release -- render --example getting_started0
Packages
.
├── src/ # ranim - 顶层 facade crate
├── packages/
│ ├── ranim-core/ # 核心动画引擎(求值、组合、组件与动画 trait)
│ ├── ranim-macros/ # proc-macro(#[scene]、#[output] 等)
│ ├── ranim-items/ # 内置可视元素(VItem、几何图形、SVG、文本)
│ ├── ranim-anims/ # 内置动画(淡入淡出、变形、书写等)
│ ├── ranim-render/ # GPU 渲染层(wgpu)
│ └── ranim-cli/ # CLI 工具(渲染、预览、热加载)
├── example-packages/app/ # 示例应用
├── benches/ # 性能基准测试
└── xtasks/xtask-examples/ # 示例构建自动化
graph BT
macros[ranim-macros]
core[ranim-core] --> macros
items[ranim-items] --> core
anims[ranim-anims] --> core
render[ranim-render] --> core
ranim[ranim] --> core
ranim --> items
ranim --> anims
ranim --> render
cli[ranim-cli] --> ranim
核心概念
Ranim 将动画定义为可按任意时间采样的值,并通过顺序和并行容器组织场景:
Eval<Output = T>
-> default Animation
-> Paramed<A>
-> AnimSequence / AnimStack
-> AnimationCell
-> SealedRanimScene
Eval、Animation与Paramed描述叶子动画如何根据局部进度产生状态并附加播放参数。AnimSequence与AnimStack分别描述顺序状态和并行动画层。RanimScene的根节点是一个AnimStack。r.play(animation)等价于向根 Stack 执行push,因此多次根级play默认从 0 秒并行。
新模型不维护 Scene 内可变的 TimelineId 或运行时物件表。需要独立生命周期的内容由各自的 AnimSequence 持有,最后通过 Stack 组合。
动画
Eval
Ranim 的叶子动画核心是一个归一化纯函数:输入进度 alpha,输出对应状态 T。
pub trait Eval {
type Output;
fn eval_alpha(&self, alpha: f64) -> Self::Output;
}
具体 evaluator 同时保存求值所需的数据。例如 Static<T> 始终返回同一个值,Morph<T> 保存插值需要的源状态和目标状态。
Eval 自动成为叶子动画
只要 Eval::Output 可以提取为场景元素,该 evaluator 就自动获得默认 linear、1 秒、enabled 的 Animation 实现:
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 { /* ... */ }
}
因此 FadeIn<T> 本身就是一个可组合动画,不需要 marker 或宏。Fn(f64) -> T 闭包也自动实现 Eval<Output = T>,可以直接设置播放参数:
let animation = (|alpha| Square::new(alpha)).with_duration(2.0);
具名 evaluator 和闭包都不会在进入动态容器前擦除类型。AnimSequence::push、AnimStack::push 或 Scene build 时会将直接子节点转换为保留层级的运行时节点。
Paramed<A>
所有尚未固定父时间坐标的 Placeable 动画通过 AnimationExt 获得统一的播放参数 API:
animation
.with_duration(2.0)
.with_rate_func(smooth)
.with_enabled(true)
第一次调用会生成 Paramed<A>。它只属于 Animation 层,负责 duration override、rate function 和 enabled,不再实现 Eval。裸动画的默认值是 linear、1 秒和 enabled。Sequence 或 Stack 被包装时,rate function 重映射整个组合的局部时间轴。
At<A> 表示已经固定在父时间坐标中的 entry,不再实现 Placeable,因此参数必须在 placement 之前设置:
animation.with_duration(2.0).at(3.0); // At<Paramed<A>>
Animation 与 build
所有可组合动画实现:
pub trait Animation: Sized {
fn build(self) -> AnimationCell;
}
Animation 不再提前暴露 time range 或 duration,它只负责将静态定义 lower 为局部坐标中的 AnimationCell:
- 普通叶子 build 为
0.0..1.0; Paramed<A>build 内层后,在外层应用 duration override、rate function 和 enabled;At<A>build 内层后移动根 time range;- Sequence push 时先 build 子动画,再将它移动到 cursor;
- Stack push 时先 build 子动画,再根据 built range 更新整体 duration。
AnimSequence 和 AnimStack 仍提供自己的 duration_secs() 查询,但通用 Animation trait 不再要求每个静态类型重复提供时间信息。
AnimationCell
Sequence、Stack 和 Scene 需要保存异构动画,因此每个直接子动画会生成一个 AnimationCell:
AnimationCell
├─ Box<dyn EvalDyn>
├─ time range
├─ rate function
├─ enabled
└─ evaluator name
EvalDyn 是私有的 object-safe 求值接口:所有 E: Eval 通过 blanket impl 进入类型擦除,AnimSequence 和 AnimStack 也直接实现该接口。Paramed 直接修改内层 build 出来的 cell,不再额外嵌套一个 AnimationCell。hold 保存的已求值结果直接使用 Static<Vec<DynItem>>。
动态求值会将结果追加到 Vec<DynItem>,但组合树本身不会被展开。类型擦除只隐藏直接子动画的 Rust 类型,不删除组合层级。时间范围位于 Box 外,供父动画调度和 preview 查询。
Requirement Trait 模式
用户通常不直接构造 evaluator,而是通过 Item 的动画扩展 Trait:
let animation = square
.fade_in()
.with_duration(2.0)
.with_rate_func(smooth);
返回值就是具体 evaluator 类型,例如:
FadeIn<Square>
只有当它进入 AnimSequence::push、AnimStack::push 或 RanimScene::play 时才会被 build 和擦除。
动画序列与并行组合
v0.3 不再使用 TimelineId 管理 Scene 内的可变时间线。动画先在用户代码中组合为完整的 Animation,再通过 RanimScene::play 加入场景。
当前有两个动态组合容器:
AnimSequence:子动画按 cursor 顺序排列,适合描述一个完整状态序列。AnimStack:子动画共享同一个局部原点,适合叠加互不干扰的动画层。
RanimScene 自带一个根 AnimStack:
pub fn play<A: Animation + 'static>(&mut self, animation: A) -> &mut Self {
self.root.push(animation);
self
}
因此,多次根级 play 默认都从 0 秒开始。它们是并行动画,不存在后一次调用覆盖前一次调用的隐含对象语义。
AnimSequence
AnimSequence::push 先将动画 build 为局部 AnimationCell,再把它移动到当前 cursor,并按 cell 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 只将直接子动画转换为一个 AnimationCell;如果子动画是 Stack 或 Sequence,其内部层级会继续保留。
Sequence 自己通过 cursor 决定子动画的位置,因此 push 只接受尚未显式放置的 Placeable。At<A> 已经固定父时间坐标,不能进入 Sequence。
Sequence 本身仍实现 Animation,所以可以先独立构造,再整体使用 at 放置或加入另一个组合:
r.play(intro.at(2.0));
forward 与 hold
两者都会推进 Sequence cursor,但输出语义不同:
forward(secs)只推进 cursor,产生的空白区间没有输出。hold(secs)取得 cursor 处的 Sequence 状态,将它保存为持续secs的静态运行时节点。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 接受普通 Placeable 动画和已经放置的 At<A>。普通动画从 Stack 局部 0 开始,At<A> 使用自己的显式 offset。参数必须在调用 at 之前设置。
运行时数量不固定时可以直接构造 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);
这种写法使空白和保持区间成为动画定义的一部分。后续可以增加默认相机或 through_scene_end 等辅助 API,但它们不改变 Sequence/Stack 的组合语义。
seq! 与 stack!
固定写法可以使用宏简化:
let intro = seq![
square.clone().fade_in(),
square.fade_out(),
];
let scene = stack![intro, camera];
r.play(scene);
seq! 返回 AnimSequence,stack! 返回 AnimStack。二者都只是构造辅助,最终 build 为保留子节点层级的运行时动画树。
v0.3
新增
BREAKING CHANGES
- 重构动画组织系统
- 弃用
Timeline,用AnimSequence和AnimStack替代 - 修改
Eval<T>Trait 的泛型参数为关联类型 - 支持直接将
Eval<T>当作动画使用(不再需要转换为AnimationCell) - 用
Paramed<A>和At<A>替代原先AnimationCell<T>的AnimationInfo - ranim-anims 中全部内置动画创建工具方法现在默认用
linear速率函数和1.0持续秒数。
- 弃用
Composable Animation Arrangement
https://github.com/AzurIce/ranim/pull/170
AnimSequence 和 AnimStack
Ranim 动画编排的本质是构造动画数据表示并放入集合,在之前的设计中整个 RanimScene 通过内部的 Vec<Timeline> 来维护动画。
Timeline 的本质是 Vec<Box<dyn CoreItemAnimation>> 动画序列容器,其中的每个元素都是前后相继的动画表示,同一时间一个 Timeline 只有一个动画激活,于是以前在动画组合代数上非常局限:
- 串行的动画必须通过
Timeline的 API 手动推进/同步时间到对应位置 - 并行的动画必须通过创建新的
Timeline来实现 - 整个场景的
Vec<Timeline>本质是一次性并行组合多个串行编排的性质
在 Ranim v0.3 中,原本的 Timeline 被弃用,新增了两个可组合的基本动画容器 AnimSequence 和 AnimStack。
比如对于如下的动画:
- 正方形:0.0s ~ 1.0s 淡入 | 1.0s ~ 2.0s 变成圆形 | 2.0s ~ 3.0s 淡出
- 文字:0.5s ~ 1.5s 写入 | 1.5s ~ 2.5s 擦除
在以前的 Timeline API 下要这样编写:
#![allow(unused)]
fn main() {
let r_vitem = r.insert_with(|t| {
t.play(item.fade_in())
.play(item.morph_to(VItem::from(Circle::default())))
.play(item.fade_out())
});
let r_text = r.insert_with(|t| {
t.forward(0.5)
.play(text.write())
.play(text.unwrite())
});
}
而使用 AnimSequence 和 AnimStack 可以这样:
#![allow(unused)]
fn main() {
let anim = stack![
seq![
item.fade_in(),
item.morph_to(VItem::from(Circle::default())),
item.fade_out(),
],
seq![
text.write(),
text.unwrite()
].at(0.5)
];
r.play(anim);
}
其中的 seq! 和 stack!(类似 vec!),会构造 AnimSequence 和 AnimStack 并将动画插入其中(类似 Vec)。
如果要把这段动画播放两遍,原来的 Timeline API 会非常繁琐,或许需要将相关时间线操作封装为闭包,而对于新的可组合 API 很简单:
#![allow(unused)]
fn main() {
r.play(seq![anim.clone(), anim]);
}
更能够表现新系统的可组合与复用能力的例子见 composable_choreaography example。
AnimationCell、Eval 与 Animation Trait
Eval<T> 的泛型参数被移除并改成了关联类型(一个求值器类型的求值结果类型是唯一的)。
以前 AnimationCell<T> 被当作动画的组织单元,所有动画必须被表示为 AnimationCell<T> 才能够被插入时间线。现在这个行为被抽象为了一个 Animation Trait:
#![allow(unused)]
fn main() {
/// A statically typed animation definition that can be lowered into a runtime animation.
pub trait Animation: Sized {
/// Lower this definition into its local runtime representation.
fn build(self) -> AnimationCell;
}
}
同时泛型参数被从 AnimationCell 移除,其内部变成类型擦除的 Box<dyn EvalDyn>。
所有的 E: Eval where E::Output: AnyExtractCoreItem 都自动实现了 Animation,于是所有的动画创建都不必返回 AnimationCell,可以直接返回自己就可以使用。
#![allow(unused)]
fn main() {
// previous
impl<T: FadingRequirement + Sized + 'static> FadingAnim for T {
fn fade_in(&mut self) -> AnimationCell<Self> {
FadeIn::new(self.clone())
.into_animation_cell()
.with_rate_func(smooth)
.apply_to(self)
}
fn fade_out(&mut self) -> AnimationCell<Self> {
FadeOut::new(self.clone())
.into_animation_cell()
.with_rate_func(smooth)
.apply_to(self)
}
}
}
#![allow(unused)]
fn main() {
impl<T: FadingRequirement + Sized + 'static> FadingAnim for T {
fn fade_in(&mut self) -> FadeIn<Self> {
FadeIn::new(self.clone()).apply_to(self)
}
fn fade_out(&mut self) -> FadeOut<Self> {
FadeOut::new(self.clone()).apply_to(self)
}
}
}
Animation Trait 也是可组合动画的核心,AnimSequence、AnimStack、Paramed<A> 和 At<A> 也实现了该 Trait,可以当作一个动画使用。
Paramed<A>、At<A>
动画本身在时间轴上“长什么样子”并不依赖于其起始时间,只有在要 放置 在某种时间坐标上的时候起始时间才存在作用。对于 AnimSequence 和 AnimStack 来说,前者反而要求动画没有被指定起始时间,因为动画要被相继紧接着放置进序列中。
原先统一在 AnimationInfo 内的动画参数现在拆分到了 Paramed<A> 和 At<A> 两个泛型结构体内:
#![allow(unused)]
fn main() {
/// An animation definition with overridden playback parameters.
pub struct Paramed<A> {
inner: A,
param: AnimationParam,
}
/// An animation fixed at an offset in its parent's time coordinates.
///
/// This is a terminal placement entry: it implements [`Animation`] but not
/// [`Placeable`], so playback parameters must be configured before calling
/// [`Placeable::at`].
pub struct At<A> {
inner: A,
offset_sec: f64,
}
}
使用 .with_duration、with_rate_func、with_enabled 会自动修改或包裹 Paramed<A>,使用 .at 会自动包裹 At<A>。
Preview App 时间轴控件重构
在新的动画组织系统下,Preview App 的时间轴控件也对应做了大幅重构:
ECS Schedule 取代 RenderGraph
https://github.com/AzurIce/ranim/pull/175
渲染侧的 ECS 化:渲染原语进入内部 RenderWorld,渲染准备与 GPU pass 由 schedule 组织;用户级 item、动画求值仍停留在 World 之外。
之前:CoreItemStore 兼任传输与查询
旧实现里,求值结果由 CoreItemStore 承载:
#![allow(unused)]
fn main() {
/// A store of [`CoreItem`]s.
#[derive(Default, Clone)]
pub struct CoreItemStore {
/// Id of [`CameraFrame`]s
pub camera_frame_ids: Vec<(usize, usize)>,
/// [`CameraFrame`]s
pub camera_frames: Vec<CameraFrame>,
/// Id of [`VItem`]s
pub vitem_ids: Vec<(usize, usize)>,
/// [`VItem`]s
pub vitems: Vec<VItem>,
/// Id of [`MeshItem`]s
pub mesh_item_ids: Vec<(usize, usize)>,
/// [`MeshItem`]s
pub mesh_items: Vec<MeshItem>,
}
}
它既用于承载并传输求值结果,又用于渲染管线查询访问——两种职责混在一起。
现在:RenderFrame 传输 + RenderWorld 查询
拆分为了 RenderFrame(帧级传输缓冲)和 Renderer 内部的 ECS World:
#![allow(unused)]
fn main() {
/// A reusable, frame-local transport buffer between evaluation and rendering.
#[derive(Default)]
pub struct RenderFrame {
items: Vec<(CoreItemId, CoreItem)>,
}
}
#![allow(unused)]
fn main() {
pub struct Renderer {
width: u32,
height: u32,
world: World,
}
}
前者只用于传输(求值线程 → 渲染线程),后者用于承载运行时的查询、变更检测与 schedule。
Reconcile:按身份增量更新实体
每帧从 RenderFrame 更新 World,再运行渲染 Schedule:
#![allow(unused)]
fn main() {
/// Reconcile and render one evaluated frame.
pub fn render_frame(
&mut self,
render_textures: &mut RenderTextures,
clear_color: wgpu::Color,
frame: &RenderFrame,
) {
reconcile(&mut self.world, frame);
self.world
.insert_resource(FrameTarget::new(render_textures, clear_color));
self.world.run_schedule(RenderPrepare);
self.world.run_schedule(RenderGraph);
}
}
reconcile 以 CoreItemIdentity(animation_id, part) 为跨帧 key,从 RenderFrame 更新渲染 World:
- 每个实体携带
CoreItemIdentity与SceneOrder;值相同则不写组件(保留Changed<T>),值变化才替换,本帧消失的 key 对应实体被移除; - 身份与顺序是两件事:
CoreItemIdentity回答“是否是上一帧的同一项“,SceneOrder回答“本帧按什么顺序消费“——ECS query 顺序不构成绘制顺序,prepare 阶段显式按SceneOrder排序分桶;
Schedule 组织渲染阶段
RenderPrepare: Collect → PrepareResources → Upload → PrepareBindGroups
RenderGraph: Begin → Render → Submit → Finish
└─ ViewRender: Clear → Compute → Depth → Color → OITResolve
RenderPrepare把组件展开为 GPU 输入(storage/index/uniform 数据、上传、绑定组);RenderGraph驱动整个画面生命周期:Begin创建 frame encoder,Render运行逐 view 子 schedule,Submit提交 command buffer,Finish结束 profiling frame;- 单相机也走完整的
ViewRender子 schedule(clear、VItem compute、depth、color、OIT resolve),避免单 view 成为以后多 view 的特殊路径; - 自制的
Graph<NodeKey, Box<dyn RenderNode>>节点图被移除——节点 trait、拓扑容器和查询都在重复 ECS schedule 已提供的能力。
迭代式动画区段
https://github.com/AzurIce/ranim/pull/177
v0.3 之前的动画区段都是函数式的:Eval::eval_alpha(alpha) 从归一化进度闭式采样。这类区段无法表达有状态的迭代式动画(粒子、弹簧、物理模拟、三体),因为求值器无法保留跨帧状态、也无法按 dt 推进。
统一求值器:单一 Eval trait
Eval 从纯函数式求值器扩展为统一求值器:函数式(闭式)与迭代式(有状态)都实现同一个 trait,按各自需要覆盖方法:
#![allow(unused)]
fn main() {
/// 统一求值器。
///
/// 函数式区段实现 [`Eval::eval_alpha`];迭代式区段实现
/// [`Eval::sample`]/[`Eval::reset`]/[`Eval::step`]——`eval_alpha` 无闭式,
/// 默认调用即 panic。
pub trait Eval {
type Output;
/// 闭式采样。函数式实现;迭代式无闭式(默认 panic,运行时经 `sample` 驱动)。
fn eval_alpha(&self, _alpha: f64) -> Self::Output {
unreachable!("iterative segment has no closed form; drive it via `sample`/`step`")
}
/// 采样当前状态(统一入口)。函数式默认 = eval_alpha(time.alpha)。
fn sample(&self, time: &SegmentTime) -> Self::Output {
self.eval_alpha(time.alpha)
}
/// 回到区段起点(确定性契约:不得依赖墙钟/未播种 RNG)。
fn reset(&mut self) {}
/// 推进一个逻辑步或 substep;`time.local_delta_secs` 是积分步长。
/// 函数式默认空操作(免费);采样不受 step 历史影响。
fn step(&mut self, _time: &SegmentTime) {}
}
}
作者视角:
- 函数式:
impl Eval { type Output; eval_alpha }——只实现eval_alpha(sample/reset/step用默认); - 迭代式:
impl Eval { type Output; sample; reset; step }——eval_alpha保持默认(无闭式,不会被调用)。
cell 对擦除后的公共类型无条件调 step:函数式空步免费,且消除了“忘了标记导致 step 被跳过“的 footgun。纯求值路径(eval_at_sec)只支持函数式区段;迭代式区段须用 SceneEvaluator(纯路径调用 eval_alpha 会 panic 以暴露误用)。
SegmentTime:传给区段的完整时间上下文
#![allow(unused)]
fn main() {
pub struct SegmentTime {
pub global_secs: f64, // 全局时间 t(秒)
pub global_delta_secs: f64, // 逻辑步长(恒稳,= 1/logic_fps)
pub start_secs: f64, // 区段起点 s
pub duration_secs: f64, // 区段时长 D
pub local_secs: f64, // 局部时间 u(t) = D·r((t−s)/D)(秒)
pub local_delta_secs: f64, // Δu = u(t_k) − u(t_{k−1}),随 rate 变化(秒)
pub alpha: f64, // local_secs / D
pub render_frame: u64, // 当前渲染帧序号(frame-coupled 内容用)
pub is_render_frame_boundary: bool,
}
}
global_delta_secs恒稳(逻辑网格构造保证);local_delta_secs仅在线性 rate 下等于逻辑步长——非线性 rate 下逐帧变化是 rate func 的本职(扭曲局部时钟),迭代区段按变步长积分编写;- 需要物理真实时间(不被 rate 扭曲)的区段改用
global_delta_secs。
SceneEvaluator:轻量会话驱动(非 ECS)
#![allow(unused)]
fn main() {
impl SceneEvaluator {
/// 渲染采样时刻驱动:内部把 `render_secs` floor 到逻辑刻并推进。
/// 唯一包含 tick 推进逻辑的入口。
fn advance_to(&mut self, render_secs: f64);
/// 纯采样:只读内部 clock(= floor 逻辑刻),不含 tick 逻辑。
fn sample_into(&self, out: &mut Vec<((usize, usize), CoreItem)>);
/// seek:全量 reset + 重放(确定性契约)。
fn seek(&mut self, render_secs: f64);
}
}
- 逻辑帧与渲染帧分离:固定逻辑网格(默认 120Hz,与 24/30/60/120 整除对齐)驱动模拟,渲染 fps 只决定读取哪些逻辑态;
- 确定性:
seek重放与正向推进逐帧一致(preview scrub 与渲染可复现); - 迭代区段要求
SceneEvaluator:纯eval_at_sec路径不推进其状态。
示例
iterative_spring:阻尼弹簧(Evaluator驱动);nbody:三体引力模拟(velocity Verlet、混沌弹射终场、无边界);cloth_wrap:零重力布料(弹簧力 + 自碰撞 + 球-布碰撞,MeshItem 曲面渲染,球穿布后布料包裹)。