Skip to content

Commit 0756a19

Browse files
authored
Support texture atlases in CustomCursor::Image (#17121)
# 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
1 parent f2e00c8 commit 0756a19

File tree

11 files changed

+826
-42
lines changed

11 files changed

+826
-42
lines changed

Cargo.toml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3311,6 +3311,18 @@ description = "Creates a solid color window"
33113311
category = "Window"
33123312
wasm = true
33133313

3314+
[[example]]
3315+
name = "custom_cursor_image"
3316+
path = "examples/window/custom_cursor_image.rs"
3317+
doc-scrape-examples = true
3318+
required-features = ["custom_cursor"]
3319+
3320+
[package.metadata.example.custom_cursor_image]
3321+
name = "Custom Cursor Image"
3322+
description = "Demonstrates creating an animated custom cursor from an image"
3323+
category = "Window"
3324+
wasm = true
3325+
33143326
[[example]]
33153327
name = "custom_user_event"
33163328
path = "examples/window/custom_user_event.rs"
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
2+
3+
Crosshair Pack
4+
5+
by Kenney Vleugels (Kenney.nl)
6+
7+
------------------------------
8+
9+
License (Creative Commons Zero, CC0)
10+
http://creativecommons.org/publicdomain/zero/1.0/
11+
12+
You may use these assets in personal and commercial projects.
13+
Credit (Kenney or www.kenney.nl) would be nice but is not mandatory.
14+
15+
------------------------------
16+
17+
Donate: http://support.kenney.nl
18+
19+
Follow on Twitter for updates: @KenneyNL (www.twitter.com/kenneynl)
Loading

crates/bevy_image/src/texture_atlas.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -182,8 +182,12 @@ impl TextureAtlasLayout {
182182
/// - [`animated sprite sheet example`](https://github.com/bevyengine/bevy/blob/latest/examples/2d/sprite_sheet.rs)
183183
/// - [`sprite animation event example`](https://github.com/bevyengine/bevy/blob/latest/examples/2d/sprite_animation.rs)
184184
/// - [`texture atlas example`](https://github.com/bevyengine/bevy/blob/latest/examples/2d/texture_atlas.rs)
185-
#[derive(Default, Debug, Clone)]
186-
#[cfg_attr(feature = "bevy_reflect", derive(Reflect), reflect(Default, Debug))]
185+
#[derive(Default, Debug, Clone, PartialEq, Eq, Hash)]
186+
#[cfg_attr(
187+
feature = "bevy_reflect",
188+
derive(Reflect),
189+
reflect(Default, Debug, PartialEq, Hash)
190+
)]
187191
pub struct TextureAtlas {
188192
/// Texture atlas layout handle
189193
pub layout: Handle<TextureAtlasLayout>,

crates/bevy_winit/src/cursor.rs

Lines changed: 51 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ use crate::{
66
};
77
#[cfg(feature = "custom_cursor")]
88
use crate::{
9+
custom_cursor::{
10+
calculate_effective_rect, extract_and_transform_rgba_pixels, extract_rgba_pixels,
11+
CustomCursorPlugin,
12+
},
913
state::{CustomCursorCache, CustomCursorCacheKey},
1014
WinitCustomCursor,
1115
};
@@ -25,21 +29,21 @@ use bevy_ecs::{
2529
world::{OnRemove, Ref},
2630
};
2731
#[cfg(feature = "custom_cursor")]
28-
use bevy_image::Image;
32+
use bevy_image::{Image, TextureAtlas, TextureAtlasLayout};
33+
#[cfg(feature = "custom_cursor")]
34+
use bevy_math::URect;
2935
use bevy_reflect::{std_traits::ReflectDefault, Reflect};
3036
use bevy_utils::HashSet;
3137
use bevy_window::{SystemCursorIcon, Window};
3238
#[cfg(feature = "custom_cursor")]
3339
use tracing::warn;
34-
#[cfg(feature = "custom_cursor")]
35-
use wgpu_types::TextureFormat;
3640

3741
pub(crate) struct CursorPlugin;
3842

3943
impl Plugin for CursorPlugin {
4044
fn build(&self, app: &mut App) {
4145
#[cfg(feature = "custom_cursor")]
42-
app.init_resource::<CustomCursorCache>();
46+
app.add_plugins(CustomCursorPlugin);
4347

4448
app.register_type::<CursorIcon>()
4549
.add_systems(Last, update_cursors);
@@ -87,6 +91,19 @@ pub enum CustomCursor {
8791
/// The image must be in 8 bit int or 32 bit float rgba. PNG images
8892
/// work well for this.
8993
handle: Handle<Image>,
94+
/// The (optional) texture atlas used to render the image.
95+
texture_atlas: Option<TextureAtlas>,
96+
/// Whether the image should be flipped along its x-axis.
97+
flip_x: bool,
98+
/// Whether the image should be flipped along its y-axis.
99+
flip_y: bool,
100+
/// An optional rectangle representing the region of the image to
101+
/// render, instead of rendering the full image. This is an easy one-off
102+
/// alternative to using a [`TextureAtlas`].
103+
///
104+
/// When used with a [`TextureAtlas`], the rect is offset by the atlas's
105+
/// minimal (top-left) corner position.
106+
rect: Option<URect>,
90107
/// X and Y coordinates of the hotspot in pixels. The hotspot must be
91108
/// within the image bounds.
92109
hotspot: (u16, u16),
@@ -108,6 +125,7 @@ fn update_cursors(
108125
windows: Query<(Entity, Ref<CursorIcon>), With<Window>>,
109126
#[cfg(feature = "custom_cursor")] cursor_cache: Res<CustomCursorCache>,
110127
#[cfg(feature = "custom_cursor")] images: Res<Assets<Image>>,
128+
#[cfg(feature = "custom_cursor")] texture_atlases: Res<Assets<TextureAtlasLayout>>,
111129
mut queue: Local<HashSet<Entity>>,
112130
) {
113131
for (entity, cursor) in windows.iter() {
@@ -117,8 +135,22 @@ fn update_cursors(
117135

118136
let cursor_source = match cursor.as_ref() {
119137
#[cfg(feature = "custom_cursor")]
120-
CursorIcon::Custom(CustomCursor::Image { handle, hotspot }) => {
121-
let cache_key = CustomCursorCacheKey::Asset(handle.id());
138+
CursorIcon::Custom(CustomCursor::Image {
139+
handle,
140+
texture_atlas,
141+
flip_x,
142+
flip_y,
143+
rect,
144+
hotspot,
145+
}) => {
146+
let cache_key = CustomCursorCacheKey::Image {
147+
id: handle.id(),
148+
texture_atlas_layout_id: texture_atlas.as_ref().map(|a| a.layout.id()),
149+
texture_atlas_index: texture_atlas.as_ref().map(|a| a.index),
150+
flip_x: *flip_x,
151+
flip_y: *flip_y,
152+
rect: *rect,
153+
};
122154

123155
if cursor_cache.0.contains_key(&cache_key) {
124156
CursorSource::CustomCached(cache_key)
@@ -130,17 +162,25 @@ fn update_cursors(
130162
queue.insert(entity);
131163
continue;
132164
};
133-
let Some(rgba) = image_to_rgba_pixels(image) else {
165+
166+
let (rect, needs_sub_image) =
167+
calculate_effective_rect(&texture_atlases, image, texture_atlas, rect);
168+
169+
let maybe_rgba = if *flip_x || *flip_y || needs_sub_image {
170+
extract_and_transform_rgba_pixels(image, *flip_x, *flip_y, rect)
171+
} else {
172+
extract_rgba_pixels(image)
173+
};
174+
175+
let Some(rgba) = maybe_rgba else {
134176
warn!("Cursor image {handle:?} not accepted because it's not rgba8 or rgba32float format");
135177
continue;
136178
};
137179

138-
let width = image.texture_descriptor.size.width;
139-
let height = image.texture_descriptor.size.height;
140180
let source = match WinitCustomCursor::from_rgba(
141181
rgba,
142-
width as u16,
143-
height as u16,
182+
rect.width() as u16,
183+
rect.height() as u16,
144184
hotspot.0,
145185
hotspot.1,
146186
) {
@@ -190,28 +230,3 @@ fn on_remove_cursor_icon(trigger: Trigger<OnRemove, CursorIcon>, mut commands: C
190230
convert_system_cursor_icon(SystemCursorIcon::Default),
191231
))));
192232
}
193-
194-
#[cfg(feature = "custom_cursor")]
195-
/// Returns the image data as a `Vec<u8>`.
196-
/// Only supports rgba8 and rgba32float formats.
197-
fn image_to_rgba_pixels(image: &Image) -> Option<Vec<u8>> {
198-
match image.texture_descriptor.format {
199-
TextureFormat::Rgba8Unorm
200-
| TextureFormat::Rgba8UnormSrgb
201-
| TextureFormat::Rgba8Snorm
202-
| TextureFormat::Rgba8Uint
203-
| TextureFormat::Rgba8Sint => Some(image.data.clone()),
204-
TextureFormat::Rgba32Float => Some(
205-
image
206-
.data
207-
.chunks(4)
208-
.map(|chunk| {
209-
let chunk = chunk.try_into().unwrap();
210-
let num = bytemuck::cast_ref::<[u8; 4], f32>(chunk);
211-
(num * 255.0) as u8
212-
})
213-
.collect(),
214-
),
215-
_ => None,
216-
}
217-
}

0 commit comments

Comments
 (0)