Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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);
}

holdforward

forward 表示明确的空白时间;hold 表示把当前状态延长一段时间。新模型不会隐式认为一个动画结束后物件仍然存在。

sequence
    .push(square.fade_in())
    .hold(1.0)    // 保持淡入后的状态
    .forward(0.5) // 接下来 0.5 秒没有该 Sequence 的输出
    .push(circle.fade_in());

showhide

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 来自 FadingAnimmorph_to 来自 MorphAnimwrite/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:

  • widthheight:输出像素尺寸,默认 1920x1080。
  • fps:帧率,默认 60。
  • formatmp4webmmovgif
  • 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
  • EvalAnimationParamed 描述叶子动画如何根据局部进度产生状态并附加播放参数。
  • AnimSequenceAnimStack 分别描述顺序状态和并行动画层。
  • RanimScene 的根节点是一个 AnimStackr.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::pushAnimStack::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。

AnimSequenceAnimStack 仍提供自己的 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 进入类型擦除,AnimSequenceAnimStack 也直接实现该接口。Paramed 直接修改内层 build 出来的 cell,不再额外嵌套一个 AnimationCellhold 保存的已求值结果直接使用 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::pushAnimStack::pushRanimScene::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 只接受尚未显式放置的 PlaceableAt<A> 已经固定父时间坐标,不能进入 Sequence。

Sequence 本身仍实现 Animation,所以可以先独立构造,再整体使用 at 放置或加入另一个组合:

r.play(intro.at(2.0));

forwardhold

两者都会推进 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 会分别保存每次调用时的求值结果,形成相邻的静态区间。

showhide 与最终求值

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! 返回 AnimSequencestack! 返回 AnimStack。二者都只是构造辅助,最终 build 为保留子节点层级的运行时动画树。

v0.3

新增

BREAKING CHANGES

  • 重构动画组织系统
    • 弃用 Timeline,用 AnimSequenceAnimStack 替代
    • 修改 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

AnimSequenceAnimStack

Ranim 动画编排的本质是构造动画数据表示并放入集合,在之前的设计中整个 RanimScene 通过内部的 Vec<Timeline> 来维护动画。

Timeline 的本质是 Vec<Box<dyn CoreItemAnimation>> 动画序列容器,其中的每个元素都是前后相继的动画表示,同一时间一个 Timeline 只有一个动画激活,于是以前在动画组合代数上非常局限:

  • 串行的动画必须通过 Timeline 的 API 手动推进/同步时间到对应位置
  • 并行的动画必须通过创建新的 Timeline 来实现
  • 整个场景的 Vec<Timeline> 本质是一次性并行组合多个串行编排的性质

在 Ranim v0.3 中,原本的 Timeline 被弃用,新增了两个可组合的基本动画容器 AnimSequenceAnimStack

比如对于如下的动画:

  • 正方形: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())
});
}

而使用 AnimSequenceAnimStack 可以这样:

#![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!),会构造 AnimSequenceAnimStack 并将动画插入其中(类似 Vec)。

如果要把这段动画播放两遍,原来的 Timeline API 会非常繁琐,或许需要将相关时间线操作封装为闭包,而对于新的可组合 API 很简单:

#![allow(unused)]
fn main() {
r.play(seq![anim.clone(), anim]);
}

更能够表现新系统的可组合与复用能力的例子见 composable_choreaography example。

AnimationCellEvalAnimation 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 也是可组合动画的核心,AnimSequenceAnimStackParamed<A>At<A> 也实现了该 Trait,可以当作一个动画使用。

Paramed<A>At<A>

动画本身在时间轴上“长什么样子”并不依赖于其起始时间,只有在要 放置 在某种时间坐标上的时候起始时间才存在作用。对于 AnimSequenceAnimStack 来说,前者反而要求动画没有被指定起始时间,因为动画要被相继紧接着放置进序列中。

原先统一在 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_durationwith_rate_funcwith_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

  • 每个实体携带 CoreItemIdentitySceneOrder;值相同则不写组件(保留 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_alphasample/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 曲面渲染,球穿布后布料包裹)。