Skip to content

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 还有两个老熟人:nearfar,取景框在 z 轴上的伸展范围。2D 默认配置里 near = -1000.0far = 1000.0——第 12 章那句“默认相机只见 z 轴 ±1000”的出处就在这里。这也解释了上一节“别乱动相机 z”的叮嘱:±1000 是以相机为基准量的,相机的 z 跑了,可见区间跟着跑。

场记的两本账

光说不练假把式。给片场加一个报点系统:每两秒,场记小鹿用两种独立的量法丈量实拍范围,相互核对——

第一本账直接读投影:拿到 area,平移到相机位置。第二本账走相反的方向:用 Camera::viewport_to_world_2d视口(viewport——画面落在渲染目标上的那块区域,眼下就是整个窗口)的两个角反算回世界坐标。视口有自己的坐标系:原点在左上角,y 朝,单位是逻辑像素——跟世界坐标的“y 朝上”正好相反,跟第 12 章讲过的“屏幕坐标系”同宗。

示意图:同一扇窗口上叠着两套坐标系——橙色的视口坐标原点在左上角且 y 朝下,蓝色的世界坐标原点在相机中心且 y 朝上;视口 (0,0) 对应世界的 (min.x, max.y),视口 (宽,高) 对应世界的 (max.x, min.y)

Figure 13-3:同一扇窗口上的两套度量——视口坐标与世界坐标在 y 轴上对头

所以视口的 (0, 0) 反算回去,应该正对世界取景框的左上角(x 最小、y 最大):

rust
/// 场记小鹿:每两秒丈量一次镜头实拍范围,两种量法相互核对
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_2dCamera(它知道视口多大)和 GlobalTransform(它知道相机在哪),读 areaProjection。投影是 enum,先用 let else 验明正身是正交流派。logical_viewport_sizeviewport_to_world_2d 都返回可失败类型——相机刚出生、尺寸还没算好的那一帧会拿不到值,let else 直接收工,下个报点周期再来。

console
cargo run -p ch13-cameras --example listing-13-05
text
老雷:小鹿,每个镜头给我报实拍范围!
场记:实拍范围 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.xmax.y。两套坐标系在 y 轴上的对头,在这一行输出里看得明明白白。

viewport_to_world_2d 的常见用途其实是反算鼠标:光标的窗口坐标就是视口坐标,过一道换算就知道它指着世界里的哪个点——第 17 章讲输入、第 25 章讲拾取时它还会回来。反方向的 world_to_viewport 也存在:世界点换算到画面位置,给“在角色头顶画血条”这类需求用(第 28 章 UI 见)。

取景框看明白了,老雷的下一个要求接踵而至:光会“跟”还不够,镜头得会推拉——下一节给投影装上变焦环。