Skip to content

Commit

Permalink
Support texture atlases in CustomCursor::Image (#17121)
Browse files Browse the repository at this point in the history
# Objective

- Bevy 0.15 added support for custom cursor images in
#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
  • Loading branch information
mgi388 authored Jan 14, 2025
1 parent f2e00c8 commit 0756a19
Show file tree
Hide file tree
Showing 11 changed files with 826 additions and 42 deletions.
12 changes: 12 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
19 changes: 19 additions & 0 deletions assets/cursors/kenney_crosshairPack/License.txt
Original file line number Diff line number Diff line change
@@ -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)
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 6 additions & 2 deletions crates/bevy_image/src/texture_atlas.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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>,
Expand Down
87 changes: 51 additions & 36 deletions crates/bevy_winit/src/cursor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand All @@ -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);
Expand Down Expand Up @@ -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),
Expand All @@ -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() {
Expand All @@ -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)
Expand All @@ -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,
) {
Expand Down Expand Up @@ -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,
}
}
Loading

0 comments on commit 0756a19

Please sign in to comment.