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

Caution

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

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::*,
};

Prelude 覆盖什么

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

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

use ranim_core::Extract;
use ranim_core::audio::AudioError;
use ranim_core::animation::build::{At, Paramed};
use ranim_core::animation::node::{AnimNode, AnimationInfo};
use ranim_core::core_item::CoreItem;
  • Extract / CoreItem:只在实现自定义 Eval::Output 的提取契约时需要;
  • AnimNode / AnimationInfo:运行时 introspection API;
  • At / Paramed:需要给返回值命名的 timing wrapper;
  • AudioError:音频解码/文件错误处理。

第一个场景

下面的场景让一个蓝色正方形淡入、保持一秒,再淡出。相机作为独立 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]
#[wasm_demo_doc]
#[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);
    r.insert_time_mark(1.0, TimeMark::Capture("preview.png".to_string()));
}

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]
#[wasm_demo_doc]
#[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),
    ];

    let total_secs = content.cursor_sec();
    r.play(CameraFrame::default().show().with_duration(total_secs));
    r.play(content);
    r.insert_time_mark(
        total_secs / 2.0,
        TimeMark::Capture("preview.png".to_string()),
    );
}

多个独立 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]
#[wasm_demo_doc]
#[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]);
    r.insert_time_mark(
        total_secs / 2.0,
        TimeMark::Capture("preview.png".to_string()),
    );
}

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 output
ranim output hello
ranim render hello

指定 workspace package 或 example target:

ranim preview -p package_name --example example_name
ranim output -p package_name --example example_name
ranim render -p package_name --example example_name hello

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

ranim inspect scenes --example example_name
ranim inspect tree --example example_name
ranim inspect frame <scene_name> --at 1.0 --example example_name

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

preview 可以接收一个可选 Scene 名称;output 可以接收零个或多个 Scene 名称,并渲染它们声明的所有 #[output(...)];render 接收恰好一个 Scene 名称,使用默认输出设置(1920x1080、60 fps、mp4)做一次临时渲染,不读取 #[output(...)]。额外的 Cargo 构建参数放在 -- 后,例如:

ranim output hello -- --release
ranim render hello -- --release

在本仓库中可以直接运行 CLI package:

cargo run -p ranim-cli --release -- preview --example getting_started0
cargo run -p ranim-cli --release -- output --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 CLI

ranim-cli 是 Ranim 的命令行工具,二进制名为 ranim。它负责把场景代码构建成 dylib、加载其中通过 #[scene] 注册的场景,并围绕场景提供四个子命令:

ranim <command>
├── preview   启动预览 app,watch 场景代码并在变更时自动重建 dylib
├── output    渲染场景声明的所有 #[output(...)](成片输出)
├── render    用默认输出设置快速渲染一个场景一次(冒烟)
└── inspect   不渲染,纯 CPU 检查场景 / 动画树 / 单帧物件

在仓库内可以直接用 cargo 运行;也可以安装到 PATH:

cargo run -p ranim-cli -- <command> ...
cargo install --path packages/ranim-cli   # 之后可直接使用 ranim <command> ...

工作方式

每次调用都会先 cargo build 目标(lib 或 example,需为 cdylib),再加载 dylib 中的 scene inventory。因此命令报错时应先看 cargo 的编译输出——大多数失败是场景 代码本身的编译错误。

通用 target 参数

以下参数对所有子命令可用:

-p, --package <PACKAGE>   指定 workspace 中的 package(优先于当前目录推断)
    --lib                 使用 package 的 lib target(与 --example 互斥)
    --example <EXAMPLE>   构建并加载指定的 example target,并自动解析到声明它的 package
    --features <FEATURES> 透传给 cargo build
-- <cargo args>...        其余 cargo 构建参数,例如 `-- --release`
  • 不显式指定时,CLI 根据当前目录推断 package 并使用其 lib target。
  • -- --release 只影响场景 dylib 的 profile,CLI 本体的 profile 由外层 cargo 决定。
  • 调试迭代一般不需要 release:仓库为 dev profile 开了 opt-level = 1、依赖 opt-level = 3,inspect 是纯 CPU 查询,渲染也足够快。

ranim preview [SCENE]

启动预览 app,并 watch 场景代码,文件变更时自动重建 dylib 刷新画面。适合编写场景 时的实时调试。

ranim output [SCENES...]

渲染每个选中场景声明的所有 #[output(...)];不指定场景时渲染全部场景。这是 交付前的最终验证命令。

#[output(...)] 可用的属性(默认值:1920x1080、60 fps、mp4、 dir = "./output"、save_frames = false):

#[output(
    width = 1920,            // 像素宽
    height = 1080,           // 像素高
    fps = 60,                // 帧率
    format = "mp4",          // mp4 / webm / mov / gif
    dir = "./output",        // 输出目录(相对路径基于当前工作目录)
    name = "my_video",       // 可选,覆盖 {name}(默认用场景名)
    name_template = "{name}_{width}x{height}_{fps}", // 输出文件主名模板
    save_frames = false,     // 同时保存 PNG 帧序列
)]

一个场景可以声明多个 #[output(...)](例如同时输出 mp4 和 gif)。

产物位置(以 dir = "./output"、场景名 hello 为例):

output/hello_1920x1080_60.mp4            视频:<dir>/<模板展开的主名>.<ext>
output/hello_1920x1080_60-frames/NNNN.png 帧序列(save_frames = true 时)
output/hello_1920x1080_60/<filename>     TimeMark::Capture 截图

场景中通过 r.insert_time_mark(sec, TimeMark::Capture("x.png".to_string())) 声明的截图,在主视频渲染完成后统一处理。

--buffer-count <N>(默认 2)控制 GPU readback 缓冲数量:越大并行度越高,但占用 更多显存。

需要 GPU 与 ffmpeg;PATH 中找不到 ffmpeg 时 CLI 会尝试在当前目录查找或下载。

ranim render <SCENE>

用固定默认设置(1920x1080、60 fps、mp4)把单个场景快速渲染一次,输出到 ./output/<scene>_1920x1080_60.mp4。

它不读取任何 #[output(...)] 声明,也不处理 TimeMark::Capture。适合 迭代中只想快速看整体效果的情况;正式验收仍应使用 ranim output。

ranim inspect

纯 CPU 检查,不创建 GPU context,可以在无 GPU 的环境运行。所有子命令支持 --format text|json(默认 text;JSON 输出顶层含 schema_version,适合脚本化)。

ranim inspect scenes

ranim inspect scenes --example hello_ranim

不调用场景构造函数,只列出 dylib 中注册的场景及其 #[output(...)] 摘要(尺寸、 fps、格式、输出目录、name_template、save_frames)。适合开工第一步:确认场景 名拼写、场景是否注册成功、输出配置是否符合预期。

ranim inspect tree [SCENE]

ranim inspect tree hello_ranim --example hello_ranim

构建场景并输出层级动画树。每个节点包含:DFS path、kind (eval/sequence/stack/lagged/static)、anim_name、父局部坐标下的 range、 content_duration_secs、rate_func、enabled 与 children;iterative 节点 额外包含 sim_step(with_steps(N) 声明的进度步长 1/N,未声明时为默认值)。 当库里只有一个场景时 [SCENE] 可省略。

注意 range 是父局部坐标,不要直接当成全局时间。

ranim inspect frame <SCENE> --at <sec>

ranim inspect frame hello_ranim --at 1.5 --example hello_ranim --verbose

以 120 Hz 逻辑时钟在 <sec> 采样一帧,输出该帧的物件列表。每个物件包含 z_order(帧内渲染/遮挡顺序)、id / animation_id / part、kind (camera/vitem/mesh)、来源根动画 source 和 data 摘要(VItem 的点数/颜色/ AABB,Mesh 的点数/三角形数/transform/AABB,Camera 的 pos/facing/up/投影参数)。 --verbose 追加完整几何数据(VItem points、Mesh 顶点/索引/颜色/法线)。

用于渲染前定位「某时刻物件不对 / 位置不对 / z-order 不对 / 颜色不对」等问题, 避免直接上 GPU 盲调。已知局限(如实输出,不要误读):

  • source 只能回溯到根动画的 animation_id,不能定位树内叶子节点;
  • SvgItem / TypstText 等用户层 item 会 extract 成多个 CoreItem(1→N),此时 part 是 extract 后的序号,不是用户层 item 的序号。

推荐工作流

inspect scenes   确认场景与输出配置
      │
inspect tree     确认动画组织、时间范围、rate_func / enabled
      │
inspect frame    在关键时刻确认物件、几何、z-order 与颜色(不上 GPU)
      │
render           快速冒烟,看整体效果
      │
output           最终验证:成片、帧序列与 Capture 截图

原则:能用便宜的 inspect 查清的问题,不要留到昂贵的 GPU 渲染之后才发现。

核心概念

Caution

ai 生成,可能叙事逻辑和表述并不是很好,仅供参考。

Ranim 将动画定义为可按任意时间采样的值,并通过 closed core + open leaves 的方式组织场景:

Eval<Output = T>                        visual leaf content
  -> IntoAnimNode                       default lowering (linear / 1s / enabled)
  -> Paramed<A> / At<A>                 playback params / placement
  -> AnimSequence / AnimStack / AnimLagged
  -> AnimNode { timing shell, NodeContent }
  -> SealedRanimScene -> SceneEvaluator
  • 定义期(open):叶子实现 Eval;容器/Sugar 位于 animation::compose,通过 IntoAnimNode lower 到运行时。
  • 运行期(closed):所有定义都 lower 成 AnimNode,其 NodeContent 是封闭的核心语言(Sequence、Stack、Leaf、Static、Audio)。视觉求值、音频烘焙、preview introspection 都是这棵树上的 interpreter。
  • Eval、IntoAnimNode、容器与运行时 描述叶子动画如何根据局部进度产生状态、附加播放参数,以及顺序 / 并行 / 交错容器如何把场景组织成动画树。
  • CoreItem 与 Extract 描述动画求值结果如何经 Extract 展开为渲染器消费的 core item。
  • Core Items 逐个介绍三种 core item(CameraFrame、VItem、MeshItem)的字段与渲染语义。
  • RanimScene 的根节点是一个 AnimStack。r.play(animation) 等价于向根 Stack 执行 push,因此多次根级 play 默认从 0 秒并行。

新模型不维护 Scene 内可变的 TimelineId 或运行时物件表。需要独立生命周期的内容由各自的 AnimSequence 持有,最后通过 Stack 组合。

动画系统

Caution

ai 生成,可能叙事逻辑和表述并不是很好,仅供参考。

Ranim 的一个场景就是一棵动画树。这棵树分成「定义期」和「运行期」两个层面:

  • 定义期(开放):叶子是任何实现了 Eval 的类型——FadeIn、Morph、 Pure、Iterative 或自定义 struct,Eval::Output 是该动画产出的 item 类型 T。容器和作者侧组合子位于 animation::compose:AnimSequence / AnimStack / AnimLagged。
  • 运行期(封闭核心):所有定义都会通过 IntoAnimNode lower 成一个 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 的音频模块 文档。

CoreItem 与 Extract

Caution

ai 生成,可能叙事逻辑和表述并不是很好,仅供参考。

场景中所有可见内容最终都归结为少数几种 core item。动画系统求值得到的是 用户层 item(ranim-items 中的 VItem、Surface 等高层类型),而渲染器只 认识 core item;连接二者的是 Extract trait。

CoreItem

CoreItem 定义在 ranim-core 的 core_item 模块,是渲染管线的输入枚举:

pub enum CoreItem {
    CameraFrame(CameraFrame),
    VItem(VItem),
    MeshItem(MeshItem),
}

三种 core item 的共同特征:数据为 f32(Vec3 / Vec4 / Mat4)、位于世界 空间、不再携带任何动画辅助结构,每种直接对应渲染管线的一条路径。字段级的 说明见 Core Items。

Extract

pub trait Extract {
    type Target: Clone;

    /// 把提取结果追加到 buf。
    fn extract_into(&self, buf: &mut Vec<Self::Target>);

    /// 提取为新分配的 Vec。
    fn extract(&self) -> Vec<Self::Target>;
}

要点:

  • 提取可以是 1→N:一个用户层 item 可以 extract 成任意个 core item。 高层 VItem 恰好产生 1 个 core VItem;Surface 经高层 MeshItem 产生 1 个 core MeshItem;而 SvgItem / TypstText 这类复合 item 会产生多个 core VItem。
  • 可组合:Vec<E>、数组、元组等都有 Extract 实现,逐个把成员的提取 结果追加进同一个 buffer,因此一帧的输出可以任意拼接。

从动画求值到 extract

动画叶子的产出先被类型擦除为 DynItem:

pub trait AnyExtractCoreItem: Any + Extract<Target = CoreItem> + DynClone {}
pub struct DynItem(pub Box<dyn AnyExtractCoreItem>);

SceneEvaluator::sample_at(render_secs, out) 对根 Stack 的每个顶层 cell 求值, 再把每个产出 item 逐个 extract() 展开,得到一帧的 core item 列表:

AnimNode::eval_at(sec)  ->  Vec<DynItem>
  每个 DynItem.extract()     ->  Vec<CoreItem>      (这里发生 1→N)
  汇总                       ->  EvaluatedFrame
                                 = Vec<((animation_id, part), CoreItem)>

EvaluatedFrame 中每个 core item 附带的身份是 (animation_id, part):

  • animation_id:该 item 来自的根 Stack 顶层动画序号;
  • part:extract 展开后的序号。由于存在 1→N 映射,part 是 core item 序号, 不等于用户层 item 的序号——这也是 ranim inspect frame 输出中 part 字段的含义(见 Ranim CLI)。

谁来消费

渲染器(ranim-render)按 CoreItem 变体分发到对应的渲染路径:core VItem 走矢量渲染(平面投影 + 三角化),core MeshItem 走 3D 网格渲染, CameraFrame 提供每帧的视图/投影矩阵。preview 与离线渲染共用同一条 sample_at → EvaluatedFrame 路径。

Transformed<T, G>:变换表示、组合与物件语义

Caution

ai 生成,可能叙事逻辑和表述并不是很好,仅供参考。

ranim 把“物件自身的数据”和“附加在物件外的变换”分开表达:

pub struct Transformed<T, G> {
    pub inner: T,
    pub transform: G,
}

T 是物件,G 是 wrapper 实际存储的变换表示。字段保持公开,可以在 自定义 evaluator 中直接读写;常规组合则推荐使用 compose_outer 和 compose_inner,使乘法顺序一目了然。

1. 变换类型与仿射端点

ranim 的模型变换层级如下:

flowchart LR
    T["Translation<br/>T(3)"] --> R["Rigid<br/>SE(3)"]
    R --> S["Similarity<br/>Sim(3)"]
    S --> A["DAffine3<br/>Aff(3)<br/>模型变换端点"]
    D["Diag<br/>轴向缩放"] --> A
    A -.-> P["Projective<br/>仅作为相机投影边界"]

    classDef endpoint fill:#dbeafe,stroke:#2563eb,stroke-width:3px,color:#172554
    classDef boundary fill:#e5e7eb,stroke:#9ca3af,stroke-width:1.5px,color:#6b7280
    class A endpoint
    class P boundary
    linkStyle 4 stroke:#9ca3af,color:#6b7280
  • Translation:纯平移;
  • Rigid:旋转和平移;
  • Similarity:正的均匀缩放、旋转和平移;
  • Diag:沿坐标轴缩放,它不属于 Similarity;
  • DAffine3:模型变换的最一般表示,可以表达剪切和一般仿射组合;
  • projective 不是模型变换的下一种存储类型,只存在于相机投影边界。

图中的实线箭头对应无损的 From 嵌入。From 在 Rust 中不传递,因此 ranim 显式提供已有层级中的全部转换:

Translation -> Rigid / Similarity / DAffine3
Rigid       -> Similarity / DAffine3
Similarity  -> DAffine3
Diag        -> DAffine3

每个表示都实现同一个 TransformGroup 能力,提供单位元和同族组合。组合顺序与 仿射矩阵一致:

也就是先作用 ,再作用 。Similarity 组合时,外层的缩放和旋转也会 作用于内层平移;它不是把三个字段分别相加。

Diag 仍保留当前数值行为,包括零缩放。这里没有额外引入“严格可逆”的运行时 检查。

2. ApplyTransform<G>:物件能直接吸收什么

基础接口是:

pub trait ApplyTransform<G> {
    fn apply(&mut self, transform: G) -> &mut Self;
}

物件通过实现范围声明自己的闭包:

// 点数据可以吸收仿射变换及其子类型
impl<G: Into<DAffine3>> ApplyTransform<G> for VItem { /* ... */ }

// 点集/网格数据可以吸收仿射变换
// canonical Circle、Ellipse、Rectangle、Square、Sphere 不直接吸收 placement

便利操作由这个接口派生:

操作提交给 ApplyTransform 的类型
shift(offset)Translation
rotate_on_axis(axis, angle)Rigid
scale_uniform(s)Similarity
scale(DVec3)Diag

canonical Rectangle、Circle、Sphere 等不直接实现 ApplyTransform;它们的 平移、旋转和缩放都应通过 Transformed<T, G> 表达。Rectangle::scale_axes 是单独的 内在尺寸编辑。点集型 VItem、Polygon、Line、MeshItem 和 Surface 才直接 吸收一般仿射 DAffine3。

3. 构造 wrapper:参数就是精确的 G

构造器同时接收物件与变换:

let item = Transformed::new(mesh, DAffine3::IDENTITY);

prelude 还导出了 blanket extension trait,可以写成:

let item = mesh.transformed::<DAffine3>(DAffine3::IDENTITY);

.transformed::<G>(g) 的参数必须恰好是 G;这个入口不会替调用者选择更宽的 存储类型。通常可以让类型推断直接从参数得到 G:

let item = mesh.transformed(Translation(offset));
// 类型是 Transformed<MeshItem, Translation>

选择较窄的 G 会让可组合的操作在编译期受限;选择 DAffine3 则是现有 mesh/surface 场景常用的存储上界。

4. outer 与 inner 组合

设 wrapper 当前存储 ,新变换为 。两种组合只有乘法方向不同:

对应 API:

item.compose_outer(h); // transform = h * transform
item.compose_inner(h); // transform = transform * h

ApplyTransform<H> for Transformed<T, G> 使用 outer composition,所以 shift、rotate、scale_uniform 和 scale 在可用时也都走左乘:

item.apply(h); // 等价于 item.compose_outer(h)

compose_outer 与 compose_inner 都要求 G: From<H>,先把 H 嵌入现有的 G,再在 G 内完成同族组合。它们不会创建新的 wrapper 类型。

4.1 不自动 widening,也不计算 join

下面的 wrapper 保持 Similarity 存储:

let mut item = sphere.transformed(Similarity::IDENTITY);
item.shift(offset);        // Translation -> Similarity
item.scale_uniform(2.0);   // 仍是 Transformed<Sphere, Similarity>

但一般 Diag 不能嵌入 Similarity,因此 item.scale(non_uniform) 不会偷偷把 类型改成 Transformed<_, DAffine3>,而是在编译期不可用。需要更一般的组合时, 显式 widening:

let narrow = mesh.transformed(Translation(offset));
let mut affine: Transformed<_, DAffine3> = narrow.into();
affine.scale(DVec3::new(2.0, 1.0, 1.0));

这种显式 Into 让 API 的返回类型稳定,也避免为任意两种变换表示自动推导 “最小共同上界”所带来的 coherence 问题。

4.2 嵌套 wrapper

嵌套依然从内向外展平:

Transformed {
    transform: outer,
    inner: Transformed {
        transform: inner,
        inner: x,
    },
}

最终组合 = outer * inner

5. bake 是编译期能力

bake 的签名直接使用 wrapper 的 G:

pub fn bake(self) -> T
where
    T: ApplyTransform<G>;

所以它是否存在完全由类型系统决定:

let polygon = Polygon::new(points)
    .transformed(DAffine3::from_scale(...))
    .bake(); // Polygon: ApplyTransform<DAffine3>

let circle = Circle::new(1.0).transformed(Similarity::IDENTITY);
// circle.bake(); // 编译失败:Circle 不吸收 placement;保留 wrapper

wrapper 不再提供 try_bake。如果调用者确实需要把动态得到的 DAffine3 向下 检查为 Similarity,应先显式执行 Similarity::try_from(affine),然后构造 Transformed<T, Similarity>;成功后 bake 仍然是静态能力。

这与几何闭包相符:

物件表示可直接吸收的上界
点、点集、VItem、一般 mesh 点数据DAffine3
ParallelogramDAffine3
Polygon / Line / VItem / MeshItem / SurfaceDAffine3
Parallelogram / ArcBetweenPointsDAffine3 / Similarity(按实现)
canonical Circle / Ellipse / EllipticArc / Sphere / Rectangle / Square不直接 bake placement

一般仿射变换会把圆变成椭圆、把矩形变成平行四边形,因此不能无损地 bake 回 原来的参数化类型。

6. anchor、extract、Aabb 与几何边界

anchor 的语义首先属于 inner 的 local space:Locate 实现先在内部物件上 定位,再把所得点通过 G -> DAffine3 变换到 wrapper 的外部空间。当前 wrapper 提供这种 forwarding 的是 core 的 Centroid,以及 geometry primitive 的 Origin / Focus;不存在一个无冲突的任意 anchor blanket impl(DVec3 已经 对所有 target 实现 Locate)。因此,未列出的 anchor 仍只对它直接支持的 inner 类型生效,不能假定任意 Locate<A> 都会自动穿过 wrapper。

Transformed<T, G> 不要求 T: ApplyTransform<G> 就能 extract。只要 G 能 转换为 DAffine3,wrapper 会在几何边界进行一次转换:

G --Into<DAffine3>--> CoreItem / Aabb geometry
  • VItem:仿射变换烘焙到点,法线使用逆转置;
  • core MeshItem:仿射矩阵左乘已有渲染矩阵,顶点保持不变;
  • Aabb:变换内部包围盒的八个角点,再重新取界。

这使高层物件可以保留语义表示,同时渲染结果仍能包含更一般的仿射效果。 DAffine3 是这里的端点;不会继续转换到 projective 模型矩阵。

6.1 local primitive、一般 local data 与 placement

canonical local primitive(例如以原点为中心的 Circle、Rectangle、Sphere) 把形状参数和 local 坐标约定写在自身类型中。一般 local data(VItem 点集、 Surface 顶点等)则只是调用者提供的坐标;两者都不会自动中心化。需要把 物件放到场景中时,使用 Transformed 的外部 transform,不要把外部 placement 误当成 primitive 的 intrinsic 参数。

Rectangle::scale_axes 例外也不是 placement:它是 intrinsic shape-data 操作,沿 Rectangle 已有的 canonical/intrinsic axes 修改尺寸。wrapper 的 compose_outer / compose_inner 才是外部变换组合。

Sphere -> Surface 只负责把 Sphere 的 canonical local 参数采样成顶点, Surface 不会再次 center;这样可避免重复 center。bake 是明确的边界操作: 只有当 T: ApplyTransform<G> 时才把外部变换吸收到 inner,否则继续保留 wrapper,并在 extract 时于几何边界转换为 DAffine3。

7. 插值

只有两个 wrapper 的 T 与 G 都相同时才能直接插值:

Self {
    inner: self.inner.lerp(&target.inner, t),
    transform: self.transform.lerp(&target.transform, t),
}

inner 与 transform 独立插值,不会先展平为点数据。若两端 wrapper 使用不同 存储类型,应先由调用者把它们显式 widening 到同一个 G。

7.1 wrapper 插值与 bake 插值是两种预期行为

wrapper 的独立插值意味着:同一点集在不同放置下的过渡始终保留各自的局部 形状,运动发生在 transform 上。例如两个仅放置不同的方块(inner 完全相同, 变换分别放在 XY 平面与 XZ 平面上),中间帧不会出现“一个平面翻到另一个平面” 的点级渐变——每个点在局部空间里静止不动,位姿由矩阵插值承载。这是预期行为, 不是缺陷。

如果需要的是经典 manim 式的形态 morph——逐点在世界空间中走直线、法线和平面 随顶点一起形变——就把两端的放置 bake 进底层数据再插值:转成裸 VItem 或 MeshItem 后,插值就是纯底层点插值。两种模式由表示方式显式选择:

模式表示插值路径
位姿插值Transformed<T, G>inner 恒定(或各自插值),G 独立插值
形态 morph裸 VItem / MeshItem(已 bake)全部控制点世界空间逐点插值

G 的选择决定位姿插值的路径质量:Translation 是线性位移; Rigid 用 slerp 旋转 + lerp 平移,刚体位姿全程保持刚性;DAffine3 及 Mat4 存储是逐分量线性混合,大角度旋转的中间帧可能出现轻微收缩, 此时应改用 Rigid 存储,或按需 bake。

8. Rectangle::scale_axes 与 wrapper 组合

Rectangle::scale_axes(DVec2) 是形状参数编辑:它只修改 size,尺寸沿 矩形已经存储的两条正交 shape axes 解释。矩形即使先旋转过,调用 scale_axes 后仍保留这组旋转后的正交轴表示。

它不同于 wrapper 组合:

rectangle.scale_axes(dvec2(2.0, 0.5));
// 修改 Rectangle 的维度参数

wrapped.compose_inner(Diag(dvec3(2.0, 0.5, 1.0)));
// inner 不变,右乘 wrapper.transform

wrapped.compose_outer(Diag(dvec3(2.0, 0.5, 1.0)));
// inner 不变,左乘 wrapper.transform

不要把 scale_axes 理解为矩阵乘法的别名:前者维护 Rectangle 的正交参数化 表示,后两者维护 wrapper 的组合顺序。

9. 几何视角

Klein 的 Erlangen 纲领把一种几何理解为“研究某个变换群下的不变量”。在 ranim 中:

  • G 描述允许组合的变换;
  • ApplyTransform<G> 描述物件表示对这个群是否闭包;
  • Transformed<T, G> 把外部组合与 T 的参数化语义分离;
  • bake 在编译期重新要求闭包;
  • extract 在仿射几何端点生成最终视觉数据。

这个分层让变换的数学顺序、Rust 类型与物件几何语义保持一致。

Core Items

Caution

ai 生成,可能叙事逻辑和表述并不是很好,仅供参考。

Core item 是渲染器直接消费的三种 primitive,定义在 ranim-core 的 core_item 模块,即 CoreItem 枚举的三个变体。与 用户层 item(见 Items 大节)相比,它们:

  • 数据为 f32(Vec3 / Vec4 / Mat4),位于世界空间,可直接进入渲染管线;
  • 不携带动画辅助结构(如 PointVec 对齐包装);
  • 每种对应一条渲染路径:矢量(平面投影)、3D 网格、相机。

用户通常不直接构造 core item,而是使用 ranim-items 中的用户层 item,由 Extract 自动转换。

  • CameraFrame — 相机:视图/投影参数与正交-透视混合。
  • VItem — 矢量图元:二次贝塞尔路径 + 描边/填充,按投影 平面渲染。
  • MeshItem — 3D 三角网格:顶点、索引、变换与每顶点 颜色/法线。

Core CameraFrame

Caution

ai 生成,可能叙事逻辑和表述并不是很好,仅供参考。

相机数据,定义于 ranim_core::core_item::camera_frame。它同时携带正交与透视 两套投影参数,由 perspective_blend 在二者之间混合。

pub struct CameraFrame {
    pub pos: DVec3,              // 位置
    pub up: DVec3,               // 上方向单位向量
    pub facing: DVec3,           // 朝向单位向量
    pub near: f64,               // 近平面(far > near)
    pub far: f64,                // 远平面
    pub perspective_blend: f64,  // 正交(0.0) ↔ 透视(1.0) 混合
    pub frame_height: f64,       // 正交:视野高度
    pub scale: f64,              // 正交:缩放系数
    pub fovy: f64,               // 透视:纵向视场角(弧度)
}

默认值(CameraFrame::default()):位于原点、朝 -Z、+Y 为上; perspective_blend = 0.0(纯 2D 正交);frame_height = 8.0; near = -1000、far = 1000;fovy = π/2。2D 场景用默认值即可。

投影

let view = cam.view_matrix();                       // look_to(pos, facing, up)
let proj = cam.projection_matrix(aspect_ratio);
//   = orthographic_mat(aspect).lerp(perspective_mat(aspect), perspective_blend)

正交矩阵由 frame_height * scale 与宽高比推出;透视矩阵使用 fovy,且 near 会被钳到至少 0.1。perspective_blend 取中间值时两矩阵逐元素插值, 可用于「2D 场景平滑进入 3D 透视」的运镜(见 examples/perspective_blend)。

3D 定位

// 球坐标定位(Z-up),看向原点;perspective_blend 自动设为 1.0
let cam = CameraFrame::from_spherical(phi, theta, distance);
// phi:与 +Z 的极角(0 = 正上方,π/2 = XY 平面)
// theta:方位角(0 = +X,π/2 = +Y)

// 或围绕任意目标点:
cam.set_spherical(phi, theta, distance, target);
cam.look_at(target); // 只改朝向

注意 from_spherical / set_spherical 把 up 固定为 +Z。

其他

  • set_view_matrix / with_view_matrix:从视图矩阵反解 pos / up / facing。
  • center_canvas_in_frame(center, width, height, up, normal, aspect_ratio): 透视模式下调整相机位置,使给定矩形画布恰好充满画面。
  • CameraFrame 实现了 Interpolatable,可以直接用 morph 做运镜动画。

Core VItem

Caution

ai 生成,可能叙事逻辑和表述并不是很好,仅供参考。

矢量图元的渲染表示,定义于 ranim_core::core_item::vitem。注意点是三维的 (世界空间),「共面」只是渲染时的假设(见下文「平面投影渲染」)。

pub struct VItem {
    /// 投影目标平面的法向;None 时由渲染器从点推导
    pub normal: Option<Vec3>,
    /// 世界空间点列;(x, y, z, is_closed)
    pub points: Vec<Vec4>,
    pub fill_rgbas: Vec<Rgba>,
    pub stroke_rgbas: Vec<Rgba>,
    pub stroke_widths: Vec<Width>,
}

点列语义

points 由用户层 VItem 的 vpoints 展开而来:二次贝塞尔路径的 anchor 与 handle 交替排列,每个 Vec4 的 w 分量是该点是否闭合路径(closepath)的 标记。颜色与线宽数组按路径段对齐(段数 = 点数 / 2 向上取整),默认描边 宽度为 DEFAULT_STROKE_WIDTH = 0.02。

平面投影渲染

渲染 core VItem 时,Ranim 假设所有点共面以计算深度,实际渲染的是它在某个 平面上的投影:

  • 投影平面的初始基为 (X, Y)、法向为 Z,且包含点列的第一个点;
  • normal 为 Some 时使用指定的投影平面;
  • normal 为 None 时由 vitem_normal_from_points 在渲染时推导:先对 anchor 点做 Newell 法(鞋带公式的 3D 形式)求面积法向;面积退化(如单段曲线)时 扫描全部点寻找非共线三元组;点共线时取一个包含该直线的确定性平面;所有点 重合时回退到 Z 轴。

因此正常使用应保证一个 core VItem 的点共面(此时投影即其本身);故意打破 共面则得到的是投影效果。

Core MeshItem

Caution

ai 生成,可能叙事逻辑和表述并不是很好,仅供参考。

3D 三角网格的渲染表示,定义于 ranim_core::core_item::mesh_item。

pub struct MeshItem {
    /// 顶点(局部空间)
    pub points: Vec<Vec3>,
    /// 三角形索引
    pub triangle_indices: Vec<u32>,
    /// 局部到世界的变换
    pub transform: Mat4,
    /// 每顶点颜色
    pub vertex_colors: Vec<Rgba>,
    /// 每顶点法线(用于平滑着色)
    pub vertex_normals: Vec<Vec3>,
}

要点:

  • points 与 triangle_indices 描述局部空间几何,渲染时统一乘 transform;平移/旋转/缩放任一动画都应优先作用在 transform 上,而不是 逐顶点改 points。
  • vertex_normals 全零或为空时,着色器回退到用 dpdx/dpdy 计算的 flat shading;需要平滑着色时由用户层(如 Surface::with_smooth_normals) 预计算法线。
  • 几何细节(折叠的边、重合顶点)不会被渲染器清理,索引中的退化三角形由 调用方避免。

Items

Caution

ai 生成,可能叙事逻辑和表述并不是很好,仅供参考。

ranim-items 提供用户层 item:编写场景时直接构造和做动画的类型。与 core item(见 Core Items 大节)相比,它们用 f64(DVec3 / DAffine3)描述、 携带动画所需的辅助结构(如 PointVec 对齐包装),并实现了一批动画/变换 trait(Interpolatable、Alignable、FillColor、ShiftTransform 等),可以 直接配合 morph、fade_in 等动画使用。渲染前由 Extract 转为 core item (见 CoreItem 与 Extract)。

当前分两类:

  • VItem 类 — vitem 模块:矢量物件。核心是 VItem, 外加几何构造器(geometry)、SvgItem、以及 typst feature 提供的文字 物件。
  • MeshItem 类 — mesh 模块:三维网格物件。核心是 MeshItem,外加参数曲面 Surface 和球体 Sphere。

另有 debug 模块提供调试辅助(如 VisualizeAabbItem<T>:把任意实现了 Aabb 的 item 的包围盒可视化为线框矩形)。

VItem 类

Caution

ai 生成,可能叙事逻辑和表述并不是很好,仅供参考。

ranim_items::vitem 模块:矢量物件。这类物件的点本来就是三维点,可以任意 摆放、旋转在 3D 空间中;只是渲染时假设单个 item 的所有点共面,实际渲染的 是它在投影平面上的投影(共面时投影即其本身),语义细节见 Core Items 的 VItem。

成员:

  • VItem — 核心类型:二次贝塞尔路径 + 描边/填充,所有同类物件 最终都转化为它。
  • 几何构造器 — Circle、Square、Arc 等数据 struct, 可直接 VItem::from(...)。
  • SvgItem — 从 SVG 构造。
  • 文字物件 — TextItem / TypstText(typst feature)。

VItem

Caution

ai 生成,可能叙事逻辑和表述并不是很好,仅供参考。

pub struct VItem {
    pub normal: Option<DVec3>,        // 投影平面法向;None 时渲染时推导
    pub vpoints: VPointVec,           // 点列(二次贝塞尔)
    pub stroke_widths: PointVec<Width>,
    pub stroke_rgbas: PointVec<Rgba>,
    pub fill_rgbas: PointVec<Rgba>,
}

vpoints:二次贝塞尔路径

vpoints 是 anchor 与 handle 交替排列的点列:[a₀, h₀, a₁, h₁, a₂, …], 每三个连续点 (aᵢ, hᵢ, aᵢ₊₁) 构成一段二次贝塞尔。颜色与线宽数组按段 对齐(长度 = 点数 / 2 向上取整),因此可以给一个 item 的不同段设置不同 颜色/线宽。

// 直接用点列构造(默认:白描边 0.02、无填充)
let vitem = VItem::from_vpoints(vec![
    dvec3(0.0, 0.0, 0.0),
    dvec3(1.0, 0.0, 0.0),
    dvec3(0.5, 1.0, 0.0),
]);

常用方法:close()(闭合路径)、shrink()(缩到包围盒中心)、 get_anchor(idx)(取第 idx 个 anchor)、extend_vpoints(...)(追加,颜色/ 线宽数组自动补齐)、put_start_and_end_on(start, end)(把首尾移到指定位 置)、with_normal(...) / set_normal(...)(指定投影平面法向)。

渲染语义:平面投影

渲染时假设 VItem 的所有点共面,实际渲染的是它在投影平面上的投影 (共面时投影即其本身)。语义细节见 Core Items 的 VItem。

normal 一般保持默认的 None 即可:投影平面在渲染时从当前点数据推导, 动画中间帧的插值点总是推导出与之一致的法向,不会漂移。反之,显式 set_normal 之后,插值就发生在法向量本身上(Some(a).lerp(Some(b), t), 普通线性插值且不重新归一化),不再跟随点数据。因此只在确有需要时才显式 设置,例如点共线/重合等自动推导存在歧义的退化情形,或故意要让非共面点 渲染成投影效果。

动画相关 trait

VItem 实现了 Interpolatable(逐点/逐颜色插值)与 Alignable(点数不同 时自动补齐对齐,morph 依赖它),因此可以直接:

let anim = square.morph(|sq| {
    sq.set_fill_color(manim::BLUE_C);
    sq.shift(DVec3::X * 2.0);
});

还实现了 FillColor / StrokeColor / StrokeWidth / Opacity / Partial(get_partial(range) 截取路径的一段,Create/Write 动画的 基础)、PointsFunc(apply_points_func 批量变换点)、Aabb 与 ShiftTransform / RotateTransform / ScaleTransform。

PointVec 是分量数组的动画包装:对齐时按规则补齐长度,插值逐分量进行。

几何构造器

Caution

ai 生成,可能叙事逻辑和表述并不是很好,仅供参考。

vitem::geometry 子模块提供常用平面图形的构造器。它们都是「数据 struct + From<...> for VItem」:字段公开可直接改,也实现了常用的定位/变换 trait。

类型说明
Circle圆(半径)
Ellipse椭圆
Arc / ArcBetweenPoints圆弧 / 过两点与半径的圆弧
EllipticArc椭圆弧
Line线段
Square / Rectangle正方形 / 矩形(canonical local 尺寸)
Polygon / RegularPolygon任意多边形 / 正多边形
Parallelogram平行四边形

这些构造器的 canonical local primitive 都有明确的局部坐标约定:通常以原点为 中心,Rectangle 的尺寸沿其 intrinsic/canonical X/Y axes 解释,ArcBetweenPoints 则保留由输入点决定的 local center。构造器不会因为输入数据“看起来偏了”就自动 中心化;一般 local data(例如 VItem 点集或 Surface 顶点)也同样保持调用者 提供的坐标。

需要把物件放到场景中的位置时,优先把 placement 放在 Transformed<_, G> 的外层;anchor 若有 forwarding,会先在 inner/local item 上计算,再应用外部变换。Origin 表示 primitive 的 local origin,Focus 仍只表示椭圆自身的焦点语义,不会被 wrapper 重新解释。AabbPoint 的通用 实现按目标的 AABB 工作,不能假定它会按任意 anchor 的 local 语义穿过 wrapper; 需要明确的 local anchor 时,应先对 inner 定位再手动应用 transform。

Rectangle::scale_axes 是 intrinsic shape-data 编辑:它改变尺寸参数,而不是 给 wrapper 做矩阵组合,也不会改变“外部 placement”的职责。

let vitem = VItem::from(
    Square::new(2.0).with(|sq| {
        sq.set_color(manim::RED_C);
    })
);

SvgItem

Caution

ai 生成,可能叙事逻辑和表述并不是很好,仅供参考。

从 SVG 构造的矢量物件,定义于 ranim_items::vitem::svg。

pub struct SvgItem(Vec<VItem>);

let svg = SvgItem::new(svg_str); // svg_str: impl AsRef<str>

内部就是一组 VItem:SVG 的每个路径解析为一个 VItem。因此 extract 时一个 SvgItem 会展开为多个 core VItem(1→N),在 ranim inspect frame 的 输出里体现为同一个 animation_id 下递增的 part 序号。

文字物件

Caution

ai 生成,可能叙事逻辑和表述并不是很好,仅供参考。

vitem::text 与 vitem::typst 提供文字物件,需要启用 typst feature。

TextItem

简单文字(vitem::text):

let text = TextItem::new("Hello Ranim", 1.0); // canonical local 文本与 em 字号
let placed = text.transformed(Translation(dvec3(1.0, 2.0, 0.0)));

字体通过 TextFont 配置:

let font = TextFont::new(["Noto Sans CJK SC", "serif"]); // 按序回退的字体族

TypstText

Typst 排版(vitem::typst),支持行内/多行代码与数学公式:

let formula = TypstText::new("$ integral_0^1 x^2 dif x $");
let code = TypstText::new_inline_code("let x = 1;");
let block = TypstText::new_multiline_code("fn main() {}", Some("rust"));

两类文字物件都经 Typst 排版为矢量轮廓,extract 时 1→N 展开为多个 core VItem。

MeshItem 类

Caution

ai 生成,可能叙事逻辑和表述并不是很好,仅供参考。

ranim_items::mesh 模块:三维网格物件。这一类物件渲染的是真正的 3D 三角 网格(每顶点颜色/法线,法线全零时 flat shading),语义细节见 Core Items 的 MeshItem。

成员:

  • MeshItem — 核心类型:顶点 + 索引 + 每顶点数据;外部变换通常使用 Transformed<MeshItem, DAffine3>。
  • Surface — 参数曲面:(u, v) 网格采样生成网格。
  • Sphere — 球体便捷构造。

选择建议:

  • 规则几何体(球、参数曲面):用 Sphere / Surface 构造;
  • 任意几何(自定义多面体、模型):直接拼 MeshItem 的顶点与索引;
  • 平滑曲面记得 with_smooth_normals();硬边物体保持法线全零走 flat shading 即可。

MeshItem

Caution

ai 生成,可能叙事逻辑和表述并不是很好,仅供参考。

pub struct MeshItem {
    pub points: PointVec<DVec3>,         // 顶点(局部空间)
    pub triangle_indices: Vec<u32>,      // 三角形索引
    pub vertex_colors: PointVec<Rgba>,   // 每顶点颜色
    pub vertex_normals: PointVec<DVec3>, // 每顶点法线;全零 → flat shading
}

与 core MeshItem 一一对应,但使用 f64 类型,且顶点/颜色/法线包在 PointVec 里以支持对齐与插值,因此可以直接参与 morph 等动画。

构造与常用操作:

// 仅顶点(无索引,适合点云)或 顶点+索引
let mesh = MeshItem::from_vertices(points);
let mesh = MeshItem::from_indexed_vertices(points, triangle_indices);

let mesh = mesh.with_color(manim::BLUE_C); // 统一每顶点颜色
mesh.vertex_colors = colors.into();        // 或逐顶点自定义

变换:Transformed<T, G>

MeshItem 自身不持有变换矩阵——顶点始终处于局部空间。需要摆放、移动、 旋转、缩放时,用 Transformed<T, G> 包裹。现有 mesh/surface 场景通常以 DAffine3 作为存储上界:

let mesh: Transformed<_, DAffine3> = Transformed::new(
    mesh,
    DAffine3::from_translation(...),
);

也可以用 prelude 中的 extension trait:

let mesh = mesh.transformed::<DAffine3>(DAffine3::IDENTITY);

Transformed<T, G> 实现了 Interpolatable(transform 与内部数据分别插值)、 Aabb、Alignable,并在 G: From<H> 时通过 ApplyTransform<H> 做 outer composition。它不会自动 widening;需要更一般的存储时显式转换为 Transformed<_, DAffine3>。extract 时才把 G 转为 DAffine3 并展平进 CoreItem:MeshItem 左乘其渲染用 transform 矩阵,VItem 则逐点烘焙。 层旋转、整体移动这类动画应优先用 wrapper(或像 examples/tetrahedron_spheres 那样在自定义 Eval 中直接更新公开的 transform 字段),而不是逐顶点改 points。

两个辅助函数:

  • generate_grid_indices(nu, nv):生成 nu × nv 行主序网格的三角形索引;
  • compute_smooth_normals(points, triangle_indices):按顶角加权的平滑法线 (退化三角形自动跳过)。

Surface

Caution

ai 生成,可能叙事逻辑和表述并不是很好,仅供参考。

参数曲面:在 (u, v) 网格上采样生成网格数据。

let surface = Surface::from_uv_func(
    |u, v| dvec3(u, v, (u * u + v * v).sin()),
    (0.0, 1.0),   // u 范围
    (0.0, 1.0),   // v 范围
    (64, 64),     // 分辨率 (nu, nv),各自 >= 2
)
.with_fill_by_z(&[(manim::BLUE_C, -1.0), (manim::RED_C, 1.0)]) // 按 z 上色
.with_smooth_normals();  // 预计算平滑法线;不调用则 flat shading

顶点是调用者提供的一般 local data,不会被自动中心化或平移;顶点按行主序存储: points[i * nv + j]。需要把曲面放到场景中时,用外层 Transformed<Surface, G> 保存 placement,而不是修改采样坐标。

  • with_vertex_colors(colors) 直接指定每顶点颜色;
  • From<Surface> for MeshItem 完成到 MeshItem 的转换;Surface 自身也实现 了 Extract,可直接作为动画输出类型。

从 Sphere 转成 Surface 时,球面的 canonical local 原点已经由 Sphere 的 采样函数确定;Surface 不会再次执行 center,因此不会发生重复 center。若 需要把参数曲面变成可直接吸收某种变换的物件,可以在明确边界处调用 bake; 否则保留 wrapper,将 placement 留在外层。

Sphere

Caution

ai 生成,可能叙事逻辑和表述并不是很好,仅供参考。

球体便捷构造,定义于 ranim_items::mesh。

let sphere = Sphere::new(0.6)                    // 半径,默认分辨率 (101, 51)
    .with_resolution((31, 16))
    .with_fill_color(manim::YELLOW_C);
let mesh = MeshItem::from(sphere);               // Sphere → Surface → MeshItem
let placed = mesh.transformed(DAffine3::from_translation(dvec3(1.0, 0.0, 0.0)));

Sphere 是 canonical local primitive:球心固定在 local 原点,半径定义 其 local 尺寸。需要场景 placement 时,应像上面的示例一样使用外层 Transformed,而不是给 Sphere 增加中心字段。球面按 u ∈ [0, 2π]、 v ∈ [0, π] 参数化。 From<Sphere> for Surface 默认 flat shading;需要平滑效果时先转 Surface 再 with_smooth_normals():

let mesh = MeshItem::from(Surface::from(sphere).with_smooth_normals());

v0.1

Status: Backfilled(补写) — 覆盖 #3–#96;按 v0.1.5(2025-10-18)发布时点快照描述。 起源期 PR(#3–#28,2024-11 → 2025-03)先于首个 tag v0.1.0-alpha.1 落地;alpha.8/alpha.10/alpha.15/alpha.16 从未发布,v0.1.1–v0.1.3 只含未经 PR 的直接修复。

v0.1 的故事,是 ranim 找到自己的渲染范式的故事:以 compute shader 描边起家(#3),短暂借道 Vello(#15/#18),最终落到 wgpu 单管线 SDF(#22,受 JAnim 启发)——这条 SDF 路线一直延续至今。支撑它的骨架也在这个版本线里成形:Extract → Prepare → Render 三阶段(#5)、Timeline 编码(#28)、Preview App(#53)、dylib 热重载的 CLI(#77),以及 v0.1.5 收官时的 crate 拆分与流水线化渲染(#94/#96)。

新增

  • SDF 渲染管线:单一 vitem.wgsl 片元着色器渲染全部 VItem——精确点到二次贝塞尔距离(解三次方程)+ 绕行判定 + 反走样;这是今天 vitem 渲染的直系祖先
  • 三阶段渲染架构:Extract(CPU)→ Prepare(CPU→GPU)→ Render,物件所有权移交 Scene 换取 Id
  • Preview App:winit + egui-wgpu 窗口内预览,时间轴 scrub;后成为 wasm 网页预览与热重载的底座
  • ranim-cli:linkme distributed slice + libloading 的 dylib 热重载,ranim preview / ranim render,#[scene]/#[output] 宏
  • CameraFrame 即物件:相机成为普通可动画 item,perspective_blend 正交↔透视连续混合,frame_height = 8.0 分辨率无关坐标
  • Item 体系:Extract::Target、VisualItem、组合物件(元组 Renderable)、几何构造器(arrow/arc/circle/polygon/line/svg/typst)、TypstText(按字符 diff 对齐)
  • crate 拆分:ranim-core / ranim-items / ranim-anims / ranim-render / ranim-app / ranim-cli / ranim-macros,façade 再导出
  • serde feature(derive_more 去样板)、wasm 构建进 CI、zola 站点(后被 mdbook 取代)

演进中的关键更名

v0.1 是 API 高速重构期,列几条主干系谱,便于读旧代码:

  • Mobject → Rabject(#3)→ TimelineId(#64)
  • RanimTimeline/RabjectTimeline → RanimScene/ItemTimeline(#64)
  • Blueprint 系统:#57 引入 → #64 移除(“items 只保留自描述数据”)
  • 场景定义:trait(SceneConstructor/SceneMeta/Scene)→ fn(&mut RanimScene) + #[scene] 宏(#77)
  • 坐标系:像素相关 → frame_height = 8.0 恒定(#44)
  • 包路径:单体 ranim:: → ranim::{core, items, anims, render}(#94)

起源:compute shader 时代

相关 PR:#3(stroke compute)、#5(三阶段架构)、#9/#10(curve fill)、#12(fading)

  • #3(首个 PR):VMobject 描边从 CPU 搬进 compute shader——每个二次贝塞尔段一个 workgroup,16 采样求点与切线,沿法向挤出描边轮廓顶点,转角连接由 joint_angles storage buffer 解决;渲染 pass 直接读 compute 写出的顶点数组(无 vertex buffer)。同 PR 里 Mobject 更名 Rabject;
  • #5:奠定至今的三阶段架构——物件无层级,插入 Scene 即移交所有权换取 RabjectId,按 Extract → Prepare → Render 渲染;Animation 包着消费进度 alpha 的 AnimationFunc(与消费 dt 的 Updater 相对)——“动画是 alpha 的函数“从这里开始;
  • #9/#10:真正的曲线填充——填充三角形带参考三角形 uv_coord 与 fill_all 旗标,片元里求值二次曲线只保留曲线内部;WgpuContext/WgpuBuffer 由此诞生;
  • #12:淡入淡出的语义确立为整个物件在零透明快照与当前状态之间插值,而非缩放透明度——Opacity trait 只负责各类型自己的透明度写入。

渲染范式三连跳

相关 PR:#15(Vello + Wgpu)、#18(Vello for 2d)、#22(SDF)

flowchart LR
    subgraph E1["时代一 · #3–#12"]
        A1["VMobject 描边<br/>compute 挤出 + joint_angles"] --> R1["wgpu"]
    end
    subgraph E2["时代二 · #15 / #18"]
        W["wgpu 手写 2D<br/>(#18 删除)"] --> C["Canvas 纹理"]
        V["vello 2D<br/>透明纹理叠加"] --> C
        C --> R2["合成进 3D 场景"]
    end
    subgraph E3["时代三 · #22 至今"]
        S["全部 VItem"] --> P["单一 vitem.wgsl<br/>SDF:点到二次贝塞尔距离"]
        P --> R3["wgpu"]
    end
    E1 -- "#15 引入 vello" --> E2
    E2 -- "#22 弃用 vello" --> E3
  • #15:世界变成 3D,2D 内容住进 Canvas(“basically a 2d scene”)。手写 wgpu 2D 与 Vello 并存:vello 渲到透明纹理再叠加混合——“所有 vello 渲染的东西都叠在别人上面”;Entity trait 取代过于僵硬的 Rabject 管线;
  • #18:删掉全部手写 wgpu 2D 路径(rabject2d/vpath/* 与三个 vpath shader,-2647 行),2D 完全交给 vello,3D 留在 wgpu;
  • #22:范式定音——完全弃用 vello(diff -7967 行),所有 VItem 经单一 SDF 片元管线渲染:storage buffer 存点(xy 坐标 + is_closed)、填/描色与描边宽度,distance_bezier 解三次方程求精确最近点,SubpathAttr 做绕行/内部判定,ANTI_ALIAS_WIDTH = 0.015 反走样。今天的 vitem.wgsl 仍是这条路线。

动画、相机与坐标系

相关 PR:#25、#28、#44

  • #25:动画二分为 Dynamic(每帧 prepare_alpha 重备实例)与 Static(一次性准备,如 creation/freeze);clip box 从 CPU 边界框搬进 compute shader 用 atomicMin/atomicMax 维护——这个思路后来在 v0.2 的 GPU-driven 合批(#138/#142)里长成主角;
  • #28:timeline 与 eval 泛化到任意类型(TimelineTrait/Evaluator/ChainedAnimation),CameraFrame 成为普通可动画 item(相机动画自此可能),“stacked” 动画(同一 item 的多条 timeline 经 sync() 同步),宏拆入 packages/ranim-macros;
  • #44:CameraFrame { pos, up, facing, scale, fovy, near, far, perspective_blend } 完全可插值——perspective_blend 在正交与透视投影矩阵间按 连续混合(closes #43);坐标改为分辨率无关的恒定 frame_height = 8.0(closes #37),示例全部重写。

Preview App

#53(closes issue #52)

egui 0.31 + winit ApplicationHandler 手写集成(当时还不是 eframe),场景经专用 AppPipeline 渲到 wgpu surface 的视口矩形,egui 时间轴控件(TimelineState)对 sealed timeline 做任意时刻 scrub;GPU profiling(wgpu-profiler/puffin)藏在 profiling feature 后。

这个原型后来长出 wasm 网页版(#64)与热重载(#77),并在 v0.2 换用 eframe(#114)。

Item 体系成型

相关 PR:#57、#60、#64、#69

  • #57:typed timeline handle(TimelineItem<'t, Mark> + marker 类型),insert(item) 返回带类型的句柄;per-item Extract 成形(VItemPrimitiveData);
  • #60:组合物件——为元组/数组实现 Renderable,Arrow { tip, line } 作为整体存进实例池。设计上明确拒绝父子层级:“如果 tip 淡出了只剩线,它还是箭头吗?”——拆分交给 decompose;(这一立场直到 v0.3 的场景图 hierarchy::Node 才被系统性重审,见 v0.3 篇。)
  • #64(本时代最大 PR,+35k 行):item 与时间线大重构——Extract 提取到关联 Target;Renderable 改名 RenderCommand、旧 Primitive 改名 RenderResource、新的 Primitive trait 声明 type RenderInstance;VisualItem 串起 Extract → Renderable → RenderInstance 流水;Blueprint 系统移除。时间线侧 RanimTimeline→RanimScene、RabjectTimeline→ItemTimeline、Rabject→TimelineId。同 PR 关闭 #67(预览上 wasm)与 #54:website 改造为 mdbook book + rustdoc + 每个示例内嵌 wasm 预览;
  • #69:DynTimeline 类型擦除——一个 item 的 timeline 集合可容纳多种动画类型,map<T, E> 在 item 状态类型变化时转换 timeline(closes #68)。

工程化:dylib、拆分与流水线

相关 PR:#73、#77、#87、#94、#96

  • #73:wasm 示例进 CI 构建,仓库里提交的 pkg/ 产物删除(-24.5k 行);
  • #77(ranim-cli 诞生,v0.1.0):#[scene]/#[output] 宏经 linkme distributed slice 收集 &'static Scene,CLI 把用户 crate 构建成 dylib、复制到临时路径后 libloading 加载——ranim preview 监听重建热重载,ranim render 构建并渲染。SceneConstructor 从此就是 fn(&mut RanimScene);输出路径规范为 <dir>/<场景名>_<宽>x<高>_<fps>.mp4(closes #76);
  • #87:每个 example 变成独立 cdylib(examples/<name>/lib.rs),一次构建同时服务 render 与 preview,与用户项目的 dylib 故事一致;
  • #94(crate 拆分):单体 crate 按职责拆为 ranim-core / ranim-items / ranim-anims / ranim-render / ranim-app(+ 既有 ranim-cli/ranim-macros),façade ranim 再导出 core/items/anims/render——用户 dylib 只依赖轻量 crate(为 issue #84 的编译时间与二进制体积);
  • #96(v0.1.5 收官):三件套——CoreItemStore 作为场景求值的交换格式;RenderPool(slotmap + 按 TypeId 回收)复用 GPU 实例;专用渲染 worker 线程(async_channel bounded(1))让第 N 帧在 GPU 上渲染时主线程求值第 N+1 帧。CPU 求值与 GPU 渲染自此解耦。
sequenceDiagram
    participant M as 主线程(求值)
    participant W as worker 线程(渲染)
    Note over M,W: async_channel bounded(1) 同步
    M->>W: 提交第 N 帧(CoreItemStore)
    W->>W: 复用 RenderPool 实例,渲染第 N 帧
    M->>M: 与渲染并行:求值第 N+1 帧
    W-->>M: 第 N 帧完成
    M->>W: 提交第 N+1 帧

其余小特性与修复

  • #23:第一个 zola 生成的网站(后由 #64 的 mdbook + wasm 方案取代);
  • #62:wgpu 24 → 25(合并顺序与 PR 号无关,见文首注);
  • #63:serde feature 与 derive_more 去样板——首个外部贡献(MilkBlock);
  • #71:修零长向量叉积归一化的 NaN(closes #70),测试重写为精确 PI 断言;
  • #90:#[scene(clear_color = "#...")] 可配清屏色;
  • #91:修 Alignable 对 VPointComponentVec/VItem/Group<T> 的对齐(双侧补齐到最大长度,resize_preserving_order);
  • #93:TypstText item——Typst 源码经 typst_svg 转字形轮廓,Alignable 按字符级 diff 实现,文本变换动画按匹配/插入/删除的字符 morph。

v0.1.1–v0.1.3(2025-08-10 → 2025-08-20)是未经 PR 的小修复版本;v0.1.4 带 #90/#91;v0.1.5(2025-10-18)以 #93/#94/#96 收官——单 crate 时代就此结束,接力棒交给 v0.2。

v0.2

Status: Backfilled(补写) — 覆盖 #99–#163;按 v0.2.0(2026-04-05)/ v0.2.1(2026-05-28)发布时点快照描述。

v0.2 的主线是把 v0.1 末期的两块“实验田“种成正文:动画编码去掉 item 状态、变成纯可求值的编码(#99/#104),渲染走进度缓冲与 OIT、再以 GPU-driven 合批收尾(#107–#112、#138/#142),然后顺势进入 3D(#146 MeshItem)。与此同时几何构造器、锚点体系与输出格式迅速铺开,包结构完成了“ranim-core 是纯动画引擎“的定位重构。

新增

  • 动画编码重写:AnimationCell + 单方法 Eval<T> trait,Timeline 不再存 item 状态,RanimScene::seal() 后任意时刻可独立求值(见“动画编码重写“一节)
  • 渲染:平面基 VItem 与真深度(depth pre-pass)、RenderGraph、OIT(flattened k-buffer)、GPU-driven 合批(单 instanced draw)、双缓冲读回(见“渲染“各节)
  • MeshItem/Surface/Sphere:3D 网格渲染,Z-up 球坐标相机
  • 几何与锚点:Arc/Circle/RegularPolygon/Ellipse/EllipticArc/Parallelogram/Line 构造器,Locate<T> 锚点体系(Origin/Focus/Centroid),TextItem(Typst SVG)
  • 输出:多格式(Mp4/Webm/Mov ProRes 4444/Gif)、#[output] 多路输出、帧级精确的采样时序、4K 输出
  • 包结构:ranim-core 纯化、ranim-app 并入 ranim、#[scene] 生成同名 module

BREAKING CHANGES

  • 动画编码:Evaluator<T>/AnimationSpan<T>/ItemTimeline<T> 移除,动画统一为 AnimationCell(Box<dyn Eval> + AnimationInfo),场景 API 改为 RanimScene::{insert, insert_with, timeline_mut, seal}(#99/#104)
  • VItem 数据模型:旧的 3D 点列表 VItem 移除,VItem 变为平面基表示(origin + Basis2d + 平面内 2D 点,每点带 is_closed)(#107/#112)
  • 锚点与 trait 重命名:BoundingBox → Aabb(aabb()/aabb_size()/aabb_center()),enum 锚点 → Locate<T> trait + AabbPoint;场景 API new_timeline* → insert_empty*(#120)
  • Rectangle 构造语义:p1/p2 从左上/右下改为最小/最大角(数学惯例的向上 Y)(#116)
  • CameraFrame::phi:从“相对 XY 平面的仰角“改为“Z-up 坐系下相对 +Z 的极角“,迁移:PI/2 - old_phi(#146)
  • Output.dir:不再拼接在固定 ./output/ 下,即输出目录本身;默认值 "./" → "./output"(#159)
  • 宏属性:pixel_size = (w, h) → width = w, height = h;frame_rate = n → fps = n(#161)
  • 包结构:Scene/Output/OutputFormat 等类型从 ranim-core 移至 ranim;ranim-app 并入 ranim(feature render/preview)(#133/#143)

动画编码重写:从带状态的 Timeline 到纯编码

相关 PR:#99(动画实现重构)、#104(移除 timeline 状态),对应 issue #95(改进动画编码结构)。

v0.1 末期的动画编码有两块累赘:Evaluator<T>/AnimationSpan<T> 的双层抽象带着 Arc 引用计数管线,ItemTimeline<T> 在动画列表之外还维护一份实时更新的 item 状态 state: T。状态意味着求值必须顺序推进——预览想 scrub 到任意时刻就得重放。

重写后的编码是纯数据:

  • trait Eval<T> { fn eval_alpha(&self, alpha: f64) -> T; }——“动画基本上是时间上的函数”(单方法 Eval trait 在此确立,v0.3 进一步演化为关联类型版本,见 v0.3 篇);
  • AnimationCell<T> { inner: Box<dyn Eval<T>>, info: AnimationInfo, anim_name } 统一承载动画,AnimationInfo { rate_func, start_sec, duration_secs, enabled } 持有全部播放参数(默认速率函数 linear);
  • Timeline 只持 Vec<Box<dyn CoreItemAnimation>> 加构造期游标(cur_sec/planning_static_start_sec),show()/hide() 用 Static 动画把窗口外的末态物化进编码,item 状态不再被存储,任意 sec 经 eval_at_sec 独立求值;
  • 场景层:RanimScene { timelines, time_marks }、seal() -> SealedRanimScene(total_secs + eval_at_sec 产出 ((timeline_idx, anim_idx), CoreItem) 流)、TimeMark::Capture 标记;Extract 的 extract_into(&self, &mut Vec<Target>) 形状也由此确立。

注意 Timeline 仍保留构造期游标(cur_sec/planning_static_start_sec),被移除的是 item 的实时状态;预览 UI 的 TimelineState(egui 控件)不受影响。

#99 同时把 eval 基准提升 20–30%(render 约 1%)。

渲染 I:平面化 VItem、深度与 OIT

相关 PR:#107(VItem2d 实验)、#109(RenderGraph)、#110(OIT 实验)、#112(转正),对应 issue #102(深度)/ #105(OIT)/ #106(RenderGraph)

VItem2d:点有了真深度(#107)

旧 VItem 是一列 3D 点,在 compute shader 里投影到相机平面——问题在于“当角点更多时,实际上无法定义曲面的形状“。

#107 把 VItem 换成平面表示:origin + Basis2d(平面在 3D 中的正交基)+ 平面内的 2D 点,每个点因此有了深度信息,也为日后与 3D mesh item 无缝融合铺路(“will be added in future”——后来是 #146)。分层关系不再依赖插入顺序,而是有了真正的 depth pre-pass:Depth32Float 深度缓冲 + VItem2dDepth/VItem2dColor 双 pass + 一个 compute pass 做 2D clip box。

RenderGraph(#109)

渲染循环从硬编码改为声明式节点图:GlobalRenderGraph(slotmap 存节点)上每个节点实现 GlobalRenderNodeTrait,以关联 Query: RenderPacketsQuery 从 RenderPackets 存储取输入(元组查询经 variadics_please 生成);资源侧出现 RenderPool/PipelinesPool/RenderTextures。节点图在此之后持续演化,直到 v0.3 被 Bevy ECS schedule 取代(见 v0.3 篇)。v0.2.0 定型的默认渲染图(含 #146 加入的 mesh 节点与两条交叉边):

flowchart TB
    CL["Clear"] --> VG
    subgraph VG["ViewRenderGraph(逐 view)"]
        direction TB
        VC["VItem compute<br/>投影 + clip box"] --> VD["VItem depth"]
        VC --> VCO["VItem color"]
        VD --> VCO
        MD["Mesh depth"] --> MCO["Mesh color"]
        MD -.-> VCO
        VD -.-> MCO
    end
    VG --> OIT["OITResolve(全局节点)"]

交叉边 Mesh depth → VItem color、VItem depth → Mesh color 正是“深度 pre-pass 跨基元类型生效“的关键——两类物件的深度互相参与对方 color pass 的遮挡判定。

OIT:flattened k-buffer(#110/#112)

透明物件自遮挡时的混合顺序错误,v0.1 用插入顺序回避,v0.2 用逐像素分层 k-buffer 正面解决:

  1. 写入:color fragment stage 用 atomicAdd 抢占该像素的层槽(pixel_idx * oit_layers + layer),写入打包成 u32 的 RGBA8 颜色与深度;超出 oit_layers 的片元丢弃;
  2. resolve:全屏 pass 每像素取至多 16 层,先丢弃被不透明深度遮挡的层,回插入序后从后往前 OVER 合成输出。
flowchart TB
    subgraph W["写入(color fragment stage)"]
        F["透明片元"] --> A["atomicAdd 抢占该像素层槽<br/>slot = pixel_idx × oit_layers + layer"]
        A -->|"layer 未满"| S["写入打包 RGBA8 颜色 + 深度"]
        A -->|"超出层数"| X["丢弃"]
    end
    subgraph R["resolve(全屏 pass)"]
        L["读至多 16 层"] --> D["丢弃被不透明深度遮挡的层"]
        D --> O["按深度回插入序"]
        O --> B["OVER 合成,输出单色"]
    end
    W --> R

效果肉眼可见——同一场景的透明物件,无 OIT 时可见性取决于绘制顺序,k-buffer 解析后按深度逐片正确合成(左半场景是不透明物件,作为不受影响的参照):

层显式可配(Renderer::new(ctx, width, height, oit_layers),后来 #156 在预览里按设备缓冲上限自适应)。

转正(#112)

vitem2d feature 与 CoreItem::VItem2D 变体删除,实验代码合并为唯一的 core_item::vitem.rs——旧 3D 点列表 VItem 与它的 map_3d_to_2d 投影管线整体拆除(diff 净 -1335 行),OITResolve 成为默认渲染图的常驻节点。

渲染 II:GPU-driven 合批

相关 PR:#138(实验)、#142(删除 per-item pipeline),对应 issue #139/#140

每个 VItem 一份 GPU buffer/bind group 的提交方式让 CPU 提交时间随物件数线性增长(3600 items 时 220 ms)。#138/#142 的方案是CPU 数据合并 + 单次 instanced draw + GPU 侧计算:

flowchart LR
    A["CPU 打包全部 VItem<br/>VItemsBuffer:item_infos / planes<br/>/ points3d / 颜色属性"] --> B["compute(workgroup 256)<br/>二分 item_infos 找所属 item<br/>投影到平面基 + atomicMin/Max 维护 clip box"]
    B --> C["单次 instanced draw<br/>draw(0..4, 0..item_count)"]
    C --> D["fragment:<br/>2D 贝塞尔/线段 SDF 求值"]
    D --> E["写入 OIT k-buffer"]
  • 每帧把全部 VItem 打包进连续 buffer(VItemsBuffer):item_infos 索引表、planes、points3d、描边宽度与填/描色属性;
  • compute pass(workgroup 256)每点一个 invocation:二分 item_infos 找到所属 item,把 3D 世界坐标点投影到平面基上,并用 atomicMin/atomicMax 维护每 item 的定 点 clip box(含描边宽度的四边形扩张界);
  • 渲染 pass 完全 instanced(draw(0..4, 0..item_count)):vertex 阶段按 clip box 生成每 item 大小的 quad,fragment 阶段做 2D 二次贝塞尔/线段的符号距离求值再写入 OIT k-buffer。注意这不是 indirect draw 式的 GPU-driven——裁剪与 quad 尺寸由 GPU 算,draw 调用仍是 CPU 发的单次 instanced。

CPU 提交成本自此与 VItem 数量无关(bench gpu_render):

VItem 数CPU 提交(前)CPU 提交(后)提升
251.61 ms1.64 ms~1×
40025.2 ms1.79 ms14×
3600220 ms1.90 ms116×

CPU+GPU 总耗时在 3600 items 下 256 ms → 5.0 ms(51×),输出与旧路径逐像素一致(含 OIT 与深度排序)。#142 同日把旧 per-item 路径整体删除,实验直接转正。

渲染 III:双缓冲读回

#132(closes #119)

输出纹理从 Renderer 中拆出,读回异步化:start_readback(非阻塞入队)/ finish_readback(阻塞拷回)/ try_finish_readback。渲染循环读回第 N 帧与渲染第 N+1 帧重叠,报告约 +20% 吞吐。

MeshItem:进入 3D

#146(closes #101)

MeshItem { points, triangle_indices, transform, vertex_colors, vertex_normals } 落地(v0.2 篇时代它还内嵌 transform: Mat4——v0.3 的变换系统重构会把它移出,见 v0.3 篇):

  • ranim-items 侧配套 Surface(参数曲面 (u, v) -> DVec3 网格生成)与 Sphere;CameraFrame 获得 Z-up 球坐标定位(from_spherical/set_spherical)与 orbit 动画;
  • 渲染走合批路径(MeshItemsBuffer + depth/color 节点,与 vitem 节点交叉连边),空/零法向时 shader 以 dpdx/dpdy 回退平面着色;
  • trait 全覆盖:Interpolatable(顶点/颜色/法向/transform 插值,索引在 t=0.5 切换)、Alignable(不同拓扑间自动补点)、Extract → CoreItem::MeshItem;
  • 新 example:mesh_morph(圆盘↔环面)、perlin_terrain(Perlin/分形/侵蚀地形)、solar_system、tetrahedron_spheres。

几何与锚点

相关 PR:#116、#120、#123、#128、#129、#149、#150

  • 锚点体系(#120,closes #117):enum 锚点换成 Locate<T> trait——“任何类型都可以是锚点”,为它实现 locate(&self, target: &T) -> DVec3 即可;内置 DVec3(自身即锚点)与 AabbPoint(bbox 相对坐标,原点为中心)。BoundingBox 更名 Aabb,get_min_max 去掉冗余中点返回(#116);transform trait 收缩为最小方法(rotate_at_point/scale_at_point/shift),其余进 extension trait 且不再依赖 bbox;
  • 几何家族:Rectangle 构造语义改为最小/最大角(向上 Y 的数学惯例,#116)并新增 from_min_size/from_two_points;Arc/ArcBetweenPoints/Circle/RegularPolygon(#123,附 Origin 锚点);Ellipse/EllipticArc(#128,附 Focus 锚点,VPointVec 的 AABB 改为曲线感知);Parallelogram 与 TextItem(#129);Line 线段 item(#150);
  • TextItem:单行文本,内部经 Typst 产出 SVG → SvgItem → VItems,携带 TextFont(字体族、FontVariant/FontWeight 等);
  • OpaqueColor 获得 Interpolatable(#149)。

输出体系

相关 PR:#125、#126、#137、#156、#159、#163

  • 去掉静态限制(#125):#[scene] 宏生成 StaticScene/StaticOutput/StaticSceneConfig(C-ABI 友好)并可转 owned Scene;find_scene 返回 owned 值,render/preview API 一律收 &Scene;新增 render_scene!/preview_scene! 声明宏直接以场景函数名调用;

  • 多格式输出(#126):#[output(format = "...")] 支持一个场景多路输出,格式矩阵如下;ffmpeg 参数顺序一并理顺,MOV 输出稳定正确;新增 rotating(angle, axis) 旋转动画(逐帧增量旋转的真实圆弧运动,区别于首末态线性插值的 Transform);

    格式codec / 像素格式alpha备注
    Mp4(默认)libx264 / yuv420p✗—
    Webmlibvpx-vp9 / yuva420p✓透明视频
    Movprores_ks / yuva444p10le(ProRes 4444)✓macOS 可直接预览
    Gifgif / rgb8✗厘秒计时,fps 上限 50
  • 帧采样间隔修复(#137,fixes #136):渲染循环此前按 i/(N-1) 取 N 个闭区间采样点,把帧距从 1/N 拉伸成 1/(N-1)——视频时长与速度有细微错误。改为按 i/fps 常距采样 ceil(total_secs * fps) + 1 帧,末帧精确收在 total_secs,并直接走 eval_at_sec 免去 sec→alpha→sec 往返;

  • 预览体验(#114/#156/#159):v0.1 的手写 winit 预览原型重构到 eframe 之上,新增深度缓冲可视化、eval/render 耗时显示与亮暗主题切换(#114,closes #78);动态分辨率与宽高比预设(切换时按设备缓冲上限自动下调 OIT 层数)、播放传输条(逐帧/跳转/循环/0.1×–10× 变速)、导出对话框带进度、图标从 emoji 换成 egui-phosphor(#156/#159);新增 render_scene_output_with_progress 进度回调;Output.dir 语义简化为直出目录;

  • 4K 输出(#163,v0.2.1 唯一 PR,来自外部贡献者 @pointer-to-bios,致谢!):设备创建改用 adapter.limits(),OIT storage buffer 在 UHD 下不再触及默认上限——4K 自此开箱即用(预览侧的分辨率自适应见 #156)。

包结构:ranim-core 成为纯动画引擎

相关 PR:#133、#143、#144(依赖维护)、#161(发布准备)

  • ranim-core 纯化(#133,closes #131):Scene/Output/OutputFormat/SceneConfig/SceneConstructor 与 link_magic(inventory 注册 + FFI 导出)全部移出 ranim-core 进 ranim,inventory/wasm-bindgen 依赖随之离开核心;依赖翻转——ranim-app 改为依赖 ranim + ranim-render 而非 ranim-core;#[scene] 不再靠 paste! 拼 _SCENE static,而是生成同名 module(Rust 允许 fn 与 mod 同名)导出 pub fn scene() -> Scene;渲染相关依赖按 cfg(not(target_family = "wasm")) 隔离,book 新增包结构一章;
  • ranim-app 并入 ranim(#143):独立 crate 消失,render_scene!/preview_scene! 变成 ranim 在 render/preview feature 下的导出,packages/ 收敛为 ranim-anims、ranim-cli、ranim-core、ranim-items、ranim-macros、ranim-render 六个;
  • v0.2.0 发布准备(#161):宏属性与字段对齐(pixel_size → width/height,frame_rate → fps),egui 0.34 + wgpu 29,book 清理与 getting started 重写。

v0.2.1(2026-05-28)携带 #163 的 4K 支持与两笔直接推送的依赖维护。

v0.3

Status: Draft — 随 main 更新,v0.3 发布时冻结。 已覆盖 #166–#202 中的全部特性与架构类 PR;基建/修复类(#178、#179、#188、#191、#199、#203)不入篇(见目录约定)。

本篇是 v0.3 的 News 纪事(体裁类似 Bevy News,写作约定见 本目录 AGENTS.md)。其中编排系统与渲染侧的 ECS 化沿用了 各自 PR 的设计;求值协议一节按当前实现(content-is-sequence 收敛后的 版本)编写。

新增

  • 音频平面
    • AudioClip/AudioTrack/Sound:音频作为叶子与视觉动画并列编排,支持 gain、fade、trim(with_play_secs 接受源轴 range);线性变速与视觉动画统一走 cell 层的 with_duration/with_rate_func(磁带式变速,音高随窗口缩放)
    • AudioClip::from_file/from_bytes(随 audio-decode feature)纯 Rust 解码(symphonia,WAV/MP3/FLAC/AAC/Ogg Vorbis)并经 rubato FFT 重采样归一到 48 kHz 立体声,不再依赖 ffmpeg 二进制
    • RanimScene::seal 时一次性 bake 成 master stereo/48 kHz buffer;线性路径预混,非线性路径走 residual forest
    • preview 原生播放(随 preview feature)与 render 端 ffmpeg muxing(MP4/MOV AAC,WebM libopus,GIF 丢弃)
  • 可组合动画编排系统(见 “Composable Animation Arrangement” 一节)
    • AnimSequence/AnimStack 容器与 seq!/stack! 宏,hold/forward/extend 等编排 API
    • AnimLagged 容器(stagger 排布 + 窗口外静态填充)与 lagged! 宏、迭代器容器收集(collect::<AnimStack>()/collect::<AnimSequence>()、into_stack/into_seq/into_lagged)
    • 播放参数 Paramed<A>(with_duration/with_rate_func/with_enabled)与放置 At<A>
  • 求值协议与适配器(见“求值与迭代式动画区段“一节)
    • 单一 Eval 协议:eval_alpha(&self, alpha) 是叶子动画唯一的求值入口
    • 进度是唯一坐标:Time/DeltaTime 结构已删除,ranim_core::time 只保留 Alpha/DeltaAlpha 两个类型别名
    • 纯/迭代特化位于 ranim-core:Pure 包装闭式闭包,Iterative 包装 IterativeEval step 逻辑;闭包经 Pure::new(|alpha| ...) / Iterative::from_fn(state, step_fn) 成为动画
    • SceneEvaluator::sample_at 是唯一 session 交互,seek/重放由 stateful 节点内部完成
  • 类型化变换系统(见“类型化变换系统“一节)
    • Transformed<T, G> 包装器、ApplyTransform<G> primitive trait 与类型化变换群(Translation/Rigid/Similarity/Diag/DAffine3),语义闭包约束的 bake()
    • 规范局部原语:语义形状移除定位字段,placement 只存在于 Transformed(“canonical local primitives” 教义)
    • 核心 VItem 携带局部到世界 transform: Mat4,渲染侧 per-item transform storage buffer,插值契约(wrapper lerp 动位姿、morph 是显式 bake)
    • 场景图层级 hierarchy::Node 与 glTF/GLB 导入(见“场景图层级与 glTF 导入“一节)
  • VItem 法向投影:Basis2d 移除,normal: Option + shader 内现场生成正交基(见“VItem 法向投影“一节)
  • 元组 Extract:1..=15 元直接实现,无需 Group 包装,ranim-core 保持 stable 兼容
  • CLI:ranim output / ranim render <scene> 拆分;inspect scenes/tree/frame 无 GPU 检查子命令;examples/agents/ agent one-shot 例子档案(见“CLI“一节)
  • 渲染与输出:渲染 worker API(RenderWorker/RenderThreadHandle/RanimRenderApp)公开;Output::name_template 输出名模板;examples 打包为单一 ranim-examples wasm 包并经 #[wasm_demo_doc] 恢复 rustdoc 实时预览;coplanar z-fighting 按 scene-order 深度偏置解决

BREAKING CHANGES

  • 动画组织系统
    • 弃用 Timeline(迁移到 AnimSequence/AnimStack)
    • 运行时节点统一为 AnimNode { timing shell, NodeContent };AnimationCell<T> 不再存在,播放参数改为 Paramed<A> / At<A>,放置状态由 Unplaced 表达
    • 作者协议改名与分层:Animation → IntoAnimNode(build() → into_anim_node()),Placeable → Unplaced,AnimationExt → PlaybackExt
    • 模块路径分层:animation::node(运行时核心)、animation::eval(叶子协议与适配器)、animation::build(lowering / playback)、animation::compose(sequence/stack/lagged)、animation::sound(音频叶子)
    • ranim-anims 中全部内置动画创建工具方法现在默认用 linear 速率函数和 1.0 持续秒数
  • 求值与内置动画 API
    • Eval<T> 泛型参数改为关联类型,方法集收敛为单一 eval_alpha(&self, alpha)(见“求值与迭代式动画区段“一节);sample/reset/step 与 PureEval 已删除
    • Time/DeltaTime 结构已删除;Eval::eval_alpha 收 f64,IterativeEval::step 收 alpha/delta_alpha
    • 内置动画工具方法直接返回具名动画类型(如 fade_in() 返回 FadeIn<T>),这些类型直接实现 Eval
    • Pure 与 Iterative 从 ranim-anims 移入 ranim-core 的 animation::eval::{pure, iterative}
    • CameraFrame::orbit 移到 ranim-anims 的 CameraFrameAnim
    • 删除 ranim-anims 的 Lagged 求值器与 lagged 模块(LaggedAnim 糖),stagger 排布改用 AnimLagged 容器
  • 变换与物件模型(见“类型化变换系统“一节)
    • MeshItem/Surface 移除内嵌 transform 字段与 with_transform,外挂变换改用 .transformed(...)
    • Square/Rectangle/Circle/Sphere/Arc/Ellipse/EllipticArc/TextItem 等语义形状移除 center/axes/p0/origin 等定位字段:构造后用 .transformed(Translation(...)) 放置;裸值不再实现 ApplyTransform,shift/rotate_*/非均匀 scale 需先包裹(或转为 Polygon/VItem 等点集类型)
    • 核心 VItem 的 points/normal 变为局部空间值,定位存放在新增的 transform: Mat4;消费提取点数据的代码需先应用 transform
    • ranim_items::mesh::MeshItem 用户层类型由 f32(Vec3/Mat4)改为 f64(DVec3/DMat4)
  • 渲染与 CLI
    • VItemsBuffer::update/MeshItemsBuffer::update 的迭代项改为 (scene_order, item) 对
    • ranim render 语义变化:批量渲染所有 #[output] 改用 ranim output;ranim render <scene> 只做单场景临时渲染,忽略 #[output] 与 Capture mark
    • ranim_items::vitem::Basis2d 移除,VItem.basis 改为 normal: Option<DVec3>(构造迁移:with_basis(Basis2d::XY) → with_normal(...) 或留空自动计算)
    • SvgItem 内部改为放置树(Transformed<Node<VItem>, DAffine3>):tree()/tree_mut()/into_tree() 返回放置而非裸节点(用 .inner 取 frame);glTF 支持为 opt-in 的 gltf feature

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。

AnimNode、Eval 与 IntoAnimNode

Eval<T> 的泛型参数被移除并改成了关联类型(一个求值器类型的求值结果类型是唯一的)。

所有作者定义最终都 lower 成运行时节点 AnimNode。AnimNode 的内容是封闭的 NodeContent,只包含运行时真正需要解释的形态:

NodeContent
├─ Sequence(Vec<AnimNode>)
├─ Stack(Vec<AnimNode>)
├─ Leaf(Box<dyn EvalDyn>)
├─ Static(Vec<DynItem>)
└─ Audio(Box<AudioTrack>)

叶子通过 Eval 保持开放,lowering 协议则是 IntoAnimNode:

#![allow(unused)]
fn main() {
/// A definition that can be lowered into the runtime animation tree.
pub trait IntoAnimNode: Sized {
    /// Lower this definition into its local runtime representation.
    fn into_anim_node(self) -> AnimNode;
}
}

所有的 E: Eval where E::Output: AnyExtractCoreItem 都自动实现 IntoAnimNode,因此动画创建直接返回自身即可使用,不需要手写 lowering:

#![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)
    }
}
}

IntoAnimNode 是可组合动画的统一入口。AnimSequence、AnimStack、AnimLagged、Paramed<A> 和 At<A> 都实现它,因此既可以独立构造,也可以被 Scene 或其它容器接纳。

AnimLagged 与迭代器收集

stagger 排布由 AnimLagged 容器表达:

#![allow(unused)]
fn main() {
let animation = lagged![0.2; a.fade_in(), b.fade_in(), c.write()];
}
  • 子动画要求 Unplaced(和 AnimSequence 一样),放置由容器计算:start_i = start_{i-1} + lag_ratio · d_{i-1}。lag_ratio 因此是 AnimStack(0.0,同时)与 AnimSequence(1.0,相继)之间的插值;
  • 窗口外时间由 with_leading/with_trailing 配置(LaggedFill::{Hold, Empty},默认都 Hold):每个元素在 build 时被物化为一条 [前填充][动画][后填充] 的 per-item AnimSequence 轨道(前=初态、后=末态,采样自窗口边缘;空填充跳过;零时长子项跳过前填充)——preview 时间线所见即所得,没有隐藏的钳制规则。想让元素窗口后消失,让它的动画以 hide 结尾(seq![item.fade_in(), item.hide()]);
  • 由于填充在 build 时采样,子动画应当是纯(闭式)动画;
  • 子动画是完整的 IntoAnimNode:可以自带 with_rate_func/with_duration 等播放参数,容器在各自 node 上施加速率;
  • 配套迭代器 API:collect::<AnimStack>()/collect::<AnimSequence>()(FromIterator)与 AnimIterExt::{into_stack, into_seq, into_lagged}。

窗口与填充语义一张图看懂——示意 lagged![0.2; a, b, c]、各动画 1 秒、默认 Hold 填充(时间轴单位 0.2 秒):

gantt
    dateFormat X
    axisFormat %s
    title lagged 容器窗口示意(lag_ratio 0.2)
    section a
    动画 :a1, 0, 5
    后填充(末态 Hold) :a2, 5, 7
    section b
    前填充(初态 Hold) :b0, 0, 1
    动画 :b1, 1, 6
    后填充(末态 Hold) :b2, 6, 7
    section c
    前填充(初态 Hold) :c0, 0, 2
    动画 :c1, 2, 7

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 [`IntoAnimNode`] but not
/// [`Unplaced`], so playback parameters must be configured before calling
/// [`Unplaced::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 已提供的能力。

求值与迭代式动画区段

相关 PR:#177(有状态区段引入)、#183(统一协议与容器重组)、#186(纯 eval_alpha 收敛)。本节按当前 content-is-sequence 收敛后的实现编写。

v0.2 的动画区段都是函数式的:从归一化进度闭式采样。这类区段无法表达有状态的迭代式动画(粒子、弹簧、物理模拟、三体),因为求值器无法保留跨帧状态、也无法按 dt 推进。v0.3 用一套通用求值协议统一两类区段,并最终把协议收敛为对进度的纯查询。

单一 Eval 协议

纯(闭式)与迭代(有状态)区段底层是同一个协议。动画内容一旦定义就不可变,evaluator 只回答一个问题:在自身归一化进度 alpha ∈ [0, 1] 处的输出是什么。

#![allow(unused)]
fn main() {
pub trait Eval {
    type Output;

    /// 在归一化进度 alpha 处求值。
    fn eval_alpha(&self, alpha: f64) -> Self::Output;
}
}
  • eval_alpha 是 &self 上的纯查询:同一个 alpha 永远得到同一个 Output,与调用顺序和次数无关;
  • evaluator 看不到秒、场景时钟或 logic_fps;AnimNode 负责把场景时间映射成 alpha 后再调用它;
  • 有状态区段在内部记忆化自己的积分快照;纯区段就是闭式函数。

EvalExt::apply_to / apply_alpha_to 是 build 期便捷方法:它们通过 eval_alpha 把 item 写成指定进度(默认末态)并返回动画本身。内置动画的工具方法(fade_in() 等)正是靠 apply_to 在创建动画的同时把 item 置为末态。

纯闭包:Pure

闭包是匿名类型,不能按名字实现 Eval,所以用 ranim_core::animation::eval::pure::Pure 包一层:

#![allow(unused)]
fn main() {
pub struct Pure<F>(pub F);

impl<T, F> Eval for Pure<F>
where
    F: Fn(f64) -> T,
{
    type Output = T;

    fn eval_alpha(&self, alpha: f64) -> T {
        (self.0)(alpha)
    }
}
}
#![allow(unused)]
fn main() {
let animation = Pure::new(|alpha| Square::new(alpha)).with_duration(2.0);
}

具名纯动画(FadeIn、Morph、Create 等)直接实现 Eval,不需要这个 wrapper。

迭代区段:IterativeEval + Iterative

#![allow(unused)]
fn main() {
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> 中。with_steps(N) 声明内容自己的步数(默认 1/120);eval_alpha(target) 前进时逐 sim_step 积分,回退时从初始状态重置重放,重复查询同一个 alpha 是 O(1)。

迭代逻辑本身简单时,直接用 Iterative::from_fn 写闭包即可;逻辑时长使用过程中的局部变量捕获,并传给 with_duration,不要使用全局 const:

#![allow(unused)]
fn main() {
let sim_secs = 4.0;

let animation = Iterative::from_fn(
    SpringState { x: 1.0, v: 0.0 },
    move |state, _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);
}
  • 闭包的状态类型位于 Fn 输入位置,stable Rust 无法从闭包类型反推出关联 Output,所以 Iterative::from_fn 通过 IterativeFn<S, F> 显式绑定二者;
  • 迭代逻辑较复杂、需要多个字段或复用方法时,实现命名 IterativeEval 结构体,并把 sim_secs 等参数放在 self 上;
  • 可变状态全部住在 Output 里,适配器持有初始状态值,恢复是结构性的;
  • 状态与渲染内容不同时,为状态类型实现 Extract(每帧投影一次),如 nbody 的 bodies+trails → VItem。

进度是唯一坐标

Time / DeltaTime / GlobalTime 已从协议中删除。ranim_core::time 只保留两个类型别名:

#![allow(unused)]
fn main() {
pub type Alpha = f64;       // 归一化进度
pub type DeltaAlpha = f64;  // 均匀进度步长
}
  • 动画逻辑只见进度、不见时间配置:起点、时长、rate 都属于 AnimNode,由它把场景时间映射成 alpha;
  • “内容即序列”:迭代动画的内容是作者声明的进度点序列 x₀…x_N,N 是定义而不是采样精度;
  • with_duration / with_rate_func / placement 是纯播放重映射(哪个进度何时可见),不改变内容本身;
  • 需要真实时间的现象(如 cloth 中球的运动)使用内容自己的逻辑时长换算:sec = sim_secs * alpha,而不是读全局时钟。

SceneEvaluator:单入口会话驱动

#![allow(unused)]
fn main() {
pub type EvaluatedFrame = Vec<((usize, usize), CoreItem)>;

impl SceneEvaluator {
    /// 对当前 render 时刻采样,输出 (animation_id, item) 流。
    pub fn sample_at(&mut self, render_secs: f64, out: &mut EvaluatedFrame);
}
}
  • sample_at 是唯一的 session 交互:渲染、preview 拖拽共用这条路径;
  • 前进 / 回退 / 原地求值由 Iterative 等 stateful 节点内部完成,session 不再维护逻辑网格;
  • logic_fps 参数仅为 API 兼容保留,不再驱动步进;步进尺度由每个迭代区段自己的 sim_step 决定。

模块布局

ranim_core::animation
├── eval
│   ├── pure       (Pure)
│   └── iterative  (IterativeEval / Iterative / IterativeFn)
├── sequence       (AnimSequence)
├── stack          (AnimStack)
└── lagged         (AnimLagged)

ranim::anims
├── camera         (Orbit、CameraFrameAnim)
├── creation       (Create/UnCreate/Write/Unwrite)
├── fading         (FadeIn/FadeOut)
├── morph          (Morph)
└── rotating       (RotatingAnimation)

示例

  • iterative_spring:阻尼弹簧,简单闭包步进,sim_secs 为过程局部变量;
  • nbody:N 体引力模拟(velocity Verlet、混沌弹射终场、无边界),同样是局部 sim_secs + 闭包;
  • cloth_wrap:零重力布料(弹簧力 + 自碰撞 + 球-布碰撞,MeshItem 曲面渲染;球的 kinematic 状态由 sim_secs * alpha 驱动,Extract 投影)。

VItem 法向投影:带宽优先于 ALU

https://github.com/AzurIce/ranim/pull/166

Basis2d 投影抽象被移除,VItem 的投影平面改由单个法向量表达:

  • ranim-items 的 VItem 字段 basis: Basis2d 改为 normal: Option<DVec3>(核心 VItem 同样持有 normal: Option<Vec3>)。不显式设置时由前三点自动计算(vitem_normal_from_points,共线时回退 Z 轴),RotateTransform 随点数据一同旋转法向;
  • 渲染侧 per-instance 的 PlaneData 从 3 个 vec4(origin + basis_u + basis_v)缩减为 2 个(normal + origin),u/v 基由 shader 内的 basis_from_normal() 现场生成:任选一根与法向足够不平行的轴,两次叉乘得到确定性正交基,compute 与 vertex 阶段复用同一 WGSL 函数保证 bit-exact。

设计原则是带宽贵于 ALU:每 item 的 per-instance 数据少 16 字节(-33%),代价只是几次 cross/normalize——vertex 阶段每 item 仅 4 个顶点,compute 阶段完全并行且本就 ALU-bound。确需固定投影面的场景仍可显式 with_normal 覆盖。

注:本节落地时 points 尚为世界坐标;“类型化变换系统“一节中 #198 进一步把提取语义调整为局部坐标 + transform 矩阵。

元组 Extract

https://github.com/AzurIce/ranim/pull/185

Extract 直接为 1..=15 元元组实现,异构物件组可以整体提取:

#![allow(unused)]
fn main() {
let items = (circle, line).extract(); // Vec<CoreItem>
}
  • 容器 blanket impl 改由 sealed marker trait(IntoExtractIter)约束:所有 impl 都在 crate 内可见,coherence 可以证明元组不满足它,因此元组的直接 impl 不再与 blanket 冲突(E0119)——不需要 Group<T: Tuple> newtype,也移除了 #![feature(tuple_trait)],ranim-core 保持 stable 兼容;
  • blanket 覆盖的容器集合与原先一致(Vec、[E; N]、&[E]、VecDeque、LinkedList、HashSet、BTreeSet、BinaryHeap、Option),下游自定义集合仍可手工实现 Extract;
  • 各元数经 variadics_please::all_tuples! 生成,与既有 Interpolatable 元组 impl 同一套模式,无 group! 宏。

渲染与输出体系

相关 PR:#181(worker API)、#182(输出名模板)、#187(wasm bundle)、#201(深度偏置)

渲染 worker API 公开

RenderWorker、RenderThreadHandle 与 RanimRenderApp 及其核心方法公开,用户可以绕过高层 render_scene* 帮助函数自建渲染管线:RenderWorker::{new, yeet, render_store, capture_frame, ...}、RenderThreadHandle::{sync_and_submit, get_store, retrive}、RanimRenderApp::{render_scene_with_progress, render_capture_marks}。

输出名模板

Output/StaticOutput 新增 name_template,支持 {name}/{width}/{height}/{fps} 占位符,默认 {name}_{width}x{height}_{fps},扩展名按输出格式自动追加;#[output(...)] 宏接受 name_template = "..." 属性(类似 Premiere/达芬奇的导出名模板)。

#![allow(unused)]
fn main() {
#[scene]
#[output(name_template = "{name}_{width}x{height}_{fps}")]
fn my_scene(r: &mut RanimScene) { /* ... */ }
}

ranim-examples wasm bundle 与 rustdoc 实时预览

全部 examples 经 #[path] 引用原始源码(根目录 ranim render --example 等用法不受影响),编译进单一 ranim_examples.wasm——此前每个 example 独立链接完整 preview 引擎并各自跑 wasm-bindgen/wasm-opt。场景可标注 #[wasm_demo_doc]:#[scene] 宏据此在生成的公开函数文档上注入画布元素与 module script,页面加载后 find_scene("<注册名>") 取出场景交给 preview_scene,在 rustdoc 页里直接跑起与 ranim preview 相同的应用(注入的是场景注册名而非函数名,#[scene(name = "hanoi")] 场景仍能正确解析)。

coplanar z-fighting 按 scene-order 深度偏置

共面曲面的遮挡结果改由场景插入顺序决定,而非光栅化舍入:VItemsBuffer::update/MeshItemsBuffer::update 的迭代项改为 (scene_order, item) 对,渲染器按全局 scene order 施加每序深度偏置;新增 z_fighting example 展示按插入顺序的稳定遮挡。

CLI:inspect、output/render 拆分与 agent 工作流

相关 PR:#190、#192

出发点:让 agent 自主完成“写场景 → 自查 → 出片“

v0.3 后期 ranim 的一个明确用户是 coding agent:它没有稳定的桌面环境,用不了交互式预览,却要独立走完“写场景代码 → 验证结构与时序 → 渲染出图 → 视觉检查 → 修改“的完整闭环。CLI 的演进由这个初衷牵引,落在两条设计原则上。

验证分层,贵的留到最后。 新增的 inspect 三个子命令全部纯 CPU、不创建 GPU context、支持 --format json(顶层带 schema_version,供脚本解析):

  • inspect scenes:不调用场景构造函数,只列出 dylib 里注册的场景与 #[output] 摘要——开工第一步确认场景注册成功、输出配置无误;
  • inspect tree:构建场景并输出层级动画树,每个节点含 kind(eval/sequence/stack/lagged/static)、anim_name、父局部坐标下的 range、content_duration_secs、rate_func、enabled,迭代节点额外报告自己的 sim_step——时序与组织是否正确,无需渲染即可确认;
  • inspect frame <scene> --at <sec>:以 120 Hz 逻辑时钟采样一帧,报告每个 CoreItem 的 z_order、id/kind、来源与几何摘要(AABB、点数、颜色;--verbose 给完整几何)——“某时刻物件不对/位置不对/z-order 不对“不再需要上 GPU 盲调。

快速冒烟与正式交付分离。 原 ranim render 一分为二:ranim output [scenes...] 批量渲染每个声明的 #[output]、处理 TimeMark::Capture 截图,是交付前的最终验证;ranim render <scene> 用固定默认设置(1080p60 mp4)把单个场景快速渲一次,忽略 #[output] 与 Capture——迭代中只想看效果时用。内部由 RenderJob 抽象统一两条路径。

配合既有的 preview(watch + 热重载)与 dylib 加载方式,这条工作流可以完全无头完成:

inspect scenes → inspect tree → inspect frame → render(冒烟出图)→ 视觉检查 → 修改 → output(交付)

原则与 cli 章的表述一致:能用便宜的 inspect 查清的问题,不要留到昂贵的 GPU 渲染之后才发现。

其他

  • 用户层 MeshItem 统一为 f64(DVec3/DMat4),与 Surface/VItem 一致;渲染侧核心表示仍为 f32,From 转换自动完成。

类型化变换系统

相关 PR:#196(包装器与变换群)、#197(规范局部原语)、#198(贯穿核心与渲染)、#200(Partial/Empty 转发)

动机:从散落的变换行为到统一模型

此前变换行为散落在各具体类型上:MeshItem/Surface 各自内嵌 DMat4 transform,VItem 靠直接改点数据,shift/rotate/scale 分散在各自独立的 trait 里;“改语义参数“与“改渲染几何“在类型层面没有区分——同一个用户操作,在这个物件上是改矩阵、在那个物件上是改几何。v0.3 用一套模型统一:物件要么通过 ApplyTransform<G> 吸收变换(当且仅当其表示对该变换族封闭),要么把变换外挂在新包装器 Transformed<T, G> 里。

Transformed<T, G> 与类型化变换群

#![allow(unused)]
fn main() {
pub struct Transformed<T, G> {
    pub inner: T,
    pub transform: G,
}

pub trait TransformGroup: Sized {
    fn identity() -> Self;
    fn compose(&self, inner: &Self) -> Self; // outer * inner(列向量约定)
}

pub trait ApplyTransform<G> {
    fn apply(&mut self, transform: G) -> &mut Self;
}
}
  • 变换群类型:Translation、Rigid、Similarity、Diag,到一般仿射边界 DAffine3;同族内组合,跨族不隐式加宽——需要更一般的表示时显式升级(Translation → Rigid → Similarity → DAffine3,Diag → DAffine3),让加宽点在源码里可见(阶梯图解见理解 Ranim · Transformed,此处不赘);
  • 外内组合显式命名:.transformed(inner).compose_outer(outer) 与 .transformed(outer).compose_inner(inner) 都得到 outer * inner;wrapper 自己的 ApplyTransform 实现做外乘,嵌套 wrapper 从内向外扁平化;
  • 便捷操作连接到 primitive action:shift 要求 ApplyTransform<Translation>、rotate_on_axis 要求 Rigid、非均匀 scale 要求 Diag、等比缩放要求 Similarity、AABB 系操作走既有 ScaleTransform/Aabb——每个物件只暴露保持其表示的操作;
  • 模型变换止步于仿射:一般 projective Mat4 带非仿射齐次行、需要透视除法,属于相机投影而非模型变换;
  • Transformed::map_inner/map_transform 支持重映射被包裹物与显式升级/受检降级变换存储(#197)。

语义闭包与 bake

bake() 只在 T: ApplyTransform<G> 精确成立时可用——语义边界由类型表达:

包装器bake理由
Transformed<Circle, Similarity>✓圆在 similarity 下仍是圆
Transformed<Circle, DAffine3>✗一般仿射会把圆变成椭圆
Transformed<VItem, DAffine3>✓点集数据吸收任意仿射

语义形状(圆/球/矩形/方块)只在 similarity 下实现吸收,点数据/VItem/一般网格数据吸收到 affine。想要“只是看起来变“的结果,留在 Transformed<T, DAffine3> 里,而不是错误地烘进语义类型。Rectangle::scale_axes(#196 由 scale_local 更名)是对固有尺寸的编辑,与外部变换组合是两件事。

规范局部原语:placement lives in Transformed only

#197 把语义形状收敛为以原点为中心的规范局部原语:Square/Rectangle 移除 center/axes/p0,Sphere 移除 center,Arc/Circle/Ellipse/EllipticArc 移除定位轴,TextItem 以内在 em_size 取代 origin/basis——净删约 1000 行 per-type 锚点/缩放管线。定位不再存在于物件上:

#![allow(unused)]
fn main() {
// 以前:Circle::new(2.0).with_center(pos)
let circle = Circle::new(2.0).transformed(Translation(pos));
}

裸的规范形状不再实现 ApplyTransform(shift/rotate_*/非均匀 scale 不可用):要么先包裹(.transformed(DAffine3::IDENTITY) 恢复完整 fluent 面),要么转成点集类型(Polygon/VItem,它们仍直接吸收仿射)。锚点(core 的 Centroid,几何原语的 Origin/Focus)在 inner 局部空间定位后再经外层变换;example 用法收窄到实际运动群(Translation 轨道、Rigid 的魔方转动与四面体旋转经群操作组合而非手写齐次矩阵积)。

变换贯穿核心与渲染管线

核心 VItem 新增局部到世界 transform: Mat4,CoreItem::apply_transform 对它做矩阵组合而非重写点数据——提取一个 Transformed 只写一个矩阵。渲染侧 per-item transform 进只读 storage buffer,vitem vertex stage 在从平面基重建 3D 位置后应用 transforms[instance]。由此确立插值契约:wrapper 的 lerp 只动位姿、inner 几何恒定;经典 morph 是显式的 bake 进裸 VItem/MeshItem。提取出的核心 VItem::points 与可选 normal 由此变为局部空间值——消费方需先应用 transform(ranim-cli inspect 已按此报告世界空间)。

flowchart LR
    U["用户空间<br/>Transformed&lt;T, G&gt;<br/>位姿在 wrapper,inner 恒定"] -->|"extract:组合为一个矩阵"| K["core VItem<br/>points/normal 局部坐标<br/>+ transform: Mat4"]
    K -->|"update:只读 storage buffer"| G["渲染侧<br/>per-item transforms"]
    G --> V["vertex stage:<br/>平面基重建后按实例应用 transform"]

法向量的仿射变换

对线性部分为 的仿射变换,显式法向按 (逆转置)变换而非按点/向量变换,mesh shader 相应使用 cofactor 形式——非均匀缩放与剪切下法向仍垂直于表面。

配套的小步:#200 为 wrapper 补齐 Partial/Empty 转发(此前已转发 Interpolatable/Opacity 与填/描色),被包裹的物件由此可直接 create()/write()——get_partial 取 inner 的部分切片并原样保留克隆的位姿,empty() 组合 T::empty() 与 G::identity()。

场景图层级与 glTF 导入

https://github.com/AzurIce/ranim/pull/202

“placement lives in Transformed only” 的教义推广到树上:

#![allow(unused)]
fn main() {
pub struct Node<I, G = DAffine3> {
    pub id: Option<String>,
    pub item: Option<I>,
    pub children: Vec<Transformed<Node<I, G>, G>>,
}
}
flowchart TB
    R["Node:根 frame(id)"] -->|pose| C1["Node:&lt;g&gt; frame"]
    R -->|pose| L1["Node:path 叶子"]
    C1 -->|pose| L2["path 叶子(id: stripes)"]
    C1 -->|pose| L3["path 叶子"]

位姿住在边上——图中每条 pose 边都是一个 Transformed 包裹:

  • Node 是纯结构(id + 可选 payload + 子节点),每个子节点的位姿住在边上的 Transformed 里;全部递归代数——extract、lerp、align、partial/empty、AABB、centroid、样式转发、按 id 寻址(by_id/by_ids/by_id_path)——由 Transformed 自身的实现组合而来,Node 不再依赖 TransformGroup;

  • 对齐遵循统一规则:缺席侧用对侧的透明克隆填充(payload 缺席、空/非空子列表同理),跨结构 lerp 平滑淡入淡出;leaf()/group()/branch() 构造器保持调用点简短,裸节点与包裹节点可在同一 vec![...] 里混排(裸节点按 identity 位姿放置);

  • SvgItem 重建在该树上:<g> 映射为纯 frame、<path> 为 payload 叶子,元素 id 全程可寻址——svg.by_id("stripes")?.set_fill_color(BLACK);SvgItem::new 把居中 + Y 翻转组合到根放置上(而非替换根变换),viewBox 缩放得以保留;提取按深度优先保持 painter’s-algorithm 顺序,From<SvgItem> for Vec<VItem> 保留旧的 bake 工作流(TextItem 依赖于此);

  • glTF/GLB 导入(新 gltf feature,opt-in):node_tree_from_path/node_tree_from_gltf 返回 GltfTree(节点树 + 文档索引→路径映射),名字(by_id)与文档索引(node,动画 channel 与 skin.joints 的寻址方式)双寻址;glTF 强制的 Y-up 自动转为 ranim 的 Z-up(翻转组合进场景根放置);单 primitive mesh 直接作 payload,多 primitive 拆为兄弟叶子。首版不含 materials/skins/morph targets/动画/data: buffer;

  • 性能:posing 从微秒级控制点重写变为纳秒级矩阵组合——posing 移出每帧 profile;extract 因遍历放置与组合矩阵带小常数,整体帧成本反而更低;渲染与原实现持平(GPU 应用 per-item 矩阵顶替了原先的预烘焙)。Ghostscript Tiger(138 paths)上的对照:

    操作(整树)旧(baked)新(树上)
    pose:rotate4.57 µs18.9 ns(~250×)
    pose:shift4.52 µs9.5 ns(~500×)
    extract54.5 µs59.5 µs(+~9%)