Projection:真正的取景框
第 12 章天文馆的取景框是一个写死的 Rect::from_center_size(Vec2::ZERO, Vec2::new(600.0, 360.0))——小满当时就嘀咕:窗口明明不止这么大,这个数哪来的?拍脑袋来的。本节把真账本找出来。
从世界到画面的换算规则
相机的第二位随从是 Projection(投影)组件。它回答的问题是:世界坐标里的一个点,落到画面上的哪里。这个换算规则有两大流派,恰好是一个 enum 的两个变体:
Projection::Orthographic(OrthographicProjection)——正交投影:平行光线垂直投向画面,物体无论远近都一样大。Camera2d的默认配置,2D 世界的标准选择;Projection::Perspective(PerspectiveProjection)——透视投影:近大远小,有纵深感。Camera3d的默认配置,本章第 7 节登场。
(还有第三个变体 Custom,给自定义投影留的后门,本书不展开。)
2D 这一侧,正交投影的换算规则简单到可以一句话说完:以相机位置为中心,世界单位与窗口逻辑像素一比一对应。窗口 1280×720,画面就覆盖以相机为中心、宽 1280 高 720 的一块世界;一个 30×30 的 Sprite 占 30×30 个像素。前面所有章节“坐标写多少、屏上挪多少”的直觉,根子就在这条默认规则上。
这个“覆盖的一块世界”不是抽象说法,OrthographicProjection 里有个现成的字段:area: Rect——以相机为原点的取景框,引擎在窗口尺寸或投影参数变化时自动维护它。把它平移到相机的世界坐标上,就是此刻镜头实拍的世界范围。这正是小满想要的那本真账。
OrthographicProjection 还有两个老熟人:near 与 far,取景框在 z 轴上的伸展范围。2D 默认配置里 near = -1000.0、far = 1000.0——第 12 章那句“默认相机只见 z 轴 ±1000”的出处就在这里。这也解释了上一节“别乱动相机 z”的叮嘱:±1000 是以相机为基准量的,相机的 z 跑了,可见区间跟着跑。
场记的两本账
光说不练假把式。给片场加一个报点系统:每两秒,场记小鹿用两种独立的量法丈量实拍范围,相互核对——
第一本账直接读投影:拿到 area,平移到相机位置。第二本账走相反的方向:用 Camera::viewport_to_world_2d 把视口(viewport——画面落在渲染目标上的那块区域,眼下就是整个窗口)的两个角反算回世界坐标。视口有自己的坐标系:原点在左上角,y 朝下,单位是逻辑像素——跟世界坐标的“y 朝上”正好相反,跟第 12 章讲过的“屏幕坐标系”同宗。
Figure 13-3:同一扇窗口上的两套度量——视口坐标与世界坐标在 y 轴上对头
所以视口的 (0, 0) 反算回去,应该正对世界取景框的左上角(x 最小、y 最大):
/// 场记小鹿:每两秒丈量一次镜头实拍范围,两种量法相互核对
fn report_frame(
time: Res<Time>,
lens: Single<(&Camera, &GlobalTransform, &Projection)>,
mut clock: Local<f32>,
) {
*clock += time.delta_secs();
if *clock < 2.0 {
return;
}
*clock -= 2.0;
let (camera, lens_pos, projection) = *lens;
// 量法一:正交投影的 area 是“以相机为原点”的取景框,平移到相机位置就是世界范围
let Projection::Orthographic(ortho) = projection else {
return;
};
let center = lens_pos.translation().truncate();
let frame = Rect {
min: ortho.area.min + center,
max: ortho.area.max + center,
};
println!(
"场记:实拍范围 x [{:.0}, {:.0}],y [{:.0}, {:.0}]",
frame.min.x, frame.max.x, frame.min.y, frame.max.y
);
// 量法二:把视口的左上角与右下角反算回世界坐标
let Some(size) = camera.logical_viewport_size() else {
return;
};
let (Ok(top_left), Ok(bottom_right)) = (
camera.viewport_to_world_2d(lens_pos, Vec2::ZERO),
camera.viewport_to_world_2d(lens_pos, size),
) else {
return;
};
println!(
"场记:视口反算 左上→[{:.0}, {:.0}],右下→[{:.0}, {:.0}],两本账对上了。",
top_left.x, top_left.y, bottom_right.x, bottom_right.y
);
}Listing 13-5:两种量法丈量同一个取景框(examples/listing-13-05.rs)
几处签名值得停一眼。查询拿的是 (&Camera, &GlobalTransform, &Projection) 三件套:viewport_to_world_2d 要 Camera(它知道视口多大)和 GlobalTransform(它知道相机在哪),读 area 要 Projection。投影是 enum,先用 let else 验明正身是正交流派。logical_viewport_size 和 viewport_to_world_2d 都返回可失败类型——相机刚出生、尺寸还没算好的那一帧会拿不到值,let else 直接收工,下个报点周期再来。
cargo run -p ch13-cameras --example listing-13-05老雷:小鹿,每个镜头给我报实拍范围!
场记:实拍范围 x [-359, 921],y [-171, 549]
场记:视口反算 左上→[-359, 549],右下→[921, -171],两本账对上了。
场记:实拍范围 x [-239, 1041],y [-432, 288]
场记:视口反算 左上→[-239, 288],右下→[1041, -432],两本账对上了。逐项验账:
- 范围永远是 1280 × 720——上面第一报,x 跨度
921 − (−359) = 1280,y 跨度549 − (−171) = 720,正是窗口的逻辑尺寸。一比一规则,实测坐实; - 范围的中心在动——镜头在跟拍阿燕,每次报数的取景框中心都不同。小满那个写死的
Rect错就错在这:真取景框是跟着相机走的; - 视口反算的“左上”是世界的“x 最小、y 最大”——
(0, 0)反算出[-359, 549],正是第一本账的min.x配max.y。两套坐标系在 y 轴上的对头,在这一行输出里看得明明白白。
viewport_to_world_2d 的常见用途其实是反算鼠标:光标的窗口坐标就是视口坐标,过一道换算就知道它指着世界里的哪个点——第 17 章讲输入、第 25 章讲拾取时它还会回来。反方向的 world_to_viewport 也存在:世界点换算到画面位置,给“在角色头顶画血条”这类需求用(第 28 章 UI 见)。
取景框看明白了,老雷的下一个要求接踵而至:光会“跟”还不够,镜头得会推拉——下一节给投影装上变焦环。