Ranim Book
On this page
  1. Getting Started
  2. 准备项目
  3. Prelude 覆盖什么
  4. 第一个场景
  5. 用 Plugin 组织项目(可选)
  6. 自定义渲染配置(可选)
  7. Scene 的根是并行 Stack
  8. 使用 AnimSequence
  9. hold 与 forward
  10. show 与 hide
  11. 并行组合
  12. 类型转换与动画扩展 trait
  13. Scene 与 Output 属性
  14. 预览与渲染
  15. WASM 预览宿主
  16. 命令行:预览与渲染

Getting Started

[!caution]本书绝大多数内容目前均由 ai 生成,可能叙事逻辑和表述并不是很好,仅供参考。

Ranim 的场景仍由一个 fn(&mut RanimScene) 函数构造,只负责定义动画,可以写在 src/lib.rs、普通模块或独立的库 crate 中;不要求把场景搬进二进制。变化在运行入口:宿主二进制用 App 显式注册场景与项目配置,app.build() 定稿后,native 下交给 run_cli,WASM 下包装为 WasmApp,通过导出的 app() 供 JavaScript 使用。Plugin 只是复用注册和配置的可选工具,并不替代场景函数。

本章介绍场景的编写。完整的 lib / bin 布局、两种平台入口和构建命令见项目入口:场景库与跨平台宿主。

准备项目

Cargo.toml 只需要 ranim 依赖:

[dependencies]
ranim = "..."

默认启用 anims、items、cmd-preview;cmd-preview → cmd-render → render。只需原生导出时,设置 default-features = false 并开启 anims,cmd-render。render 本身只提供渲染器和 WASM 宿主,不包含内置物件或原生命令。例如包名为 my-animation,场景定义在 src/lib.rs 时,native 的src/main.rs 从库中导入并注册场景,build() 定稿后交给 run_cli:

use ranim::prelude::*;
use my_animation::hello;

fn main() {
    let mut app = App::new();
    app.add_scenes([hello::scene()]);
    run_cli(app.build());
}

app.build()(RanimAppExt,prelude 已导出)是「构建中的 App → 可开会话的项目」的显式转换:断言插件全部就绪(Ready),执行一次 bevy 的finish() / cleanup(),返回 ranim::RanimApp。RanimApp 是项目的会话期形态——Deref / DerefMut 到 bevy App,update、渲染 SubApp安装、资源访问与场景选择照常工作,但不再是插件构建目标;run_cli、preview_scene 等会话入口都以它为输入。

run_cli 安装 tracing、解析进程参数,随即提供preview / output / render / inspect 子命令,详见命令行入口。inspect 是纯 CPU 查询,不执行渲染装配(RenderSetup)的 GPU 回调。

动画代码通常从 prelude、item 类型和对应的动画扩展 trait 中导入 API:

use ranim::{
    anims::fading::FadingAnim,
    color::palettes::manim,
    items::vitem::geometry::Square,
    prelude::*,
};

Prelude 覆盖什么

ranim::prelude::* 覆盖日常作者 API:Eval / IntoAnimNode / Unplaced/ PlaybackExt 等动画协议,AnimSequence / AnimStack / AnimLagged 与seq! / stack! / lagged!,Pure / Iterative / Static,Sound /AudioClip,以及 RanimScene / TimeMark / App / Plugin。

以下类型有意不放进 prelude,需要时显式导入:

use ranim_core::audio::AudioError;
use ranim_core::animation::build::{At, Paramed};
use ranim_core::animation::node::{AnimNode, AnimationInfo};
use ranim_core::frame::EvaluatedFrame;
  • EvaluatedFrame:读取求值结果或直接驱动渲染时使用;

  • AnimNode / AnimationInfo:运行时 introspection API;

  • At / Paramed:需要给返回值命名的 timing wrapper;

  • AudioError:音频解码/文件错误处理。

第一个场景

下面的场景让一个蓝色正方形淡入、保持一秒,再淡出。相机作为独立 Sequence 与内容并行播放:

// src/lib.rs
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")]
pub 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] 保留场景函数,并生成同名模块中的 hello::scene() -> Scene。每次调用都创建一个 owned 描述符,其中包含名称、构造函数、配置和输出列表。这里的 pub 同时使场景函数和生成的同名模块对二进制可见;在 lib 中定义场景不会自动注册它,也不会启动渲染。把描述符注册进 App(add_scene / add_scenes 由 AppExt 提供),build() 定稿后交给 run_cli:

// src/main.rs(native)
use my_animation::hello;
use ranim::prelude::*;

fn main() {
    let mut app = App::new();
    app.add_scenes([hello::scene()]);
    run_cli(app.build());
    // 多个场景:app.add_scenes([first::scene(), second::scene()])
}

只有加入 App 的场景才会出现在命令行和 Web 入口中。场景目录保留显式注册顺序,场景名称必须唯一。

渲染上面的场景不需要任何装配:场景输出裸 Square 和 CameraFrame,启用 items 的项目未注册RenderSetup 时渲染与预览回退到默认配置,内置相机与矢量/网格渲染开箱即用(见下文「自定义渲染配置」)。

用 Plugin 组织项目(可选)

ranim::Plugin 就是 bevy 的 Plugin。插件持有自己的配置,在 build 中安装项目能力——build 的签名是 fn build(&self, app: &mut bevy_app::App);ranim::App 与 bevy_app::App 是同一类型,写成 &mut App 完全等价。场景注册等方法由 AppExt trait(prelude 已导出)在 App 上提供。场景变多、需要跨二进制复用,或要携带自定义渲染配置时,把它们收进插件:

// 追加到 src/lib.rs
pub struct DemoScenesPlugin;

impl Plugin for DemoScenesPlugin {
    fn build(&self, app: &mut App) {
        app.add_scenes([hello::scene()]);
    }
}

二进制入口安装库导出的插件,场景定义仍留在 lib 中:

// src/main.rs(native)
use my_animation::DemoScenesPlugin;
use ranim::prelude::*;

fn main() {
    let mut app = App::new();
    app.add_plugins(DemoScenesPlugin);
    run_cli(app.build());
}

宿主既可以直接安装插件类型,也可以安装另一个 crate 的普通 Rust 工厂函数:

let mut app = App::new();
app.add_plugins(DemoScenesPlugin);

// 在另一个应用中使用这个 crate:
let mut app = App::new();
app.add_plugins(my_project::plugin());

插件在添加时执行构建,也可以通过 app.add_plugins(...) 继续安装依赖插件。多个插件可写成元组,例如 app.add_plugins((GeometryPlugin, TextPlugin))。带配置的插件直接构造,例如 DemoPlugin { quality: 4 },不要求实现 Default。项目资源就是 bevy world 资源:app.insert_resource(...) 存入(类型需#[derive(Resource)],ranim::Resource 已再导出),经 app.world() 读取;多个项目互不共享全局注册表。

自定义渲染配置(可选)

渲染配置依赖 ranim 的 render feature(默认的 cmd-preview 会间接启用它)。启用 items 的项目未注册RenderSetup 时,渲染与预览使用的默认配置等价于:

RenderSetup::new(|render_app| {
    render_app.add_plugins(RanimItemsPlugin);
})

ranim_items::RanimItemsPlugin 捆绑三个内置物件插件(CameraFramePlugin、VItemPlugin、MeshItemPlugin),覆盖裸内置类型与 Vec<VItem>。它与三个单件插件互斥:Bevy 插件在 App 内唯一,重复安装会 panic。

关闭 items 时默认配置为空,通用 render 不会引入内置物件;自定义渲染项目通过 RenderSetup 安装自己的插件。

若完整输出是默认配置未覆盖的类型——如 Vec<Circle>、Transformed<T, G> 或自定义类型——在插件中注册自己的 RenderSetup,为实际完整类型加入ExtractPlugin<T>(只注册 T 不会读取这些组合):

use ranim::{
    items::RanimItemsPlugin,
    render::{RenderSetup, extract::ExtractPlugin},
};

pub struct ProjectPlugin;

impl Plugin for ProjectPlugin {
    fn build(&self, app: &mut bevy_app::App) {
        app.add_scenes([hello::scene()]);
        app.insert_resource(RenderSetup::new(|render_app| {
            render_app.add_plugins((
                RanimItemsPlugin,
                ExtractPlugin::<MyOutput>::default(),
            ));
        }));
    }
}

RenderSetup 是 bevy Resource,保存在项目 App 中,preview / output 从所属App 取得并在每次安装渲染 SubApp 时重放,分辨率变化和后台导出也使用同一项目的配置。保存 setup 不会初始化 GPU,纯 CPU 求值与 inspect 也不执行该回调。详见完整求值帧与渲染提取。

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 示例可以直接查看:

{{#rustdoc_include ../../examples/getting_started0/scenes.rs:construct}}

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() 转换:

{{#rustdoc_include ../../examples/getting_started1/scenes.rs:construct}}

多个独立 Sequence 的组合示例:

{{#rustdoc_include ../../examples/getting_started2/scenes.rs}}

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] 时会使用默认输出配置。

预览与渲染

run_cli 已把常用流程做成子命令;需要在 Rust 代码里直接驱动时,创建 App、注册场景,再选择场景(app.scene(name) 返回克隆的描述符):

let mut app = App::new();
app.add_scenes([hello::scene()]); // 或安装项目插件:app.add_plugins(my_project::plugin())
let scene = app.scene("hello").expect("hello is registered");

// 项目的渲染装配:读取 RenderSetup 资源,未注册时回退到默认配置
let setup = app
    .world()
    .get_resource::<ranim::render::RenderSetup>()
    .cloned()
    .unwrap_or_else(|| {
        ranim::render::RenderSetup::new(|render_app| {
            render_app.add_plugins(ranim::items::RanimItemsPlugin);
        })
    });

// 需要 cmd-render feature,渲染场景声明的所有输出;2 是 readback 缓冲数量。
ranim::cmd::render_scene(&scene, &setup, 2);
// 需要 cmd-preview feature,预览 app 接管定稿后的项目(RanimApp),项目资源全程可用。
ranim::cmd::preview_scene(app.build(), scene);

WASM 预览宿主

WASM 的入口同样来自宿主装配,不限制场景源码的位置:场景可以继续放在 lib 中,入口从库中取得描述符或 Plugin,再导出 app() -> WasmApp。本节展示仓库 example 的写法;普通项目的 src/lib.rs + src/main.rs 完整示例见项目入口。

命令行入口 run_cli 只面向原生目标(需要 cmd-render feature)。仓库的 example把平台入口与共享装配放在两个文件中:

examples/arc/
├── main.rs    # 唯一 bin target:native CLI / WASM 空 main 和 app() 导出
└── scenes.rs  # 共享场景、ScenesPlugin 与 RenderSetup

main.rs 声明 mod scenes。原生 main() 创建 App、安装 scenes::ScenesPlugin后调用 run_cli(app.build());WASM 下则提供空 main(),并在同一文件中导出 app(),使用同一套装配,把定稿后的项目交给 JavaScript:

#[cfg(target_arch = "wasm32")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn app() -> ranim::wasm::WasmApp {
    use ranim::prelude::*;

    let mut app = App::new();
    app.add_plugins(scenes::ScenesPlugin);
    ranim::wasm::WasmApp::new(app.build())
}

在根 Cargo.toml 中只声明一个 example,两种平台都使用它:

[[example]]
name = "arc"
path = "examples/arc/main.rs"
required-features = ["anims", "render"]

required-features 声明共享能力,场景所需的 typst、gltf 等 feature 也在其中。原生 CLI 还需 cmd-render,egui 预览由 cmd-preview 提供;两者默认均启用。关闭 default 的原生导出使用 anims,cmd-render,WASM 使用 anims,render。原生未开启 cmd-render 时,入口给出编译错误,提示启用该 feature。

main.rs 用函数级 cfg 切换入口;app() 导出也只在 WASM 下编译:

mod scenes;

#[cfg(not(target_arch = "wasm32"))]
fn main() { /* 创建项目,run_cli(app.build()) */ }

#[cfg(target_arch = "wasm32")]
fn main() {}

WASM bin 和 cdylib 在该平台都生成 .wasm 模块,且都支持 wasm-bindgen 导出。bin 需要 main(),wasm-bindgen 将它接入模块初始化;这里的空函数立即返回,模块与导出函数继续存在。加载模块不会自动装配项目或启动预览,JavaScript在 await init() 后显式调用 app() 创建项目,再调用 createPreview 申请 GPU。cdylib 无需 main(),但绑定运行时初始化和显式的 #[wasm_bindgen(start)]仍会在加载时执行。本项目没有声明自动启动应用的函数。独立项目仍可选择 [lib] crate-type = ["cdylib"],开启 ranim/render 并直接依赖wasm-bindgen;库入口直接声明 mod scenes 并导出 app() 即可。

cargo build --example arc --target wasm32-unknown-unknown --no-default-features --features anims,render
wasm-bindgen --target web --out-dir pkg --out-name arc target/wasm32-unknown-unknown/debug/examples/arc.wasm

页面初始化模块后,显式选择场景并启动预览:

<canvas id="preview" width="1280" height="720"></canvas>
<script type="module">
  import init, { app } from "./pkg/arc.js";
  await init();
  const project = app();
  console.log(project.sceneNames());
  const canvas = document.getElementById("preview");
  const preview = await project.createPreview(canvas, "arc");
  project.free();
  preview.renderAt(1.5);
  // 页面卸载预览时:preview.dispose(); preview.free();
</script>

sceneNames() 返回以换行分隔的注册场景名。createPreview 的 Promise 等待GPU 与场景初始化;返回的会话提供 duration、renderAt(seconds)、resize(width, height)(物理像素)与 dispose()。播放时钟由 Web 端管理,WASM 不自行启动事件循环。每个 WasmApp 创建一次预览;需要新的预览时重新调用 app()。

仓库的 cargo examples build [EXAMPLES...] 会完成编译、wasm-bindgen和 wasm-opt。视口与控制条源码分别位于 website/static/ranim-viewport.js和 website/static/ranim-controls.js,由 Notist SSG 直接提供;为 rustdoc 等其他输出目录构建时,会把这两个组件及其音频和 timeline 模块复制到输出根目录。<ranim-viewport> 负责 canvas 和播放时钟,<ranim-controls> 通过 for绑定同一 DOM 根中的视口 ID,可放在页面任意位置;同一视口也能绑定多个控制条。JavaScript 可以用 controls.target = viewport 显式绑定,包括跨Shadow DOM 的组合。网站和 rustdoc 都用这两个组件,无需 egui:

<script type="module" src="./ranim-viewport.js"></script>
<script type="module" src="./ranim-controls.js"></script>
<ranim-viewport id="arc-view" src="./examples/arc/pkg/ranim_example.js" scene="arc"></ranim-viewport>
<ranim-controls for="arc-view"></ranim-controls>

进度条右侧的展开按钮会打开动画 timeline,显示与原生 egui 相同来源的动画树和时间片段;点击片段只选中并查看详情,点击或拖动上方时间刻度才改变进度。还可折叠子轨道、缩放时间轴。给控制条添加timeline-expanded,或设置 controls.timelineExpanded = true 可默认展开。视口在会话就绪时读取一次 WasmPreview.timeline(),通过 viewport.timeline提供元数据;播放时只更新指针,不会反复跨 WASM 读取整棵动画树。

场景有声音时,视口在初始化时读取一次 WasmPreview.audio() 的预混音声道,点击播放后通过 Web Audio 播放,画面时间跟随音频输出时钟。播放、暂停和拖动进度条会同时控制声音;暂停中定位保持安静,播放中定位从新位置继续。切换场景或卸载视口会关闭音频设备,无声音场景仍按墙钟播放。控制条的扬声器按钮可调音量或静音;同一视口的多个控制条共享设置,切换场景后保留音量。自定义宿主可设置 viewport.volume(0–1)和 viewport.muted,并监听 volumechange。

命令行:预览与渲染

项目二进制的 run_cli 提供全部命令行入口:

cargo run -- preview
cargo run -- output
cargo run -- output hello
cargo run -- render hello

不渲染、只查询场景信息时使用 inspect:

cargo run -- inspect scenes
cargo run -- inspect tree
cargo run -- inspect frame hello --at 1.0

tree 的 Scene 名称在只有一个 Scene 时可以省略;frame 必须指定 Scene 名称,--at 为采样时间(秒)。需要完整几何数据时给 frame 加 --verbose,机器可读输出加 --format json。

preview 可以接收一个可选 Scene 名称;output 可以接收零个或多个 Scene 名称,并渲染它们声明的所有 #[output(...)];render 接收恰好一个 Scene 名称,使用默认输出设置(1920x1080、60 fps、mp4)做一次临时渲染,不读取 #[output(...)]。完整说明见命令行入口。

在本仓库中运行示例:

cargo run --example getting_started0 -- preview
cargo run --example getting_started0 --no-default-features --features anims,cmd-render -- output

带 required-features(如 typst、gltf)的示例需要同时开启对应 feature。