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%)