Skip to content

杀青

全章零件总装。机位单上三台机器:左路 order 0 跟拍阿燕,右路 order 1 跟拍踏雪,沙盘机 order 2 叠在右上角、两层通吃;马克点和两位主演的图例住在工作层,正片机位一概看不见;窗口一变,导播台整体重排;场记每四秒报一次左路的实拍范围。

rust
//! Listing 13-12:杀青——青蝉影视城全机位联动
//! 左路跟拍阿燕,右路跟拍踏雪,右上角沙盘鸟瞰全场;
//! 马克点与图例住工作层,正式机位看不见;场记定时报实拍范围

use bevy::camera::visibility::RenderLayers;
use bevy::camera::{ScalingMode, Viewport};
use bevy::prelude::*;
use bevy::window::WindowResized;

/// 工作层:马克点、沙盘图例——正式机位看不见
const CREW_LAYER: usize = 1;

/// 标记:侠客阿燕
#[derive(Component)]
struct Hero;

/// 标记:白马踏雪
#[derive(Component)]
struct Horse;

/// 标记:左路机位(跟拍阿燕)
#[derive(Component)]
struct LeftLens;

/// 标记:右路机位(跟拍踏雪)
#[derive(Component)]
struct RightLens;

/// 标记:沙盘机位(小地图)
#[derive(Component)]
struct MinimapLens;

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .insert_resource(ClearColor(Color::srgb(0.05, 0.07, 0.15)))
        .add_systems(Startup, setup)
        .add_systems(
            Update,
            (
                (walk_hero, run_horse, follow_hero, follow_horse).chain(),
                layout_viewports,
                report_frame,
            ),
        )
        .run();
}

fn setup(mut commands: Commands) {
    // —— 三台机位 ——
    // 左路:跟拍阿燕,先画
    commands.spawn((
        Camera2d,
        LeftLens,
        Camera {
            order: 0,
            ..default()
        },
        Projection::Orthographic(OrthographicProjection {
            scaling_mode: ScalingMode::FixedVertical {
                viewport_height: 600.0,
            },
            ..OrthographicProjection::default_2d()
        }),
    ));
    // 右路:跟拍踏雪
    commands.spawn((
        Camera2d,
        RightLens,
        Camera {
            order: 1,
            ..default()
        },
        Projection::Orthographic(OrthographicProjection {
            scaling_mode: ScalingMode::FixedVertical {
                viewport_height: 600.0,
            },
            ..OrthographicProjection::default_2d()
        }),
    ));
    // 沙盘:最后画,叠在最上层;看第 0 层与工作层
    commands.spawn((
        Camera2d,
        MinimapLens,
        Camera {
            order: 2,
            clear_color: ClearColorConfig::Custom(Color::srgb(0.09, 0.08, 0.07)),
            ..default()
        },
        Projection::Orthographic(OrthographicProjection {
            scaling_mode: ScalingMode::Fixed {
                width: 1200.0,
                height: 900.0,
            },
            ..OrthographicProjection::default_2d()
        }),
        RenderLayers::from_layers(&[0, CREW_LAYER]),
    ));

    // —— 布景 ——
    commands.spawn((
        Sprite::from_color(Color::srgb(0.16, 0.13, 0.11), Vec2::new(1000.0, 560.0)),
        Transform::from_xyz(0.0, 0.0, -10.0),
    ));
    for i in -3..=3 {
        for y in [-240.0, 240.0] {
            commands.spawn((
                Sprite::from_color(Color::srgb(0.95, 0.75, 0.25), Vec2::splat(22.0)),
                Transform::from_xyz(i as f32 * 160.0, y, -5.0),
            ));
        }
    }
    // 马克点:走位记号,只在工作层
    for (x, y) in [(0.0, 0.0), (420.0, 0.0), (-420.0, 0.0), (297.0, 210.0), (-297.0, -210.0)] {
        commands.spawn((
            Sprite::from_color(Color::srgb(1.0, 0.3, 0.7), Vec2::splat(16.0)),
            Transform::from_xyz(x, y, -4.0),
            RenderLayers::layer(CREW_LAYER),
        ));
    }

    // —— 主演与沙盘图例(图例在工作层,RenderLayers 不沿层级继承)——
    commands.spawn((
        Hero,
        Sprite::from_color(Color::srgb(0.85, 0.2, 0.2), Vec2::splat(30.0)),
        Transform::from_xyz(0.0, 0.0, 0.0),
        children![(
            Sprite::from_color(Color::srgb(1.0, 0.4, 0.4), Vec2::splat(72.0)),
            Transform::from_xyz(0.0, 0.0, 5.0),
            RenderLayers::layer(CREW_LAYER),
        )],
    ));
    commands.spawn((
        Horse,
        Sprite::from_color(Color::srgb(0.92, 0.92, 0.95), Vec2::new(52.0, 30.0)),
        Transform::from_xyz(350.0, 0.0, 0.0),
        children![(
            Sprite::from_color(Color::srgb(0.95, 0.95, 1.0), Vec2::splat(72.0)),
            Transform::from_xyz(0.0, 0.0, 5.0),
            RenderLayers::layer(CREW_LAYER),
        )],
    ));

    println!("老雷:《侠客行》最后一场——全机位联动,开机!");
}

/// 走位:阿燕满场跑“8”字
fn walk_hero(time: Res<Time>, mut hero: Single<&mut Transform, With<Hero>>) {
    let t = time.elapsed_secs() * 0.5;
    hero.translation.x = 420.0 * t.sin();
    hero.translation.y = 210.0 * (2.0 * t).sin();
}

/// 走位:踏雪绕场奔驰
fn run_horse(time: Res<Time>, mut horse: Single<&mut Transform, With<Horse>>) {
    let t = time.elapsed_secs() * 0.4;
    horse.translation.x = 350.0 * t.cos();
    horse.translation.y = 240.0 * t.sin();
}

/// 左路跟拍:平滑追向阿燕
fn follow_hero(
    time: Res<Time>,
    mut lens: Single<&mut Transform, (With<LeftLens>, Without<Hero>)>,
    hero: Single<&Transform, With<Hero>>,
) {
    let target = hero.translation.with_z(lens.translation.z);
    lens.translation.smooth_nudge(&target, 2.0, time.delta_secs());
}

/// 右路跟拍:平滑追向踏雪
fn follow_horse(
    time: Res<Time>,
    mut lens: Single<&mut Transform, (With<RightLens>, Without<Horse>)>,
    horse: Single<&Transform, With<Horse>>,
) {
    let target = horse.translation.with_z(lens.translation.z);
    lens.translation.smooth_nudge(&target, 2.0, time.delta_secs());
}

/// 导播台排版:左右分屏 + 右上角沙盘,窗口一变全部重排
fn layout_viewports(
    windows: Query<&Window>,
    mut resized: MessageReader<WindowResized>,
    mut left: Single<&mut Camera, (With<LeftLens>, Without<RightLens>, Without<MinimapLens>)>,
    mut right: Single<&mut Camera, (With<RightLens>, Without<LeftLens>, Without<MinimapLens>)>,
    mut minimap: Single<&mut Camera, (With<MinimapLens>, Without<LeftLens>, Without<RightLens>)>,
) {
    for message in resized.read() {
        let Ok(window) = windows.get(message.window) else {
            continue;
        };
        let half = window.physical_size() / UVec2::new(2, 1);
        left.viewport = Some(Viewport {
            physical_position: UVec2::ZERO,
            physical_size: half,
            ..default()
        });
        right.viewport = Some(Viewport {
            physical_position: UVec2::new(half.x, 0),
            physical_size: half,
            ..default()
        });
        let width = window.physical_width() / 4;
        let size = UVec2::new(width, width * 3 / 4);
        minimap.viewport = Some(Viewport {
            physical_position: UVec2::new(window.physical_width() - size.x - 16, 16),
            physical_size: size,
            ..default()
        });
        println!("场记:导播台重排——每路 {} × {},沙盘 {} × {}。", half.x, half.y, size.x, size.y);
    }
}

/// 场记:每四秒报一次左路的实拍范围
fn report_frame(
    time: Res<Time>,
    lens: Single<(&GlobalTransform, &Projection), With<LeftLens>>,
    mut clock: Local<f32>,
) {
    *clock += time.delta_secs();
    if *clock < 4.0 {
        return;
    }
    *clock -= 4.0;
    let (lens_pos, projection) = *lens;
    let Projection::Orthographic(ortho) = projection else {
        return;
    };
    let center = lens_pos.translation().truncate();
    println!(
        "场记:左路实拍 x [{:.0}, {:.0}],y [{:.0}, {:.0}]。",
        ortho.area.min.x + center.x,
        ortho.area.max.x + center.x,
        ortho.area.min.y + center.y,
        ortho.area.max.y + center.y
    );
}

Listing 13-12:完整示例——青蝉影视城全机位联动(src/main.rs)

console
cargo run -p ch13-cameras

窗口左右分屏各追各的主角,右上角的沙盘里两个大图例在小黄点阵中画着各自的轨迹;控制台同步记着台账:

text
老雷:《侠客行》最后一场——全机位联动,开机!
场记:导播台重排——每路 800 × 900,沙盘 400 × 300。
场记:导播台重排——每路 800 × 900,沙盘 400 × 300。
场记:左路实拍 x [134, 667],y [-372, 228]。
场记:左路实拍 x [-501, 32],y [-122, 478]。

总装画面:左右分屏分别跟拍侠客与白马,右上角叠着沙盘小图,沙盘里有马克点与两位主演的大号图例,分屏画面里则没有

Figure 13-9:全机位联动——order 排出三层画面,RenderLayers 各自结算

三处值得回头多看一眼:

  • 三台相机同一个窗口,警告一条没有order 排成 0、1、2 的阶梯,引擎对绘制次序再无疑问。沙盘叠在右路画面之上,靠的也是 order 而非 z——后画的压住先画的,这是相机之间的规矩;z 图层只在同一台相机取景的实体之间论资排辈;
  • 左路实拍永远是 533 × 600。两条报点的跨度都是 667 − 134 = 533228 − (−372) = 600FixedVertical { viewport_height: 600 } 锁死纵向,横向按视口 800×900 物理(即 640×720 逻辑)的宽高比配平出 640 / 720 × 600 ≈ 533。投影、视口、缩放因子三笔账在一行输出里咬合;
  • 同一个世界,三种画面。马克点与图例对左右两路隐形、对沙盘可见——RenderLayers 的交集规则在三台相机上各自独立结算。运动系统一个没改:演员只管走位,“谁看见什么”全是相机侧的配置。世界与取景,各管各的。

小结

  • 相机是实体Camera2d/Camera3d 靠 required components 带齐 CameraProjectionTransform 等全家桶;2D 与 3D 各启用一条渲染管线。没有相机就没有画面
  • 清屏色:底色是相机每帧擦出来的。全局默认在 ClearColor 资源(出厂深灰),每台相机可用 clear_color: ClearColorConfig 改持 Custom(颜色)None(不擦、直接叠画)
  • 运镜即改 Transform:跟拍是每帧更新相机坐标;smooth_nudge(&target, decay_rate, delta) 给镜头帧率无关的平滑迟滞,速率越大追得越紧。相机的 z 别乱动——±1000 的可见区间以它为基准
  • Projection 一个枚举两大流派:正交(远近等大,2D 默认)与透视(近大远小,3D 默认)。OrthographicProjection 故意不实现 Default,构造用 ..default_2d()(near = −1000)或 ..default_3d()(near = 0)打底;area 字段是以相机为原点的真取景框,平移到相机位置即实拍范围
  • 世界↔视口换算:视口坐标原点在左上、y 朝下。viewport_to_world_2d 把视口点(如鼠标)反算成世界坐标,world_to_viewport 走反向(血条、标签定位)
  • 推拉与适配scale 乘在取景框上——越大看得越多、物体越小;ScalingMode 决定窗口拉伸时的对策,游戏常用 FixedVertical/AutoMin 锁视野,默认 WindowSize 是一比一
  • 多相机:同一渲染目标上每台相机的 order 必须独一无二,否则每帧警告、绘制次序不可预测;Viewport物理像素划地盘(左上原点),配 MessageReader<WindowResized> 维护——开窗也算一次 resize,初始化捎带办了
  • RenderLayers:实体与相机各持层号集合,有交集才入画;不挂组件 = 第 0 层,空集合 = 隐形,不沿父子层级继承。引入路径 bevy::camera::visibility::RenderLayers
  • 透视投影fov 纵向视场角(默认 45°),near/far 截出视锥;贴脸与身后不入镜。Camera3d 配正交照样合法——等距游戏的标准操作

练习

  1. 手感调校:把 Listing 13-12 左路跟拍的 smooth_nudge 速率从 2.0 分别改成 0.510.0(右路保持 2.0),先预测分屏两侧的运镜差异,再运行对比。
  2. 电影感推拉:Listing 13-7 的切镜是硬切。把 cut_shots 改成“每帧把 ortho.scale 向目标档位平滑逼近”——f32 同样实现了 StableInterpolatescale.smooth_nudge(&target, 3.0, delta) 即可。分镜切换瞬间的观感会发生什么变化?
  3. 沙盘三连改:基于 Listing 13-10 依次实验——把沙盘机的 clear_color 改成 ClearColorConfig::None;把它的 RenderLayers 改成只看 from_layers(&[1]);删掉图例子实体上的 RenderLayers::layer(CREW_LAYER)。每一步先预测沙盘和正片各自的变化,再运行验证。第三步的结果请用“RenderLayers 不沿层级继承”解释。
  4. 谁埋了沙盘:把 Listing 13-12 里沙盘机的 order 改成 0、左路改成 2,预测右上角会发生什么,运行印证。提示:每台相机动笔前都要按自己的 clear_color 擦一遍自己的地盘。

下一章拍摄继续,但镜头转向幕后——片场里所有看得见的东西至今都是代码里现编的色块。真正的美术素材(图片、音频、字体、模型)怎么进入游戏?第 14 章拆 Bevy 的 Asset 系统:AssetServerHandle,以及“加载是异步的”这件事如何改变你组织代码的方式。