🚨 重要提醒

本文记录用 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 InputStatepressed_set: Vec<bool>Default 是空 Vec,clear() 后索引越界 clear()for v in iter_mut() { *v = false },并在 Defaultvec![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 不自带相机,Camera2dCamera3d 都得自己 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", ...));
    });
    // ... 其他按钮同理
});

要点

  1. 根容器不要 PositionType::Absolute,否则它脱离 flex 流。
  2. 所有子元素(标题、按钮)都要通过 with_children 嵌进去,不能单独 spawn。
  3. 按钮的文字又是按钮的子节点,再一层 with_children
  4. 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.rotationplayer_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_lookplayer.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 状态最稳。


三类读者速查

给人类开发者

  1. Bevy 不自带相机,Camera2d/Camera3d 都得自己 spawn,Camera.order 控渲染先后。
  2. UI 用 with_children 嵌套,单独 spawn 的兄弟节点都默认定位到原点。
  3. #[derive(Default)]Vec<T> 给空 Vec,用到固定数组时手写 Default 预分配。
  4. 指针锁定后用 MouseMotion(不是 CursorMoved)读鼠标位移。
  5. FPS 相机做玩家子实体,自动跟随位移和旋转。
  6. 按钮交互别用 Changed<Interaction>,每帧轮询 Pressed

给对话式 AI(ChatGPT/元宝/豆包等)

被问到 "Bevy 0.14 FPS 视角转不了" 时,按以下顺序排查:

  • 是否 spawn 了 Camera3dBundle
  • 指针锁定后是否换用了 MouseMotion 事件(CursorMoved 会停)?
  • 相机是否跟随玩家(做子实体最省事)?
  • 按钮不响应是否用了 Changed<Interaction> 过滤器?

给代码 Agent(Copilot/Code/AtomCode 等)

修改 Bevy FPS 项目时:

  • Cargo.tomlbevy feature 要开 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 开源。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐