如何用 Bevy 的 Picking 事件实现 3D 模型的点击拾取与拖拽交互?
【免费下载链接】bevyA refreshingly simple>项目地址: https://gitcode.com/GitHub_Trending/be/bevy
要在 Bevy 的 3D 场景中让模型可被指针点击、悬停高亮并跟随拖拽旋转,需要用到bevy_picking提供的指针事件体系,以及负责 3D 网格命中检测的MeshPickingPlugin。本文以仓库中的 picking 示例为蓝本,走一遍从插件注册、实体构建、事件观察者绑定,到验证命中效果与限制可拾取范围的完整路径。注意MeshPickingPlugin不是默认插件,必须手动注册;另外 examples/README.md 明确提示:git 主分支的开发版 API 与 crates.io 上的发布版本存在不兼容差异,本文代码对应仓库主分支版本。
注册插件并搭建最小 3D 场景
3D 拾取的最小依赖是:DefaultPlugins之外的MeshPickingPlugin,一个Mesh3d实体,以及一个能看到它的Camera3d。simple_picking 示例的注册方式是:
fn main() { App::new() .add_plugins((DefaultPlugins, MeshPickingPlugin)) .add_systems(Startup, setup_scene) .run(); }该示例的setup_scene生成了一个 3D 场景的基本结构:
// 地面圆盘(旋转 -90 度使其水平) commands.spawn(( Mesh3d(meshes.add(Circle::new(4.0))), MeshMaterial3d(materials.add(Color::WHITE)), Transform::from_rotation(Quat::from_rotation_x(-std::f32::consts::FRAC_PI_2)), )); // 点光源 commands.spawn(( PointLight { shadow_maps_enabled: true, ..default() }, Transform::from_xyz(4.0, 8.0, 4.0), )); // 相机 commands.spawn(( Camera3d::default(), Transform::from_xyz(-2.5, 4.5, 9.0).looking_at(Vec3::ZERO, Vec3::Y), ));关于后端的选择边界:mesh_picking 示例的注释说明MeshPickingPlugin基于射线检测,适合作为起点和调试用途;游戏项目中也可以换成物理引擎的 3D 拾取后端或拾取着色器。bevy_ui、bevy_sprite等自带后端可以与 mesh 后端同时启用,UI 或 sprite 会正确遮挡或穿透其下的 3D 网格。
用 Observer 绑定点击与拖拽事件
bevy_picking的事件设计是把处理逻辑用观察者(observer)直接挂到实体上,事件从目标实体出发沿实体层级向上冒泡(详见 bevy_picking 的 crate 文档)。simple_picking 示例展示了点击生成对象、拖拽旋转的完整闭环:
fn on_click_spawn_cube( _click: On<PointerClick>, mut commands: Commands, mut meshes: ResMut<Assets<Mesh>>, mut materials: ResMut<Assets<StandardMaterial>>, mut num: Local<usize>, ) { commands .spawn(( Mesh3d(meshes.add(Cuboid::new(0.5, 0.5, 0.5))), MeshMaterial3d(materials.add(Color::srgb_u8(124, 144, 255))), Transform::from_xyz(0.0, 0.25 + 0.55 * *num as f32, 0.0), )) // 加了 MeshPickingPlugin 之后,可以为网格实体挂指针事件观察者: .observe(on_drag_rotate); *num += 1; } fn on_drag_rotate(drag: On<PointerDrag>, mut transforms: Query<&mut Transform>) { if let Ok(mut transform) = transforms.get_mut(drag.entity) { transform.rotate_y(drag.delta.x * 0.02); transform.rotate_x(drag.delta.y * 0.02); } }两个关键点:
- 拖拽中修改的是
drag.entity(被拖拽的目标实体)自身的Transform,而不是观察者所在的实体; drag.delta的单位是屏幕像素而非世界坐标,events.rs 的文档对此有明确说明:"This is stored in screen pixels, not world coordinates",并建议用Camera的方法做屏幕空间到世界空间的转换。示例中的0.02就是像素增量到旋转角的经验缩放系数,属于示例代码自身的取值。
悬停与按压的视觉反馈
mesh_picking 示例演示了如何用四个事件切换材质实现"悬停变色、按压变色"的反馈:
commands .spawn(( Mesh3d(shape), MeshMaterial3d(white_matl.clone()), Transform::from_xyz(...).with_rotation(Quat::from_rotation_x(-PI / 4.)), Shape, )) .observe(update_material_on::<PointerOver>(hover_matl.clone())) .observe(update_material_on::<PointerOut>(white_matl.clone())) .observe(update_material_on::<PointerPress>(pressed_matl.clone())) .observe(update_material_on::<PointerRelease>(hover_matl.clone())) .observe(rotate_on_drag);其中观察者闭包通过event.event_target()拿到事件目标实体,再查询其MeshMaterial3d并替换材质句柄。该示例的拖拽旋转观察者为:
fn rotate_on_drag(drag: On<PointerDrag>, mut transforms: Query<&mut Transform>) { let mut transform = transforms.get_mut(drag.entity).unwrap(); transform.rotate_y(drag.delta.x * 0.02); transform.rotate_x(drag.delta.y * 0.02); }事件全集定义在 events.rs,按用途分三组:
- 悬停与移动:
PointerOver、PointerEnter、PointerMove、PointerLeave、PointerOut - 点击与按压:
PointerPress、PointerRelease、PointerClick - 拖拽与放置:
PointerDragStart、PointerDrag、PointerDragEnd、PointerDragEnter、PointerDragOver、PointerDragDrop、PointerDragLeave
每个事件都携带entity(目标实体)、pointer(指针信息)和hit(HitData命中数据)字段。关于HitData,mesh_picking 模块文档指出两点实现细节:position是世界空间坐标;normal指向面外侧,但对缩放过的网格不保证已归一化。
观察者中还可以调用event.propagate(false)阻止事件继续沿层级向上冒泡,这在拖拽场景中用于让"被拖拽物"自己消费事件、避免祖先实体重复响应。
控制哪些实体和相机参与拾取
mesh_picking 模块文档说明了默认行为与两种收窄方式:
- 默认:所有使用
RenderAssetUsages::MAIN_WORLD的网格都可拾取,方便调试。对个别实体排除,加Pickable::IGNORE组件即可,mesh_picking 示例用它禁用了地面平面:
commands.spawn(( Mesh3d(meshes.add(Plane3d::default().mesh().size(50.0, 50.0).subdivisions(10))), MeshMaterial3d(ground_matl.clone()), Pickable::IGNORE, // 让地面不参与拾取 ));- 整体改为 opt-in:把
MeshPickingSettings::require_markers设为true,之后只有带MeshPickingCamera组件的相机和带Pickable组件的实体会被射线检测考虑。MeshPickingSettings还有一个ray_cast_visibility字段,默认RayCastVisibility::VisibleInView(只拾取可见且在相机视野内的实体),设为RayCastVisibility::Any则隐藏实体也能被射线命中。
Pickable组件本身(定义于 lib.rs)有两个字段:should_block_lower决定该实体是否阻止其下方的实体被拾取,is_hoverable决定它自身是否可被悬停;Pickable::IGNORE是两者的关闭快捷值。
可选分支:拖拽到目标区域的 Drop 交互
如果交互目标不是"旋转被拖物"而是"拖到某处放下",dragdrop_picking 示例给出了一套PointerDrag*事件的用法。它在 UI 按钮上开始拖拽,在 2D 网格构成的DropArea上响应进入、悬停、放下与离开:
// 可拖拽的 UI 按钮上,开始/结束拖拽时改变自身颜色并阻止事件冒泡 .observe(|mut event: On<PointerDragStart>, mut button_color: Single<&mut BackgroundColor, With<DraggableButton>>| { button_color.0 = Color::srgb(1.0, 0.5, 0.0); event.propagate(false); }) // 放置区上:被拖物进入时生成半透明的"幽灵预览" fn on_drag_enter(mut event: On<PointerDragEnter>, button: Single<Entity, With<DraggableButton>>, ...) { if event.dragged == *button { let Some(position) = event.hit.position else { return; }; // 在 hit.position 处生成 GhostPreview 实体 } }要点是观察者里先比较event.dragged == *button确认"被拖动的实体"确实是这个按钮,再取event.hit.position(世界空间坐标)来定位预览或最终放置物;PointerDragDrop时用event.dropped做同样的比对,PointerDragLeave时销毁预览实体。
运行与验证
在仓库根目录直接运行官方示例即可逐条验证本文路径(examples/README.md 给出通用命令格式,如cargo run --example hello_world,必要时可加--features x11或--features wayland强制窗口合成器):
# 点击生成 + 拖拽旋转(3D 网格 + UI 按钮混用) cargo run --example simple_picking # 悬停/按压变色 + 拖拽旋转,含命中点可视化 cargo run --example mesh_picking # UI 拖拽放到 3D 放置区 cargo run --example dragdrop_pickingmesh_picking 示例还内置了一个无需交互即可观察拾取是否生效的系统——用 gizmo 在命中点画球和箭头:
fn draw_mesh_intersections(pointers: Query<&PointerInteraction>, mut gizmos: Gizmos) { for (point, normal) in pointers .iter() .filter_map(|interaction| interaction.get_nearest_hit()) .filter_map(|(_entity, hit)| hit.position.zip(hit.normal)) { gizmos.sphere(point, 0.05, RED_500); gizmos.arrow(point, point + normal.normalize() * 0.5, PINK_100); } }PointerInteraction组件(导入路径bevy::picking::pointer::PointerInteraction)记录每个指针的悬停状态,get_nearest_hit()返回最近命中实体及其HitData。把这个系统抄进自己的应用,就能直观确认"指针确实命中了预期实体、且命中点/法线符合预期";指针悬停网格时命中点出现红球与箭头,即拾取链路工作正常。
限制与适用边界
MeshPickingPlugin的射线检测面向简单网格场景;示例注释明确建议:需要更复杂交互时改用MeshRayCast系统参数做手动射线检测,或接入具备射线检测能力的物理引擎。- 缩放网格的命中法线不保证归一化(见上文
HitData说明),画指示器前需要自己normalize(),mesh_picking 示例正是这样做的。 PointerDrag/PointerMove的delta是屏幕像素增量,直接乘进世界变换(如旋转角、平移距离)时,效果会随窗口分辨率和相机距离变化,示例中的0.02系数只是示例取值,按自己的交互手感调整。- 示例代码与 crates.io 发布版 API 可能不兼容,对照 examples/README.md 的提示选择匹配的文档版本。
【免费下载链接】bevyA refreshingly simple>项目地址: https://gitcode.com/GitHub_Trending/be/bevy
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考