Support texture atlases in CustomCursor::Image (#17121)
# Objective - Bevy 0.15 added support for custom cursor images in https://github.com/bevyengine/bevy/pull/14284. - However, to do animated cursors using the initial support shipped in 0.15 means you'd have to animate the `Handle<Image>`: You can't use a `TextureAtlas` like you can with sprites and UI images. - For my use case, my cursors are spritesheets. To animate them, I'd have to break them down into multiple `Image` assets, but that seems less than ideal. ## Solution - Allow users to specify a `TextureAtlas` field when creating a custom cursor image. - To create parity with Bevy's `TextureAtlas` support on `Sprite`s and `ImageNode`s, this also allows users to specify `rect`, `flip_x` and `flip_y`. In fact, for my own use case, I need to `flip_y`. ## Testing - I added unit tests for `calculate_effective_rect` and `extract_and_transform_rgba_pixels`. - I added a brand new example for custom cursor images. It has controls to toggle fields on and off. I opted to add a new example because the existing cursor example (`window_settings`) would be far too messy for showcasing these custom cursor features (I did start down that path but decided to stop and make a brand new example). - The new example uses a [Kenny cursor icon] sprite sheet. I included the licence even though it's not required (and it's CC0). - I decided to make the example just loop through all cursor icons for its animation even though it's not a _realistic_ in-game animation sequence. - I ran the PNG through https://tinypng.com. Looks like it's about 35KB. - I'm open to adjusting the example spritesheet if required, but if it's fine as is, great. [Kenny cursor icon]: https://kenney-assets.itch.io/crosshair-pack --- ## Showcase https://github.com/user-attachments/assets/8f6be8d7-d1d4-42f9-b769-ef8532367749 ## Migration Guide The `CustomCursor::Image` enum variant has some new fields. Update your code to set them. Before: ```rust CustomCursor::Image { handle: asset_server.load("branding/icon.png"), hotspot: (128, 128), } ``` After: ```rust CustomCursor::Image { handle: asset_server.load("branding/icon.png"), texture_atlas: None, flip_x: false, flip_y: false, rect: None, hotspot: (128, 128), } ``` ## References - Feature request [originally raised in Discord]. [originally raised in Discord]: https://discord.com/channels/691052431525675048/692572690833473578/1319836362219847681
This commit is contained in:
parent
f2e00c8ed5
commit
0756a19f28
12
Cargo.toml
12
Cargo.toml
@ -3311,6 +3311,18 @@ description = "Creates a solid color window"
|
||||
category = "Window"
|
||||
wasm = true
|
||||
|
||||
[[example]]
|
||||
name = "custom_cursor_image"
|
||||
path = "examples/window/custom_cursor_image.rs"
|
||||
doc-scrape-examples = true
|
||||
required-features = ["custom_cursor"]
|
||||
|
||||
[package.metadata.example.custom_cursor_image]
|
||||
name = "Custom Cursor Image"
|
||||
description = "Demonstrates creating an animated custom cursor from an image"
|
||||
category = "Window"
|
||||
wasm = true
|
||||
|
||||
[[example]]
|
||||
name = "custom_user_event"
|
||||
path = "examples/window/custom_user_event.rs"
|
||||
|
19
assets/cursors/kenney_crosshairPack/License.txt
Normal file
19
assets/cursors/kenney_crosshairPack/License.txt
Normal file
@ -0,0 +1,19 @@
|
||||
|
||||
|
||||
Crosshair Pack
|
||||
|
||||
by Kenney Vleugels (Kenney.nl)
|
||||
|
||||
------------------------------
|
||||
|
||||
License (Creative Commons Zero, CC0)
|
||||
http://creativecommons.org/publicdomain/zero/1.0/
|
||||
|
||||
You may use these assets in personal and commercial projects.
|
||||
Credit (Kenney or www.kenney.nl) would be nice but is not mandatory.
|
||||
|
||||
------------------------------
|
||||
|
||||
Donate: http://support.kenney.nl
|
||||
|
||||
Follow on Twitter for updates: @KenneyNL (www.twitter.com/kenneynl)
|
Binary file not shown.
After Width: | Height: | Size: 34 KiB |
@ -182,8 +182,12 @@ impl TextureAtlasLayout {
|
||||
/// - [`animated sprite sheet example`](https://github.com/bevyengine/bevy/blob/latest/examples/2d/sprite_sheet.rs)
|
||||
/// - [`sprite animation event example`](https://github.com/bevyengine/bevy/blob/latest/examples/2d/sprite_animation.rs)
|
||||
/// - [`texture atlas example`](https://github.com/bevyengine/bevy/blob/latest/examples/2d/texture_atlas.rs)
|
||||
#[derive(Default, Debug, Clone)]
|
||||
#[cfg_attr(feature = "bevy_reflect", derive(Reflect), reflect(Default, Debug))]
|
||||
#[derive(Default, Debug, Clone, PartialEq, Eq, Hash)]
|
||||
#[cfg_attr(
|
||||
feature = "bevy_reflect",
|
||||
derive(Reflect),
|
||||
reflect(Default, Debug, PartialEq, Hash)
|
||||
)]
|
||||
pub struct TextureAtlas {
|
||||
/// Texture atlas layout handle
|
||||
pub layout: Handle<TextureAtlasLayout>,
|
||||
|
@ -6,6 +6,10 @@ use crate::{
|
||||
};
|
||||
#[cfg(feature = "custom_cursor")]
|
||||
use crate::{
|
||||
custom_cursor::{
|
||||
calculate_effective_rect, extract_and_transform_rgba_pixels, extract_rgba_pixels,
|
||||
CustomCursorPlugin,
|
||||
},
|
||||
state::{CustomCursorCache, CustomCursorCacheKey},
|
||||
WinitCustomCursor,
|
||||
};
|
||||
@ -25,21 +29,21 @@ use bevy_ecs::{
|
||||
world::{OnRemove, Ref},
|
||||
};
|
||||
#[cfg(feature = "custom_cursor")]
|
||||
use bevy_image::Image;
|
||||
use bevy_image::{Image, TextureAtlas, TextureAtlasLayout};
|
||||
#[cfg(feature = "custom_cursor")]
|
||||
use bevy_math::URect;
|
||||
use bevy_reflect::{std_traits::ReflectDefault, Reflect};
|
||||
use bevy_utils::HashSet;
|
||||
use bevy_window::{SystemCursorIcon, Window};
|
||||
#[cfg(feature = "custom_cursor")]
|
||||
use tracing::warn;
|
||||
#[cfg(feature = "custom_cursor")]
|
||||
use wgpu_types::TextureFormat;
|
||||
|
||||
pub(crate) struct CursorPlugin;
|
||||
|
||||
impl Plugin for CursorPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
#[cfg(feature = "custom_cursor")]
|
||||
app.init_resource::<CustomCursorCache>();
|
||||
app.add_plugins(CustomCursorPlugin);
|
||||
|
||||
app.register_type::<CursorIcon>()
|
||||
.add_systems(Last, update_cursors);
|
||||
@ -87,6 +91,19 @@ pub enum CustomCursor {
|
||||
/// The image must be in 8 bit int or 32 bit float rgba. PNG images
|
||||
/// work well for this.
|
||||
handle: Handle<Image>,
|
||||
/// The (optional) texture atlas used to render the image.
|
||||
texture_atlas: Option<TextureAtlas>,
|
||||
/// Whether the image should be flipped along its x-axis.
|
||||
flip_x: bool,
|
||||
/// Whether the image should be flipped along its y-axis.
|
||||
flip_y: bool,
|
||||
/// An optional rectangle representing the region of the image to
|
||||
/// render, instead of rendering the full image. This is an easy one-off
|
||||
/// alternative to using a [`TextureAtlas`].
|
||||
///
|
||||
/// When used with a [`TextureAtlas`], the rect is offset by the atlas's
|
||||
/// minimal (top-left) corner position.
|
||||
rect: Option<URect>,
|
||||
/// X and Y coordinates of the hotspot in pixels. The hotspot must be
|
||||
/// within the image bounds.
|
||||
hotspot: (u16, u16),
|
||||
@ -108,6 +125,7 @@ fn update_cursors(
|
||||
windows: Query<(Entity, Ref<CursorIcon>), With<Window>>,
|
||||
#[cfg(feature = "custom_cursor")] cursor_cache: Res<CustomCursorCache>,
|
||||
#[cfg(feature = "custom_cursor")] images: Res<Assets<Image>>,
|
||||
#[cfg(feature = "custom_cursor")] texture_atlases: Res<Assets<TextureAtlasLayout>>,
|
||||
mut queue: Local<HashSet<Entity>>,
|
||||
) {
|
||||
for (entity, cursor) in windows.iter() {
|
||||
@ -117,8 +135,22 @@ fn update_cursors(
|
||||
|
||||
let cursor_source = match cursor.as_ref() {
|
||||
#[cfg(feature = "custom_cursor")]
|
||||
CursorIcon::Custom(CustomCursor::Image { handle, hotspot }) => {
|
||||
let cache_key = CustomCursorCacheKey::Asset(handle.id());
|
||||
CursorIcon::Custom(CustomCursor::Image {
|
||||
handle,
|
||||
texture_atlas,
|
||||
flip_x,
|
||||
flip_y,
|
||||
rect,
|
||||
hotspot,
|
||||
}) => {
|
||||
let cache_key = CustomCursorCacheKey::Image {
|
||||
id: handle.id(),
|
||||
texture_atlas_layout_id: texture_atlas.as_ref().map(|a| a.layout.id()),
|
||||
texture_atlas_index: texture_atlas.as_ref().map(|a| a.index),
|
||||
flip_x: *flip_x,
|
||||
flip_y: *flip_y,
|
||||
rect: *rect,
|
||||
};
|
||||
|
||||
if cursor_cache.0.contains_key(&cache_key) {
|
||||
CursorSource::CustomCached(cache_key)
|
||||
@ -130,17 +162,25 @@ fn update_cursors(
|
||||
queue.insert(entity);
|
||||
continue;
|
||||
};
|
||||
let Some(rgba) = image_to_rgba_pixels(image) else {
|
||||
|
||||
let (rect, needs_sub_image) =
|
||||
calculate_effective_rect(&texture_atlases, image, texture_atlas, rect);
|
||||
|
||||
let maybe_rgba = if *flip_x || *flip_y || needs_sub_image {
|
||||
extract_and_transform_rgba_pixels(image, *flip_x, *flip_y, rect)
|
||||
} else {
|
||||
extract_rgba_pixels(image)
|
||||
};
|
||||
|
||||
let Some(rgba) = maybe_rgba else {
|
||||
warn!("Cursor image {handle:?} not accepted because it's not rgba8 or rgba32float format");
|
||||
continue;
|
||||
};
|
||||
|
||||
let width = image.texture_descriptor.size.width;
|
||||
let height = image.texture_descriptor.size.height;
|
||||
let source = match WinitCustomCursor::from_rgba(
|
||||
rgba,
|
||||
width as u16,
|
||||
height as u16,
|
||||
rect.width() as u16,
|
||||
rect.height() as u16,
|
||||
hotspot.0,
|
||||
hotspot.1,
|
||||
) {
|
||||
@ -190,28 +230,3 @@ fn on_remove_cursor_icon(trigger: Trigger<OnRemove, CursorIcon>, mut commands: C
|
||||
convert_system_cursor_icon(SystemCursorIcon::Default),
|
||||
))));
|
||||
}
|
||||
|
||||
#[cfg(feature = "custom_cursor")]
|
||||
/// Returns the image data as a `Vec<u8>`.
|
||||
/// Only supports rgba8 and rgba32float formats.
|
||||
fn image_to_rgba_pixels(image: &Image) -> Option<Vec<u8>> {
|
||||
match image.texture_descriptor.format {
|
||||
TextureFormat::Rgba8Unorm
|
||||
| TextureFormat::Rgba8UnormSrgb
|
||||
| TextureFormat::Rgba8Snorm
|
||||
| TextureFormat::Rgba8Uint
|
||||
| TextureFormat::Rgba8Sint => Some(image.data.clone()),
|
||||
TextureFormat::Rgba32Float => Some(
|
||||
image
|
||||
.data
|
||||
.chunks(4)
|
||||
.map(|chunk| {
|
||||
let chunk = chunk.try_into().unwrap();
|
||||
let num = bytemuck::cast_ref::<[u8; 4], f32>(chunk);
|
||||
(num * 255.0) as u8
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
490
crates/bevy_winit/src/custom_cursor.rs
Normal file
490
crates/bevy_winit/src/custom_cursor.rs
Normal file
@ -0,0 +1,490 @@
|
||||
use bevy_app::{App, Plugin};
|
||||
use bevy_asset::Assets;
|
||||
use bevy_image::{Image, TextureAtlas, TextureAtlasLayout, TextureAtlasPlugin};
|
||||
use bevy_math::{ops, Rect, URect, UVec2, Vec2};
|
||||
use wgpu_types::TextureFormat;
|
||||
|
||||
use crate::state::CustomCursorCache;
|
||||
|
||||
/// Adds support for custom cursors.
|
||||
pub(crate) struct CustomCursorPlugin;
|
||||
|
||||
impl Plugin for CustomCursorPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
if !app.is_plugin_added::<TextureAtlasPlugin>() {
|
||||
app.add_plugins(TextureAtlasPlugin);
|
||||
}
|
||||
|
||||
app.init_resource::<CustomCursorCache>();
|
||||
}
|
||||
}
|
||||
|
||||
/// Determines the effective rect and returns it along with a flag to indicate
|
||||
/// whether a sub-image operation is needed. The flag allows the caller to
|
||||
/// determine whether the image data needs a sub-image extracted from it. Note:
|
||||
/// To avoid lossy comparisons between [`Rect`] and [`URect`], the flag is
|
||||
/// always set to `true` when a [`TextureAtlas`] is used.
|
||||
#[inline(always)]
|
||||
pub(crate) fn calculate_effective_rect(
|
||||
texture_atlas_layouts: &Assets<TextureAtlasLayout>,
|
||||
image: &Image,
|
||||
texture_atlas: &Option<TextureAtlas>,
|
||||
rect: &Option<URect>,
|
||||
) -> (Rect, bool) {
|
||||
let atlas_rect = texture_atlas
|
||||
.as_ref()
|
||||
.and_then(|s| s.texture_rect(texture_atlas_layouts))
|
||||
.map(|r| r.as_rect());
|
||||
|
||||
match (atlas_rect, rect) {
|
||||
(None, None) => (
|
||||
Rect {
|
||||
min: Vec2::ZERO,
|
||||
max: Vec2::new(
|
||||
image.texture_descriptor.size.width as f32,
|
||||
image.texture_descriptor.size.height as f32,
|
||||
),
|
||||
},
|
||||
false,
|
||||
),
|
||||
(None, Some(image_rect)) => (
|
||||
image_rect.as_rect(),
|
||||
image_rect
|
||||
!= &URect {
|
||||
min: UVec2::ZERO,
|
||||
max: UVec2::new(
|
||||
image.texture_descriptor.size.width,
|
||||
image.texture_descriptor.size.height,
|
||||
),
|
||||
},
|
||||
),
|
||||
(Some(atlas_rect), None) => (atlas_rect, true),
|
||||
(Some(atlas_rect), Some(image_rect)) => (
|
||||
{
|
||||
let mut image_rect = image_rect.as_rect();
|
||||
image_rect.min += atlas_rect.min;
|
||||
image_rect.max += atlas_rect.min;
|
||||
image_rect
|
||||
},
|
||||
true,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Extracts the RGBA pixel data from `image`, converting it if necessary.
|
||||
///
|
||||
/// Only supports rgba8 and rgba32float formats.
|
||||
pub(crate) fn extract_rgba_pixels(image: &Image) -> Option<Vec<u8>> {
|
||||
match image.texture_descriptor.format {
|
||||
TextureFormat::Rgba8Unorm
|
||||
| TextureFormat::Rgba8UnormSrgb
|
||||
| TextureFormat::Rgba8Snorm
|
||||
| TextureFormat::Rgba8Uint
|
||||
| TextureFormat::Rgba8Sint => Some(image.data.clone()),
|
||||
TextureFormat::Rgba32Float => Some(
|
||||
image
|
||||
.data
|
||||
.chunks(4)
|
||||
.map(|chunk| {
|
||||
let chunk = chunk.try_into().unwrap();
|
||||
let num = bytemuck::cast_ref::<[u8; 4], f32>(chunk);
|
||||
ops::round(num.clamp(0.0, 1.0) * 255.0) as u8
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the `image` data as a `Vec<u8>` for the specified sub-region.
|
||||
///
|
||||
/// The image is flipped along the x and y axes if `flip_x` and `flip_y` are
|
||||
/// `true`, respectively.
|
||||
///
|
||||
/// Only supports rgba8 and rgba32float formats.
|
||||
pub(crate) fn extract_and_transform_rgba_pixels(
|
||||
image: &Image,
|
||||
flip_x: bool,
|
||||
flip_y: bool,
|
||||
rect: Rect,
|
||||
) -> Option<Vec<u8>> {
|
||||
let image_data = extract_rgba_pixels(image)?;
|
||||
|
||||
let width = rect.width() as usize;
|
||||
let height = rect.height() as usize;
|
||||
let mut sub_image_data = Vec::with_capacity(width * height * 4); // assuming 4 bytes per pixel (RGBA8)
|
||||
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
let src_x = if flip_x { width - 1 - x } else { x };
|
||||
let src_y = if flip_y { height - 1 - y } else { y };
|
||||
let index = ((rect.min.y as usize + src_y)
|
||||
* image.texture_descriptor.size.width as usize
|
||||
+ (rect.min.x as usize + src_x))
|
||||
* 4;
|
||||
sub_image_data.extend_from_slice(&image_data[index..index + 4]);
|
||||
}
|
||||
}
|
||||
|
||||
Some(sub_image_data)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use bevy_app::App;
|
||||
use bevy_asset::RenderAssetUsages;
|
||||
use bevy_image::Image;
|
||||
use bevy_math::Rect;
|
||||
use bevy_math::Vec2;
|
||||
use wgpu_types::{Extent3d, TextureDimension};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn create_image_rgba8(data: &[u8]) -> Image {
|
||||
Image::new(
|
||||
Extent3d {
|
||||
width: 3,
|
||||
height: 3,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
TextureDimension::D2,
|
||||
data.to_vec(),
|
||||
TextureFormat::Rgba8UnormSrgb,
|
||||
RenderAssetUsages::default(),
|
||||
)
|
||||
}
|
||||
|
||||
macro_rules! test_calculate_effective_rect {
|
||||
($name:ident, $use_texture_atlas:expr, $rect:expr, $expected_rect:expr, $expected_needs_sub_image:expr) => {
|
||||
#[test]
|
||||
fn $name() {
|
||||
let mut app = App::new();
|
||||
let mut texture_atlas_layout_assets = Assets::<TextureAtlasLayout>::default();
|
||||
|
||||
// Create a simple 3x3 texture atlas layout for the test cases
|
||||
// that use a texture atlas. In the future we could adjust the
|
||||
// test cases to use different texture atlas layouts.
|
||||
let layout = TextureAtlasLayout::from_grid(UVec2::new(3, 3), 1, 1, None, None);
|
||||
let layout_handle = texture_atlas_layout_assets.add(layout);
|
||||
|
||||
app.insert_resource(texture_atlas_layout_assets);
|
||||
|
||||
let texture_atlases = app
|
||||
.world()
|
||||
.get_resource::<Assets<TextureAtlasLayout>>()
|
||||
.unwrap();
|
||||
|
||||
let image = create_image_rgba8(&[0; 3 * 3 * 4]); // 3x3 image
|
||||
|
||||
let texture_atlas = if $use_texture_atlas {
|
||||
Some(TextureAtlas::from(layout_handle))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let rect = $rect;
|
||||
|
||||
let (result_rect, needs_sub_image) =
|
||||
calculate_effective_rect(&texture_atlases, &image, &texture_atlas, &rect);
|
||||
|
||||
assert_eq!(result_rect, $expected_rect);
|
||||
assert_eq!(needs_sub_image, $expected_needs_sub_image);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
test_calculate_effective_rect!(
|
||||
no_texture_atlas_no_rect,
|
||||
false,
|
||||
None,
|
||||
Rect {
|
||||
min: Vec2::ZERO,
|
||||
max: Vec2::new(3.0, 3.0)
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
test_calculate_effective_rect!(
|
||||
no_texture_atlas_with_partial_rect,
|
||||
false,
|
||||
Some(URect {
|
||||
min: UVec2::new(1, 1),
|
||||
max: UVec2::new(3, 3)
|
||||
}),
|
||||
Rect {
|
||||
min: Vec2::new(1.0, 1.0),
|
||||
max: Vec2::new(3.0, 3.0)
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
test_calculate_effective_rect!(
|
||||
no_texture_atlas_with_full_rect,
|
||||
false,
|
||||
Some(URect {
|
||||
min: UVec2::ZERO,
|
||||
max: UVec2::new(3, 3)
|
||||
}),
|
||||
Rect {
|
||||
min: Vec2::ZERO,
|
||||
max: Vec2::new(3.0, 3.0)
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
test_calculate_effective_rect!(
|
||||
texture_atlas_no_rect,
|
||||
true,
|
||||
None,
|
||||
Rect {
|
||||
min: Vec2::ZERO,
|
||||
max: Vec2::new(3.0, 3.0)
|
||||
},
|
||||
true // always needs sub-image to avoid comparing Rect against URect
|
||||
);
|
||||
|
||||
test_calculate_effective_rect!(
|
||||
texture_atlas_rect,
|
||||
true,
|
||||
Some(URect {
|
||||
min: UVec2::ZERO,
|
||||
max: UVec2::new(3, 3)
|
||||
}),
|
||||
Rect {
|
||||
min: Vec2::new(0.0, 0.0),
|
||||
max: Vec2::new(3.0, 3.0)
|
||||
},
|
||||
true // always needs sub-image to avoid comparing Rect against URect
|
||||
);
|
||||
|
||||
fn create_image_rgba32float(data: &[u8]) -> Image {
|
||||
let float_data: Vec<f32> = data
|
||||
.chunks(4)
|
||||
.flat_map(|chunk| {
|
||||
chunk
|
||||
.iter()
|
||||
.map(|&x| x as f32 / 255.0) // convert each channel to f32
|
||||
.collect::<Vec<f32>>()
|
||||
})
|
||||
.collect();
|
||||
|
||||
Image::new(
|
||||
Extent3d {
|
||||
width: 3,
|
||||
height: 3,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
TextureDimension::D2,
|
||||
bytemuck::cast_slice(&float_data).to_vec(),
|
||||
TextureFormat::Rgba32Float,
|
||||
RenderAssetUsages::default(),
|
||||
)
|
||||
}
|
||||
|
||||
macro_rules! test_extract_and_transform_rgba_pixels {
|
||||
($name:ident, $flip_x:expr, $flip_y:expr, $rect:expr, $expected:expr) => {
|
||||
#[test]
|
||||
fn $name() {
|
||||
let image_data: &[u8] = &[
|
||||
// Row 1: Red, Green, Blue
|
||||
255, 0, 0, 255, // Red
|
||||
0, 255, 0, 255, // Green
|
||||
0, 0, 255, 255, // Blue
|
||||
// Row 2: Yellow, Cyan, Magenta
|
||||
255, 255, 0, 255, // Yellow
|
||||
0, 255, 255, 255, // Cyan
|
||||
255, 0, 255, 255, // Magenta
|
||||
// Row 3: White, Gray, Black
|
||||
255, 255, 255, 255, // White
|
||||
128, 128, 128, 255, // Gray
|
||||
0, 0, 0, 255, // Black
|
||||
];
|
||||
|
||||
// RGBA8 test
|
||||
{
|
||||
let image = create_image_rgba8(image_data);
|
||||
let rect = $rect;
|
||||
let result = extract_and_transform_rgba_pixels(&image, $flip_x, $flip_y, rect);
|
||||
assert_eq!(result, Some($expected.to_vec()));
|
||||
}
|
||||
|
||||
// RGBA32Float test
|
||||
{
|
||||
let image = create_image_rgba32float(image_data);
|
||||
let rect = $rect;
|
||||
let result = extract_and_transform_rgba_pixels(&image, $flip_x, $flip_y, rect);
|
||||
assert_eq!(result, Some($expected.to_vec()));
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
test_extract_and_transform_rgba_pixels!(
|
||||
no_flip_full_image,
|
||||
false,
|
||||
false,
|
||||
Rect {
|
||||
min: Vec2::ZERO,
|
||||
max: Vec2::new(3.0, 3.0)
|
||||
},
|
||||
[
|
||||
// Row 1: Red, Green, Blue
|
||||
255, 0, 0, 255, // Red
|
||||
0, 255, 0, 255, // Green
|
||||
0, 0, 255, 255, // Blue
|
||||
// Row 2: Yellow, Cyan, Magenta
|
||||
255, 255, 0, 255, // Yellow
|
||||
0, 255, 255, 255, // Cyan
|
||||
255, 0, 255, 255, // Magenta
|
||||
// Row 3: White, Gray, Black
|
||||
255, 255, 255, 255, // White
|
||||
128, 128, 128, 255, // Gray
|
||||
0, 0, 0, 255, // Black
|
||||
]
|
||||
);
|
||||
|
||||
test_extract_and_transform_rgba_pixels!(
|
||||
flip_x_full_image,
|
||||
true,
|
||||
false,
|
||||
Rect {
|
||||
min: Vec2::ZERO,
|
||||
max: Vec2::new(3.0, 3.0)
|
||||
},
|
||||
[
|
||||
// Row 1 flipped: Blue, Green, Red
|
||||
0, 0, 255, 255, // Blue
|
||||
0, 255, 0, 255, // Green
|
||||
255, 0, 0, 255, // Red
|
||||
// Row 2 flipped: Magenta, Cyan, Yellow
|
||||
255, 0, 255, 255, // Magenta
|
||||
0, 255, 255, 255, // Cyan
|
||||
255, 255, 0, 255, // Yellow
|
||||
// Row 3 flipped: Black, Gray, White
|
||||
0, 0, 0, 255, // Black
|
||||
128, 128, 128, 255, // Gray
|
||||
255, 255, 255, 255, // White
|
||||
]
|
||||
);
|
||||
|
||||
test_extract_and_transform_rgba_pixels!(
|
||||
flip_y_full_image,
|
||||
false,
|
||||
true,
|
||||
Rect {
|
||||
min: Vec2::ZERO,
|
||||
max: Vec2::new(3.0, 3.0)
|
||||
},
|
||||
[
|
||||
// Row 3: White, Gray, Black
|
||||
255, 255, 255, 255, // White
|
||||
128, 128, 128, 255, // Gray
|
||||
0, 0, 0, 255, // Black
|
||||
// Row 2: Yellow, Cyan, Magenta
|
||||
255, 255, 0, 255, // Yellow
|
||||
0, 255, 255, 255, // Cyan
|
||||
255, 0, 255, 255, // Magenta
|
||||
// Row 1: Red, Green, Blue
|
||||
255, 0, 0, 255, // Red
|
||||
0, 255, 0, 255, // Green
|
||||
0, 0, 255, 255, // Blue
|
||||
]
|
||||
);
|
||||
|
||||
test_extract_and_transform_rgba_pixels!(
|
||||
flip_both_full_image,
|
||||
true,
|
||||
true,
|
||||
Rect {
|
||||
min: Vec2::ZERO,
|
||||
max: Vec2::new(3.0, 3.0)
|
||||
},
|
||||
[
|
||||
// Row 3 flipped: Black, Gray, White
|
||||
0, 0, 0, 255, // Black
|
||||
128, 128, 128, 255, // Gray
|
||||
255, 255, 255, 255, // White
|
||||
// Row 2 flipped: Magenta, Cyan, Yellow
|
||||
255, 0, 255, 255, // Magenta
|
||||
0, 255, 255, 255, // Cyan
|
||||
255, 255, 0, 255, // Yellow
|
||||
// Row 1 flipped: Blue, Green, Red
|
||||
0, 0, 255, 255, // Blue
|
||||
0, 255, 0, 255, // Green
|
||||
255, 0, 0, 255, // Red
|
||||
]
|
||||
);
|
||||
|
||||
test_extract_and_transform_rgba_pixels!(
|
||||
no_flip_rect,
|
||||
false,
|
||||
false,
|
||||
Rect {
|
||||
min: Vec2::new(1.0, 1.0),
|
||||
max: Vec2::new(3.0, 3.0)
|
||||
},
|
||||
[
|
||||
// Only includes part of the original image (sub-rectangle)
|
||||
// Row 2, columns 2-3: Cyan, Magenta
|
||||
0, 255, 255, 255, // Cyan
|
||||
255, 0, 255, 255, // Magenta
|
||||
// Row 3, columns 2-3: Gray, Black
|
||||
128, 128, 128, 255, // Gray
|
||||
0, 0, 0, 255, // Black
|
||||
]
|
||||
);
|
||||
|
||||
test_extract_and_transform_rgba_pixels!(
|
||||
flip_x_rect,
|
||||
true,
|
||||
false,
|
||||
Rect {
|
||||
min: Vec2::new(1.0, 1.0),
|
||||
max: Vec2::new(3.0, 3.0)
|
||||
},
|
||||
[
|
||||
// Row 2 flipped: Magenta, Cyan
|
||||
255, 0, 255, 255, // Magenta
|
||||
0, 255, 255, 255, // Cyan
|
||||
// Row 3 flipped: Black, Gray
|
||||
0, 0, 0, 255, // Black
|
||||
128, 128, 128, 255, // Gray
|
||||
]
|
||||
);
|
||||
|
||||
test_extract_and_transform_rgba_pixels!(
|
||||
flip_y_rect,
|
||||
false,
|
||||
true,
|
||||
Rect {
|
||||
min: Vec2::new(1.0, 1.0),
|
||||
max: Vec2::new(3.0, 3.0)
|
||||
},
|
||||
[
|
||||
// Row 3 first: Gray, Black
|
||||
128, 128, 128, 255, // Gray
|
||||
0, 0, 0, 255, // Black
|
||||
// Row 2 second: Cyan, Magenta
|
||||
0, 255, 255, 255, // Cyan
|
||||
255, 0, 255, 255, // Magenta
|
||||
]
|
||||
);
|
||||
|
||||
test_extract_and_transform_rgba_pixels!(
|
||||
flip_both_rect,
|
||||
true,
|
||||
true,
|
||||
Rect {
|
||||
min: Vec2::new(1.0, 1.0),
|
||||
max: Vec2::new(3.0, 3.0)
|
||||
},
|
||||
[
|
||||
// Row 3 flipped: Black, Gray
|
||||
0, 0, 0, 255, // Black
|
||||
128, 128, 128, 255, // Gray
|
||||
// Row 2 flipped: Magenta, Cyan
|
||||
255, 0, 255, 255, // Magenta
|
||||
0, 255, 255, 255, // Cyan
|
||||
]
|
||||
);
|
||||
}
|
@ -50,6 +50,8 @@ use crate::{
|
||||
pub mod accessibility;
|
||||
mod converters;
|
||||
pub mod cursor;
|
||||
#[cfg(feature = "custom_cursor")]
|
||||
mod custom_cursor;
|
||||
mod state;
|
||||
mod system;
|
||||
mod winit_config;
|
||||
|
@ -11,12 +11,14 @@ use bevy_ecs::{
|
||||
world::FromWorld,
|
||||
};
|
||||
#[cfg(feature = "custom_cursor")]
|
||||
use bevy_image::Image;
|
||||
use bevy_image::{Image, TextureAtlasLayout};
|
||||
use bevy_input::{
|
||||
gestures::*,
|
||||
mouse::{MouseButtonInput, MouseMotion, MouseScrollUnit, MouseWheel},
|
||||
};
|
||||
use bevy_log::{error, trace, warn};
|
||||
#[cfg(feature = "custom_cursor")]
|
||||
use bevy_math::URect;
|
||||
use bevy_math::{ivec2, DVec2, Vec2};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use bevy_tasks::tick_global_task_pools_on_main_thread;
|
||||
@ -150,10 +152,17 @@ impl<T: Event> WinitAppRunnerState<T> {
|
||||
/// Identifiers for custom cursors used in caching.
|
||||
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
|
||||
pub enum CustomCursorCacheKey {
|
||||
/// An `AssetId` to a cursor.
|
||||
Asset(AssetId<Image>),
|
||||
/// A custom cursor with an image.
|
||||
Image {
|
||||
id: AssetId<Image>,
|
||||
texture_atlas_layout_id: Option<AssetId<TextureAtlasLayout>>,
|
||||
texture_atlas_index: Option<usize>,
|
||||
flip_x: bool,
|
||||
flip_y: bool,
|
||||
rect: Option<URect>,
|
||||
},
|
||||
#[cfg(all(target_family = "wasm", target_os = "unknown"))]
|
||||
/// An URL to a cursor.
|
||||
/// A custom cursor with a URL.
|
||||
Url(String),
|
||||
}
|
||||
|
||||
|
@ -544,6 +544,7 @@ Example | Description
|
||||
Example | Description
|
||||
--- | ---
|
||||
[Clear Color](../examples/window/clear_color.rs) | Creates a solid color window
|
||||
[Custom Cursor Image](../examples/window/custom_cursor_image.rs) | Demonstrates creating an animated custom cursor from an image
|
||||
[Custom User Event](../examples/window/custom_user_event.rs) | Handles custom user events within the event loop
|
||||
[Low Power](../examples/window/low_power.rs) | Demonstrates settings to reduce power use for bevy applications
|
||||
[Monitor info](../examples/window/monitor_info.rs) | Displays information about available monitors (displays).
|
||||
|
228
examples/window/custom_cursor_image.rs
Normal file
228
examples/window/custom_cursor_image.rs
Normal file
@ -0,0 +1,228 @@
|
||||
//! Illustrates how to use a custom cursor image with a texture atlas and
|
||||
//! animation.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use bevy::winit::cursor::CustomCursor;
|
||||
use bevy::{prelude::*, winit::cursor::CursorIcon};
|
||||
|
||||
fn main() {
|
||||
App::new()
|
||||
.add_plugins(DefaultPlugins)
|
||||
.add_systems(
|
||||
Startup,
|
||||
(setup_cursor_icon, setup_camera, setup_instructions),
|
||||
)
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
execute_animation,
|
||||
toggle_texture_atlas,
|
||||
toggle_flip_x,
|
||||
toggle_flip_y,
|
||||
cycle_rect,
|
||||
),
|
||||
)
|
||||
.run();
|
||||
}
|
||||
|
||||
fn setup_cursor_icon(
|
||||
mut commands: Commands,
|
||||
asset_server: Res<AssetServer>,
|
||||
mut texture_atlas_layouts: ResMut<Assets<TextureAtlasLayout>>,
|
||||
window: Single<Entity, With<Window>>,
|
||||
) {
|
||||
let layout =
|
||||
TextureAtlasLayout::from_grid(UVec2::splat(64), 20, 10, Some(UVec2::splat(5)), None);
|
||||
let texture_atlas_layout = texture_atlas_layouts.add(layout);
|
||||
|
||||
let animation_config = AnimationConfig::new(0, 199, 1, 4);
|
||||
|
||||
commands.entity(*window).insert((
|
||||
CursorIcon::Custom(CustomCursor::Image {
|
||||
// Image to use as the cursor.
|
||||
handle: asset_server
|
||||
.load("cursors/kenney_crosshairPack/Tilesheet/crosshairs_tilesheet_white.png"),
|
||||
// Optional texture atlas allows you to pick a section of the image
|
||||
// and animate it.
|
||||
texture_atlas: Some(TextureAtlas {
|
||||
layout: texture_atlas_layout.clone(),
|
||||
index: animation_config.first_sprite_index,
|
||||
}),
|
||||
flip_x: false,
|
||||
flip_y: false,
|
||||
// Optional section of the image to use as the cursor.
|
||||
rect: None,
|
||||
// The hotspot is the point in the cursor image that will be
|
||||
// positioned at the mouse cursor's position.
|
||||
hotspot: (0, 0),
|
||||
}),
|
||||
animation_config,
|
||||
));
|
||||
}
|
||||
|
||||
fn setup_camera(mut commands: Commands) {
|
||||
commands.spawn(Camera3d::default());
|
||||
}
|
||||
|
||||
fn setup_instructions(mut commands: Commands) {
|
||||
commands.spawn((
|
||||
Text::new(
|
||||
"Press T to toggle the cursor's `texture_atlas`.\n
|
||||
Press X to toggle the cursor's `flip_x` setting.\n
|
||||
Press Y to toggle the cursor's `flip_y` setting.\n
|
||||
Press C to cycle through the sections of the cursor's image using `rect`.",
|
||||
),
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
bottom: Val::Px(12.0),
|
||||
left: Val::Px(12.0),
|
||||
..default()
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
#[derive(Component)]
|
||||
struct AnimationConfig {
|
||||
first_sprite_index: usize,
|
||||
last_sprite_index: usize,
|
||||
increment: usize,
|
||||
fps: u8,
|
||||
frame_timer: Timer,
|
||||
}
|
||||
|
||||
impl AnimationConfig {
|
||||
fn new(first: usize, last: usize, increment: usize, fps: u8) -> Self {
|
||||
Self {
|
||||
first_sprite_index: first,
|
||||
last_sprite_index: last,
|
||||
increment,
|
||||
fps,
|
||||
frame_timer: Self::timer_from_fps(fps),
|
||||
}
|
||||
}
|
||||
|
||||
fn timer_from_fps(fps: u8) -> Timer {
|
||||
Timer::new(Duration::from_secs_f32(1.0 / (fps as f32)), TimerMode::Once)
|
||||
}
|
||||
}
|
||||
|
||||
/// This system loops through all the sprites in the [`CursorIcon`]'s
|
||||
/// [`TextureAtlas`], from [`AnimationConfig`]'s `first_sprite_index` to
|
||||
/// `last_sprite_index`.
|
||||
fn execute_animation(time: Res<Time>, mut query: Query<(&mut AnimationConfig, &mut CursorIcon)>) {
|
||||
for (mut config, mut cursor_icon) in &mut query {
|
||||
if let CursorIcon::Custom(CustomCursor::Image {
|
||||
ref mut texture_atlas,
|
||||
..
|
||||
}) = *cursor_icon
|
||||
{
|
||||
config.frame_timer.tick(time.delta());
|
||||
|
||||
if config.frame_timer.finished() {
|
||||
if let Some(atlas) = texture_atlas {
|
||||
atlas.index += config.increment;
|
||||
|
||||
if atlas.index > config.last_sprite_index {
|
||||
atlas.index = config.first_sprite_index;
|
||||
}
|
||||
|
||||
config.frame_timer = AnimationConfig::timer_from_fps(config.fps);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn toggle_texture_atlas(
|
||||
input: Res<ButtonInput<KeyCode>>,
|
||||
mut query: Query<&mut CursorIcon, With<Window>>,
|
||||
mut cached_atlas: Local<Option<TextureAtlas>>, // this lets us restore the previous value
|
||||
) {
|
||||
if input.just_pressed(KeyCode::KeyT) {
|
||||
for mut cursor_icon in &mut query {
|
||||
if let CursorIcon::Custom(CustomCursor::Image {
|
||||
ref mut texture_atlas,
|
||||
..
|
||||
}) = *cursor_icon
|
||||
{
|
||||
*texture_atlas = match texture_atlas.take() {
|
||||
Some(a) => {
|
||||
*cached_atlas = Some(a.clone());
|
||||
None
|
||||
}
|
||||
None => cached_atlas.take(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn toggle_flip_x(
|
||||
input: Res<ButtonInput<KeyCode>>,
|
||||
mut query: Query<&mut CursorIcon, With<Window>>,
|
||||
) {
|
||||
if input.just_pressed(KeyCode::KeyX) {
|
||||
for mut cursor_icon in &mut query {
|
||||
if let CursorIcon::Custom(CustomCursor::Image { ref mut flip_x, .. }) = *cursor_icon {
|
||||
*flip_x = !*flip_x;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn toggle_flip_y(
|
||||
input: Res<ButtonInput<KeyCode>>,
|
||||
mut query: Query<&mut CursorIcon, With<Window>>,
|
||||
) {
|
||||
if input.just_pressed(KeyCode::KeyY) {
|
||||
for mut cursor_icon in &mut query {
|
||||
if let CursorIcon::Custom(CustomCursor::Image { ref mut flip_y, .. }) = *cursor_icon {
|
||||
*flip_y = !*flip_y;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// This system alternates the [`CursorIcon`]'s `rect` field between `None` and
|
||||
/// 4 sections/rectangles of the cursor's image.
|
||||
fn cycle_rect(input: Res<ButtonInput<KeyCode>>, mut query: Query<&mut CursorIcon, With<Window>>) {
|
||||
if !input.just_pressed(KeyCode::KeyC) {
|
||||
return;
|
||||
}
|
||||
|
||||
const RECT_SIZE: u32 = 32; // half the size of a tile in the texture atlas
|
||||
|
||||
const SECTIONS: [Option<URect>; 5] = [
|
||||
Some(URect {
|
||||
min: UVec2::ZERO,
|
||||
max: UVec2::splat(RECT_SIZE),
|
||||
}),
|
||||
Some(URect {
|
||||
min: UVec2::new(RECT_SIZE, 0),
|
||||
max: UVec2::new(2 * RECT_SIZE, RECT_SIZE),
|
||||
}),
|
||||
Some(URect {
|
||||
min: UVec2::new(0, RECT_SIZE),
|
||||
max: UVec2::new(RECT_SIZE, 2 * RECT_SIZE),
|
||||
}),
|
||||
Some(URect {
|
||||
min: UVec2::new(RECT_SIZE, RECT_SIZE),
|
||||
max: UVec2::splat(2 * RECT_SIZE),
|
||||
}),
|
||||
None, // reset to None
|
||||
];
|
||||
|
||||
for mut cursor_icon in &mut query {
|
||||
if let CursorIcon::Custom(CustomCursor::Image { ref mut rect, .. }) = *cursor_icon {
|
||||
let next_rect = SECTIONS
|
||||
.iter()
|
||||
.cycle()
|
||||
.skip_while(|&&corner| corner != *rect)
|
||||
.nth(1) // move to the next element
|
||||
.unwrap_or(&None);
|
||||
|
||||
*rect = *next_rect;
|
||||
}
|
||||
}
|
||||
}
|
@ -165,6 +165,10 @@ fn init_cursor_icons(
|
||||
#[cfg(feature = "custom_cursor")]
|
||||
CustomCursor::Image {
|
||||
handle: asset_server.load("branding/icon.png"),
|
||||
texture_atlas: None,
|
||||
flip_x: false,
|
||||
flip_y: false,
|
||||
rect: None,
|
||||
hotspot: (128, 128),
|
||||
}
|
||||
.into(),
|
||||
|
Loading…
Reference in New Issue
Block a user