From 0756a19f28d70a5af222243d13988852f5ba4409 Mon Sep 17 00:00:00 2001 From: mgi388 <135186256+mgi388@users.noreply.github.com> Date: Wed, 15 Jan 2025 09:27:24 +1100 Subject: [PATCH] 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`: 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 --- Cargo.toml | 12 + .../cursors/kenney_crosshairPack/License.txt | 19 + .../Tilesheet/crosshairs_tilesheet_white.png | Bin 0 -> 35335 bytes crates/bevy_image/src/texture_atlas.rs | 8 +- crates/bevy_winit/src/cursor.rs | 87 ++-- crates/bevy_winit/src/custom_cursor.rs | 490 ++++++++++++++++++ crates/bevy_winit/src/lib.rs | 2 + crates/bevy_winit/src/state.rs | 17 +- examples/README.md | 1 + examples/window/custom_cursor_image.rs | 228 ++++++++ examples/window/window_settings.rs | 4 + 11 files changed, 826 insertions(+), 42 deletions(-) create mode 100644 assets/cursors/kenney_crosshairPack/License.txt create mode 100644 assets/cursors/kenney_crosshairPack/Tilesheet/crosshairs_tilesheet_white.png create mode 100644 crates/bevy_winit/src/custom_cursor.rs create mode 100644 examples/window/custom_cursor_image.rs diff --git a/Cargo.toml b/Cargo.toml index 729279dc9e..b528e5b7d5 100644 --- a/Cargo.toml +++ b/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" diff --git a/assets/cursors/kenney_crosshairPack/License.txt b/assets/cursors/kenney_crosshairPack/License.txt new file mode 100644 index 0000000000..d6eaa6cb6b --- /dev/null +++ b/assets/cursors/kenney_crosshairPack/License.txt @@ -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) diff --git a/assets/cursors/kenney_crosshairPack/Tilesheet/crosshairs_tilesheet_white.png b/assets/cursors/kenney_crosshairPack/Tilesheet/crosshairs_tilesheet_white.png new file mode 100644 index 0000000000000000000000000000000000000000..76c8b2f85141447948d1712e046ec83ddcf7b72d GIT binary patch literal 35335 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 Ds7LFc literal 0 HcmV?d00001 diff --git a/crates/bevy_image/src/texture_atlas.rs b/crates/bevy_image/src/texture_atlas.rs index e9cf114ae2..57aa0c379e 100644 --- a/crates/bevy_image/src/texture_atlas.rs +++ b/crates/bevy_image/src/texture_atlas.rs @@ -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, diff --git a/crates/bevy_winit/src/cursor.rs b/crates/bevy_winit/src/cursor.rs index c2a8139ef8..1dbb66e636 100644 --- a/crates/bevy_winit/src/cursor.rs +++ b/crates/bevy_winit/src/cursor.rs @@ -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::(); + app.add_plugins(CustomCursorPlugin); app.register_type::() .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, + /// The (optional) texture atlas used to render the image. + texture_atlas: Option, + /// 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, /// 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), With>, #[cfg(feature = "custom_cursor")] cursor_cache: Res, #[cfg(feature = "custom_cursor")] images: Res>, + #[cfg(feature = "custom_cursor")] texture_atlases: Res>, mut queue: Local>, ) { 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, mut commands: C convert_system_cursor_icon(SystemCursorIcon::Default), )))); } - -#[cfg(feature = "custom_cursor")] -/// Returns the image data as a `Vec`. -/// Only supports rgba8 and rgba32float formats. -fn image_to_rgba_pixels(image: &Image) -> Option> { - 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, - } -} diff --git a/crates/bevy_winit/src/custom_cursor.rs b/crates/bevy_winit/src/custom_cursor.rs new file mode 100644 index 0000000000..6776c59ad7 --- /dev/null +++ b/crates/bevy_winit/src/custom_cursor.rs @@ -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::() { + app.add_plugins(TextureAtlasPlugin); + } + + app.init_resource::(); + } +} + +/// 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, + image: &Image, + texture_atlas: &Option, + rect: &Option, +) -> (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> { + 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` 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> { + 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::::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::>() + .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 = data + .chunks(4) + .flat_map(|chunk| { + chunk + .iter() + .map(|&x| x as f32 / 255.0) // convert each channel to f32 + .collect::>() + }) + .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 + ] + ); +} diff --git a/crates/bevy_winit/src/lib.rs b/crates/bevy_winit/src/lib.rs index cac00ecbf7..123f2c123c 100644 --- a/crates/bevy_winit/src/lib.rs +++ b/crates/bevy_winit/src/lib.rs @@ -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; diff --git a/crates/bevy_winit/src/state.rs b/crates/bevy_winit/src/state.rs index e8cfd0a691..370b4a87e3 100644 --- a/crates/bevy_winit/src/state.rs +++ b/crates/bevy_winit/src/state.rs @@ -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 WinitAppRunnerState { /// Identifiers for custom cursors used in caching. #[derive(Debug, Clone, Hash, PartialEq, Eq)] pub enum CustomCursorCacheKey { - /// An `AssetId` to a cursor. - Asset(AssetId), + /// A custom cursor with an image. + Image { + id: AssetId, + texture_atlas_layout_id: Option>, + texture_atlas_index: Option, + flip_x: bool, + flip_y: bool, + rect: Option, + }, #[cfg(all(target_family = "wasm", target_os = "unknown"))] - /// An URL to a cursor. + /// A custom cursor with a URL. Url(String), } diff --git a/examples/README.md b/examples/README.md index a39043e7c7..aef50d9627 100644 --- a/examples/README.md +++ b/examples/README.md @@ -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). diff --git a/examples/window/custom_cursor_image.rs b/examples/window/custom_cursor_image.rs new file mode 100644 index 0000000000..b63a90e2df --- /dev/null +++ b/examples/window/custom_cursor_image.rs @@ -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, + mut texture_atlas_layouts: ResMut>, + window: Single>, +) { + 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