From f8e04845edf388377a9d017394f9979da3c56a83 Mon Sep 17 00:00:00 2001 From: Duncan Date: Fri, 23 Feb 2024 14:43:58 -0800 Subject: [PATCH] Add the `RayMap` resource and use it in rapier and mod_raycast backends (#289) * add a RayMap resource This map will hold a mapping from (camera entity, pointer id) to Ray. This will be created before any backends run. It will be available for use by backends and picking event handlers. * use the RayMap in the rapier backend * use RayMap in the bevy_mod_raycast backend * add changelog entries * use RenderLayers::default() instead of all() * Fix changelog * Move ray module into backend module * add window scale test example --------- Co-authored-by: Aevyrie --- CHANGELOG.md | 10 +- backends/bevy_picking_rapier/src/lib.rs | 108 ++++++++------------ backends/bevy_picking_raycast/src/lib.rs | 112 +++++++++------------ crates/bevy_picking_core/Cargo.toml | 1 + crates/bevy_picking_core/src/backend.rs | 119 ++++++++++++++++++++++- crates/bevy_picking_core/src/lib.rs | 5 +- examples/window_scale.rs | 57 +++++++++++ 7 files changed, 275 insertions(+), 137 deletions(-) create mode 100644 examples/window_scale.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 40792e3f..5b4e01fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # UNRELEASED +- Fixed: Ray construction now respects DPI / window scale +- Added: `RayMap` resource that contains a `Ray` for every (camera, pointer) pair +- Changed: rapier and bevy_mod_raycast backends use the `RayMap` instead of constructing their own + rays +- Fixed: rapier and bevy_mod_raycast backends use `RenderLayers::default` when a camera is missing + them - Added: support for `bevy_ui` `UiScale`. - Fixed: the bevy ui backend now ignores clipped areas of UI nodes. - Added: `RaycastBackendSettings::raycast_visibility` to support picking hidden meshes. @@ -21,8 +27,8 @@ - Faster compile times. - Sprites now support atlases, scale, rotation, and anchors. - All `egui` widgets, including side panels, are now supported. -- `bevy_mod_raycast` and `bevy_rapier` backends are now even simpler, no longer requiring any - marker components to function. +- `bevy_mod_raycast` and `bevy_rapier` backends are now even simpler, no longer requiring any marker + components to function. - More flexible picking behavior and `bevy_ui` compatibility with the updated `Pickable` component. - Better support for cameras settings such as `is_active`, `RenderLayers`, and `show_ui`. diff --git a/backends/bevy_picking_rapier/src/lib.rs b/backends/bevy_picking_rapier/src/lib.rs index 2f52021f..9e806149 100644 --- a/backends/bevy_picking_rapier/src/lib.rs +++ b/backends/bevy_picking_rapier/src/lib.rs @@ -29,8 +29,6 @@ use bevy_app::prelude::*; use bevy_ecs::prelude::*; use bevy_reflect::{std_traits::ReflectDefault, Reflect}; use bevy_render::{prelude::*, view::RenderLayers}; -use bevy_transform::prelude::*; -use bevy_window::PrimaryWindow; use bevy_picking_core::backend::prelude::*; use bevy_rapier3d::prelude::*; @@ -74,19 +72,12 @@ pub struct RapierPickable; /// Raycasts into the scene using [`RapierBackendSettings`] and [`PointerLocation`]s, then outputs /// [`PointerHits`]. pub fn update_hits( - pointers: Query<(&PointerId, &PointerLocation)>, - primary_window_entity: Query>, - picking_cameras: Query<( - Entity, - &Camera, - &GlobalTransform, - Option<&RapierPickable>, - Option<&RenderLayers>, - )>, + backend_settings: Res, + ray_map: Res, + picking_cameras: Query<(&Camera, Option<&RapierPickable>, Option<&RenderLayers>)>, pickables: Query<&Pickable>, marked_targets: Query<&RapierPickable>, layers: Query<&RenderLayers>, - backend_settings: Res, rapier_context: Option>, mut output_events: EventWriter, ) { @@ -94,62 +85,49 @@ pub fn update_hits( return; }; - for (pointer_id, pointer_location) in &pointers { - let pointer_location = match pointer_location.location() { - Some(l) => l, - None => continue, + for (&ray_id, &ray) in ray_map.map().iter() { + let Ok((camera, cam_pickable, cam_layers)) = picking_cameras.get(ray_id.camera) else { + continue; }; - for (cam_entity, camera, ray, cam_layers) in picking_cameras - .iter() - .filter(|(_, camera, ..)| { - camera.is_active && pointer_location.is_in_viewport(camera, &primary_window_entity) - }) - .filter(|(.., marker, _)| marker.is_some() || !backend_settings.require_markers) - .filter_map(|(entity, camera, transform, _, layers)| { - let mut viewport_pos = pointer_location.position; - if let Some(viewport) = &camera.viewport { - viewport_pos -= viewport.physical_position.as_vec2(); - } - camera - .viewport_to_world(transform, viewport_pos) - .map(|ray| (entity, camera, ray, layers)) - }) - { - if let Some((entity, hit_data)) = rapier_context - .cast_ray_and_get_normal( - ray.origin, - ray.direction, - f32::MAX, - true, - QueryFilter::new().predicate(&|entity| { - let marker_requirement = - !backend_settings.require_markers || marked_targets.get(entity).is_ok(); + if backend_settings.require_markers && cam_pickable.is_none() { + continue; + } - // Cameras missing render layers intersect all layers - let cam_layers = cam_layers.copied().unwrap_or(RenderLayers::all()); - // Other entities missing render layers are on the default layer 0 - let entity_layers = layers.get(entity).copied().unwrap_or_default(); - let render_layers_match = cam_layers.intersects(&entity_layers); + let cam_layers = cam_layers.copied().unwrap_or_default(); - let pickable = pickables - .get(entity) - .map(|p| *p != Pickable::IGNORE) - .unwrap_or(true); - marker_requirement && render_layers_match && pickable - }), - ) - .map(|(entity, hit)| { - let hit_data = - HitData::new(cam_entity, hit.toi, Some(hit.point), Some(hit.normal)); - (entity, hit_data) - }) - { - output_events.send(PointerHits::new( - *pointer_id, - vec![(entity, hit_data)], - camera.order as f32, - )); - } + let predicate = |entity| { + let marker_requirement = + !backend_settings.require_markers || marked_targets.get(entity).is_ok(); + + // Other entities missing render layers are on the default layer 0 + let entity_layers = layers.get(entity).copied().unwrap_or_default(); + let render_layers_match = cam_layers.intersects(&entity_layers); + + let pickable = pickables + .get(entity) + .map(|p| *p != Pickable::IGNORE) + .unwrap_or(true); + marker_requirement && render_layers_match && pickable + }; + if let Some((entity, hit_data)) = rapier_context + .cast_ray_and_get_normal( + ray.origin, + ray.direction, + f32::MAX, + true, + QueryFilter::new().predicate(&predicate), + ) + .map(|(entity, hit)| { + let hit_data = + HitData::new(ray_id.camera, hit.toi, Some(hit.point), Some(hit.normal)); + (entity, hit_data) + }) + { + output_events.send(PointerHits::new( + ray_id.pointer, + vec![(entity, hit_data)], + camera.order as f32, + )); } } } diff --git a/backends/bevy_picking_raycast/src/lib.rs b/backends/bevy_picking_raycast/src/lib.rs index 4a7efb38..da6a942d 100644 --- a/backends/bevy_picking_raycast/src/lib.rs +++ b/backends/bevy_picking_raycast/src/lib.rs @@ -18,8 +18,6 @@ use bevy_app::prelude::*; use bevy_ecs::prelude::*; use bevy_reflect::prelude::*; use bevy_render::{prelude::*, view::RenderLayers}; -use bevy_transform::prelude::*; -use bevy_window::{PrimaryWindow, Window}; use bevy_mod_raycast::prelude::*; use bevy_picking_core::backend::prelude::*; @@ -75,81 +73,59 @@ impl Plugin for RaycastBackend { /// Raycasts into the scene using [`RaycastBackendSettings`] and [`PointerLocation`]s, then outputs /// [`PointerHits`]. pub fn update_hits( - pointers: Query<(&PointerId, &PointerLocation)>, - primary_window_entity: Query>, - primary_window: Query<&Window, With>, - picking_cameras: Query<( - Entity, - &Camera, - &GlobalTransform, - Option<&RaycastPickable>, - Option<&RenderLayers>, - )>, + backend_settings: Res, + ray_map: Res, + picking_cameras: Query<(&Camera, Option<&RaycastPickable>, Option<&RenderLayers>)>, pickables: Query<&Pickable>, marked_targets: Query<&RaycastPickable>, layers: Query<&RenderLayers>, - backend_settings: Res, mut raycast: Raycast, mut output_events: EventWriter, ) { - for (pointer_id, pointer_location) in &pointers { - let pointer_location = match pointer_location.location() { - Some(l) => l, - None => continue, + for (&ray_id, &ray) in ray_map.map().iter() { + let Ok((camera, cam_pickable, cam_layers)) = picking_cameras.get(ray_id.camera) else { + continue; }; - for (cam_entity, camera, ray, cam_layers) in picking_cameras - .iter() - .filter(|(_, camera, ..)| { - camera.is_active && pointer_location.is_in_viewport(camera, &primary_window_entity) - }) - .filter(|(.., marker, _)| marker.is_some() || !backend_settings.require_markers) - .filter_map(|(entity, camera, transform, _, layers)| { - Ray3d::from_screenspace( - pointer_location.position, - camera, - transform, - primary_window.single(), - ) - .map(|ray| (entity, camera, ray, layers)) - }) - { - let settings = RaycastSettings { - visibility: backend_settings.raycast_visibility, - filter: &|entity| { - let marker_requirement = - !backend_settings.require_markers || marked_targets.get(entity).is_ok(); + if backend_settings.require_markers && cam_pickable.is_none() { + continue; + } - // Cameras missing render layers intersect all layers - let cam_layers = cam_layers.copied().unwrap_or(RenderLayers::all()); - // Other entities missing render layers are on the default layer 0 - let entity_layers = layers.get(entity).copied().unwrap_or_default(); - let render_layers_match = cam_layers.intersects(&entity_layers); + let cam_layers = cam_layers.copied().unwrap_or_default(); - marker_requirement && render_layers_match - }, - early_exit_test: &|entity_hit| { - pickables - .get(entity_hit) - .is_ok_and(|pickable| pickable.should_block_lower) - }, - }; - let picks = raycast - .cast_ray(ray, &settings) - .iter() - .map(|(entity, hit)| { - let hit_data = HitData::new( - cam_entity, - hit.distance(), - Some(hit.position()), - Some(hit.normal()), - ); - (*entity, hit_data) - }) - .collect::>(); - let order = camera.order as f32; - if !picks.is_empty() { - output_events.send(PointerHits::new(*pointer_id, picks, order)); - } + let settings = RaycastSettings { + visibility: backend_settings.raycast_visibility, + filter: &|entity| { + let marker_requirement = + !backend_settings.require_markers || marked_targets.get(entity).is_ok(); + + // Other entities missing render layers are on the default layer 0 + let entity_layers = layers.get(entity).copied().unwrap_or_default(); + let render_layers_match = cam_layers.intersects(&entity_layers); + + marker_requirement && render_layers_match + }, + early_exit_test: &|entity_hit| { + pickables + .get(entity_hit) + .is_ok_and(|pickable| pickable.should_block_lower) + }, + }; + let picks = raycast + .cast_ray(ray.into(), &settings) + .iter() + .map(|(entity, hit)| { + let hit_data = HitData::new( + ray_id.camera, + hit.distance(), + Some(hit.position()), + Some(hit.normal()), + ); + (*entity, hit_data) + }) + .collect::>(); + let order = camera.order as f32; + if !picks.is_empty() { + output_events.send(PointerHits::new(ray_id.pointer, picks, order)); } } } diff --git a/crates/bevy_picking_core/Cargo.toml b/crates/bevy_picking_core/Cargo.toml index 7766ba35..e532307d 100644 --- a/crates/bevy_picking_core/Cargo.toml +++ b/crates/bevy_picking_core/Cargo.toml @@ -19,6 +19,7 @@ bevy_ecs = { version = "0.12", default-features = false } bevy_math = { version = "0.12", default-features = false } bevy_reflect = { version = "0.12", default-features = false } bevy_render = { version = "0.12", default-features = false } +bevy_transform = { version = "0.12", default-features = false } bevy_utils = { version = "0.12", default-features = false } bevy_window = { version = "0.12", default-features = false } diff --git a/crates/bevy_picking_core/src/backend.rs b/crates/bevy_picking_core/src/backend.rs index d258e69c..618e7ca0 100644 --- a/crates/bevy_picking_core/src/backend.rs +++ b/crates/bevy_picking_core/src/backend.rs @@ -21,6 +21,12 @@ //! use it for optimization purposes. For example, a backend that traverses a spatial hierarchy //! may want to early exit if it intersects an entity that blocks lower entities from being //! picked. +//! +//! ### Raycasting Backends +//! +//! Backends that require a ray to cast into the scene should use [`ray::RayMap`]. This +//! automatically constructs rays in world space for all cameras and pointers, handling details like +//! viewports and DPI for you. use bevy_ecs::prelude::*; use bevy_math::Vec3; @@ -28,7 +34,7 @@ use bevy_reflect::Reflect; /// Common imports for implementing a picking backend. pub mod prelude { - pub use super::{HitData, PointerHits}; + pub use super::{ray::RayMap, HitData, PointerHits}; pub use crate::{ pointer::{PointerId, PointerLocation}, PickSet, Pickable, @@ -107,3 +113,114 @@ impl HitData { } } } + +pub mod ray { + //! Types and systems for constructing rays from cameras and pointers. + + use crate::backend::prelude::{PointerId, PointerLocation}; + use bevy_ecs::prelude::*; + use bevy_math::Ray; + use bevy_reflect::Reflect; + use bevy_render::camera::Camera; + use bevy_transform::prelude::GlobalTransform; + use bevy_utils::{hashbrown::hash_map::Iter, HashMap}; + use bevy_window::PrimaryWindow; + + /// Identifies a ray constructed from some (pointer, camera) combination. A pointer can be over + /// multiple cameras, which is why a single pointer may have multiple rays. + #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, Reflect)] + pub struct RayId { + /// The camera whose projection was used to calculate the ray. + pub camera: Entity, + /// The pointer whose pixel coordinates were used to calculate the ray. + pub pointer: PointerId, + } + + impl RayId { + /// Construct a [`RayId`]. + pub fn new(camera: Entity, pointer: PointerId) -> Self { + Self { camera, pointer } + } + } + + /// A map from [`RayId`] to [`Ray`]. + /// + /// This map is cleared and re-populated every frame before any backends run. Ray-based picking + /// backends should use this when possible, as it automatically handles viewports, DPI, and + /// other details of building rays from pointer locations. + /// + /// ## Usage + /// + /// Iterate over each [`Ray`] and its [`RayId`] with [`RayMap::iter`]. + /// + /// ``` + /// // My raycasting backend + /// pub fn update_hits(ray_map: Res, mut output_events: EventWriter,) { + /// for (&ray_id, &ray) in ray_map.iter() { + /// /// Run a raycast with each ray, returning any `PointerHits` found. + /// } + /// } + /// ``` + #[derive(Clone, Debug, Default, Resource)] + pub struct RayMap { + map: HashMap, + } + + impl RayMap { + /// Iterates over all world space rays for every picking pointer. + pub fn iter(&self) -> Iter<'_, RayId, Ray> { + self.map.iter() + } + + /// The hash map of all rays cast in the current frame. + pub fn map(&self) -> &HashMap { + &self.map + } + + /// Clears the [`RayMap`] and re-populates it with one ray for each + /// combination of pointer entity and camera entity where the pointer + /// intersects the camera's viewport. + pub fn repopulate( + mut ray_map: ResMut, + primary_window_entity: Query>, + cameras: Query<(Entity, &Camera, &GlobalTransform)>, + pointers: Query<(&PointerId, &PointerLocation)>, + ) { + ray_map.map.clear(); + + for (camera_entity, camera, camera_tfm) in &cameras { + if !camera.is_active { + continue; + } + + for (&pointer_id, pointer_loc) in &pointers { + if let Some(ray) = + make_ray(&primary_window_entity, camera, camera_tfm, pointer_loc) + { + ray_map + .map + .insert(RayId::new(camera_entity, pointer_id), ray); + } + } + } + } + } + + fn make_ray( + primary_window_entity: &Query>, + camera: &Camera, + camera_tfm: &GlobalTransform, + pointer_loc: &PointerLocation, + ) -> Option { + let pointer_loc = pointer_loc.location()?; + if !pointer_loc.is_in_viewport(camera, primary_window_entity) { + return None; + } + let mut viewport_pos = pointer_loc.position; + if let Some(viewport) = &camera.viewport { + let viewport_logical = camera.to_logical(viewport.physical_position)?; + viewport_pos -= viewport_logical; + } + camera.viewport_to_world(camera_tfm, viewport_pos) + } +} diff --git a/crates/bevy_picking_core/src/lib.rs b/crates/bevy_picking_core/src/lib.rs index f832d491..a18fecab 100644 --- a/crates/bevy_picking_core/src/lib.rs +++ b/crates/bevy_picking_core/src/lib.rs @@ -159,6 +159,7 @@ impl Plugin for CorePlugin { fn build(&self, app: &mut App) { app.init_resource::() .init_resource::() + .init_resource::() .add_event::() .add_event::() .add_event::() @@ -168,6 +169,7 @@ impl Plugin for CorePlugin { pointer::update_pointer_map, pointer::InputMove::receive, pointer::InputPress::receive, + backend::ray::RayMap::repopulate, ) .in_set(PickSet::ProcessInput), ) @@ -189,7 +191,8 @@ impl Plugin for CorePlugin { .register_type::() .register_type::() .register_type::() - .register_type::(); + .register_type::() + .register_type::(); } } diff --git a/examples/window_scale.rs b/examples/window_scale.rs new file mode 100644 index 00000000..2cbb33cd --- /dev/null +++ b/examples/window_scale.rs @@ -0,0 +1,57 @@ +//! Tests window scaling. + +use bevy::prelude::*; +use bevy_mod_picking::prelude::*; + +fn main() { + App::new() + .add_plugins(DefaultPlugins.set(WindowPlugin { + primary_window: Some(Window { + present_mode: bevy_window::PresentMode::AutoNoVsync, + resolution: + bevy_window::WindowResolution::default().with_scale_factor_override(3.0), + ..default() + }), + ..default() + })) + .add_plugins(DefaultPickingPlugins) + .add_systems(Startup, setup) + .run(); +} + +fn setup( + mut commands: Commands, + mut meshes: ResMut>, + mut materials: ResMut>, +) { + commands.spawn(( + PbrBundle { + mesh: meshes.add(Mesh::from(shape::Plane::from_size(5.0))), + material: materials.add(Color::rgb(0.3, 0.5, 0.3).into()), + ..default() + }, + PickableBundle::default(), // Optional: adds selection, highlighting, and helper components. + )); + commands.spawn(( + PbrBundle { + mesh: meshes.add(Mesh::from(shape::Cube { size: 1.0 })), + material: materials.add(Color::rgb(0.8, 0.7, 0.6).into()), + transform: Transform::from_xyz(0.0, 0.5, 0.0), + ..default() + }, + PickableBundle::default(), // Optional: adds selection, highlighting, and helper components. + )); + commands.spawn(PointLightBundle { + point_light: PointLight { + intensity: 1500.0, + shadows_enabled: true, + ..default() + }, + transform: Transform::from_xyz(4.0, 8.0, -4.0), + ..default() + }); + commands.spawn((Camera3dBundle { + transform: Transform::from_xyz(3.0, 3.0, 3.0).looking_at(Vec3::ZERO, Vec3::Y), + ..default() + },)); +}