Skip to content

指针事件的冒泡与相机控制器

事件冒泡:父级也能接住子级的事件

Pointer<E> 实现了 Bevy 的 EntityEvent trait,带有 auto_propagate 标记——事件会自动从目标实体向父级传播。这意味着:你在父级节点挂一个 Observer,就能接住所有子节点的指针事件。

Listing 25-5 演示了冒泡:一个父实体(ShapeGroup)下挂了三个子 Mesh,父级的 Observer 能接住任何一个子 Mesh 的点击:

rust
use bevy::prelude::*;

fn main() {
    App::new()
        .add_plugins((DefaultPlugins, MeshPickingPlugin))
        .add_systems(Startup, setup)
        .run();
}

#[derive(Component)]
struct ShapeGroup;

fn setup(
    mut commands: Commands,
    mut meshes: ResMut<Assets<Mesh>>,
    mut materials: ResMut<Assets<StandardMaterial>>,
) {
    commands.spawn((
        Camera3d::default(),
        Transform::from_xyz(0.0, 5.0, 10.0).looking_at(Vec3::ZERO, Vec3::Y),
    ));

    commands.spawn((
        PointLight {
            contact_shadows_enabled: true,
            ..default()
        },
        Transform::from_xyz(4.0, 8.0, 4.0),
    ));

    commands
        .spawn((
            ShapeGroup,
            Transform::default(),
            Visibility::default(),
        ))
        .observe(|click: On<Pointer<Click>>| {
            info!("group caught click from {:?}", click.entity);
        })
        .with_children(|parent| {
            parent.spawn((
                Mesh3d(meshes.add(Cuboid::new(0.8, 0.8, 0.8))),
                MeshMaterial3d(materials.add(Color::srgb(0.2, 0.7, 0.9))),
                Transform::from_xyz(-1.5, 0.0, 0.0),
            ));

            parent.spawn((
                Mesh3d(meshes.add(Sphere::new(0.5))),
                MeshMaterial3d(materials.add(Color::srgb(0.9, 0.3, 0.3))),
                Transform::from_xyz(0.0, 0.0, 0.0),
            ));

            parent.spawn((
                Mesh3d(meshes.add(Torus::new(0.3, 0.6))),
                MeshMaterial3d(materials.add(Color::srgb(0.3, 0.9, 0.3))),
                Transform::from_xyz(1.5, 0.0, 0.0),
            ));
        });
}

Listing 25-5:事件冒泡——父级 Observer 接住子级的 Click 事件

console
cargo run -p ch25-picking --example listing-25-05

点任意一个子物体,控制台输出类似:

text
INFO ch25_picking::listing_25_05: group caught click from Entity(5v1#4294967301)

click.entity 始终是最初被点击的子实体,而不是父实体。事件冒泡只是让父级的 Observer 也能触发,不会改变事件的目标。

控制冒泡

在 Observer 里调用 event.propagate(false) 可以阻止事件继续向上传播。这在需要"事件只由某一层处理"时很有用——比如 UI 面板拦截点击,不让它穿透到后面的 3D 物体。

冒泡的遍历路径

Pointer<E> 的冒泡路径由 PointerTraversal 决定:先走到父级(通过 ChildOf 关系),如果没有父级就走到窗口实体。这保证了事件不会无缘无故丢失。

FreeCamera:WASD 飞行控制

到目前为止,相机都是固定在一个位置的。在 3D 编辑器、调试工具或探索型游戏里,你通常需要一个可以自由飞行的相机——WASD 移动、鼠标转向。

Bevy 在 bevy_camera_controller crate 里提供了 FreeCamera 组件,但它是 feature-gated 的,需要在 Cargo.toml 里开启:

toml
bevy = { version = "=0.18.1", features = ["free_camera"] }

Listing 25-6 展示了 FreeCamera 的基本用法:

rust
use bevy::{
    camera_controller::free_camera::{FreeCamera, FreeCameraPlugin},
    prelude::*,
};

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_plugins(FreeCameraPlugin)
        .add_systems(Startup, setup)
        .run();
}

fn setup(
    mut commands: Commands,
    mut meshes: ResMut<Assets<Mesh>>,
    mut materials: ResMut<Assets<StandardMaterial>>,
) {
    commands.spawn((
        Camera3d::default(),
        Transform::from_xyz(0.0, 1.0, 5.0).looking_to(Vec3::NEG_Z, Vec3::Y),
        FreeCamera {
            walk_speed: 5.0,
            run_speed: 15.0,
            sensitivity: 0.2,
            ..default()
        },
    ));

    commands.spawn((
        PointLight {
            contact_shadows_enabled: true,
            ..default()
        },
        Transform::from_xyz(4.0, 8.0, 4.0),
    ));

    let floor = meshes.add(Plane3d::default().mesh().size(20.0, 20.0));
    let cube = meshes.add(Cuboid::new(1.0, 1.0, 1.0));
    let white = materials.add(Color::WHITE);
    let blue = materials.add(Color::srgb(0.2, 0.7, 0.9));

    commands.spawn((
        Mesh3d(floor),
        MeshMaterial3d(white),
    ));

    for i in -2..=2 {
        commands.spawn((
            Mesh3d(cube.clone()),
            MeshMaterial3d(blue.clone()),
            Transform::from_xyz(i as f32 * 2.0, 0.5, 0.0),
        ));
    }

    info!("Click to grab mouse, WASD to move, QE for up/down, Shift to run, M to toggle grab");
}

Listing 25-6:FreeCamera 控制器——WASD 飞行,鼠标转向

console
cargo run -p ch25-picking --example listing-25-06

两步接入:

  1. 加插件——FreeCameraPlugin 注册了读取输入、更新相机位置的系统;
  2. 挂组件——FreeCamera 挂在相机实体上,配置按键绑定和速度参数。

FreeCamera 的默认键位:

按键动作
鼠标移动转向
左键按住捕获鼠标
M切换鼠标捕获
W / S前进 / 后退
A / D左移 / 右移
E / Q上升 / 下降
Shift加速
滚轮调整移动速度

FreeCamera 的字段

FreeCamera 存储的是静态配置——运行时不变的参数(除非你手动改):

  • sensitivity: f32——鼠标灵敏度(默认 0.2)
  • walk_speed: f32——普通移动速度(默认 5.0)
  • run_speed: f32——加速移动速度(默认 15.0)
  • scroll_factor: f32——滚轮调速的倍率
  • friction: f32——松手后的减速摩擦力
  • key_forward/back/left/right/up/down: KeyCode——按键绑定

运行时状态(当前速度、朝向等)存在 FreeCameraState 组件里,它通过 #[require(FreeCameraState)] 自动添加,不需要手动管理。

与 Picking 的配合

FreeCamera 默认用左键捕获鼠标。这意味着如果你同时用了 Picking 和 FreeCamera,左键既会触发 Click 事件,也会触发鼠标捕获。通常的做法是:

  • 拖拽物体时,相机不捕获鼠标(鼠标需要自由移动)
  • 右键或特定按键控制相机转向

Listing 25-7 把 Picking 和 FreeCamera 组合在一起,展示两者如何共存。