bevy_sprite/picking_backend.rs
1//! A [`bevy_picking`] backend for sprites. Works for simple sprites and sprite atlases. Works for
2//! sprites with arbitrary transforms.
3//!
4//! By default, picking for sprites is based on pixel opacity.
5//! A sprite is picked only when a pointer is over an opaque pixel.
6//! Alternatively, you can configure picking to be based on sprite bounds.
7//!
8//! ## Implementation Notes
9//!
10//! - The `position` reported in `HitData` in world space, and the `normal` is a normalized
11//! vector provided by the target's `GlobalTransform::back()`.
12
13use crate::{Anchor, Sprite};
14use bevy_app::prelude::*;
15use bevy_asset::prelude::*;
16use bevy_camera::{
17 visibility::{RenderLayers, ViewVisibility},
18 Camera, Projection, RenderTarget,
19};
20use bevy_color::Alpha;
21use bevy_ecs::prelude::*;
22use bevy_image::prelude::*;
23use bevy_math::{prelude::*, FloatExt};
24use bevy_picking::backend::prelude::*;
25use bevy_reflect::prelude::*;
26use bevy_transform::prelude::*;
27use bevy_window::PrimaryWindow;
28
29/// An optional component that marks cameras that should be used in the [`SpritePickingPlugin`].
30///
31/// Only needed if [`SpritePickingSettings::require_markers`] is set to `true`, and ignored
32/// otherwise.
33#[derive(Debug, Clone, Default, Component, Reflect)]
34#[reflect(Debug, Default, Component, Clone)]
35pub struct SpritePickingCamera;
36
37/// How should the [`SpritePickingPlugin`] handle picking and how should it handle transparent pixels
38#[derive(Debug, Clone, Copy, Reflect)]
39#[reflect(Debug, Clone)]
40pub enum SpritePickingMode {
41 /// Even if a sprite is picked on a transparent pixel, it should still count within the backend.
42 /// Only consider the rect of a given sprite.
43 BoundingBox,
44 /// Ignore any part of a sprite which has a lower alpha value than the threshold (inclusive)
45 /// Threshold is given as an f32 representing the alpha value in a Bevy Color Value
46 AlphaThreshold(f32),
47}
48
49/// Runtime settings for the [`SpritePickingPlugin`].
50#[derive(Resource, Reflect)]
51#[reflect(Resource, Default)]
52pub struct SpritePickingSettings {
53 /// When set to `true` sprite picking will only consider cameras marked with
54 /// [`SpritePickingCamera`]. Defaults to `false`.
55 /// Regardless of this setting, only sprites marked with [`Pickable`] will be considered.
56 ///
57 /// This setting is provided to give you fine-grained control over which cameras
58 /// should be used by the sprite picking backend at runtime.
59 pub require_markers: bool,
60 /// Should the backend count transparent pixels as part of the sprite for picking purposes or should it use the bounding box of the sprite alone.
61 ///
62 /// Defaults to an inclusive alpha threshold of 0.1
63 pub picking_mode: SpritePickingMode,
64}
65
66impl Default for SpritePickingSettings {
67 fn default() -> Self {
68 Self {
69 require_markers: false,
70 picking_mode: SpritePickingMode::AlphaThreshold(0.1),
71 }
72 }
73}
74
75/// Enables the sprite picking backend, allowing you to click on, hover over and drag sprites.
76#[derive(Clone)]
77pub struct SpritePickingPlugin;
78
79impl Plugin for SpritePickingPlugin {
80 fn build(&self, app: &mut App) {
81 app.init_resource::<SpritePickingSettings>()
82 .add_systems(PreUpdate, sprite_picking.in_set(PickingSystems::Backend));
83 }
84}
85
86fn sprite_picking(
87 pointers: Query<(&PointerId, &PointerLocation)>,
88 cameras: Query<(
89 Entity,
90 &Camera,
91 &RenderTarget,
92 &GlobalTransform,
93 &Projection,
94 Has<SpritePickingCamera>,
95 Option<&RenderLayers>,
96 )>,
97 primary_window: Query<Entity, With<PrimaryWindow>>,
98 images: Res<Assets<Image>>,
99 texture_atlas_layout: Res<Assets<TextureAtlasLayout>>,
100 settings: Res<SpritePickingSettings>,
101 sprite_query: Query<(
102 Entity,
103 &Sprite,
104 &GlobalTransform,
105 &Anchor,
106 &Pickable,
107 &ViewVisibility,
108 Option<&RenderLayers>,
109 )>,
110 mut pointer_hits_writer: MessageWriter<PointerHits>,
111 ray_map: Res<RayMap>,
112) {
113 let mut sorted_sprites: Vec<_> = sprite_query
114 .iter()
115 .filter_map(
116 |(entity, sprite, transform, anchor, pickable, vis, render_layers)| {
117 if !transform.affine().is_nan() && vis.get() {
118 Some((entity, sprite, transform, anchor, pickable, render_layers))
119 } else {
120 None
121 }
122 },
123 )
124 .collect();
125
126 // radsort is a stable radix sort that performed better than `slice::sort_by_key`
127 radsort::sort_by_key(&mut sorted_sprites, |(_, _, transform, _, _, _)| {
128 -transform.translation().z
129 });
130
131 let primary_window = primary_window.single().ok();
132
133 let pick_sets = ray_map.iter().flat_map(|(ray_id, ray)| {
134 let mut blocked = false;
135
136 let Ok((
137 cam_entity,
138 camera,
139 render_target,
140 cam_transform,
141 Projection::Orthographic(cam_ortho),
142 cam_can_pick,
143 cam_render_layers,
144 )) = cameras.get(ray_id.camera)
145 else {
146 return None;
147 };
148
149 let marker_requirement = !settings.require_markers || cam_can_pick;
150 if !camera.is_active || !marker_requirement {
151 return None;
152 }
153
154 let location = pointers.iter().find_map(|(id, loc)| {
155 if *id == ray_id.pointer {
156 return loc.location.as_ref();
157 }
158 None
159 })?;
160
161 if render_target
162 .normalize(primary_window)
163 .is_none_or(|x| x != location.target)
164 {
165 return None;
166 }
167
168 let viewport_pos = location.position;
169 if let Some(viewport) = camera.logical_viewport_rect()
170 && !viewport.contains(viewport_pos)
171 {
172 // The pointer is outside the viewport, skip it
173 return None;
174 }
175
176 let cursor_ray_len = cam_ortho.far - cam_ortho.near;
177 let cursor_ray_end = ray.origin + ray.direction * cursor_ray_len;
178
179 let picks: Vec<(Entity, HitData)> = sorted_sprites
180 .iter()
181 .copied()
182 .filter_map(
183 |(entity, sprite, sprite_transform, anchor, pickable, sprite_render_layers)| {
184 if blocked {
185 return None;
186 }
187
188 // Filter out sprites based on whether they share RenderLayers with the current
189 // ray's associated camera.
190 // Any entity without a RenderLayers component will by default be
191 // on RenderLayers::layer(0) only.
192 if !cam_render_layers
193 .unwrap_or_default()
194 .intersects(sprite_render_layers.unwrap_or_default())
195 {
196 return None;
197 }
198
199 // Transform cursor line segment to sprite coordinate system
200 let world_to_sprite = sprite_transform.affine().inverse();
201 let cursor_start_sprite = world_to_sprite.transform_point3(ray.origin);
202 let cursor_end_sprite = world_to_sprite.transform_point3(cursor_ray_end);
203
204 // Find where the cursor segment intersects the plane Z=0 (which is the sprite's
205 // plane in sprite-local space). It may not intersect if, for example, we're
206 // viewing the sprite side-on
207 if cursor_start_sprite.z == cursor_end_sprite.z {
208 // Cursor ray is parallel to the sprite and misses it
209 return None;
210 }
211 let lerp_factor =
212 f32::inverse_lerp(cursor_start_sprite.z, cursor_end_sprite.z, 0.0);
213 if !(0.0..=1.0).contains(&lerp_factor) {
214 // Lerp factor is out of range, meaning that while an infinite line cast by
215 // the cursor would intersect the sprite, the sprite is not between the
216 // camera's near and far planes
217 return None;
218 }
219 // Otherwise we can interpolate the xy of the start and end positions by the
220 // lerp factor to get the cursor position in sprite space!
221 let cursor_pos_sprite = cursor_start_sprite
222 .lerp(cursor_end_sprite, lerp_factor)
223 .xy();
224
225 let Ok(cursor_pixel_space) = sprite.compute_pixel_space_point(
226 cursor_pos_sprite,
227 *anchor,
228 &images,
229 &texture_atlas_layout,
230 ) else {
231 return None;
232 };
233
234 // Since the pixel space coordinate is `Ok`, we know the cursor is in the bounds of
235 // the sprite.
236
237 let cursor_in_valid_pixels_of_sprite = 'valid_pixel: {
238 match settings.picking_mode {
239 SpritePickingMode::AlphaThreshold(cutoff) => {
240 let Some(image) = images.get(&sprite.image) else {
241 // [`Sprite::from_color`] returns a defaulted handle.
242 // This handle doesn't return a valid image, so returning false here would make picking "color sprites" impossible
243 break 'valid_pixel true;
244 };
245 // grab pixel and check alpha
246 let Ok(color) = image.get_color_at(
247 cursor_pixel_space.x as u32,
248 cursor_pixel_space.y as u32,
249 ) else {
250 // We don't know how to interpret the pixel.
251 break 'valid_pixel false;
252 };
253 // Check the alpha is above the cutoff.
254 color.alpha() > cutoff
255 }
256 SpritePickingMode::BoundingBox => true,
257 }
258 };
259
260 blocked = cursor_in_valid_pixels_of_sprite && pickable.should_block_lower;
261
262 cursor_in_valid_pixels_of_sprite.then(|| {
263 let hit_pos_world =
264 sprite_transform.transform_point(cursor_pos_sprite.extend(0.0));
265 // Transform point from world to camera space to get the Z distance
266 let hit_pos_cam = cam_transform
267 .affine()
268 .inverse()
269 .transform_point3(hit_pos_world);
270 // HitData requires a depth as calculated from the camera's near clipping plane
271 let depth = -cam_ortho.near - hit_pos_cam.z;
272 (
273 entity,
274 HitData::new(
275 cam_entity,
276 depth,
277 Some(hit_pos_world),
278 Some(*sprite_transform.back()),
279 ),
280 )
281 })
282 },
283 )
284 .collect();
285
286 Some((ray_id.pointer, picks, camera.order))
287 });
288
289 pick_sets.for_each(|(pointer, picks, order)| {
290 pointer_hits_writer.write(PointerHits::new(pointer, picks, order as f32));
291 });
292}