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