Skip to content

Commit 3540b87

Browse files
jnhyattaevyrie
andauthored
Add bevy_picking sprite backend (#14757)
# Objective Add `bevy_picking` sprite backend as part of the `bevy_mod_picking` upstreamening (#12365). ## Solution More or less a copy/paste from `bevy_mod_picking`, with the changes [here](aevyrie/bevy_mod_picking#354). I'm putting that link here since those changes haven't yet made it through review, so should probably be reviewed on their own. ## Testing I couldn't find any sprite-backend-specific tests in `bevy_mod_picking` and unfortunately I'm not familiar enough with Bevy's testing patterns to write tests for code that relies on windowing and input. I'm willing to break the pointer hit system into testable blocks and add some more modular tests if that's deemed important enough to block, otherwise I can open an issue for adding tests as follow-up. ## Follow-up work - More docs/tests - Ignore pick events on transparent sprite pixels with potential opt-out --------- Co-authored-by: Aevyrie <[email protected]>
1 parent 6819e99 commit 3540b87

File tree

9 files changed

+364
-1
lines changed

9 files changed

+364
-1
lines changed

Cargo.toml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3384,6 +3384,17 @@ description = "Demonstrates how to use picking events to spawn simple objects"
33843384
category = "Picking"
33853385
wasm = true
33863386

3387+
[[example]]
3388+
name = "sprite_picking"
3389+
path = "examples/picking/sprite_picking.rs"
3390+
doc-scrape-examples = true
3391+
3392+
[package.metadata.example.sprite_picking]
3393+
name = "Sprite Picking"
3394+
description = "Demonstrates picking sprites and sprite atlases"
3395+
category = "Picking"
3396+
wasm = true
3397+
33873398
[profile.wasm-release]
33883399
inherits = "release"
33893400
opt-level = "z"

crates/bevy_internal/Cargo.toml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,11 @@ meshlet_processor = ["bevy_pbr?/meshlet_processor"]
190190
bevy_dev_tools = ["dep:bevy_dev_tools"]
191191

192192
# Provides a picking functionality
193-
bevy_picking = ["dep:bevy_picking", "bevy_ui?/bevy_picking"]
193+
bevy_picking = [
194+
"dep:bevy_picking",
195+
"bevy_ui?/bevy_picking",
196+
"bevy_sprite?/bevy_picking",
197+
]
194198

195199
# Enable support for the ios_simulator by downgrading some rendering capabilities
196200
ios_simulator = ["bevy_pbr?/ios_simulator", "bevy_render?/ios_simulator"]

crates/bevy_picking/src/backend.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,10 @@ pub mod prelude {
5050
/// Some backends may only support providing the topmost entity; this is a valid limitation of some
5151
/// backends. For example, a picking shader might only have data on the topmost rendered output from
5252
/// its buffer.
53+
///
54+
/// Note that systems reading these events in [`PreUpdate`](bevy_app) will not report ordering
55+
/// ambiguities with picking backends. Take care to ensure such systems are explicitly ordered
56+
/// against [`PickSet::Backends`](crate), or better, avoid reading `PointerHits` in `PreUpdate`.
5357
#[derive(Event, Debug, Clone)]
5458
pub struct PointerHits {
5559
/// The pointer associated with this hit test.

crates/bevy_picking/src/lib.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,10 @@ impl Plugin for PickingPlugin {
207207
.add_event::<pointer::InputPress>()
208208
.add_event::<pointer::InputMove>()
209209
.add_event::<backend::PointerHits>()
210+
// Rather than try to mark all current and future backends as ambiguous with each other,
211+
// we allow them to send their hits in any order. These are later sorted, so submission
212+
// order doesn't matter. See `PointerHits` docs for caveats.
213+
.allow_ambiguous_resource::<Events<backend::PointerHits>>()
210214
.add_systems(
211215
PreUpdate,
212216
(

crates/bevy_sprite/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ license = "MIT OR Apache-2.0"
99
keywords = ["bevy"]
1010

1111
[features]
12+
bevy_picking = ["dep:bevy_picking", "dep:bevy_window"]
1213
webgl = []
1314
webgpu = []
1415

@@ -20,12 +21,14 @@ bevy_color = { path = "../bevy_color", version = "0.15.0-dev" }
2021
bevy_core_pipeline = { path = "../bevy_core_pipeline", version = "0.15.0-dev" }
2122
bevy_ecs = { path = "../bevy_ecs", version = "0.15.0-dev" }
2223
bevy_math = { path = "../bevy_math", version = "0.15.0-dev" }
24+
bevy_picking = { path = "../bevy_picking", version = "0.15.0-dev", optional = true }
2325
bevy_reflect = { path = "../bevy_reflect", version = "0.15.0-dev", features = [
2426
"bevy",
2527
] }
2628
bevy_render = { path = "../bevy_render", version = "0.15.0-dev" }
2729
bevy_transform = { path = "../bevy_transform", version = "0.15.0-dev" }
2830
bevy_utils = { path = "../bevy_utils", version = "0.15.0-dev" }
31+
bevy_window = { path = "../bevy_window", version = "0.15.0-dev", optional = true }
2932
bevy_derive = { path = "../bevy_derive", version = "0.15.0-dev" }
3033

3134
# other

crates/bevy_sprite/src/lib.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
mod bundle;
1212
mod dynamic_texture_atlas_builder;
1313
mod mesh2d;
14+
#[cfg(feature = "bevy_picking")]
15+
mod picking_backend;
1416
mod render;
1517
mod sprite;
1618
mod texture_atlas;
@@ -133,6 +135,9 @@ impl Plugin for SpritePlugin {
133135
),
134136
);
135137

138+
#[cfg(feature = "bevy_picking")]
139+
app.add_plugins(picking_backend::SpritePickingBackend);
140+
136141
if let Some(render_app) = app.get_sub_app_mut(RenderApp) {
137142
render_app
138143
.init_resource::<ImageBindGroups>()
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
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+
}

examples/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,7 @@ Example | Description
358358
Example | Description
359359
--- | ---
360360
[Showcases simple picking events and usage](../examples/picking/simple_picking.rs) | Demonstrates how to use picking events to spawn simple objects
361+
[Sprite Picking](../examples/picking/sprite_picking.rs) | Demonstrates picking sprites and sprite atlases
361362

362363
## Reflection
363364

0 commit comments

Comments
 (0)