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,通过IntoAnimNodelower 到运行时。 - 运行期(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。 - 运行期(封闭核心):所有定义都会通过
IntoAnimNodelower 成一个AnimNode。AnimNode的内容是封闭的NodeContent:Sequence/Stack/Leaf/Static/Audio。视觉求值、seal 时音频 烘焙、preview introspection 都是这棵运行时树上的 interpreter。
本章自底向上整理这条链路:
trait Eval<Output = T> 叶子协议:alpha -> T 的纯函数
│ 具体叶子 struct:FadeIn / Morph / Pure / Iterative / 自定义 …
│ blanket IntoAnimNode(默认 linear、1 秒、enabled)
▼
struct Paramed<A> / At<A> PlaybackExt / Unplaced::at
▼
struct AnimSequence / AnimStack / AnimLagged 顺序 / 并行 / 交错容器
│ IntoAnimNode::into_anim_node
▼
struct AnimNode { timing shell, NodeContent }
NodeContent:
Sequence(Vec<AnimNode>)
Stack(Vec<AnimNode>)
Leaf(Box<dyn EvalDyn>)
Static(Vec<DynItem>)
Audio(Box<AudioTrack>)
▼
RanimScene 根 AnimStack → SceneEvaluator 采样
Eval:叶子求值协议
Ranim 的叶子动画核心是一个统一的求值协议。动画内容一旦定义就不可变:它是自身
归一化进度 alpha ∈ [0, 1] 的纯函数。
pub trait Eval {
type Output;
/// 在归一化进度 alpha 处求值。
fn eval_alpha(&self, alpha: f64) -> Self::Output;
}
- 协议只有一个入口:
eval_alpha(&self, alpha); - 它是
&self上的纯查询:无论调用顺序和次数,同一个alpha得到同一个Output; - evaluator 看不到秒、场景时钟或
logic_fps。AnimNode负责把场景时间 映射成进度后才调用它; - 有状态(迭代)区段在内部记忆化自己的积分快照;纯区段就是闭式。
运行期的 NodeContent::Leaf 持有 Box<dyn EvalDyn>,它是 Eval 的擦除
对应物:Eval 带有关联类型 Output,不能直接作为 trait object,因此
animation::eval 为所有满足 Output: AnyExtractCoreItem 的 E: Eval
自动实现了 EvalDyn。只有用户叶子会进入 Leaf;内置容器是 core 自己的
variant。
EvalExt 提供两个 build 期便捷方法:
pub trait EvalExt: Eval + Sized {
fn apply_alpha_to(self, item: &mut Self::Output, alpha: f64) -> Self;
fn apply_to(self, item: &mut Self::Output) -> Self; // alpha = 1.0
}
内置动画的工具方法(fade_in() 等)正是靠 apply_to 在创建动画的同时把 item
置为动画末态。
进度是唯一坐标
ranim::core::time 只有两个类型别名:
pub type Alpha = f64; // 归一化进度
pub type DeltaAlpha = f64; // 均匀进度步长
「内容即序列」:迭代动画的内容是作者声明的进度点序列 x₀…x_N。N 是定义而
不是采样精度;rate_func、with_duration、placement 都只是「哪个进度何时可
见」的采样重映射。
内容的两种来源:Pure 与 Iterative
两者都是 Eval 的实现:Pure 适配闭式求值,Iterative 适配逐步积分。它们与
具名动画(FadeIn、Morph 等直接 impl Eval 的类型)地位相同,只是内容的
产生方式不同。
纯闭包:Pure
闭包是匿名类型,不能按名字实现 Eval,所以用 Pure 包一层:
use ranim::core::animation::eval::pure::Pure;
let animation = Pure::new(|alpha| Square::new(alpha)).with_duration(2.0);
具名纯动画(FadeIn、Morph、Create 等)直接实现 Eval,不需要这个
wrapper。
迭代区段:IterativeEval + Iterative
物理模拟、混沌系统等没有闭式的内容,用逐步推进的方式定义:
pub trait IterativeEval {
type Output;
/// 推进一个内容步。alpha 是当前进度,delta_alpha = 1/N。
fn step(&self, output: &mut Self::Output, alpha: f64, delta_alpha: f64);
}
Iterative::new(initial, evaluator) 持有不可变的定义(初始状态、sim_step、
step 逻辑),把积分快照放在内部 RefCell<Snapshot> 中:
let sim_secs = 4.0;
let animation = Iterative::from_fn(
SpringState { x: 1.0, v: 0.0 },
move |state: &mut SpringState, _alpha, delta_alpha| {
let dt = sim_secs * delta_alpha; // 内容自己的物理秒
let acc = -K * state.x - C * state.v;
state.v += acc * dt;
state.x += state.v * dt;
},
)
.with_steps(240)
.with_duration(sim_secs);
- 逻辑时长用过程中的局部变量(例如
sim_secs)捕获,并同时传给with_duration,不要使用全局const; - 迭代逻辑较复杂时,实现命名
IterativeEval结构体,把sim_secs等参数放在self上; with_steps(N)声明内容自己的步数,默认1/120;eval_alpha(target)前进时逐sim_step积分,回退时从初始状态重置重放, 重复查询同一个alpha是 O(1);- 可变状态全部住在
Output里; - 闭包的状态类型位于
Fn输入位置,无法从闭包类型反推出关联Output,所以Iterative::from_fn通过IterativeFn<S, F>显式绑定二者。
Iterative 实现的 Eval::sim_step() 返回 Some(1/N),供 ranim inspect tree
等工具内省;它不影响求值本身。
从 Eval 到可播放的动画
以一行最常见的代码为例,自顶向下拆开它经过的每一层:
let animation = square.fade_in().with_duration(1.0);
第 1 层:fade_in()。 它来自 ranim-anims 的 FadingAnim trait(对满足
Opacity + Interpolatable + Clone 的类型自动实现)。它做两件事:构造具名
evaluator FadeIn<T>,并通过 EvalExt::apply_to 把 square 就地置为动画末
态——所以动画创建完成时,item 本身已经是「播完」的样子,后续 build 出的新
状态都从这个末态出发:
fn fade_in(&mut self) -> FadeIn<Self> {
FadeIn::new(self.clone()).apply_to(self)
}
第 2 层:FadeIn<T>。 它就是一个普通的 Eval 实现——持有初末两个状态,
按 alpha 插值:
pub struct FadeIn<T: FadingRequirement> {
src: T,
dst: T,
}
impl<T: FadingRequirement> Eval for FadeIn<T> {
type Output = T;
fn eval_alpha(&self, alpha: f64) -> Self::Output {
self.src.lerp(&self.dst, alpha)
}
}
第 3 层:blanket impl。 任何 Eval 实现,只要 Output 可提取为场景元素
(AnyExtractCoreItem),就自动实现 IntoAnimNode,默认参数为 linear、时长
1 秒、enabled:
impl<E> IntoAnimNode for E
where
E: Eval + 'static,
E::Output: AnyExtractCoreItem,
{
fn into_anim_node(self) -> AnimNode {
AnimNode {
content: NodeContent::Leaf(Box::new(self)),
internal_time_secs: 1.0,
// rate_func = none(linear), time_range = 0.0..1.0, enabled = true
...
}
}
}
第 4 层:with_duration(1.0)。 来自 PlaybackExt,把动画包成
Paramed<A> 携带播放参数(见下节)。
ranim-anims 只包含这类具名叶子动画家族,通用适配器(Pure / Iterative
/ Static)与 lowering 协议在 ranim_core::animation 中:
ranim::anims
├── camera (Orbit、CameraFrameAnim)
├── creation (Create/UnCreate/Write/Unwrite)
├── fading (FadeIn/FadeOut)
├── morph (Morph)
└── rotating (RotatingAnimation)
Unplaced、PlaybackExt、Paramed 与 At
所有尚未固定父时间坐标的 Unplaced 动画通过 PlaybackExt 获得统一的播放
参数 API:
animation
.with_duration(2.0)
.with_rate_func(smooth)
.with_enabled(true)
At<A> 表示已经固定在父时间坐标中的 entry,不再实现 Unplaced,因此参数
必须在 placement 之前设置:
animation.with_duration(2.0).at(3.0); // At<Paramed<A>>
这些方法定义在 animation::build,返回的 Paramed<A> / At<A> 也都是
IntoAnimNode,所以可以继续被容器或 Scene 接纳。
animation::compose:顺序、并行、交错
顺序容器:AnimSequence
AnimSequence::push 先将动画 lower 为局部 AnimNode,再把它移动到当前
cursor,并按 node duration 推进 cursor:
let mut intro = AnimSequence::new();
intro
.push(square.clone().fade_in())
.hold(1.0)
.push(square.fade_out());
r.play(intro);
Sequence 是动态类型擦除边界,但不会展开传入动画的组合树。每次 push 只将
直接子动画转换为一个 AnimNode;如果子动画是 Stack 或 Sequence,其内部层级
会继续保留。AnimSequence::into_anim_node 最终产生
NodeContent::Sequence(Vec<AnimNode>)。
Sequence 自己通过 cursor 决定子动画的位置,因此 push 只接受尚未显式放置的
Unplaced。At<A> 已经固定父时间坐标,不能进入 Sequence。
Sequence 本身仍实现 IntoAnimNode 与 Unplaced,所以可以先独立构造,再整体
使用 at 放置或加入另一个组合:
r.play(intro.at(2.0));
forward 与 hold
两者都会推进 Sequence cursor,但输出语义不同:
forward(secs)只推进 cursor,产生的空白区间没有输出。hold(secs)取得 cursor 处的 Sequence 状态,将它保存为持续secs的静态 运行时节点(NodeContent::Static)。forward_to(target)和hold_to(target)是对应的绝对 cursor 版本。
hold 没有额外的状态协议,它直接采用 Sequence 在 cursor 处的正常求值结果。
Sequence 在同一时刻只求值最后一个适用的直接子动画;如果这个子动画是 Stack,
则由 Stack 求值其中所有仍然适用的子动画。已经提前结束的 Stack 子动画不会被
自动延长。
child A: [0, 1)
child B: [0, 2)
cursor: 2
hold at 2 -> 只保持 B 的左侧终态
连续 hold 会分别保存每次调用时的求值结果,形成相邻的静态区间。
show、hide 与最终求值
show() 和 hide() 都是普通的零时长动画:
show()是 enabled 的静态动画,求值时输出对应物件;hide()是 disabled 的静态动画,求值时不输出内容。
它们不需要 hold 特判。因为 Sequence 在边界上选择最后一个适用的直接子动画,
末尾的 show() 会成为最终求值结果,末尾的 hide() 则自然得到空结果;hold
只负责把这个结果保存为静态动画。
let mut content = AnimSequence::new();
content
.push(square.show())
.hold(1.0)
.push(square.hide())
.hold(1.0);
这里 hide 只改变 content 这条 Sequence 的状态。它不会查找或影响根 Stack
中另一个独立动画。
如果两个物件需要独立生命周期,应分别使用两个 Sequence:
r.play(square_sequence);
r.play(circle_sequence);
如果两个物件需要在同一时刻一起求值,应直接 push 一个 stack![...] 组合。
并行容器:AnimStack 与根场景
AnimStack::push 不推进其他子动画;Stack duration 是所有子动画 duration 的
最大值:
let animation = stack![
background.show().with_duration(5.0),
content.at(1.0),
camera.show().with_duration(5.0),
];
r.play(animation);
Stack 接受普通 Unplaced 动画和已经放置的 At<A>。普通动画从 Stack 局部 0
开始,At<A> 使用自己的显式 offset。参数必须在调用 at 之前设置。
RanimScene 自带一个根 AnimStack:
pub fn play<A: IntoAnimNode + 'static>(&mut self, animation: A) -> &mut Self {
self.root.push(animation);
self
}
因此,多次根级 play 默认都从 0 秒开始。它们是并行动画,不存在后一次调用
覆盖前一次调用的隐含对象语义。
运行时数量不固定时可以直接构造 AnimStack:
let mut layers = AnimStack::new();
for animation in animations {
layers.push(animation);
}
r.play(layers);
场景时长与显式生命周期
Scene 总时长是根 Stack 中最长子动画的 duration。新模型不会像旧 Timeline 那样 在 seal 时自动把静态物件和相机延长到 Scene 结束。
需要全程存在的内容应显式指定生命周期:
let total_secs = content.cursor_sec();
let mut camera = AnimSequence::new();
camera
.push(CameraFrame::default().show())
.hold_to(total_secs);
r.play(camera);
r.play(content);
这种写法使空白和保持区间成为动画定义的一部分。
交错容器:AnimLagged
AnimLagged 把一组未放置(Unplaced)的子动画按 stagger 规则相继排布:
第 i 个子动画的起点是 start_{i-1} + lag_ratio · d_{i-1}。lag_ratio 插值
在两种容器语义之间:
0.0—— 所有子动画同时开始(类似AnimStack);1.0—— 首尾相接(类似AnimSequence);- 中间值 —— 重叠相继。
let animation = lagged![0.2;
square.fade_in(),
circle.fade_in(),
text.write(),
];
r.play(animation);
子动画窗口之外的时间默认由真实的静态动画填充:每个元素在 build 时被物化
为一条 [前填充][动画][后填充] 的 per-item AnimSequence 轨道(前=初态,
后=末态,采样自窗口边缘,空的填充会被跳过),因此 preview 时间线看到的就是
实际渲染的内容,没有隐藏的求值规则。每端的行为可以用
with_leading/with_trailing 配置(LaggedFill::{Hold, Empty},默认都是
Hold);若希望元素在窗口结束后消失,让它的动画以 hide 结尾即可(如
seq![item.fade_in(), item.hide()])。
填充在 build 时采样,因此子动画应当是纯(闭式)动画——迭代式子动画的末态 填充会得到其初态。
对一组元素施加同一个动画时,用迭代器收集(core 的 AnimIterExt):
let animation = group
.iter_mut()
.map(|item| item.fade_in().with_rate_func(smooth))
.into_lagged(0.2);
迭代器还可以收集为另外两个容器:into_stack()/into_seq(),或直接
collect::<AnimStack>()/collect::<AnimSequence>()。
seq!、stack! 与 lagged!
固定写法可以使用宏简化:
let intro = seq![
square.clone().fade_in(),
square.fade_out(),
];
let scene = stack![intro, camera];
r.play(scene);
seq! 返回 AnimSequence,stack! 返回 AnimStack。二者都只是构造辅助,
最终 lower 为保留子节点层级的运行时动画树。lagged![0.2; a, b, c] 以 0.2 的
stagger ratio 返回 AnimLagged(见上文)。
运行期:AnimNode、NodeContent 与 SceneEvaluator
Sequence、Stack 和 Scene 需要保存异构动画,因此每个直接子动画会 lower 成一个
AnimNode:
AnimNode
├─ content: NodeContent
├─ time_range: Range<f64> 在父坐标中的窗口
├─ internal_time_secs: f64 content 轴长度
├─ rate_func: Option<fn(f64) -> f64>
├─ enabled: bool
└─ anim_name: &'static str
NodeContent 是封闭的运行时语言:
NodeContent
├─ Sequence(Vec<AnimNode>) 最后一个命中的子节点求值
├─ Stack(Vec<AnimNode>) 所有命中的子节点叠加求值
├─ Leaf(Box<dyn EvalDyn>) 用户 Eval 叶子的类型擦除
├─ Static(Vec<DynItem>) 已经采样好的输出批次
└─ Audio(Box<AudioTrack>) seal 时烘焙的音频叶子
AnimNode::eval_at(sec, out) 是唯一的时间管理入口:node 先检查
enabled / active,再用自己的 time_range 和 rate_func 把 sec 映射成局部
alpha,最后交给 NodeContent:
Sequence选择最后一个包含 content 时间的子节点;Stack求值所有包含 content 时间的子节点;Leaf调用擦除后的EvalDyn::eval_into(alpha, out);Static克隆保存的输出;Audio不参与逐帧视觉求值,由 seal 时的音频 interpreter 处理。
这里的关键是:运行时核心是封闭的,因此不同 consumer 可以各自遍历同一棵树, 互不污染:
eval_at:逐帧视觉求值;bake_audio:seal 时对音频叶子做一次性混音;has_audio:判断树里是否存在音频叶子;animation_info:为 preview 生成层级 introspection 树。
叶子仍然通过 Eval 保持开放;如果一个组合子能用核心构造子和窗口 placement
表达,就在 build 时 desugar,而不是增加新的 runtime variant。AnimLagged
就是这种 sugar:它最终 lower 成 Stack + Sequence。
SceneEvaluator::sample_at(render_secs, out) 是唯一的 session 交互:
- 对每个顶层
AnimNode调用eval_at(render_secs); - 前进 / 回退 / 原地求值的判断在
Iterative等 stateful 节点内部完成; - preview 拖拽和 render 采样共用同一条路径。
logic_fps 参数仅为 API 兼容保留,不再驱动步进;步进尺度由每个迭代区段自己
的 sim_step 决定。
音频叶子
Sound 是音频平面的叶子 atom,使用方式与视觉动画一致(from_file 需要
audio-decode feature):
let mut scene = RanimScene::new();
scene.play(seq![
square.fade_in(),
Sound::new(AudioClip::from_file("narration.wav")?)
.with_fade_in(0.25),
square.write(),
]);
Sound::into_anim_node 产生 NodeContent::Audio(Box<AudioTrack>)。它同样参与
seq! / stack! / lagged!、.at()、.with_duration()、
.with_rate_func() 和 .with_enabled()。音频不进入逐帧视觉管线,而是在
RanimScene::seal 时通过 bake_audio 一次性混音;见 ranim-core 的音频模块
文档。
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 个 coreVItem;Surface经高层MeshItem产生 1 个 coreMeshItem;而SvgItem/TypstText这类复合 item 会产生多个 coreVItem。 - 可组合:
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 |
Parallelogram | DAffine3 |
Polygon / Line / VItem / MeshItem / Surface | DAffine3 |
Parallelogram / ArcBetweenPoints | DAffine3 / 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、以及typstfeature 提供的文字 物件。 - 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(typstfeature)。
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:
linkmedistributed 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_anglesstorage 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:淡入淡出的语义确立为整个物件在零透明快照与当前状态之间插值,而非缩放透明度——
Opacitytrait 只负责各类型自己的透明度写入。
渲染范式三连跳
相关 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 渲染的东西都叠在别人上面”;Entitytrait 取代过于僵硬的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仍是这条路线。
动画、相机与坐标系
- #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
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 体系成型
- #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、新的Primitivetrait 声明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、拆分与流水线
- #73:wasm 示例进 CI 构建,仓库里提交的
pkg/产物删除(-24.5k 行); - #77(ranim-cli 诞生,v0.1.0):
#[scene]/#[output]宏经linkmedistributed 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_channelbounded(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:
serdefeature 与derive_more去样板——首个外部贡献(MilkBlock); - #71:修零长向量叉积归一化的 NaN(closes #70),测试重写为精确
PI断言; - #90:
#[scene(clear_color = "#...")]可配清屏色; - #91:修
Alignable对VPointComponentVec/VItem/Group<T>的对齐(双侧补齐到最大长度,resize_preserving_order); - #93:
TypstTextitem——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;场景 APInew_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(featurerender/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; }——“动画基本上是时间上的函数”(单方法Evaltrait 在此确立,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 正面解决:
- 写入:color fragment stage 用
atomicAdd抢占该像素的层槽(pixel_idx * oit_layers + layer),写入打包成u32的 RGBA8 颜色与深度;超出oit_layers的片元丢弃; - 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 提交(后) | 提升 |
|---|---|---|---|
| 25 | 1.61 ms | 1.64 ms | ~1× |
| 400 | 25.2 ms | 1.79 ms | 14× |
| 3600 | 220 ms | 1.90 ms | 116× |
CPU+GPU 总耗时在 3600 items 下 256 ms → 5.0 ms(51×),输出与旧路径逐像素一致(含 OIT 与深度排序)。#142 同日把旧 per-item 路径整体删除,实验直接转正。
渲染 III:双缓冲读回
输出纹理从 Renderer 中拆出,读回异步化:start_readback(非阻塞入队)/ finish_readback(阻塞拷回)/ try_finish_readback。渲染循环读回第 N 帧与渲染第 N+1 帧重叠,报告约 +20% 吞吐。
MeshItem:进入 3D
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 友好)并可转 ownedScene;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 ✗ — Webm libvpx-vp9 / yuva420p ✓ 透明视频 Mov prores_ks / yuva444p10le(ProRes 4444) ✓ macOS 可直接预览 Gif gif / 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!拼_SCENEstatic,而是生成同名 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/previewfeature 下的导出,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-decodefeature)纯 Rust 解码(symphonia,WAV/MP3/FLAC/AAC/Ogg Vorbis)并经 rubato FFT 重采样归一到 48 kHz 立体声,不再依赖 ffmpeg 二进制RanimScene::seal时一次性 bake 成 master stereo/48 kHz buffer;线性路径预混,非线性路径走 residual forest- preview 原生播放(随
previewfeature)与 render 端 ffmpeg muxing(MP4/MOV AAC,WebM libopus,GIF 丢弃)
- 可组合动画编排系统(见 “Composable Animation Arrangement” 一节)
AnimSequence/AnimStack容器与seq!/stack!宏,hold/forward/extend等编排 APIAnimLagged容器(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包装IterativeEvalstep 逻辑;闭包经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-exampleswasm 包并经#[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 markranim_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 的gltffeature
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-itemAnimSequence轨道(前=初态、后=末态,采样自窗口边缘;空填充跳过;零时长子项跳过前填充)——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 工作流
出发点:让 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<T, G><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:<g> 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 导入(新
gltffeature,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:rotate 4.57 µs 18.9 ns(~250×) pose:shift 4.52 µs 9.5 ns(~500×) extract 54.5 µs 59.5 µs(+~9%)