mirror of
https://github.com/bevyengine/bevy
synced 2024-11-24 21:53:07 +00:00
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](https://github.com/aevyrie/bevy_mod_picking/pull/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 <aevyrie@gmail.com>
This commit is contained in:
parent
6819e998c0
commit
3540b87e17
9 changed files with 364 additions and 1 deletions
11
Cargo.toml
11
Cargo.toml
|
@ -3384,6 +3384,17 @@ description = "Demonstrates how to use picking events to spawn simple objects"
|
|||
category = "Picking"
|
||||
wasm = true
|
||||
|
||||
[[example]]
|
||||
name = "sprite_picking"
|
||||
path = "examples/picking/sprite_picking.rs"
|
||||
doc-scrape-examples = true
|
||||
|
||||
[package.metadata.example.sprite_picking]
|
||||
name = "Sprite Picking"
|
||||
description = "Demonstrates picking sprites and sprite atlases"
|
||||
category = "Picking"
|
||||
wasm = true
|
||||
|
||||
[profile.wasm-release]
|
||||
inherits = "release"
|
||||
opt-level = "z"
|
||||
|
|
|
@ -190,7 +190,11 @@ meshlet_processor = ["bevy_pbr?/meshlet_processor"]
|
|||
bevy_dev_tools = ["dep:bevy_dev_tools"]
|
||||
|
||||
# Provides a picking functionality
|
||||
bevy_picking = ["dep:bevy_picking", "bevy_ui?/bevy_picking"]
|
||||
bevy_picking = [
|
||||
"dep:bevy_picking",
|
||||
"bevy_ui?/bevy_picking",
|
||||
"bevy_sprite?/bevy_picking",
|
||||
]
|
||||
|
||||
# Enable support for the ios_simulator by downgrading some rendering capabilities
|
||||
ios_simulator = ["bevy_pbr?/ios_simulator", "bevy_render?/ios_simulator"]
|
||||
|
|
|
@ -50,6 +50,10 @@ pub mod prelude {
|
|||
/// Some backends may only support providing the topmost entity; this is a valid limitation of some
|
||||
/// backends. For example, a picking shader might only have data on the topmost rendered output from
|
||||
/// its buffer.
|
||||
///
|
||||
/// Note that systems reading these events in [`PreUpdate`](bevy_app) will not report ordering
|
||||
/// ambiguities with picking backends. Take care to ensure such systems are explicitly ordered
|
||||
/// against [`PickSet::Backends`](crate), or better, avoid reading `PointerHits` in `PreUpdate`.
|
||||
#[derive(Event, Debug, Clone)]
|
||||
pub struct PointerHits {
|
||||
/// The pointer associated with this hit test.
|
||||
|
|
|
@ -207,6 +207,10 @@ impl Plugin for PickingPlugin {
|
|||
.add_event::<pointer::InputPress>()
|
||||
.add_event::<pointer::InputMove>()
|
||||
.add_event::<backend::PointerHits>()
|
||||
// Rather than try to mark all current and future backends as ambiguous with each other,
|
||||
// we allow them to send their hits in any order. These are later sorted, so submission
|
||||
// order doesn't matter. See `PointerHits` docs for caveats.
|
||||
.allow_ambiguous_resource::<Events<backend::PointerHits>>()
|
||||
.add_systems(
|
||||
PreUpdate,
|
||||
(
|
||||
|
|
|
@ -9,6 +9,7 @@ license = "MIT OR Apache-2.0"
|
|||
keywords = ["bevy"]
|
||||
|
||||
[features]
|
||||
bevy_picking = ["dep:bevy_picking", "dep:bevy_window"]
|
||||
webgl = []
|
||||
webgpu = []
|
||||
|
||||
|
@ -20,12 +21,14 @@ bevy_color = { path = "../bevy_color", version = "0.15.0-dev" }
|
|||
bevy_core_pipeline = { path = "../bevy_core_pipeline", version = "0.15.0-dev" }
|
||||
bevy_ecs = { path = "../bevy_ecs", version = "0.15.0-dev" }
|
||||
bevy_math = { path = "../bevy_math", version = "0.15.0-dev" }
|
||||
bevy_picking = { path = "../bevy_picking", version = "0.15.0-dev", optional = true }
|
||||
bevy_reflect = { path = "../bevy_reflect", version = "0.15.0-dev", features = [
|
||||
"bevy",
|
||||
] }
|
||||
bevy_render = { path = "../bevy_render", version = "0.15.0-dev" }
|
||||
bevy_transform = { path = "../bevy_transform", version = "0.15.0-dev" }
|
||||
bevy_utils = { path = "../bevy_utils", version = "0.15.0-dev" }
|
||||
bevy_window = { path = "../bevy_window", version = "0.15.0-dev", optional = true }
|
||||
bevy_derive = { path = "../bevy_derive", version = "0.15.0-dev" }
|
||||
|
||||
# other
|
||||
|
|
|
@ -11,6 +11,8 @@
|
|||
mod bundle;
|
||||
mod dynamic_texture_atlas_builder;
|
||||
mod mesh2d;
|
||||
#[cfg(feature = "bevy_picking")]
|
||||
mod picking_backend;
|
||||
mod render;
|
||||
mod sprite;
|
||||
mod texture_atlas;
|
||||
|
@ -133,6 +135,9 @@ impl Plugin for SpritePlugin {
|
|||
),
|
||||
);
|
||||
|
||||
#[cfg(feature = "bevy_picking")]
|
||||
app.add_plugins(picking_backend::SpritePickingBackend);
|
||||
|
||||
if let Some(render_app) = app.get_sub_app_mut(RenderApp) {
|
||||
render_app
|
||||
.init_resource::<ImageBindGroups>()
|
||||
|
|
171
crates/bevy_sprite/src/picking_backend.rs
Normal file
171
crates/bevy_sprite/src/picking_backend.rs
Normal file
|
@ -0,0 +1,171 @@
|
|||
//! A [`bevy_picking`] backend for sprites. Works for simple sprites and sprite atlases. Works for
|
||||
//! sprites with arbitrary transforms. Picking is done based on sprite bounds, not visible pixels.
|
||||
//! This means a partially transparent sprite is pickable even in its transparent areas.
|
||||
|
||||
use std::cmp::Ordering;
|
||||
|
||||
use crate::{Sprite, TextureAtlas, TextureAtlasLayout};
|
||||
use bevy_app::prelude::*;
|
||||
use bevy_asset::prelude::*;
|
||||
use bevy_ecs::prelude::*;
|
||||
use bevy_math::{prelude::*, FloatExt};
|
||||
use bevy_picking::backend::prelude::*;
|
||||
use bevy_render::prelude::*;
|
||||
use bevy_transform::prelude::*;
|
||||
use bevy_window::PrimaryWindow;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SpritePickingBackend;
|
||||
|
||||
impl Plugin for SpritePickingBackend {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.add_systems(PreUpdate, sprite_picking.in_set(PickSet::Backend));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sprite_picking(
|
||||
pointers: Query<(&PointerId, &PointerLocation)>,
|
||||
cameras: Query<(Entity, &Camera, &GlobalTransform, &OrthographicProjection)>,
|
||||
primary_window: Query<Entity, With<PrimaryWindow>>,
|
||||
images: Res<Assets<Image>>,
|
||||
texture_atlas_layout: Res<Assets<TextureAtlasLayout>>,
|
||||
sprite_query: Query<
|
||||
(
|
||||
Entity,
|
||||
Option<&Sprite>,
|
||||
Option<&TextureAtlas>,
|
||||
Option<&Handle<Image>>,
|
||||
&GlobalTransform,
|
||||
Option<&Pickable>,
|
||||
&ViewVisibility,
|
||||
),
|
||||
Or<(With<Sprite>, With<TextureAtlas>)>,
|
||||
>,
|
||||
mut output: EventWriter<PointerHits>,
|
||||
) {
|
||||
let mut sorted_sprites: Vec<_> = sprite_query.iter().collect();
|
||||
sorted_sprites.sort_by(|a, b| {
|
||||
(b.4.translation().z)
|
||||
.partial_cmp(&a.4.translation().z)
|
||||
.unwrap_or(Ordering::Equal)
|
||||
});
|
||||
|
||||
let primary_window = primary_window.get_single().ok();
|
||||
|
||||
for (pointer, location) in pointers.iter().filter_map(|(pointer, pointer_location)| {
|
||||
pointer_location.location().map(|loc| (pointer, loc))
|
||||
}) {
|
||||
let mut blocked = false;
|
||||
let Some((cam_entity, camera, cam_transform, cam_ortho)) = cameras
|
||||
.iter()
|
||||
.filter(|(_, camera, _, _)| camera.is_active)
|
||||
.find(|(_, camera, _, _)| {
|
||||
camera
|
||||
.target
|
||||
.normalize(primary_window)
|
||||
.map(|x| x == location.target)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let Some(cursor_ray_world) = camera.viewport_to_world(cam_transform, location.position)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let cursor_ray_len = cam_ortho.far - cam_ortho.near;
|
||||
let cursor_ray_end = cursor_ray_world.origin + cursor_ray_world.direction * cursor_ray_len;
|
||||
|
||||
let picks: Vec<(Entity, HitData)> = sorted_sprites
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|(.., visibility)| visibility.get())
|
||||
.filter_map(
|
||||
|(entity, sprite, atlas, image, sprite_transform, pickable, ..)| {
|
||||
if blocked {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Hit box in sprite coordinate system
|
||||
let (extents, anchor) = if let Some((sprite, atlas)) = sprite.zip(atlas) {
|
||||
let extents = sprite.custom_size.or_else(|| {
|
||||
texture_atlas_layout
|
||||
.get(&atlas.layout)
|
||||
.map(|f| f.textures[atlas.index].size().as_vec2())
|
||||
})?;
|
||||
let anchor = sprite.anchor.as_vec();
|
||||
(extents, anchor)
|
||||
} else if let Some((sprite, image)) = sprite.zip(image) {
|
||||
let extents = sprite
|
||||
.custom_size
|
||||
.or_else(|| images.get(image).map(|f| f.size().as_vec2()))?;
|
||||
let anchor = sprite.anchor.as_vec();
|
||||
(extents, anchor)
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let center = -anchor * extents;
|
||||
let rect = Rect::from_center_half_size(center, extents / 2.0);
|
||||
|
||||
// Transform cursor line segment to sprite coordinate system
|
||||
let world_to_sprite = sprite_transform.affine().inverse();
|
||||
let cursor_start_sprite =
|
||||
world_to_sprite.transform_point3(cursor_ray_world.origin);
|
||||
let cursor_end_sprite = world_to_sprite.transform_point3(cursor_ray_end);
|
||||
|
||||
// Find where the cursor segment intersects the plane Z=0 (which is the sprite's
|
||||
// plane in sprite-local space). It may not intersect if, for example, we're
|
||||
// viewing the sprite side-on
|
||||
if cursor_start_sprite.z == cursor_end_sprite.z {
|
||||
// Cursor ray is parallel to the sprite and misses it
|
||||
return None;
|
||||
}
|
||||
let lerp_factor =
|
||||
f32::inverse_lerp(cursor_start_sprite.z, cursor_end_sprite.z, 0.0);
|
||||
if !(0.0..=1.0).contains(&lerp_factor) {
|
||||
// Lerp factor is out of range, meaning that while an infinite line cast by
|
||||
// the cursor would intersect the sprite, the sprite is not between the
|
||||
// camera's near and far planes
|
||||
return None;
|
||||
}
|
||||
// Otherwise we can interpolate the xy of the start and end positions by the
|
||||
// lerp factor to get the cursor position in sprite space!
|
||||
let cursor_pos_sprite = cursor_start_sprite
|
||||
.lerp(cursor_end_sprite, lerp_factor)
|
||||
.xy();
|
||||
|
||||
let is_cursor_in_sprite = rect.contains(cursor_pos_sprite);
|
||||
|
||||
blocked = is_cursor_in_sprite
|
||||
&& pickable.map(|p| p.should_block_lower) != Some(false);
|
||||
|
||||
is_cursor_in_sprite.then(|| {
|
||||
let hit_pos_world =
|
||||
sprite_transform.transform_point(cursor_pos_sprite.extend(0.0));
|
||||
// Transform point from world to camera space to get the Z distance
|
||||
let hit_pos_cam = cam_transform
|
||||
.affine()
|
||||
.inverse()
|
||||
.transform_point3(hit_pos_world);
|
||||
// HitData requires a depth as calculated from the camera's near clipping plane
|
||||
let depth = -cam_ortho.near - hit_pos_cam.z;
|
||||
(
|
||||
entity,
|
||||
HitData::new(
|
||||
cam_entity,
|
||||
depth,
|
||||
Some(hit_pos_world),
|
||||
Some(*sprite_transform.back()),
|
||||
),
|
||||
)
|
||||
})
|
||||
},
|
||||
)
|
||||
.collect();
|
||||
|
||||
let order = camera.order as f32;
|
||||
output.send(PointerHits::new(*pointer, picks, order));
|
||||
}
|
||||
}
|
|
@ -358,6 +358,7 @@ Example | Description
|
|||
Example | Description
|
||||
--- | ---
|
||||
[Showcases simple picking events and usage](../examples/picking/simple_picking.rs) | Demonstrates how to use picking events to spawn simple objects
|
||||
[Sprite Picking](../examples/picking/sprite_picking.rs) | Demonstrates picking sprites and sprite atlases
|
||||
|
||||
## Reflection
|
||||
|
||||
|
|
160
examples/picking/sprite_picking.rs
Normal file
160
examples/picking/sprite_picking.rs
Normal file
|
@ -0,0 +1,160 @@
|
|||
//! Demonstrates picking for sprites and sprite atlases. The picking backend only tests against the
|
||||
//! sprite bounds, so the sprite atlas can be picked by clicking on its trnasparent areas.
|
||||
|
||||
use bevy::{prelude::*, sprite::Anchor};
|
||||
use std::fmt::Debug;
|
||||
|
||||
fn main() {
|
||||
App::new()
|
||||
.add_plugins(DefaultPlugins)
|
||||
.add_systems(Startup, (setup, setup_atlas))
|
||||
.add_systems(Update, (move_sprite, animate_sprite))
|
||||
.run();
|
||||
}
|
||||
|
||||
fn move_sprite(
|
||||
time: Res<Time>,
|
||||
mut sprite: Query<&mut Transform, (Without<Sprite>, With<Children>)>,
|
||||
) {
|
||||
let t = time.elapsed_seconds() * 0.1;
|
||||
for mut transform in &mut sprite {
|
||||
let new = Vec2 {
|
||||
x: 50.0 * t.sin(),
|
||||
y: 50.0 * (t * 2.0).sin(),
|
||||
};
|
||||
transform.translation.x = new.x;
|
||||
transform.translation.y = new.y;
|
||||
}
|
||||
}
|
||||
|
||||
/// Set up a scene that tests all sprite anchor types.
|
||||
fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
|
||||
commands.spawn(Camera2dBundle::default());
|
||||
|
||||
let len = 128.0;
|
||||
let sprite_size = Some(Vec2::splat(len / 2.0));
|
||||
|
||||
commands
|
||||
.spawn(SpatialBundle::default())
|
||||
.with_children(|commands| {
|
||||
for (anchor_index, anchor) in [
|
||||
Anchor::TopLeft,
|
||||
Anchor::TopCenter,
|
||||
Anchor::TopRight,
|
||||
Anchor::CenterLeft,
|
||||
Anchor::Center,
|
||||
Anchor::CenterRight,
|
||||
Anchor::BottomLeft,
|
||||
Anchor::BottomCenter,
|
||||
Anchor::BottomRight,
|
||||
Anchor::Custom(Vec2::new(0.5, 0.5)),
|
||||
]
|
||||
.iter()
|
||||
.enumerate()
|
||||
{
|
||||
let i = (anchor_index % 3) as f32;
|
||||
let j = (anchor_index / 3) as f32;
|
||||
|
||||
// spawn black square behind sprite to show anchor point
|
||||
commands
|
||||
.spawn(SpriteBundle {
|
||||
sprite: Sprite {
|
||||
custom_size: sprite_size,
|
||||
color: Color::BLACK,
|
||||
..default()
|
||||
},
|
||||
transform: Transform::from_xyz(i * len - len, j * len - len, -1.0),
|
||||
..default()
|
||||
})
|
||||
.observe(recolor_on::<Pointer<Over>>(Color::srgb(0.0, 1.0, 1.0)))
|
||||
.observe(recolor_on::<Pointer<Out>>(Color::BLACK))
|
||||
.observe(recolor_on::<Pointer<Down>>(Color::srgb(1.0, 1.0, 0.0)))
|
||||
.observe(recolor_on::<Pointer<Up>>(Color::srgb(0.0, 1.0, 1.0)));
|
||||
|
||||
commands
|
||||
.spawn(SpriteBundle {
|
||||
sprite: Sprite {
|
||||
custom_size: sprite_size,
|
||||
color: Color::srgb(1.0, 0.0, 0.0),
|
||||
anchor: anchor.to_owned(),
|
||||
..default()
|
||||
},
|
||||
texture: asset_server.load("branding/bevy_bird_dark.png"),
|
||||
// 3x3 grid of anchor examples by changing transform
|
||||
transform: Transform::from_xyz(i * len - len, j * len - len, 0.0)
|
||||
.with_scale(Vec3::splat(1.0 + (i - 1.0) * 0.2))
|
||||
.with_rotation(Quat::from_rotation_z((j - 1.0) * 0.2)),
|
||||
..default()
|
||||
})
|
||||
.observe(recolor_on::<Pointer<Over>>(Color::srgb(0.0, 1.0, 0.0)))
|
||||
.observe(recolor_on::<Pointer<Out>>(Color::srgb(1.0, 0.0, 0.0)))
|
||||
.observe(recolor_on::<Pointer<Down>>(Color::srgb(0.0, 0.0, 1.0)))
|
||||
.observe(recolor_on::<Pointer<Up>>(Color::srgb(0.0, 1.0, 0.0)));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[derive(Component)]
|
||||
struct AnimationIndices {
|
||||
first: usize,
|
||||
last: usize,
|
||||
}
|
||||
|
||||
#[derive(Component, Deref, DerefMut)]
|
||||
struct AnimationTimer(Timer);
|
||||
|
||||
fn animate_sprite(
|
||||
time: Res<Time>,
|
||||
mut query: Query<(&AnimationIndices, &mut AnimationTimer, &mut TextureAtlas)>,
|
||||
) {
|
||||
for (indices, mut timer, mut sprite) in &mut query {
|
||||
timer.tick(time.delta());
|
||||
if timer.just_finished() {
|
||||
sprite.index = if sprite.index == indices.last {
|
||||
indices.first
|
||||
} else {
|
||||
sprite.index + 1
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn setup_atlas(
|
||||
mut commands: Commands,
|
||||
asset_server: Res<AssetServer>,
|
||||
mut texture_atlas_layouts: ResMut<Assets<TextureAtlasLayout>>,
|
||||
) {
|
||||
let texture_handle = asset_server.load("textures/rpg/chars/gabe/gabe-idle-run.png");
|
||||
let layout = TextureAtlasLayout::from_grid(UVec2::new(24, 24), 7, 1, None, None);
|
||||
let texture_atlas_layout_handle = texture_atlas_layouts.add(layout);
|
||||
// Use only the subset of sprites in the sheet that make up the run animation
|
||||
let animation_indices = AnimationIndices { first: 1, last: 6 };
|
||||
commands
|
||||
.spawn((
|
||||
TextureAtlas {
|
||||
layout: texture_atlas_layout_handle,
|
||||
index: animation_indices.first,
|
||||
},
|
||||
SpriteBundle {
|
||||
texture: texture_handle,
|
||||
transform: Transform::from_xyz(300.0, 0.0, 0.0).with_scale(Vec3::splat(6.0)),
|
||||
..default()
|
||||
},
|
||||
animation_indices,
|
||||
AnimationTimer(Timer::from_seconds(0.1, TimerMode::Repeating)),
|
||||
))
|
||||
.observe(recolor_on::<Pointer<Over>>(Color::srgb(0.0, 1.0, 1.0)))
|
||||
.observe(recolor_on::<Pointer<Out>>(Color::srgb(1.0, 1.0, 1.0)))
|
||||
.observe(recolor_on::<Pointer<Down>>(Color::srgb(1.0, 1.0, 0.0)))
|
||||
.observe(recolor_on::<Pointer<Up>>(Color::srgb(0.0, 1.0, 1.0)));
|
||||
}
|
||||
|
||||
// An observer listener that changes the target entity's color.
|
||||
fn recolor_on<E: Debug + Clone + Reflect>(color: Color) -> impl Fn(Trigger<E>, Query<&mut Sprite>) {
|
||||
move |ev, mut sprites| {
|
||||
let Ok(mut sprite) = sprites.get_mut(ev.entity()) else {
|
||||
return;
|
||||
};
|
||||
sprite.color = color;
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue