朝向:Quat、Rot2 与 Dir
订单上的彗星有个细节要求:彗星是个长条,飞行时头得朝着飞的方向——横着漂移的彗星会被小孩子笑话。上一节的旋转都是“匀速拧螺丝”,这次不一样:每一帧都要算出“此刻该朝哪”,再把姿态摆过去。
先想清楚“朝向”的本质。精灵自己没有“脸”的概念,它只有一套随身的局部轴:自己的 +X、+Y、+Z。所谓“让机头朝向运动方向”,就是选定一根局部轴当机头(彗星是竖长条,选 +Y),然后构造一个旋转,把这根轴拧到目标方向上。
fn setup(mut commands: Commands) {
commands.spawn(Camera2d);
// 太阳留在原点做参照物
commands.spawn(Sprite::from_color(Color::srgb(1.0, 0.8, 0.2), Vec2::splat(80.0)));
// 彗星是个窄长条:竖放时窄边在上,所以“机头”是它自己的 +Y 方向
commands.spawn((
Comet,
Sprite::from_color(Color::srgb(0.85, 0.95, 1.0), Vec2::new(14.0, 36.0)),
Transform::from_xyz(320.0, 0.0, 0.0),
));
}Listing 12-6(其一):窄长条彗星——机头是它自己的 +Y(examples/listing-12-06.rs)
/// 沿椭圆飞行,机头始终指向运动方向
fn fly_comet(time: Res<Time>, mut comets: Query<&mut Transform, With<Comet>>) {
let t = time.elapsed_secs() * 0.8;
for mut transform in &mut comets {
// 椭圆轨道的参数方程:横半轴 320,纵半轴 180
let next = Vec3::new(320.0 * t.cos(), 180.0 * t.sin(), 0.0);
// 运动方向 = 下一处落点 − 当前位置(z 分量不参与,截掉)
let motion = (next - transform.translation).truncate();
transform.translation = next;
// Dir2::new 拒收零向量:原地未动时保持旧朝向,而不是突然朝乱
if let Ok(heading) = Dir2::new(motion) {
// 把精灵自带的 +Y(机头)转到 heading 指的方向
transform.rotation = Quat::from_rotation_arc(Vec3::Y, heading.extend(0.0));
}
}
}Listing 12-6(其二):每帧定向——差分出方向,把 +Y 拧过去
cargo run -p ch12-transforms --example listing-12-06
Figure 12-6:彗星过弯的两个时刻——姿态每帧重算,长轴始终切着轨道
彗星沿椭圆扫过穹顶,机头始终切着轨道的方向,过弯时自然侧倾。三个零件各管一段:
- 运动方向哪来的:“下一处落点 − 当前位置”,两个位置一减就是箭头——上上节的老朋友。z 分量是图层号,
truncate()截掉它再谈方向; Dir2::new的查验:方向类型登场。它返回Result——彗星若一帧没动,差分就是零向量,而零向量没有方向。还记得normalize()遇零给 NaN 的坑吗?Dir2把这个坑变成了编译器强迫你处理的Err:这里的对策是保持旧朝向,跳过这帧;Quat::from_rotation_arc(from, to):“从方向from拧到方向to的最短旋转”。把机头轴Vec3::Y和目标方向喂给它,得到的正是该写进rotation的姿态。
反过来想查询“现在朝哪”也有现成的:transform.up() 给出此刻局部 +Y 在世界里的指向(对彗星就是机头方向),同族还有 right()、forward() 等——返回的都是下面要说的 Dir3。
方向类型的护照制度
Dir2/Dir3(二维/三维方向——保证归一化的单位向量)值得专门一讲。它们解决的问题很朴素:函数签名写 Vec2 时,谁也拦不住调用方传进一个零向量或者长度 37.5 的家伙;写 Dir2,类型本身就是承诺——凡能构造出来的,长度必为 1。检查只在入境时做一次:
// Dir2 是“归一化护照”:入境查验,零向量当场拒签
match Dir2::new(Vec2::ZERO) {
Ok(heading) => println!("方向 {heading}"),
Err(err) => println!("Dir2 拒签零向量:{err:?}"),
}
// (3, 4) 这样的合法向量,入境后长度一律压成 1
let heading = Dir2::new(Vec2::new(3.0, 4.0)).unwrap();
println!("(3, 4) 入境后变成 {heading},长度 {}", heading.length());
// 方向之间用 slerp 匀速摆头:从正东转向正北,走三分之一
let third = Dir2::EAST.slerp(Dir2::NORTH, 1.0 / 3.0);
println!("从正东朝正北摆头三分之一:{:.3}(恰好 30°)", third.as_vec2());Listing 12-7(其一):Dir2 的入境查验——拒签零向量,合格者压成单位长度(examples/listing-12-07.rs)
Bevy 自己的 API 大量收发这种“护照”:上文 transform.up() 返回 Dir3,looking_to 收 impl TryInto<Dir3>。你自己的函数遇到“这个参数必须是个方向”时,也用它。Dir2 能当 Vec2 用(解引用即得),乘标量回到普通向量:heading * 120.0。
Quat 与 Rot2
最后把两位旋转选手对齐。Quat 用四个分量编码任意 3D 旋转,内部数值不直观,但用法只有三招:构造(from_rotation_z、from_rotation_arc……)、复合(两个 Quat 相乘 = 先转一个再转另一个)、作用(Quat * Vec3 = 把向量转过去):
// Quat 是 3D 旋转的通用货币:绕 +Z 转 90°,把 +X 送到 +Y
let quarter = Quat::from_rotation_z(FRAC_PI_2);
println!("Quat 把 +X 转到 {}", (quarter * Vec3::X).round());Listing 12-7(其二):Quat 作用于向量
而 2D 世界只有“绕 z”一种转法,为此 bevy_math 备了轻量的 Rot2(二维旋转)——能用角度直观构造、直接乘 Vec2:
// Rot2 是 2D 专用的旋转:能用角度直观构造,直接乘 Vec2
let r = Rot2::degrees(45.0);
println!("Rot2 把 (1, 0) 转到 {:.3}", r * Vec2::X);
println!("再把角度读回来:{} 度", r.as_degrees());Listing 12-7(其三):Rot2——2D 专用旋转
cargo run -p ch12-transforms --example listing-12-07Quat 把 +X 转到 [0, 1, 0]
Rot2 把 (1, 0) 转到 [0.707, 0.707]
再把角度读回来:45 度
Dir2 拒签零向量:Zero
(3, 4) 入境后变成 [0.6, 0.8],长度 1
从正东朝正北摆头三分之一:[0.866, 0.500](恰好 30°)注意分工:Rot2 是给平面数学用的(配 Vec2、Dir2,做输入处理、几何计算);Transform::rotation 的字段类型永远是 Quat——哪怕 2D 游戏也是。两边换算不亏:Quat::from_rotation_z(rot2.as_radians())。
输出最后一行的 slerp(球面插值)按角度匀速“摆头”,是做“炮塔缓缓转向目标”的正确工具——直接 lerp 向量再归一化,转动的角速度会不匀。
兵器全齐了:摆放、旋转、缩放、朝向。回头收拾那个躲不掉的难题——月亮。