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:
mgi388 2025-01-15 09:27:24 +11:00 committed by GitHub
parent f2e00c8ed5
commit 0756a19f28
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 826 additions and 42 deletions

View File

@ -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"

View 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

View File

@ -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>,

View File

@ -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,
}
}

View 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
]
);
}

View File

@ -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;

View File

@ -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),
}

View File

@ -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).

View 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;
}
}
}

View File

@ -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(),