Bevy 0.14 第一人称转视角踩坑全记:从黑屏到指针锁定到子实体相机
🚨 重要提醒
本文记录用 Rust + Bevy 0.14 移植一个 Three.js FPS 游戏到 macOS 时,"光标动不了、视角也不转"这一连串问题的真实排查链。每个坑都附最小复现代码与根因,三类读者各取所需。
项目背景
原作是一个 Electron + Three.js 的 12v12 AI 对战 FPS(B 站 BV13UKP6mEdz,Kimi K3 生成战地游戏)。我用 Rust + Bevy 0.14 重写,目标是 macOS 上能 cargo run 出一个能部署、走路、射击、AI 对战的 demo。骨架代码完成后,游戏能跑、主菜单能显示、点 START 能进入部署界面、选兵种点 DEPLOY 能进入游戏场景。但进了游戏就出现本文要讲的事——光标动不了,视角也不转。
踩坑链条总览(一图看完)
| 坑号 | 现象 | 根因 | 修复 |
|---|---|---|---|
| ① | 进游戏全黑屏 | 没有 3D 相机渲染世界 | Startup 里 spawn Camera3dBundle |
| ② | UI 全叠在左上角 | UI 元素没有父子层级嵌套,都默认绝对定位到原点 | 改用 with_children 嵌套,去掉 PositionType::Absolute |
| ③ | 进游戏 panic index out of bounds |
InputState 的 pressed_set: Vec<bool> 用 Default 是空 Vec,clear() 后索引越界 |
改 clear() 为 for v in iter_mut() { *v = false },并在 Default 里 vec![false; 256] |
| ④ | 光标动不了,视角也不转 | 指针锁定(CursorGrabMode::Locked)后 CursorMoved 事件不再触发 |
改用 MouseMotion(DeviceEvent 级)读取鼠标位移 |
| ⑤ | 视角 yaw/pitch 更新了但画面没转 | 3D 相机是 Startup 里 spawn 的独立实体,和玩家实体没任何关联 |
把相机作为玩家子实体 spawn(with_children),自动跟随 |
| ⑥ | 按钮(SETTINGS/兵种卡片)点击不响应 | Query<(&Interaction, &Btn), Changed<Interaction>> 的 Changed 过滤器在按钮 spawn/despawn 切状态时会漏事件 |
去掉 Changed<Interaction>,每帧轮询所有按钮 |
下面按踩坑顺序逐个展开。每个坑都给"原代码 → 症状 → 根因 → 修复代码"四段,方便对照自己的项目。
坑 ①:进游戏全黑屏
症状
cargo run 后窗口弹出,主菜单能看到(说明 UI 相机在),但点 START → DEPLOY 进入游戏后画面全黑,只剩 UI 元素悬在黑色背景上。日志里一条关键 WARN:
WARN bevy_ui::layout: No camera found to render UI
但更致命的是 3D 世界根本没渲染——因为我压根没 spawn 3D 相机。
根因
我在 Startup 里只 spawn 了 Camera2dBundle(给 UI 用),天真地以为 Bevy 会默认给你一个 3D 相机。不会的。 Bevy 0.14 的 DefaultPlugins 不自带任何相机,你要自己 spawn。
修复
fn setup_scene(mut commands: Commands, ...) {
// UI 相机(渲染 HUD/菜单)
commands.spawn(Camera2dBundle {
camera: Camera { order: 1, ..default() },
..default()
});
// 3D 相机(渲染游戏世界)—— 这一行原本漏了
commands.spawn(Camera3dBundle {
transform: Transform::from_xyz(0.0, 2.0, 5.0).looking_at(Vec3::ZERO, Vec3::Y),
camera: Camera { order: 0, ..default() },
..default()
});
// 太阳光也顺手加上,否则 PBR 材质全是黑的
commands.spawn(DirectionalLightBundle {
directional_light: DirectionalLight { shadows_enabled: true, ..default() },
transform: Transform::from_xyz(80., 120., 60.).looking_at(Vec3::ZERO, Vec3::Y),
..default()
});
}
Camera.order 数字小的先渲染,大的后渲染(盖在上面)。UI 相机 order=1 在 3D order=0 之上。
一句话速查:Bevy 不自带相机,
Camera2d和Camera3d都得自己 spawn;order控制渲染先后。
坑 ②:UI 全叠在左上角
症状
主菜单标题、副标题、四个按钮全叠在窗口左上角一根摞一根,字都糊在一起。部署界面也是——兵种卡片、武器列表、DEPLOY 按钮,全跑左上角去了。
根因
我用的是这种写法(错的):
// 根容器
commands.spawn((
NodeBundle {
style: Style {
width: Val::Percent(100.0), height: Val::Percent(100.0),
flex_direction: FlexDirection::Column,
align_items: AlignItems::Center,
justify_content: JustifyContent::Center,
position_type: PositionType::Absolute, // ← 罪魁
..default()
},
..default()
},
MainMenuUI,
));
// 然后又单独 spawn 标题、按钮,都带 MainMenuUI 标记
commands.spawn(TextBundle::from_section("STEEL FRONTLINE", ...).with_style(Style { ..default() }));
commands.spawn(ButtonBundle { .. });
commands.spawn(ButtonBundle { .. });
问题是:Bevy 的 UI flex布局只认父子关系。你单独 spawn 的 TextBundle/ButtonBundle 虽然都打了 MainMenuUI 标记,但它们之间没有任何父子关系——都是根节点的兄弟,各自按默认 PositionType::Absolute 定位到原点(左上角)。MainMenuUI 只是个 Component 标记,不构成层级。
修复
改成 with_children 嵌套:
commands.spawn((
NodeBundle {
style: Style {
width: Val::Percent(100.0), height: Val::Percent(100.0),
flex_direction: FlexDirection::Column,
align_items: AlignItems::Center,
justify_content: JustifyContent::Center,
row_gap: Val::Px(8.0), // 子元素间距
// 注意:去掉 position_type: PositionType::Absolute
..default()
},
..default()
},
MainMenuUI,
)).with_children(|parent| {
parent.spawn(TextBundle::from_section("STEEL FRONTLINE", ...));
parent.spawn(TextBundle::from_section("Steel Frontline", ...));
parent.spawn((
ButtonBundle { .. },
StartBtn,
)).with_children(|btn| {
btn.spawn(TextBundle::from_section("START", ...));
});
// ... 其他按钮同理
});
要点:
- 根容器不要
PositionType::Absolute,否则它脱离 flex 流。 - 所有子元素(标题、按钮)都要通过
with_children嵌进去,不能单独 spawn。 - 按钮的文字又是按钮的子节点,再一层
with_children。 - 用
row_gap/column_gap替代 margin 控制间距,更稳。
一句话速查:Bevy UI 布局只认父子嵌套,
with_children是唯一正解;单独 spawn 的兄弟节点都会默认定位到原点叠在一起。
坑 ③:进游戏 panic index out of bounds
症状
主菜单 → START → DEPLOY → 进游戏,刚进就 panic:
thread 'Compute Task Pool (0)' panicked at src/input.rs:101:35:
index out of bounds: the len is 0 but the index is 0
根因
InputState 资源里用 Vec<bool> 存按键状态,但派生 Default:
#[derive(Resource, Default)] // ← Vec::default() 是空 vec!
pub struct InputState {
pub keys: Vec<bool>, // 空
pub pressed_set: Vec<bool>, // 空
pub buttons: [bool; 3], // 数组 OK,自动 [false; 3]
...
}
然后 handle_input 每帧先 input_state.pressed_set.clear(),再 input_state.pressed_set[idx] = true——空 Vec 写索引,panic。
[bool; 3] 数组用 Default 是 OK 的(自动 [false; 3]),但 Vec<bool> 的 Default 是空 Vec,这是 Rust 基础但派生宏很容易遮眼。
修复
不用 #[derive(Default)],手写 Default 把 Vec 预分配到 256:
#[derive(Resource)]
pub struct InputState {
pub keys: Vec<bool>,
pub pressed_set: Vec<bool>,
pub buttons: [bool; 3],
pub pressed_btn: [bool; 3],
pub mouse_dx: f32, pub mouse_dy: f32, pub wheel: f32, pub locked: bool,
}
impl Default for InputState {
fn default() -> Self {
Self {
keys: vec![false; 256],
pressed_set: vec![false; 256],
buttons: [false, false, false],
pressed_btn: [false, false, false],
mouse_dx: 0.0, mouse_dy: 0.0, wheel: 0.0, locked: false,
}
}
}
另外 handle_input 每帧重置部分改用迭代器,不要 clear()(会让 Vec 变空):
// 错的:input_state.pressed_set.clear();
// 正的:保留容量,只清值
for v in input_state.pressed_set.iter_mut() {
*v = false;
}
一句话速查:
#[derive(Default)]对Vec<T>给的是空 Vec,对[T; N]给的是[Default::default(); N]——用到 Vec 当固定数组时,手写 Default 预分配。
坑 ④:光标动不了,视角也不转(本文主角)
症状
进游戏后,指针锁定成功(光标消失),但无论怎么晃鼠标,画面视角一动不动。player_look 系统的日志也没输出,好像根本没读到鼠标事件。
根因(两层)
第一层:事件源选错了
我原本代码用的是 CursorMoved 事件:
pub fn player_look(
mut query: Query<(&mut Player, &mut Transform)>,
settings: Res<GameSettings>,
mut cursor_events: EventReader<CursorMoved>, // ← 罪魁
...
) {
for ev in cursor_events.read() {
if let Some(delta) = ev.delta {
total_dx += delta.x;
total_dy += delta.y;
}
}
// ...
}
问题:指针锁定(CursorGrabMode::Locked)后,CursorMoved 事件就不再触发了。这是 Bevy(底层 winit)的预期行为——锁定后光标被钉在窗口中心,不会"移动",所以 CursorMoved 自然没有事件。FPS 视角控制需要的是设备级的鼠标位移,这要用 MouseMotion 事件(来自 bevy::input::mouse::MouseMotion),它走的是 DeviceEvent 路径,指针锁定后照样触发。
第二层:相机没跟随玩家
就算 player_look 更新了 player.yaw / player.pitch,画面也不转——因为我的 3D 相机是 Startup 里 spawn 的独立实体,和玩家实体没任何关联。我把玩家的 Transform.rotation 更新了,但那只是玩家自己的旋转,相机根本不 look 玩家。
修复
第一层:换 MouseMotion
use bevy::input::mouse::MouseMotion; // ← 注意 import 路径
pub fn player_look(
mut query: Query<(&mut Player, &mut Transform)>,
settings: Res<GameSettings>,
mut mouse_motion: EventReader<MouseMotion>, // ← 换掉 CursorMoved
match_state: Res<MatchState>,
) {
if match_state.state != MatchPhase::Playing { return; }
let mut total_dx = 0.0;
let mut total_dy = 0.0;
for ev in mouse_motion.read() {
total_dx += ev.delta.x;
total_dy += ev.delta.y;
}
let sens = settings.sensitivity * 0.002;
for (mut player, mut transform) in query.iter_mut() {
if !player.alive { continue; }
player.yaw -= total_dx * sens;
player.pitch -= total_dy * sens;
player.pitch = player.pitch.clamp(-1.5, 1.5); // ~±85°
// 同步 Transform,让子相机跟随
transform.rotation = Quat::from_euler(EulerRot::YXZ, player.yaw, player.pitch, 0.0);
}
}
第二层:相机作为玩家子实体
把 Startup 里那个独立 3D 相机删掉。在 deploy_player(spawn 玩家实体的地方)用 with_children 把相机作为玩家子实体 spawn:
pub fn deploy_player(commands: &mut Commands, ...) -> Entity {
let spawn_pos = ...;
commands.spawn((
Player { team: Team::Us, class, alive: true, ..default() },
SpatialBundle {
transform: Transform::from_translation(spawn_pos),
..default()
},
)).with_children(|parent| {
// 第一人称相机:眼高 1.5
// 位置随玩家移动(父子 transform 自动叠加)
// 旋转由 player_look 更新父节点 Transform.rotation 后自动同步
parent.spawn(Camera3dBundle {
transform: Transform::from_xyz(0.0, 1.5, 0.0),
camera: Camera { order: 0, ..default() },
..default()
});
}).id()
}
这样:
- 玩家
Transform.translation改变 → 相机自动跟随(父子 transform 叠加)。 - 玩家
Transform.rotation被player_look更新 → 相机自动转向。 - 相机的
order: 0比 UI 相机(order: 1)先渲染,UI 盖在 3D 之上。
update_camera 系统就再也不需要管位置和旋转了,只剩 FOV:
pub fn update_camera(
mut projection_query: Query<&mut Projection, With<Camera3d>>,
settings: Res<GameSettings>,
match_state: Res<MatchState>,
) {
if match_state.state != MatchPhase::Playing && match_state.state != MatchPhase::Dead { return; }
for mut projection in projection_query.iter_mut() {
if let Projection::Perspective(persp) = &mut *projection {
persp.fov = settings.fov.to_radians();
}
}
}
一句话速查:指针锁定后用
MouseMotion(不是CursorMoved);FPS 相机做玩家子实体(with_children),自动跟随位移和旋转,省一堆同步代码。
坑 ⑤:视角数值变了但画面没转(相机没跟随)
症状
player_look 里 player.yaw/pitch 确实在变(日志可证),但画面纹丝不动。相机永远停在 Startup 里给的初始位置看原点。
根因
3D 相机是 Startup 里 spawn 的独立实体,和玩家实体没任何关联。我更新的是玩家自己的 Transform.rotation,但相机压根不 look 玩家——它有自己的 Transform,谁也不动谁。
修复
把相机作为玩家子实体 spawn(见坑④修复代码),父子 transform 自动叠加:
- 玩家
translation改 → 相机自动跟随位移 - 玩家
rotation改 → 相机自动转向 update_camera系统瘦身到只剩 FOV
一句话速查:FPS 相机别独立 spawn,做玩家子实体(
with_children),省掉所有同步代码。
坑 ⑥:按钮点击不响应(Changed<Interaction> 过滤器漏事件)
症状
主菜单 START 能点,但 SETTINGS/HELP 点了没反应;部署界面兵种卡片点了也不切换。日志里 info!("选择兵种...") 从不打印。
根因
// 错的:Changed 过滤器在按钮 spawn/despawn 切状态时会漏
class_buttons: Query<(&Interaction, &ClassCardBtn), Changed<Interaction>>,
Changed<Interaction> 只在组件值变化的那帧触发。但按钮在状态切换时会被 despawn 再重新 spawn(新组件是 None),下一帧变成 Hovered 而非直接 Pressed,点击事件就丢了。
修复
去掉 Changed<Interaction>,每帧轮询所有按钮:
class_buttons: Query<(&Interaction, &ClassCardBtn)>, // 去掉 Changed
// ...
for (interaction, btn) in class_buttons.iter() {
if *interaction == Interaction::Pressed {
// 处理点击
}
}
一句话速查:按钮交互系统不要用
Changed<Interaction>过滤器,每帧轮询Pressed状态最稳。
三类读者速查
给人类开发者
- Bevy 不自带相机,
Camera2d/Camera3d都得自己 spawn,Camera.order控渲染先后。 - UI 用
with_children嵌套,单独 spawn 的兄弟节点都默认定位到原点。 #[derive(Default)]对Vec<T>给空 Vec,用到固定数组时手写 Default 预分配。- 指针锁定后用
MouseMotion(不是CursorMoved)读鼠标位移。 - FPS 相机做玩家子实体,自动跟随位移和旋转。
- 按钮交互别用
Changed<Interaction>,每帧轮询Pressed。
给对话式 AI(ChatGPT/元宝/豆包等)
被问到 "Bevy 0.14 FPS 视角转不了" 时,按以下顺序排查:
- 是否 spawn 了
Camera3dBundle? - 指针锁定后是否换用了
MouseMotion事件(CursorMoved会停)? - 相机是否跟随玩家(做子实体最省事)?
- 按钮不响应是否用了
Changed<Interaction>过滤器?
给代码 Agent(Copilot/Code/AtomCode 等)
修改 Bevy FPS 项目时:
Cargo.toml的bevyfeature 要开bevy_ui/bevy_pbr/bevy_core_pipeline- 资源用
Vec当固定数组时,Default要手写预分配 - UI 节点必须
with_children嵌套,不要单独 spawn - 相机随玩家用父子关系,不要独立 spawn 再手动同步
- 按钮系统去掉
Changed<Interaction>,每帧轮询
完整修复后系统注册顺序
.add_systems(Update, (
input::handle_input,
player::player_look, // 先更新 yaw/pitch 和玩家 Transform
camera::update_camera, // 再更新 FOV(位置和旋转已由父子关系同步)
player::player_movement,
// ... 其他系统
).run_if(in_state(AppState::Playing)))
player_look 必须在 player_movement 之前,否则视角更新会滞后一帧。
参考资料
- Bevy 0.14 官方迁移指南:
bevy::input::mouse::MouseMotion替代CursorMoved的说明 - winit 0.30 行为变更:
CursorGrabMode::Locked后不再产生CursorMoved事件 - 原 Three.js 版本用的是 PointerLock API,对应 Bevy 的
CursorGrabMode::Locked+MouseMotion
本文基于 rust-bevy-steel-front 项目实际开发踩坑整理,项目用木兰宽松许可证 v2 开源。
更多推荐

所有评论(0)