杀青
全章零件总装。机位单上三台机器:左路 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 = 533、228 − (−372) = 600:FixedVertical { viewport_height: 600 }锁死纵向,横向按视口 800×900 物理(即 640×720 逻辑)的宽高比配平出640 / 720 × 600 ≈ 533。投影、视口、缩放因子三笔账在一行输出里咬合; - 同一个世界,三种画面。马克点与图例对左右两路隐形、对沙盘可见——
RenderLayers的交集规则在三台相机上各自独立结算。运动系统一个没改:演员只管走位,“谁看见什么”全是相机侧的配置。世界与取景,各管各的。
小结
- 相机是实体:
Camera2d/Camera3d靠 required components 带齐Camera、Projection、Transform等全家桶;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配正交照样合法——等距游戏的标准操作
练习
- 手感调校:把 Listing 13-12 左路跟拍的
smooth_nudge速率从2.0分别改成0.5和10.0(右路保持2.0),先预测分屏两侧的运镜差异,再运行对比。 - 电影感推拉:Listing 13-7 的切镜是硬切。把
cut_shots改成“每帧把ortho.scale向目标档位平滑逼近”——f32同样实现了StableInterpolate,scale.smooth_nudge(&target, 3.0, delta)即可。分镜切换瞬间的观感会发生什么变化? - 沙盘三连改:基于 Listing 13-10 依次实验——把沙盘机的
clear_color改成ClearColorConfig::None;把它的RenderLayers改成只看from_layers(&[1]);删掉图例子实体上的RenderLayers::layer(CREW_LAYER)。每一步先预测沙盘和正片各自的变化,再运行验证。第三步的结果请用“RenderLayers 不沿层级继承”解释。 - 谁埋了沙盘:把 Listing 13-12 里沙盘机的
order改成0、左路改成2,预测右上角会发生什么,运行印证。提示:每台相机动笔前都要按自己的clear_color擦一遍自己的地盘。
下一章拍摄继续,但镜头转向幕后——片场里所有看得见的东西至今都是代码里现编的色块。真正的美术素材(图片、音频、字体、模型)怎么进入游戏?第 14 章拆 Bevy 的 Asset 系统:AssetServer、Handle,以及“加载是异步的”这件事如何改变你组织代码的方式。