Skip to content

Commit 93dc596

Browse files
vandieandriyDev
andauthored
Add optional transparency passthrough for sprite backend with bevy_picking (#16388)
# Objective - Allow bevy_sprite_picking backend to pass through transparent sections of the sprite. - Fixes #14929 ## Solution - After sprite picking detects the cursor is within a sprites rect, check the pixel at that location on the texture and check that it meets an optional transparency cutoff. Change originally created for mod_picking on bevy 0.14 (aevyrie/bevy_mod_picking#373) ## Testing - Ran Sprite Picking example to check it was working both with transparency enabled and disabled - ModPicking version is currently in use in my own isometric game where this has been an extremely noticeable issue ## Showcase ![Sprite Picking Text](https://github.com/user-attachments/assets/76568c0d-c359-422b-942d-17c84d3d3009) ## Migration Guide Sprite picking now ignores transparent regions (with an alpha value less than or equal to 0.1). To configure this, modify the `SpriteBackendSettings` resource. --------- Co-authored-by: andriyDev <[email protected]>
1 parent 5adf831 commit 93dc596

File tree

3 files changed

+406
-25
lines changed

3 files changed

+406
-25
lines changed

crates/bevy_sprite/src/lib.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ use bevy_reflect::{std_traits::ReflectDefault, Reflect};
4141
pub use bundle::*;
4242
pub use dynamic_texture_atlas_builder::*;
4343
pub use mesh2d::*;
44+
#[cfg(feature = "bevy_sprite_picking_backend")]
45+
pub use picking_backend::*;
4446
pub use render::*;
4547
pub use sprite::*;
4648
pub use texture_atlas::*;
@@ -148,7 +150,7 @@ impl Plugin for SpritePlugin {
148150

149151
#[cfg(feature = "bevy_sprite_picking_backend")]
150152
if self.add_picking {
151-
app.add_plugins(picking_backend::SpritePickingPlugin);
153+
app.add_plugins(SpritePickingPlugin);
152154
}
153155

154156
if let Some(render_app) = app.get_sub_app_mut(RenderApp) {

crates/bevy_sprite/src/picking_backend.rs

Lines changed: 71 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -7,29 +7,63 @@ use core::cmp::Reverse;
77
use crate::{Sprite, TextureAtlasLayout};
88
use bevy_app::prelude::*;
99
use bevy_asset::prelude::*;
10+
use bevy_color::Alpha;
1011
use bevy_ecs::prelude::*;
1112
use bevy_image::Image;
1213
use bevy_math::{prelude::*, FloatExt, FloatOrd};
1314
use bevy_picking::backend::prelude::*;
15+
use bevy_reflect::prelude::*;
1416
use bevy_render::prelude::*;
1517
use bevy_transform::prelude::*;
1618
use bevy_window::PrimaryWindow;
1719

20+
/// How should the [`SpritePickingPlugin`] handle picking and how should it handle transparent pixels
21+
#[derive(Debug, Clone, Copy, Reflect)]
22+
pub enum SpritePickingMode {
23+
/// Even if a sprite is picked on a transparent pixel, it should still count within the backend.
24+
/// Only consider the rect of a given sprite.
25+
BoundingBox,
26+
/// Ignore any part of a sprite which has a lower alpha value than the threshold (inclusive)
27+
/// Threshold is given as an f32 representing the alpha value in a Bevy Color Value
28+
AlphaThreshold(f32),
29+
}
30+
31+
/// Runtime settings for the [`SpritePickingPlugin`].
32+
#[derive(Resource, Reflect)]
33+
#[reflect(Resource, Default)]
34+
pub struct SpritePickingSettings {
35+
/// 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.
36+
///
37+
/// Defaults to an incusive alpha threshold of 0.1
38+
pub picking_mode: SpritePickingMode,
39+
}
40+
41+
impl Default for SpritePickingSettings {
42+
fn default() -> Self {
43+
Self {
44+
picking_mode: SpritePickingMode::AlphaThreshold(0.1),
45+
}
46+
}
47+
}
48+
1849
#[derive(Clone)]
1950
pub struct SpritePickingPlugin;
2051

2152
impl Plugin for SpritePickingPlugin {
2253
fn build(&self, app: &mut App) {
23-
app.add_systems(PreUpdate, sprite_picking.in_set(PickSet::Backend));
54+
app.init_resource::<SpritePickingSettings>()
55+
.add_systems(PreUpdate, sprite_picking.in_set(PickSet::Backend));
2456
}
2557
}
2658

27-
pub fn sprite_picking(
59+
#[allow(clippy::too_many_arguments)]
60+
fn sprite_picking(
2861
pointers: Query<(&PointerId, &PointerLocation)>,
2962
cameras: Query<(Entity, &Camera, &GlobalTransform, &OrthographicProjection)>,
3063
primary_window: Query<Entity, With<PrimaryWindow>>,
3164
images: Res<Assets<Image>>,
3265
texture_atlas_layout: Res<Assets<TextureAtlasLayout>>,
66+
settings: Res<SpritePickingSettings>,
3367
sprite_query: Query<(
3468
Entity,
3569
&Sprite,
@@ -91,22 +125,6 @@ pub fn sprite_picking(
91125
return None;
92126
}
93127

94-
// Hit box in sprite coordinate system
95-
let extents = match (sprite.custom_size, &sprite.texture_atlas) {
96-
(Some(custom_size), _) => custom_size,
97-
(None, None) => images.get(&sprite.image)?.size().as_vec2(),
98-
(None, Some(atlas)) => texture_atlas_layout
99-
.get(&atlas.layout)
100-
.and_then(|layout| layout.textures.get(atlas.index))
101-
// Dropped atlas layouts and indexes out of bounds are rendered as a sprite
102-
.map_or(images.get(&sprite.image)?.size().as_vec2(), |rect| {
103-
rect.size().as_vec2()
104-
}),
105-
};
106-
let anchor = sprite.anchor.as_vec();
107-
let center = -anchor * extents;
108-
let rect = Rect::from_center_half_size(center, extents / 2.0);
109-
110128
// Transform cursor line segment to sprite coordinate system
111129
let world_to_sprite = sprite_transform.affine().inverse();
112130
let cursor_start_sprite = world_to_sprite.transform_point3(cursor_ray_world.origin);
@@ -133,14 +151,46 @@ pub fn sprite_picking(
133151
.lerp(cursor_end_sprite, lerp_factor)
134152
.xy();
135153

136-
let is_cursor_in_sprite = rect.contains(cursor_pos_sprite);
154+
let Ok(cursor_pixel_space) = sprite.compute_pixel_space_point(
155+
cursor_pos_sprite,
156+
&images,
157+
&texture_atlas_layout,
158+
) else {
159+
return None;
160+
};
161+
162+
// Since the pixel space coordinate is `Ok`, we know the cursor is in the bounds of
163+
// the sprite.
164+
165+
let cursor_in_valid_pixels_of_sprite = 'valid_pixel: {
166+
match settings.picking_mode {
167+
SpritePickingMode::AlphaThreshold(cutoff) => {
168+
let Some(image) = images.get(&sprite.image) else {
169+
// [`Sprite::from_color`] returns a defaulted handle.
170+
// This handle doesn't return a valid image, so returning false here would make picking "color sprites" impossible
171+
break 'valid_pixel true;
172+
};
173+
// grab pixel and check alpha
174+
let Ok(color) = image.get_color_at(
175+
cursor_pixel_space.x as u32,
176+
cursor_pixel_space.y as u32,
177+
) else {
178+
// We don't know how to interpret the pixel.
179+
break 'valid_pixel false;
180+
};
181+
// Check the alpha is above the cutoff.
182+
color.alpha() > cutoff
183+
}
184+
SpritePickingMode::BoundingBox => true,
185+
}
186+
};
137187

138-
blocked = is_cursor_in_sprite
188+
blocked = cursor_in_valid_pixels_of_sprite
139189
&& picking_behavior
140190
.map(|p| p.should_block_lower)
141191
.unwrap_or(true);
142192

143-
is_cursor_in_sprite.then(|| {
193+
cursor_in_valid_pixels_of_sprite.then(|| {
144194
let hit_pos_world =
145195
sprite_transform.transform_point(cursor_pos_sprite.extend(0.0));
146196
// Transform point from world to camera space to get the Z distance

0 commit comments

Comments
 (0)