Skip to main content

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}