diff --git a/Cargo.toml b/Cargo.toml index 729279dc9ebb4..b528e5b7d56c8 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 0000000000000..d6eaa6cb6b7d6 --- /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 0000000000000..76c8b2f851414 Binary files /dev/null and b/assets/cursors/kenney_crosshairPack/Tilesheet/crosshairs_tilesheet_white.png differ diff --git a/crates/bevy_image/src/texture_atlas.rs b/crates/bevy_image/src/texture_atlas.rs index e9cf114ae27e4..57aa0c379e6ac 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 c2a8139ef8458..1dbb66e6368b0 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 0000000000000..6776c59ad798f --- /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 cac00ecbf7712..123f2c123c172 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 e8cfd0a691054..370b4a87e37c0 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 a39043e7c7833..aef50d96279a4 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 0000000000000..b63a90e2df0ea --- /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