Transformed<T, G>:变换表示、组合与物件语义
[!caution]ai 生成,可能叙事逻辑和表述并不是很好,仅供参考。
ranim 把“物件自身的数据”和“附加在物件外的变换”分开表达:
T 是物件,G 是 wrapper 实际存储的变换表示。字段保持公开,可以在自定义 evaluator 中直接读写;常规组合则推荐使用 compose_outer 和compose_inner,使乘法顺序一目了然。
1. 变换类型与仿射端点
ranim 的模型变换层级如下:
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>:物件能直接吸收什么
基础接口是:
物件通过实现范围声明自己的闭包:
// 点数据可以吸收仿射变换及其子类型
// 点集/网格数据可以吸收仿射变换
// canonical Circle、Ellipse、Rectangle、Square、Sphere 不直接吸收 placement
便利操作由这个接口派生:
操作 | 提交给 |
|---|---|
|
|
|
|
|
|
|
|
canonical Rectangle、Circle、Sphere 等不直接实现 ApplyTransform;它们的平移、旋转和缩放都应通过 Transformed<T, G> 表达。Rectangle::scale_axes 是单独的内在尺寸编辑。点集型 VItem、Polygon、Line、MeshItem 和 Surface 才直接吸收一般仿射 DAffine3。
3. 构造 wrapper:参数就是精确的 G
构造器同时接收物件与变换:
let item = new;
prelude 还导出了 blanket extension trait,可以写成:
let item = mesh.;
.transformed::<G>(g) 的参数必须恰好是 G;这个入口不会替调用者选择更宽的存储类型。通常可以让类型推断直接从参数得到 G:
let item = mesh.transformed;
// 类型是 Transformed<MeshItem, Translation>
选择较窄的 G 会让可组合的操作在编译期受限;选择 DAffine3 则是现有mesh/surface 场景常用的存储上界。
4. outer 与 inner 组合
设 wrapper 当前存储
对应 API:
item.compose_outer; // transform = h * transform
item.compose_inner; // transform = transform * h
ApplyTransform<H> for Transformed<T, G> 使用 outer composition,所以shift、rotate、scale_uniform 和 scale 在可用时也都走左乘:
item.apply; // 等价于 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;
item.shift; // Translation -> Similarity
item.scale_uniform; // 仍是 Transformed<Sphere, Similarity>
但一般 Diag 不能嵌入 Similarity,因此 item.scale(non_uniform) 不会偷偷把类型改成 Transformed<_, DAffine3>,而是在编译期不可用。需要更一般的组合时,显式 widening:
let narrow = mesh.transformed;
let mut affine: = narrow.into;
affine.scale;
这种显式 Into 让 API 的返回类型稳定,也避免为任意两种变换表示自动推导“最小共同上界”所带来的 coherence 问题。
4.2 嵌套 wrapper
嵌套依然从内向外展平:
Transformed {
transform: outer,
inner: Transformed {
transform: inner,
inner: x,
},
}
最终组合 = outer * inner
5. bake 是编译期能力
bake 的签名直接使用 wrapper 的 G:
;
所以它是否存在完全由类型系统决定:
let polygon = new
.transformed
.bake; // Polygon: ApplyTransform<DAffine3>
let circle = new.transformed;
// circle.bake(); // 编译失败:Circle 不吸收 placement;保留 wrapper
wrapper 不再提供 try_bake。如果调用者确实需要把动态得到的 DAffine3 向下检查为 Similarity,应先显式执行 Similarity::try_from(affine),然后构造Transformed<T, Similarity>;成功后 bake 仍然是静态能力。
这与几何闭包相符:
物件表示 | 可直接吸收的上界 |
|---|---|
点、点集、 |
|
|
|
|
|
|
|
canonical | 不直接 bake placement |
一般仿射变换会把圆变成椭圆、把矩形变成平行四边形,因此不能无损地 bake 回原来的参数化类型。
6. anchor、渲染提取、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> 在求值帧中保留完整 wrapper,不要求 T 实现渲染 trait。到了渲染侧,只要 T: Extract、G: Clone + Into<DAffine3>,就能通过值级Extract 组合变换并递归处理内部值:
G --Into<DAffine3>--> ExtractContext 累计变换 --> primitive 的渲染矩阵
Extract:ctx.with_transform按parent * child组合本层变换,递归提取内部值,保留局部几何;逻辑物件:VItem/MeshItem 提取时把累计变换写入渲染组件的矩阵,顶点保持不变;相机则变换位置与方向;
Aabb:变换内部包围盒的八个角点,再重新取界。
宿主需为完整输出注册 ExtractPlugin<Transformed<T, G>>;只注册 T 的插件不会读取 wrapper 批次。值级协议与插件装配见完整求值帧与渲染提取。这使高层物件可以保留语义表示,同时渲染结果仍能包含更一般的仿射效果。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,直到渲染提取时才将变换转为 DAffine3,与父上下文组合。
7. 插值
只有两个 wrapper 的 T 与 G 都相同时才能直接插值:
Self
inner 与 transform 独立插值,不会先展平为点数据。若两端 wrapper 使用不同存储类型,应先由调用者把它们显式 widening 到同一个 G。
7.1 wrapper 插值与 bake 插值是两种预期行为
wrapper 的独立插值意味着:同一点集在不同放置下的过渡始终保留各自的局部形状,运动发生在 transform 上。例如两个仅放置不同的方块(inner 完全相同,变换分别放在 XY 平面与 XZ 平面上),中间帧不会出现“一个平面翻到另一个平面”的点级渐变——每个点在局部空间里静止不动,位姿由矩阵插值承载。这是预期行为,不是缺陷。
如果需要的是经典 manim 式的形态 morph——逐点在世界空间中走直线、法线和平面随顶点一起形变——就把两端的放置 bake 进底层数据再插值:转成裸 VItem或 MeshItem 后,插值就是纯底层点插值。两种模式由表示方式显式选择:
模式 | 表示 | 插值路径 |
|---|---|---|
位姿插值 |
| inner 恒定(或各自插值), |
形态 morph | 裸 | 全部控制点世界空间逐点插值 |
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;
// 修改 Rectangle 的维度参数
wrapped.compose_inner;
// inner 不变,右乘 wrapper.transform
wrapped.compose_outer;
// inner 不变,左乘 wrapper.transform
不要把 scale_axes 理解为矩阵乘法的别名:前者维护 Rectangle 的正交参数化表示,后两者维护 wrapper 的组合顺序。
9. 几何视角
Klein 的 Erlangen 纲领把一种几何理解为“研究某个变换群下的不变量”。在ranim 中:
G描述允许组合的变换;ApplyTransform<G>描述物件表示对这个群是否闭包;Transformed<T, G>把外部组合与T的参数化语义分离;bake在编译期重新要求闭包;求值帧保留完整 wrapper,值级
Extract在渲染侧的仿射边界组合变换并生成视觉数据。
这个分层让变换的数学顺序、Rust 类型与物件几何语义保持一致。