From 51295600dab5b52c18e2306f57df5fba848ec79a Mon Sep 17 00:00:00 2001 From: bloopyboop <186878072+bloopyboop@users.noreply.github.com> Date: Thu, 27 Nov 2025 20:54:31 +0100 Subject: [PATCH 1/4] Logic Upgrade: Iterate RayMap to generate pointer hits for each viable Camera, rather than just the first viable camera found. --- crates/bevy_sprite/src/picking_backend.rs | 62 +++++++++++++---------- 1 file changed, 35 insertions(+), 27 deletions(-) diff --git a/crates/bevy_sprite/src/picking_backend.rs b/crates/bevy_sprite/src/picking_backend.rs index e861fe10907ad..bb562ea701a70 100644 --- a/crates/bevy_sprite/src/picking_backend.rs +++ b/crates/bevy_sprite/src/picking_backend.rs @@ -102,6 +102,7 @@ fn sprite_picking( &ViewVisibility, )>, mut pointer_hits_writer: MessageWriter, + ray_map: Res, ) { let mut sorted_sprites: Vec<_> = sprite_query .iter() @@ -121,40 +122,44 @@ fn sprite_picking( let primary_window = primary_window.single().ok(); - for (pointer, location) in pointers.iter().filter_map(|(pointer, pointer_location)| { - pointer_location.location().map(|loc| (pointer, loc)) - }) { + let pick_sets = ray_map.iter().flat_map(|(ray_id, ray)| { let mut blocked = false; - let Some((cam_entity, camera, cam_transform, Projection::Orthographic(cam_ortho), _)) = - cameras - .iter() - .filter(|(_, camera, _, _, cam_can_pick)| { - let marker_requirement = !settings.require_markers || *cam_can_pick; - camera.is_active && marker_requirement - }) - .find(|(_, camera, _, _, _)| { - camera - .target - .normalize(primary_window) - .is_some_and(|x| x == location.target) - }) - else { - continue; + + let Ok((cam_entity, camera, cam_transform, Projection::Orthographic(cam_ortho), cam_can_pick)) = + cameras.get(ray_id.camera) + else { + return None }; + let marker_requirement = !settings.require_markers || cam_can_pick; + if !camera.is_active || !marker_requirement { + return None + } + + let location = pointers.iter().find_map(|(id, loc)| { + if *id == ray_id.pointer { + return loc.location.as_ref(); + } + None + })?; + + if camera.target + .normalize(primary_window) + .is_none_or(|x| x != location.target) + { + return None; + } + let viewport_pos = location.position; if let Some(viewport) = camera.logical_viewport_rect() && !viewport.contains(viewport_pos) { // The pointer is outside the viewport, skip it - continue; + return None; } - let Ok(cursor_ray_world) = camera.viewport_to_world(cam_transform, viewport_pos) else { - continue; - }; let cursor_ray_len = cam_ortho.far - cam_ortho.near; - let cursor_ray_end = cursor_ray_world.origin + cursor_ray_world.direction * cursor_ray_len; + let cursor_ray_end = ray.origin + ray.direction * cursor_ray_len; let picks: Vec<(Entity, HitData)> = sorted_sprites .iter() @@ -166,7 +171,7 @@ fn sprite_picking( // Transform cursor line segment to sprite coordinate system let world_to_sprite = sprite_transform.affine().inverse(); - let cursor_start_sprite = world_to_sprite.transform_point3(cursor_ray_world.origin); + let cursor_start_sprite = world_to_sprite.transform_point3(ray.origin); let cursor_end_sprite = world_to_sprite.transform_point3(cursor_ray_end); // Find where the cursor segment intersects the plane Z=0 (which is the sprite's @@ -250,7 +255,10 @@ fn sprite_picking( }) .collect(); - let order = camera.order as f32; - pointer_hits_writer.write(PointerHits::new(*pointer, picks, order)); - } + Some((ray_id.pointer,picks,camera.order)) + }); + + pick_sets.for_each(|(pointer,picks,order)| { + pointer_hits_writer.write(PointerHits::new(pointer, picks, order as f32)); + }) } From 73b859b53ec5214fcafb805bc8d7a5a7714e071a Mon Sep 17 00:00:00 2001 From: bloopyboop <186878072+bloopyboop@users.noreply.github.com> Date: Thu, 27 Nov 2025 20:55:55 +0100 Subject: [PATCH 2/4] Take RenderLayers into account when generating pointer hits. --- crates/bevy_sprite/src/picking_backend.rs | 25 +++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/crates/bevy_sprite/src/picking_backend.rs b/crates/bevy_sprite/src/picking_backend.rs index bb562ea701a70..fe91cff6c7739 100644 --- a/crates/bevy_sprite/src/picking_backend.rs +++ b/crates/bevy_sprite/src/picking_backend.rs @@ -13,7 +13,7 @@ use crate::{Anchor, Sprite}; use bevy_app::prelude::*; use bevy_asset::prelude::*; -use bevy_camera::{visibility::ViewVisibility, Camera, Projection}; +use bevy_camera::{visibility::{ViewVisibility, RenderLayers}, Camera, Projection}; use bevy_color::Alpha; use bevy_ecs::prelude::*; use bevy_image::prelude::*; @@ -88,6 +88,7 @@ fn sprite_picking( &GlobalTransform, &Projection, Has, + Option<&RenderLayers>, )>, primary_window: Query>, images: Res>, @@ -100,15 +101,16 @@ fn sprite_picking( &Anchor, &Pickable, &ViewVisibility, + Option<&RenderLayers>, )>, mut pointer_hits_writer: MessageWriter, ray_map: Res, ) { let mut sorted_sprites: Vec<_> = sprite_query .iter() - .filter_map(|(entity, sprite, transform, anchor, pickable, vis)| { + .filter_map(|(entity, sprite, transform, anchor, pickable, vis, render_layers)| { if !transform.affine().is_nan() && vis.get() { - Some((entity, sprite, transform, anchor, pickable)) + Some((entity, sprite, transform, anchor, pickable, render_layers)) } else { None } @@ -116,7 +118,7 @@ fn sprite_picking( .collect(); // radsort is a stable radix sort that performed better than `slice::sort_by_key` - radsort::sort_by_key(&mut sorted_sprites, |(_, _, transform, _, _)| { + radsort::sort_by_key(&mut sorted_sprites, |(_, _, transform, _, _, _)| { -transform.translation().z }); @@ -125,7 +127,7 @@ fn sprite_picking( let pick_sets = ray_map.iter().flat_map(|(ray_id, ray)| { let mut blocked = false; - let Ok((cam_entity, camera, cam_transform, Projection::Orthographic(cam_ortho), cam_can_pick)) = + let Ok((cam_entity, camera, cam_transform, Projection::Orthographic(cam_ortho), cam_can_pick, cam_render_layers)) = cameras.get(ray_id.camera) else { return None @@ -164,11 +166,22 @@ fn sprite_picking( let picks: Vec<(Entity, HitData)> = sorted_sprites .iter() .copied() - .filter_map(|(entity, sprite, sprite_transform, anchor, pickable)| { + .filter_map(|(entity, sprite, sprite_transform, anchor, pickable, sprite_render_layers)| { if blocked { return None; } + // Filter out sprites based on whether they share RenderLayers with the current + // ray's associated camera. + // Any entity without a RenderLayers component will by default be + // on RenderLayers::layer(0) only. + if !cam_render_layers + .unwrap_or_default() + .intersects(sprite_render_layers.unwrap_or_default()) + { + return None; + } + // Transform cursor line segment to sprite coordinate system let world_to_sprite = sprite_transform.affine().inverse(); let cursor_start_sprite = world_to_sprite.transform_point3(ray.origin); From 1769e437615c3611d244f6f92a998b1661c34898 Mon Sep 17 00:00:00 2001 From: bloopyboop <186878072+bloopyboop@users.noreply.github.com> Date: Thu, 27 Nov 2025 22:08:46 +0100 Subject: [PATCH 3/4] cargo fmt --- crates/bevy_sprite/src/picking_backend.rs | 228 ++++++++++++---------- 1 file changed, 121 insertions(+), 107 deletions(-) diff --git a/crates/bevy_sprite/src/picking_backend.rs b/crates/bevy_sprite/src/picking_backend.rs index fe91cff6c7739..d4f79d271e96c 100644 --- a/crates/bevy_sprite/src/picking_backend.rs +++ b/crates/bevy_sprite/src/picking_backend.rs @@ -13,7 +13,10 @@ use crate::{Anchor, Sprite}; use bevy_app::prelude::*; use bevy_asset::prelude::*; -use bevy_camera::{visibility::{ViewVisibility, RenderLayers}, Camera, Projection}; +use bevy_camera::{ + visibility::{RenderLayers, ViewVisibility}, + Camera, Projection, +}; use bevy_color::Alpha; use bevy_ecs::prelude::*; use bevy_image::prelude::*; @@ -108,13 +111,15 @@ fn sprite_picking( ) { let mut sorted_sprites: Vec<_> = sprite_query .iter() - .filter_map(|(entity, sprite, transform, anchor, pickable, vis, render_layers)| { - if !transform.affine().is_nan() && vis.get() { - Some((entity, sprite, transform, anchor, pickable, render_layers)) - } else { - None - } - }) + .filter_map( + |(entity, sprite, transform, anchor, pickable, vis, render_layers)| { + if !transform.affine().is_nan() && vis.get() { + Some((entity, sprite, transform, anchor, pickable, render_layers)) + } else { + None + } + }, + ) .collect(); // radsort is a stable radix sort that performed better than `slice::sort_by_key` @@ -127,15 +132,21 @@ fn sprite_picking( let pick_sets = ray_map.iter().flat_map(|(ray_id, ray)| { let mut blocked = false; - let Ok((cam_entity, camera, cam_transform, Projection::Orthographic(cam_ortho), cam_can_pick, cam_render_layers)) = - cameras.get(ray_id.camera) - else { - return None + let Ok(( + cam_entity, + camera, + cam_transform, + Projection::Orthographic(cam_ortho), + cam_can_pick, + cam_render_layers, + )) = cameras.get(ray_id.camera) + else { + return None; }; let marker_requirement = !settings.require_markers || cam_can_pick; if !camera.is_active || !marker_requirement { - return None + return None; } let location = pointers.iter().find_map(|(id, loc)| { @@ -145,7 +156,8 @@ fn sprite_picking( None })?; - if camera.target + if camera + .target .normalize(primary_window) .is_none_or(|x| x != location.target) { @@ -166,112 +178,114 @@ fn sprite_picking( let picks: Vec<(Entity, HitData)> = sorted_sprites .iter() .copied() - .filter_map(|(entity, sprite, sprite_transform, anchor, pickable, sprite_render_layers)| { - if blocked { - return None; - } + .filter_map( + |(entity, sprite, sprite_transform, anchor, pickable, sprite_render_layers)| { + if blocked { + return None; + } - // Filter out sprites based on whether they share RenderLayers with the current - // ray's associated camera. - // Any entity without a RenderLayers component will by default be - // on RenderLayers::layer(0) only. - if !cam_render_layers - .unwrap_or_default() - .intersects(sprite_render_layers.unwrap_or_default()) - { - return None; - } + // Filter out sprites based on whether they share RenderLayers with the current + // ray's associated camera. + // Any entity without a RenderLayers component will by default be + // on RenderLayers::layer(0) only. + if !cam_render_layers + .unwrap_or_default() + .intersects(sprite_render_layers.unwrap_or_default()) + { + return None; + } - // Transform cursor line segment to sprite coordinate system - let world_to_sprite = sprite_transform.affine().inverse(); - let cursor_start_sprite = world_to_sprite.transform_point3(ray.origin); - let cursor_end_sprite = world_to_sprite.transform_point3(cursor_ray_end); + // Transform cursor line segment to sprite coordinate system + let world_to_sprite = sprite_transform.affine().inverse(); + let cursor_start_sprite = world_to_sprite.transform_point3(ray.origin); + let cursor_end_sprite = world_to_sprite.transform_point3(cursor_ray_end); - // Find where the cursor segment intersects the plane Z=0 (which is the sprite's - // plane in sprite-local space). It may not intersect if, for example, we're - // viewing the sprite side-on - if cursor_start_sprite.z == cursor_end_sprite.z { - // Cursor ray is parallel to the sprite and misses it - return None; - } - let lerp_factor = - f32::inverse_lerp(cursor_start_sprite.z, cursor_end_sprite.z, 0.0); - if !(0.0..=1.0).contains(&lerp_factor) { - // Lerp factor is out of range, meaning that while an infinite line cast by - // the cursor would intersect the sprite, the sprite is not between the - // camera's near and far planes - return None; - } - // Otherwise we can interpolate the xy of the start and end positions by the - // lerp factor to get the cursor position in sprite space! - let cursor_pos_sprite = cursor_start_sprite - .lerp(cursor_end_sprite, lerp_factor) - .xy(); + // Find where the cursor segment intersects the plane Z=0 (which is the sprite's + // plane in sprite-local space). It may not intersect if, for example, we're + // viewing the sprite side-on + if cursor_start_sprite.z == cursor_end_sprite.z { + // Cursor ray is parallel to the sprite and misses it + return None; + } + let lerp_factor = + f32::inverse_lerp(cursor_start_sprite.z, cursor_end_sprite.z, 0.0); + if !(0.0..=1.0).contains(&lerp_factor) { + // Lerp factor is out of range, meaning that while an infinite line cast by + // the cursor would intersect the sprite, the sprite is not between the + // camera's near and far planes + return None; + } + // Otherwise we can interpolate the xy of the start and end positions by the + // lerp factor to get the cursor position in sprite space! + let cursor_pos_sprite = cursor_start_sprite + .lerp(cursor_end_sprite, lerp_factor) + .xy(); - let Ok(cursor_pixel_space) = sprite.compute_pixel_space_point( - cursor_pos_sprite, - *anchor, - &images, - &texture_atlas_layout, - ) else { - return None; - }; + let Ok(cursor_pixel_space) = sprite.compute_pixel_space_point( + cursor_pos_sprite, + *anchor, + &images, + &texture_atlas_layout, + ) else { + return None; + }; - // Since the pixel space coordinate is `Ok`, we know the cursor is in the bounds of - // the sprite. + // Since the pixel space coordinate is `Ok`, we know the cursor is in the bounds of + // the sprite. - let cursor_in_valid_pixels_of_sprite = 'valid_pixel: { - match settings.picking_mode { - SpritePickingMode::AlphaThreshold(cutoff) => { - let Some(image) = images.get(&sprite.image) else { - // [`Sprite::from_color`] returns a defaulted handle. - // This handle doesn't return a valid image, so returning false here would make picking "color sprites" impossible - break 'valid_pixel true; - }; - // grab pixel and check alpha - let Ok(color) = image.get_color_at( - cursor_pixel_space.x as u32, - cursor_pixel_space.y as u32, - ) else { - // We don't know how to interpret the pixel. - break 'valid_pixel false; - }; - // Check the alpha is above the cutoff. - color.alpha() > cutoff + let cursor_in_valid_pixels_of_sprite = 'valid_pixel: { + match settings.picking_mode { + SpritePickingMode::AlphaThreshold(cutoff) => { + let Some(image) = images.get(&sprite.image) else { + // [`Sprite::from_color`] returns a defaulted handle. + // This handle doesn't return a valid image, so returning false here would make picking "color sprites" impossible + break 'valid_pixel true; + }; + // grab pixel and check alpha + let Ok(color) = image.get_color_at( + cursor_pixel_space.x as u32, + cursor_pixel_space.y as u32, + ) else { + // We don't know how to interpret the pixel. + break 'valid_pixel false; + }; + // Check the alpha is above the cutoff. + color.alpha() > cutoff + } + SpritePickingMode::BoundingBox => true, } - SpritePickingMode::BoundingBox => true, - } - }; + }; - blocked = cursor_in_valid_pixels_of_sprite && pickable.should_block_lower; + blocked = cursor_in_valid_pixels_of_sprite && pickable.should_block_lower; - cursor_in_valid_pixels_of_sprite.then(|| { - let hit_pos_world = - sprite_transform.transform_point(cursor_pos_sprite.extend(0.0)); - // Transform point from world to camera space to get the Z distance - let hit_pos_cam = cam_transform - .affine() - .inverse() - .transform_point3(hit_pos_world); - // HitData requires a depth as calculated from the camera's near clipping plane - let depth = -cam_ortho.near - hit_pos_cam.z; - ( - entity, - HitData::new( - cam_entity, - depth, - Some(hit_pos_world), - Some(*sprite_transform.back()), - ), - ) - }) - }) + cursor_in_valid_pixels_of_sprite.then(|| { + let hit_pos_world = + sprite_transform.transform_point(cursor_pos_sprite.extend(0.0)); + // Transform point from world to camera space to get the Z distance + let hit_pos_cam = cam_transform + .affine() + .inverse() + .transform_point3(hit_pos_world); + // HitData requires a depth as calculated from the camera's near clipping plane + let depth = -cam_ortho.near - hit_pos_cam.z; + ( + entity, + HitData::new( + cam_entity, + depth, + Some(hit_pos_world), + Some(*sprite_transform.back()), + ), + ) + }) + }, + ) .collect(); - Some((ray_id.pointer,picks,camera.order)) + Some((ray_id.pointer, picks, camera.order)) }); - pick_sets.for_each(|(pointer,picks,order)| { + pick_sets.for_each(|(pointer, picks, order)| { pointer_hits_writer.write(PointerHits::new(pointer, picks, order as f32)); }) } From 036b33530263051d88be3b678b7b1e58f07d677e Mon Sep 17 00:00:00 2001 From: bloopyboop <186878072+bloopyboop@users.noreply.github.com> Date: Thu, 27 Nov 2025 22:14:15 +0100 Subject: [PATCH 4/4] satisfying clippy --- crates/bevy_sprite/src/picking_backend.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/bevy_sprite/src/picking_backend.rs b/crates/bevy_sprite/src/picking_backend.rs index d4f79d271e96c..9dbd3ac2d413a 100644 --- a/crates/bevy_sprite/src/picking_backend.rs +++ b/crates/bevy_sprite/src/picking_backend.rs @@ -287,5 +287,5 @@ fn sprite_picking( pick_sets.for_each(|(pointer, picks, order)| { pointer_hits_writer.write(PointerHits::new(pointer, picks, order as f32)); - }) + }); }