|
| 1 | +//! A [`bevy_picking`] backend for sprites. Works for simple sprites and sprite atlases. Works for |
| 2 | +//! sprites with arbitrary transforms. Picking is done based on sprite bounds, not visible pixels. |
| 3 | +//! This means a partially transparent sprite is pickable even in its transparent areas. |
| 4 | +
|
| 5 | +use std::cmp::Ordering; |
| 6 | + |
| 7 | +use crate::{Sprite, TextureAtlas, TextureAtlasLayout}; |
| 8 | +use bevy_app::prelude::*; |
| 9 | +use bevy_asset::prelude::*; |
| 10 | +use bevy_ecs::prelude::*; |
| 11 | +use bevy_math::{prelude::*, FloatExt}; |
| 12 | +use bevy_picking::backend::prelude::*; |
| 13 | +use bevy_render::prelude::*; |
| 14 | +use bevy_transform::prelude::*; |
| 15 | +use bevy_window::PrimaryWindow; |
| 16 | + |
| 17 | +#[derive(Clone)] |
| 18 | +pub struct SpritePickingBackend; |
| 19 | + |
| 20 | +impl Plugin for SpritePickingBackend { |
| 21 | + fn build(&self, app: &mut App) { |
| 22 | + app.add_systems(PreUpdate, sprite_picking.in_set(PickSet::Backend)); |
| 23 | + } |
| 24 | +} |
| 25 | + |
| 26 | +pub fn sprite_picking( |
| 27 | + pointers: Query<(&PointerId, &PointerLocation)>, |
| 28 | + cameras: Query<(Entity, &Camera, &GlobalTransform, &OrthographicProjection)>, |
| 29 | + primary_window: Query<Entity, With<PrimaryWindow>>, |
| 30 | + images: Res<Assets<Image>>, |
| 31 | + texture_atlas_layout: Res<Assets<TextureAtlasLayout>>, |
| 32 | + sprite_query: Query< |
| 33 | + ( |
| 34 | + Entity, |
| 35 | + Option<&Sprite>, |
| 36 | + Option<&TextureAtlas>, |
| 37 | + Option<&Handle<Image>>, |
| 38 | + &GlobalTransform, |
| 39 | + Option<&Pickable>, |
| 40 | + &ViewVisibility, |
| 41 | + ), |
| 42 | + Or<(With<Sprite>, With<TextureAtlas>)>, |
| 43 | + >, |
| 44 | + mut output: EventWriter<PointerHits>, |
| 45 | +) { |
| 46 | + let mut sorted_sprites: Vec<_> = sprite_query.iter().collect(); |
| 47 | + sorted_sprites.sort_by(|a, b| { |
| 48 | + (b.4.translation().z) |
| 49 | + .partial_cmp(&a.4.translation().z) |
| 50 | + .unwrap_or(Ordering::Equal) |
| 51 | + }); |
| 52 | + |
| 53 | + let primary_window = primary_window.get_single().ok(); |
| 54 | + |
| 55 | + for (pointer, location) in pointers.iter().filter_map(|(pointer, pointer_location)| { |
| 56 | + pointer_location.location().map(|loc| (pointer, loc)) |
| 57 | + }) { |
| 58 | + let mut blocked = false; |
| 59 | + let Some((cam_entity, camera, cam_transform, cam_ortho)) = cameras |
| 60 | + .iter() |
| 61 | + .filter(|(_, camera, _, _)| camera.is_active) |
| 62 | + .find(|(_, camera, _, _)| { |
| 63 | + camera |
| 64 | + .target |
| 65 | + .normalize(primary_window) |
| 66 | + .map(|x| x == location.target) |
| 67 | + .unwrap_or(false) |
| 68 | + }) |
| 69 | + else { |
| 70 | + continue; |
| 71 | + }; |
| 72 | + |
| 73 | + let Some(cursor_ray_world) = camera.viewport_to_world(cam_transform, location.position) |
| 74 | + else { |
| 75 | + continue; |
| 76 | + }; |
| 77 | + let cursor_ray_len = cam_ortho.far - cam_ortho.near; |
| 78 | + let cursor_ray_end = cursor_ray_world.origin + cursor_ray_world.direction * cursor_ray_len; |
| 79 | + |
| 80 | + let picks: Vec<(Entity, HitData)> = sorted_sprites |
| 81 | + .iter() |
| 82 | + .copied() |
| 83 | + .filter(|(.., visibility)| visibility.get()) |
| 84 | + .filter_map( |
| 85 | + |(entity, sprite, atlas, image, sprite_transform, pickable, ..)| { |
| 86 | + if blocked { |
| 87 | + return None; |
| 88 | + } |
| 89 | + |
| 90 | + // Hit box in sprite coordinate system |
| 91 | + let (extents, anchor) = if let Some((sprite, atlas)) = sprite.zip(atlas) { |
| 92 | + let extents = sprite.custom_size.or_else(|| { |
| 93 | + texture_atlas_layout |
| 94 | + .get(&atlas.layout) |
| 95 | + .map(|f| f.textures[atlas.index].size().as_vec2()) |
| 96 | + })?; |
| 97 | + let anchor = sprite.anchor.as_vec(); |
| 98 | + (extents, anchor) |
| 99 | + } else if let Some((sprite, image)) = sprite.zip(image) { |
| 100 | + let extents = sprite |
| 101 | + .custom_size |
| 102 | + .or_else(|| images.get(image).map(|f| f.size().as_vec2()))?; |
| 103 | + let anchor = sprite.anchor.as_vec(); |
| 104 | + (extents, anchor) |
| 105 | + } else { |
| 106 | + return None; |
| 107 | + }; |
| 108 | + |
| 109 | + let center = -anchor * extents; |
| 110 | + let rect = Rect::from_center_half_size(center, extents / 2.0); |
| 111 | + |
| 112 | + // Transform cursor line segment to sprite coordinate system |
| 113 | + let world_to_sprite = sprite_transform.affine().inverse(); |
| 114 | + let cursor_start_sprite = |
| 115 | + world_to_sprite.transform_point3(cursor_ray_world.origin); |
| 116 | + let cursor_end_sprite = world_to_sprite.transform_point3(cursor_ray_end); |
| 117 | + |
| 118 | + // Find where the cursor segment intersects the plane Z=0 (which is the sprite's |
| 119 | + // plane in sprite-local space). It may not intersect if, for example, we're |
| 120 | + // viewing the sprite side-on |
| 121 | + if cursor_start_sprite.z == cursor_end_sprite.z { |
| 122 | + // Cursor ray is parallel to the sprite and misses it |
| 123 | + return None; |
| 124 | + } |
| 125 | + let lerp_factor = |
| 126 | + f32::inverse_lerp(cursor_start_sprite.z, cursor_end_sprite.z, 0.0); |
| 127 | + if !(0.0..=1.0).contains(&lerp_factor) { |
| 128 | + // Lerp factor is out of range, meaning that while an infinite line cast by |
| 129 | + // the cursor would intersect the sprite, the sprite is not between the |
| 130 | + // camera's near and far planes |
| 131 | + return None; |
| 132 | + } |
| 133 | + // Otherwise we can interpolate the xy of the start and end positions by the |
| 134 | + // lerp factor to get the cursor position in sprite space! |
| 135 | + let cursor_pos_sprite = cursor_start_sprite |
| 136 | + .lerp(cursor_end_sprite, lerp_factor) |
| 137 | + .xy(); |
| 138 | + |
| 139 | + let is_cursor_in_sprite = rect.contains(cursor_pos_sprite); |
| 140 | + |
| 141 | + blocked = is_cursor_in_sprite |
| 142 | + && pickable.map(|p| p.should_block_lower) != Some(false); |
| 143 | + |
| 144 | + is_cursor_in_sprite.then(|| { |
| 145 | + let hit_pos_world = |
| 146 | + sprite_transform.transform_point(cursor_pos_sprite.extend(0.0)); |
| 147 | + // Transform point from world to camera space to get the Z distance |
| 148 | + let hit_pos_cam = cam_transform |
| 149 | + .affine() |
| 150 | + .inverse() |
| 151 | + .transform_point3(hit_pos_world); |
| 152 | + // HitData requires a depth as calculated from the camera's near clipping plane |
| 153 | + let depth = -cam_ortho.near - hit_pos_cam.z; |
| 154 | + ( |
| 155 | + entity, |
| 156 | + HitData::new( |
| 157 | + cam_entity, |
| 158 | + depth, |
| 159 | + Some(hit_pos_world), |
| 160 | + Some(*sprite_transform.back()), |
| 161 | + ), |
| 162 | + ) |
| 163 | + }) |
| 164 | + }, |
| 165 | + ) |
| 166 | + .collect(); |
| 167 | + |
| 168 | + let order = camera.order as f32; |
| 169 | + output.send(PointerHits::new(*pointer, picks, order)); |
| 170 | + } |
| 171 | +} |
0 commit comments