Skip to content

Commit fe6e04f

Browse files
authored
Use RayMap and RenderLayers in bevy_sprite/picking_backend (#21959)
# Credit - @bytemunch for the original PR and the brunt of the work - @akimakinai for reviewing and introducing an important change to the RenderLayers logic # Objective - Adoption of the PR found [here](#18069) (as was recommended to me on the bevy discord) - I upgraded the changes proposed there to work with bevy/main - This likely fixes a bunch of unintuitive picking behavior around Viewports and RenderLayers. See the original PR for details. ## Solution - Rewrote parts of `bevy_sprite/picking_backend.rs`. It now iterates over bevy_picking's `RayMap` to generate `PointerHit`s for each viable `Camera`, rather than just the first viable `Camera` found. - Introduced checking if the `Camera`'s and `Sprite`'s `RenderLayers` intersect before generating a hit. ## Testing - I tested it with my own game, running on bevy 0.17.3 - I don't expect the behavior to be different for a game built with bevy/main, as it's just the picking backend. However, that is still open for testing. ## What reviewers can test - Before this change, `PointerHit`s would not generate on viewports of higher order, if their area overlapped with a viewport of lower order. For example, if you had a `Camera` render to the entire window, and then a second `Camera` render a smaller viewport on top, the smaller viewport would not detect `PointerHit`s. This should be fixed now. - Before this change, a `Camera` not on the default `RenderLayers` layer, would generate and consume `PointerHit`s, even if it did not share any layers with the picked `Sprite`s. This could also block `Sprite`s from being picked at all. This should be fixed now. ## Extra note The behavior of the picking backend is now to produce `PointerHit`s for each viable viewport under the cursor. This can lead to the unintuitive result, that you can pick a `Sprite` you can not see, due to being obscured by another viewport. I have a solution for this and it would be a flag in `SpritePickingSettings`. Should I open a new PR for this or enter a discussion?
1 parent 00f6eb7 commit fe6e04f

File tree

1 file changed

+150
-115
lines changed

1 file changed

+150
-115
lines changed

crates/bevy_sprite/src/picking_backend.rs

Lines changed: 150 additions & 115 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@
1313
use crate::{Anchor, Sprite};
1414
use bevy_app::prelude::*;
1515
use bevy_asset::prelude::*;
16-
use bevy_camera::{visibility::ViewVisibility, Camera, Projection};
16+
use bevy_camera::{
17+
visibility::{RenderLayers, ViewVisibility},
18+
Camera, Projection,
19+
};
1720
use bevy_color::Alpha;
1821
use bevy_ecs::prelude::*;
1922
use bevy_image::prelude::*;
@@ -88,6 +91,7 @@ fn sprite_picking(
8891
&GlobalTransform,
8992
&Projection,
9093
Has<SpritePickingCamera>,
94+
Option<&RenderLayers>,
9195
)>,
9296
primary_window: Query<Entity, With<PrimaryWindow>>,
9397
images: Res<Assets<Image>>,
@@ -100,157 +104,188 @@ fn sprite_picking(
100104
&Anchor,
101105
&Pickable,
102106
&ViewVisibility,
107+
Option<&RenderLayers>,
103108
)>,
104109
mut pointer_hits_writer: MessageWriter<PointerHits>,
110+
ray_map: Res<RayMap>,
105111
) {
106112
let mut sorted_sprites: Vec<_> = sprite_query
107113
.iter()
108-
.filter_map(|(entity, sprite, transform, anchor, pickable, vis)| {
109-
if !transform.affine().is_nan() && vis.get() {
110-
Some((entity, sprite, transform, anchor, pickable))
111-
} else {
112-
None
113-
}
114-
})
114+
.filter_map(
115+
|(entity, sprite, transform, anchor, pickable, vis, render_layers)| {
116+
if !transform.affine().is_nan() && vis.get() {
117+
Some((entity, sprite, transform, anchor, pickable, render_layers))
118+
} else {
119+
None
120+
}
121+
},
122+
)
115123
.collect();
116124

117125
// radsort is a stable radix sort that performed better than `slice::sort_by_key`
118-
radsort::sort_by_key(&mut sorted_sprites, |(_, _, transform, _, _)| {
126+
radsort::sort_by_key(&mut sorted_sprites, |(_, _, transform, _, _, _)| {
119127
-transform.translation().z
120128
});
121129

122130
let primary_window = primary_window.single().ok();
123131

124-
for (pointer, location) in pointers.iter().filter_map(|(pointer, pointer_location)| {
125-
pointer_location.location().map(|loc| (pointer, loc))
126-
}) {
132+
let pick_sets = ray_map.iter().flat_map(|(ray_id, ray)| {
127133
let mut blocked = false;
128-
let Some((cam_entity, camera, cam_transform, Projection::Orthographic(cam_ortho), _)) =
129-
cameras
130-
.iter()
131-
.filter(|(_, camera, _, _, cam_can_pick)| {
132-
let marker_requirement = !settings.require_markers || *cam_can_pick;
133-
camera.is_active && marker_requirement
134-
})
135-
.find(|(_, camera, _, _, _)| {
136-
camera
137-
.target
138-
.normalize(primary_window)
139-
.is_some_and(|x| x == location.target)
140-
})
134+
135+
let Ok((
136+
cam_entity,
137+
camera,
138+
cam_transform,
139+
Projection::Orthographic(cam_ortho),
140+
cam_can_pick,
141+
cam_render_layers,
142+
)) = cameras.get(ray_id.camera)
141143
else {
142-
continue;
144+
return None;
143145
};
144146

147+
let marker_requirement = !settings.require_markers || cam_can_pick;
148+
if !camera.is_active || !marker_requirement {
149+
return None;
150+
}
151+
152+
let location = pointers.iter().find_map(|(id, loc)| {
153+
if *id == ray_id.pointer {
154+
return loc.location.as_ref();
155+
}
156+
None
157+
})?;
158+
159+
if camera
160+
.target
161+
.normalize(primary_window)
162+
.is_none_or(|x| x != location.target)
163+
{
164+
return None;
165+
}
166+
145167
let viewport_pos = location.position;
146168
if let Some(viewport) = camera.logical_viewport_rect()
147169
&& !viewport.contains(viewport_pos)
148170
{
149171
// The pointer is outside the viewport, skip it
150-
continue;
172+
return None;
151173
}
152174

153-
let Ok(cursor_ray_world) = camera.viewport_to_world(cam_transform, viewport_pos) else {
154-
continue;
155-
};
156175
let cursor_ray_len = cam_ortho.far - cam_ortho.near;
157-
let cursor_ray_end = cursor_ray_world.origin + cursor_ray_world.direction * cursor_ray_len;
176+
let cursor_ray_end = ray.origin + ray.direction * cursor_ray_len;
158177

159178
let picks: Vec<(Entity, HitData)> = sorted_sprites
160179
.iter()
161180
.copied()
162-
.filter_map(|(entity, sprite, sprite_transform, anchor, pickable)| {
163-
if blocked {
164-
return None;
165-
}
181+
.filter_map(
182+
|(entity, sprite, sprite_transform, anchor, pickable, sprite_render_layers)| {
183+
if blocked {
184+
return None;
185+
}
166186

167-
// Transform cursor line segment to sprite coordinate system
168-
let world_to_sprite = sprite_transform.affine().inverse();
169-
let cursor_start_sprite = world_to_sprite.transform_point3(cursor_ray_world.origin);
170-
let cursor_end_sprite = world_to_sprite.transform_point3(cursor_ray_end);
187+
// Filter out sprites based on whether they share RenderLayers with the current
188+
// ray's associated camera.
189+
// Any entity without a RenderLayers component will by default be
190+
// on RenderLayers::layer(0) only.
191+
if !cam_render_layers
192+
.unwrap_or_default()
193+
.intersects(sprite_render_layers.unwrap_or_default())
194+
{
195+
return None;
196+
}
171197

172-
// Find where the cursor segment intersects the plane Z=0 (which is the sprite's
173-
// plane in sprite-local space). It may not intersect if, for example, we're
174-
// viewing the sprite side-on
175-
if cursor_start_sprite.z == cursor_end_sprite.z {
176-
// Cursor ray is parallel to the sprite and misses it
177-
return None;
178-
}
179-
let lerp_factor =
180-
f32::inverse_lerp(cursor_start_sprite.z, cursor_end_sprite.z, 0.0);
181-
if !(0.0..=1.0).contains(&lerp_factor) {
182-
// Lerp factor is out of range, meaning that while an infinite line cast by
183-
// the cursor would intersect the sprite, the sprite is not between the
184-
// camera's near and far planes
185-
return None;
186-
}
187-
// Otherwise we can interpolate the xy of the start and end positions by the
188-
// lerp factor to get the cursor position in sprite space!
189-
let cursor_pos_sprite = cursor_start_sprite
190-
.lerp(cursor_end_sprite, lerp_factor)
191-
.xy();
198+
// Transform cursor line segment to sprite coordinate system
199+
let world_to_sprite = sprite_transform.affine().inverse();
200+
let cursor_start_sprite = world_to_sprite.transform_point3(ray.origin);
201+
let cursor_end_sprite = world_to_sprite.transform_point3(cursor_ray_end);
202+
203+
// Find where the cursor segment intersects the plane Z=0 (which is the sprite's
204+
// plane in sprite-local space). It may not intersect if, for example, we're
205+
// viewing the sprite side-on
206+
if cursor_start_sprite.z == cursor_end_sprite.z {
207+
// Cursor ray is parallel to the sprite and misses it
208+
return None;
209+
}
210+
let lerp_factor =
211+
f32::inverse_lerp(cursor_start_sprite.z, cursor_end_sprite.z, 0.0);
212+
if !(0.0..=1.0).contains(&lerp_factor) {
213+
// Lerp factor is out of range, meaning that while an infinite line cast by
214+
// the cursor would intersect the sprite, the sprite is not between the
215+
// camera's near and far planes
216+
return None;
217+
}
218+
// Otherwise we can interpolate the xy of the start and end positions by the
219+
// lerp factor to get the cursor position in sprite space!
220+
let cursor_pos_sprite = cursor_start_sprite
221+
.lerp(cursor_end_sprite, lerp_factor)
222+
.xy();
192223

193-
let Ok(cursor_pixel_space) = sprite.compute_pixel_space_point(
194-
cursor_pos_sprite,
195-
*anchor,
196-
&images,
197-
&texture_atlas_layout,
198-
) else {
199-
return None;
200-
};
224+
let Ok(cursor_pixel_space) = sprite.compute_pixel_space_point(
225+
cursor_pos_sprite,
226+
*anchor,
227+
&images,
228+
&texture_atlas_layout,
229+
) else {
230+
return None;
231+
};
201232

202-
// Since the pixel space coordinate is `Ok`, we know the cursor is in the bounds of
203-
// the sprite.
233+
// Since the pixel space coordinate is `Ok`, we know the cursor is in the bounds of
234+
// the sprite.
204235

205-
let cursor_in_valid_pixels_of_sprite = 'valid_pixel: {
206-
match settings.picking_mode {
207-
SpritePickingMode::AlphaThreshold(cutoff) => {
208-
let Some(image) = images.get(&sprite.image) else {
209-
// [`Sprite::from_color`] returns a defaulted handle.
210-
// This handle doesn't return a valid image, so returning false here would make picking "color sprites" impossible
211-
break 'valid_pixel true;
212-
};
213-
// grab pixel and check alpha
214-
let Ok(color) = image.get_color_at(
215-
cursor_pixel_space.x as u32,
216-
cursor_pixel_space.y as u32,
217-
) else {
218-
// We don't know how to interpret the pixel.
219-
break 'valid_pixel false;
220-
};
221-
// Check the alpha is above the cutoff.
222-
color.alpha() > cutoff
236+
let cursor_in_valid_pixels_of_sprite = 'valid_pixel: {
237+
match settings.picking_mode {
238+
SpritePickingMode::AlphaThreshold(cutoff) => {
239+
let Some(image) = images.get(&sprite.image) else {
240+
// [`Sprite::from_color`] returns a defaulted handle.
241+
// This handle doesn't return a valid image, so returning false here would make picking "color sprites" impossible
242+
break 'valid_pixel true;
243+
};
244+
// grab pixel and check alpha
245+
let Ok(color) = image.get_color_at(
246+
cursor_pixel_space.x as u32,
247+
cursor_pixel_space.y as u32,
248+
) else {
249+
// We don't know how to interpret the pixel.
250+
break 'valid_pixel false;
251+
};
252+
// Check the alpha is above the cutoff.
253+
color.alpha() > cutoff
254+
}
255+
SpritePickingMode::BoundingBox => true,
223256
}
224-
SpritePickingMode::BoundingBox => true,
225-
}
226-
};
257+
};
227258

228-
blocked = cursor_in_valid_pixels_of_sprite && pickable.should_block_lower;
259+
blocked = cursor_in_valid_pixels_of_sprite && pickable.should_block_lower;
229260

230-
cursor_in_valid_pixels_of_sprite.then(|| {
231-
let hit_pos_world =
232-
sprite_transform.transform_point(cursor_pos_sprite.extend(0.0));
233-
// Transform point from world to camera space to get the Z distance
234-
let hit_pos_cam = cam_transform
235-
.affine()
236-
.inverse()
237-
.transform_point3(hit_pos_world);
238-
// HitData requires a depth as calculated from the camera's near clipping plane
239-
let depth = -cam_ortho.near - hit_pos_cam.z;
240-
(
241-
entity,
242-
HitData::new(
243-
cam_entity,
244-
depth,
245-
Some(hit_pos_world),
246-
Some(*sprite_transform.back()),
247-
),
248-
)
249-
})
250-
})
261+
cursor_in_valid_pixels_of_sprite.then(|| {
262+
let hit_pos_world =
263+
sprite_transform.transform_point(cursor_pos_sprite.extend(0.0));
264+
// Transform point from world to camera space to get the Z distance
265+
let hit_pos_cam = cam_transform
266+
.affine()
267+
.inverse()
268+
.transform_point3(hit_pos_world);
269+
// HitData requires a depth as calculated from the camera's near clipping plane
270+
let depth = -cam_ortho.near - hit_pos_cam.z;
271+
(
272+
entity,
273+
HitData::new(
274+
cam_entity,
275+
depth,
276+
Some(hit_pos_world),
277+
Some(*sprite_transform.back()),
278+
),
279+
)
280+
})
281+
},
282+
)
251283
.collect();
252284

253-
let order = camera.order as f32;
254-
pointer_hits_writer.write(PointerHits::new(*pointer, picks, order));
255-
}
285+
Some((ray_id.pointer, picks, camera.order))
286+
});
287+
288+
pick_sets.for_each(|(pointer, picks, order)| {
289+
pointer_hits_writer.write(PointerHits::new(pointer, picks, order as f32));
290+
});
256291
}

0 commit comments

Comments
 (0)