bevy_sprite/
sprite.rs

1use bevy_asset::{Assets, Handle};
2use bevy_color::Color;
3use bevy_ecs::{component::Component, reflect::ReflectComponent};
4use bevy_image::{Image, TextureAtlas, TextureAtlasLayout};
5use bevy_math::{Rect, UVec2, Vec2};
6use bevy_reflect::{std_traits::ReflectDefault, Reflect};
7use bevy_render::{
8    sync_world::SyncToRenderWorld,
9    view::{self, Visibility, VisibilityClass},
10};
11use bevy_transform::components::Transform;
12
13use crate::TextureSlicer;
14
15/// Describes a sprite to be rendered to a 2D camera
16#[derive(Component, Debug, Default, Clone, Reflect)]
17#[require(Transform, Visibility, SyncToRenderWorld, VisibilityClass)]
18#[reflect(Component, Default, Debug, Clone)]
19#[component(on_add = view::add_visibility_class::<Sprite>)]
20pub struct Sprite {
21    /// The image used to render the sprite
22    pub image: Handle<Image>,
23    /// The (optional) texture atlas used to render the sprite
24    pub texture_atlas: Option<TextureAtlas>,
25    /// The sprite's color tint
26    pub color: Color,
27    /// Flip the sprite along the `X` axis
28    pub flip_x: bool,
29    /// Flip the sprite along the `Y` axis
30    pub flip_y: bool,
31    /// An optional custom size for the sprite that will be used when rendering, instead of the size
32    /// of the sprite's image
33    pub custom_size: Option<Vec2>,
34    /// An optional rectangle representing the region of the sprite's image to render, instead of rendering
35    /// the full image. This is an easy one-off alternative to using a [`TextureAtlas`].
36    ///
37    /// When used with a [`TextureAtlas`], the rect
38    /// is offset by the atlas's minimal (top-left) corner position.
39    pub rect: Option<Rect>,
40    /// [`Anchor`] point of the sprite in the world
41    pub anchor: Anchor,
42    /// How the sprite's image will be scaled.
43    pub image_mode: SpriteImageMode,
44}
45
46impl Sprite {
47    /// Create a Sprite with a custom size
48    pub fn sized(custom_size: Vec2) -> Self {
49        Sprite {
50            custom_size: Some(custom_size),
51            ..Default::default()
52        }
53    }
54
55    /// Create a sprite from an image
56    pub fn from_image(image: Handle<Image>) -> Self {
57        Self {
58            image,
59            ..Default::default()
60        }
61    }
62
63    /// Create a sprite from an image, with an associated texture atlas
64    pub fn from_atlas_image(image: Handle<Image>, atlas: TextureAtlas) -> Self {
65        Self {
66            image,
67            texture_atlas: Some(atlas),
68            ..Default::default()
69        }
70    }
71
72    /// Create a sprite from a solid color
73    pub fn from_color(color: impl Into<Color>, size: Vec2) -> Self {
74        Self {
75            color: color.into(),
76            custom_size: Some(size),
77            ..Default::default()
78        }
79    }
80
81    /// Computes the pixel point where `point_relative_to_sprite` is sampled
82    /// from in this sprite. `point_relative_to_sprite` must be in the sprite's
83    /// local frame. Returns an Ok if the point is inside the bounds of the
84    /// sprite (not just the image), and returns an Err otherwise.
85    pub fn compute_pixel_space_point(
86        &self,
87        point_relative_to_sprite: Vec2,
88        images: &Assets<Image>,
89        texture_atlases: &Assets<TextureAtlasLayout>,
90    ) -> Result<Vec2, Vec2> {
91        let image_size = images
92            .get(&self.image)
93            .map(Image::size)
94            .unwrap_or(UVec2::ONE);
95
96        let atlas_rect = self
97            .texture_atlas
98            .as_ref()
99            .and_then(|s| s.texture_rect(texture_atlases))
100            .map(|r| r.as_rect());
101        let texture_rect = match (atlas_rect, self.rect) {
102            (None, None) => Rect::new(0.0, 0.0, image_size.x as f32, image_size.y as f32),
103            (None, Some(sprite_rect)) => sprite_rect,
104            (Some(atlas_rect), None) => atlas_rect,
105            (Some(atlas_rect), Some(mut sprite_rect)) => {
106                // Make the sprite rect relative to the atlas rect.
107                sprite_rect.min += atlas_rect.min;
108                sprite_rect.max += atlas_rect.min;
109                sprite_rect
110            }
111        };
112
113        let sprite_size = self.custom_size.unwrap_or_else(|| texture_rect.size());
114        let sprite_center = -self.anchor.as_vec() * sprite_size;
115
116        let mut point_relative_to_sprite_center = point_relative_to_sprite - sprite_center;
117
118        if self.flip_x {
119            point_relative_to_sprite_center.x *= -1.0;
120        }
121        // Texture coordinates start at the top left, whereas world coordinates start at the bottom
122        // left. So flip by default, and then don't flip if `flip_y` is set.
123        if !self.flip_y {
124            point_relative_to_sprite_center.y *= -1.0;
125        }
126
127        let sprite_to_texture_ratio = {
128            let texture_size = texture_rect.size();
129            let div_or_zero = |a, b| if b == 0.0 { 0.0 } else { a / b };
130            Vec2::new(
131                div_or_zero(texture_size.x, sprite_size.x),
132                div_or_zero(texture_size.y, sprite_size.y),
133            )
134        };
135
136        let point_relative_to_texture =
137            point_relative_to_sprite_center * sprite_to_texture_ratio + texture_rect.center();
138
139        // TODO: Support `SpriteImageMode`.
140
141        if texture_rect.contains(point_relative_to_texture) {
142            Ok(point_relative_to_texture)
143        } else {
144            Err(point_relative_to_texture)
145        }
146    }
147}
148
149impl From<Handle<Image>> for Sprite {
150    fn from(image: Handle<Image>) -> Self {
151        Self::from_image(image)
152    }
153}
154
155/// Controls how the image is altered when scaled.
156#[derive(Default, Debug, Clone, Reflect, PartialEq)]
157#[reflect(Debug, Default, Clone)]
158pub enum SpriteImageMode {
159    /// The sprite will take on the size of the image by default, and will be stretched or shrunk if [`Sprite::custom_size`] is set.
160    #[default]
161    Auto,
162    /// The texture will be scaled to fit the rect bounds defined in [`Sprite::custom_size`].
163    /// Otherwise no scaling will be applied.
164    Scale(ScalingMode),
165    /// The texture will be cut in 9 slices, keeping the texture in proportions on resize
166    Sliced(TextureSlicer),
167    /// The texture will be repeated if stretched beyond `stretched_value`
168    Tiled {
169        /// Should the image repeat horizontally
170        tile_x: bool,
171        /// Should the image repeat vertically
172        tile_y: bool,
173        /// The texture will repeat when the ratio between the *drawing dimensions* of texture and the
174        /// *original texture size* are above this value.
175        stretch_value: f32,
176    },
177}
178
179impl SpriteImageMode {
180    /// Returns true if this mode uses slices internally ([`SpriteImageMode::Sliced`] or [`SpriteImageMode::Tiled`])
181    #[inline]
182    pub fn uses_slices(&self) -> bool {
183        matches!(
184            self,
185            SpriteImageMode::Sliced(..) | SpriteImageMode::Tiled { .. }
186        )
187    }
188
189    /// Returns [`ScalingMode`] if scale is presented or [`Option::None`] otherwise.
190    #[inline]
191    #[must_use]
192    pub const fn scale(&self) -> Option<ScalingMode> {
193        if let SpriteImageMode::Scale(scale) = self {
194            Some(*scale)
195        } else {
196            None
197        }
198    }
199}
200
201/// Represents various modes for proportional scaling of a texture.
202///
203/// Can be used in [`SpriteImageMode::Scale`].
204#[derive(Debug, Clone, Copy, PartialEq, Default, Reflect)]
205#[reflect(Debug, Default, Clone)]
206pub enum ScalingMode {
207    /// Scale the texture uniformly (maintain the texture's aspect ratio)
208    /// so that both dimensions (width and height) of the texture will be equal
209    /// to or larger than the corresponding dimension of the target rectangle.
210    /// Fill sprite with a centered texture.
211    #[default]
212    FillCenter,
213    /// Scales the texture to fill the target rectangle while maintaining its aspect ratio.
214    /// One dimension of the texture will match the rectangle's size,
215    /// while the other dimension may exceed it.
216    /// The exceeding portion is aligned to the start:
217    /// * Horizontal overflow is left-aligned if the width exceeds the rectangle.
218    /// * Vertical overflow is top-aligned if the height exceeds the rectangle.
219    FillStart,
220    /// Scales the texture to fill the target rectangle while maintaining its aspect ratio.
221    /// One dimension of the texture will match the rectangle's size,
222    /// while the other dimension may exceed it.
223    /// The exceeding portion is aligned to the end:
224    /// * Horizontal overflow is right-aligned if the width exceeds the rectangle.
225    /// * Vertical overflow is bottom-aligned if the height exceeds the rectangle.
226    FillEnd,
227    /// Scaling the texture will maintain the original aspect ratio
228    /// and ensure that the original texture fits entirely inside the rect.
229    /// At least one axis (x or y) will fit exactly. The result is centered inside the rect.
230    FitCenter,
231    /// Scaling the texture will maintain the original aspect ratio
232    /// and ensure that the original texture fits entirely inside rect.
233    /// At least one axis (x or y) will fit exactly.
234    /// Aligns the result to the left and top edges of rect.
235    FitStart,
236    /// Scaling the texture will maintain the original aspect ratio
237    /// and ensure that the original texture fits entirely inside rect.
238    /// At least one axis (x or y) will fit exactly.
239    /// Aligns the result to the right and bottom edges of rect.
240    FitEnd,
241}
242
243/// How a sprite is positioned relative to its [`Transform`].
244/// It defaults to `Anchor::Center`.
245#[derive(Component, Debug, Clone, Copy, PartialEq, Default, Reflect)]
246#[reflect(Component, Default, Debug, PartialEq, Clone)]
247#[doc(alias = "pivot")]
248pub enum Anchor {
249    #[default]
250    Center,
251    BottomLeft,
252    BottomCenter,
253    BottomRight,
254    CenterLeft,
255    CenterRight,
256    TopLeft,
257    TopCenter,
258    TopRight,
259    /// Custom anchor point. Top left is `(-0.5, 0.5)`, center is `(0.0, 0.0)`. The value will
260    /// be scaled with the sprite size.
261    Custom(Vec2),
262}
263
264impl Anchor {
265    pub fn as_vec(&self) -> Vec2 {
266        match self {
267            Anchor::Center => Vec2::ZERO,
268            Anchor::BottomLeft => Vec2::new(-0.5, -0.5),
269            Anchor::BottomCenter => Vec2::new(0.0, -0.5),
270            Anchor::BottomRight => Vec2::new(0.5, -0.5),
271            Anchor::CenterLeft => Vec2::new(-0.5, 0.0),
272            Anchor::CenterRight => Vec2::new(0.5, 0.0),
273            Anchor::TopLeft => Vec2::new(-0.5, 0.5),
274            Anchor::TopCenter => Vec2::new(0.0, 0.5),
275            Anchor::TopRight => Vec2::new(0.5, 0.5),
276            Anchor::Custom(point) => *point,
277        }
278    }
279}
280
281#[cfg(test)]
282mod tests {
283    use bevy_asset::{Assets, RenderAssetUsages};
284    use bevy_color::Color;
285    use bevy_image::Image;
286    use bevy_image::{TextureAtlas, TextureAtlasLayout};
287    use bevy_math::{Rect, URect, UVec2, Vec2};
288    use bevy_render::render_resource::{Extent3d, TextureDimension, TextureFormat};
289
290    use crate::Anchor;
291
292    use super::Sprite;
293
294    /// Makes a new image of the specified size.
295    fn make_image(size: UVec2) -> Image {
296        Image::new_fill(
297            Extent3d {
298                width: size.x,
299                height: size.y,
300                depth_or_array_layers: 1,
301            },
302            TextureDimension::D2,
303            &[0, 0, 0, 255],
304            TextureFormat::Rgba8Unorm,
305            RenderAssetUsages::all(),
306        )
307    }
308
309    #[test]
310    fn compute_pixel_space_point_for_regular_sprite() {
311        let mut image_assets = Assets::<Image>::default();
312        let texture_atlas_assets = Assets::<TextureAtlasLayout>::default();
313
314        let image = image_assets.add(make_image(UVec2::new(5, 10)));
315
316        let sprite = Sprite {
317            image,
318            ..Default::default()
319        };
320
321        let compute =
322            |point| sprite.compute_pixel_space_point(point, &image_assets, &texture_atlas_assets);
323        assert_eq!(compute(Vec2::new(-2.0, -4.5)), Ok(Vec2::new(0.5, 9.5)));
324        assert_eq!(compute(Vec2::new(0.0, 0.0)), Ok(Vec2::new(2.5, 5.0)));
325        assert_eq!(compute(Vec2::new(0.0, 4.5)), Ok(Vec2::new(2.5, 0.5)));
326        assert_eq!(compute(Vec2::new(3.0, 0.0)), Err(Vec2::new(5.5, 5.0)));
327        assert_eq!(compute(Vec2::new(-3.0, 0.0)), Err(Vec2::new(-0.5, 5.0)));
328    }
329
330    #[test]
331    fn compute_pixel_space_point_for_color_sprite() {
332        let image_assets = Assets::<Image>::default();
333        let texture_atlas_assets = Assets::<TextureAtlasLayout>::default();
334
335        // This also tests the `custom_size` field.
336        let sprite = Sprite::from_color(Color::BLACK, Vec2::new(50.0, 100.0));
337
338        let compute = |point| {
339            sprite
340                .compute_pixel_space_point(point, &image_assets, &texture_atlas_assets)
341                // Round to remove floating point errors.
342                .map(|x| (x * 1e5).round() / 1e5)
343                .map_err(|x| (x * 1e5).round() / 1e5)
344        };
345        assert_eq!(compute(Vec2::new(-20.0, -40.0)), Ok(Vec2::new(0.1, 0.9)));
346        assert_eq!(compute(Vec2::new(0.0, 10.0)), Ok(Vec2::new(0.5, 0.4)));
347        assert_eq!(compute(Vec2::new(75.0, 100.0)), Err(Vec2::new(2.0, -0.5)));
348        assert_eq!(compute(Vec2::new(-75.0, -100.0)), Err(Vec2::new(-1.0, 1.5)));
349        assert_eq!(compute(Vec2::new(-30.0, -40.0)), Err(Vec2::new(-0.1, 0.9)));
350    }
351
352    #[test]
353    fn compute_pixel_space_point_for_sprite_with_anchor_bottom_left() {
354        let mut image_assets = Assets::<Image>::default();
355        let texture_atlas_assets = Assets::<TextureAtlasLayout>::default();
356
357        let image = image_assets.add(make_image(UVec2::new(5, 10)));
358
359        let sprite = Sprite {
360            image,
361            anchor: Anchor::BottomLeft,
362            ..Default::default()
363        };
364
365        let compute =
366            |point| sprite.compute_pixel_space_point(point, &image_assets, &texture_atlas_assets);
367        assert_eq!(compute(Vec2::new(0.5, 9.5)), Ok(Vec2::new(0.5, 0.5)));
368        assert_eq!(compute(Vec2::new(2.5, 5.0)), Ok(Vec2::new(2.5, 5.0)));
369        assert_eq!(compute(Vec2::new(2.5, 9.5)), Ok(Vec2::new(2.5, 0.5)));
370        assert_eq!(compute(Vec2::new(5.5, 5.0)), Err(Vec2::new(5.5, 5.0)));
371        assert_eq!(compute(Vec2::new(-0.5, 5.0)), Err(Vec2::new(-0.5, 5.0)));
372    }
373
374    #[test]
375    fn compute_pixel_space_point_for_sprite_with_anchor_top_right() {
376        let mut image_assets = Assets::<Image>::default();
377        let texture_atlas_assets = Assets::<TextureAtlasLayout>::default();
378
379        let image = image_assets.add(make_image(UVec2::new(5, 10)));
380
381        let sprite = Sprite {
382            image,
383            anchor: Anchor::TopRight,
384            ..Default::default()
385        };
386
387        let compute =
388            |point| sprite.compute_pixel_space_point(point, &image_assets, &texture_atlas_assets);
389        assert_eq!(compute(Vec2::new(-4.5, -0.5)), Ok(Vec2::new(0.5, 0.5)));
390        assert_eq!(compute(Vec2::new(-2.5, -5.0)), Ok(Vec2::new(2.5, 5.0)));
391        assert_eq!(compute(Vec2::new(-2.5, -0.5)), Ok(Vec2::new(2.5, 0.5)));
392        assert_eq!(compute(Vec2::new(0.5, -5.0)), Err(Vec2::new(5.5, 5.0)));
393        assert_eq!(compute(Vec2::new(-5.5, -5.0)), Err(Vec2::new(-0.5, 5.0)));
394    }
395
396    #[test]
397    fn compute_pixel_space_point_for_sprite_with_anchor_flip_x() {
398        let mut image_assets = Assets::<Image>::default();
399        let texture_atlas_assets = Assets::<TextureAtlasLayout>::default();
400
401        let image = image_assets.add(make_image(UVec2::new(5, 10)));
402
403        let sprite = Sprite {
404            image,
405            anchor: Anchor::BottomLeft,
406            flip_x: true,
407            ..Default::default()
408        };
409
410        let compute =
411            |point| sprite.compute_pixel_space_point(point, &image_assets, &texture_atlas_assets);
412        assert_eq!(compute(Vec2::new(0.5, 9.5)), Ok(Vec2::new(4.5, 0.5)));
413        assert_eq!(compute(Vec2::new(2.5, 5.0)), Ok(Vec2::new(2.5, 5.0)));
414        assert_eq!(compute(Vec2::new(2.5, 9.5)), Ok(Vec2::new(2.5, 0.5)));
415        assert_eq!(compute(Vec2::new(5.5, 5.0)), Err(Vec2::new(-0.5, 5.0)));
416        assert_eq!(compute(Vec2::new(-0.5, 5.0)), Err(Vec2::new(5.5, 5.0)));
417    }
418
419    #[test]
420    fn compute_pixel_space_point_for_sprite_with_anchor_flip_y() {
421        let mut image_assets = Assets::<Image>::default();
422        let texture_atlas_assets = Assets::<TextureAtlasLayout>::default();
423
424        let image = image_assets.add(make_image(UVec2::new(5, 10)));
425
426        let sprite = Sprite {
427            image,
428            anchor: Anchor::TopRight,
429            flip_y: true,
430            ..Default::default()
431        };
432
433        let compute =
434            |point| sprite.compute_pixel_space_point(point, &image_assets, &texture_atlas_assets);
435        assert_eq!(compute(Vec2::new(-4.5, -0.5)), Ok(Vec2::new(0.5, 9.5)));
436        assert_eq!(compute(Vec2::new(-2.5, -5.0)), Ok(Vec2::new(2.5, 5.0)));
437        assert_eq!(compute(Vec2::new(-2.5, -0.5)), Ok(Vec2::new(2.5, 9.5)));
438        assert_eq!(compute(Vec2::new(0.5, -5.0)), Err(Vec2::new(5.5, 5.0)));
439        assert_eq!(compute(Vec2::new(-5.5, -5.0)), Err(Vec2::new(-0.5, 5.0)));
440    }
441
442    #[test]
443    fn compute_pixel_space_point_for_sprite_with_rect() {
444        let mut image_assets = Assets::<Image>::default();
445        let texture_atlas_assets = Assets::<TextureAtlasLayout>::default();
446
447        let image = image_assets.add(make_image(UVec2::new(5, 10)));
448
449        let sprite = Sprite {
450            image,
451            rect: Some(Rect::new(1.5, 3.0, 3.0, 9.5)),
452            anchor: Anchor::BottomLeft,
453            ..Default::default()
454        };
455
456        let compute =
457            |point| sprite.compute_pixel_space_point(point, &image_assets, &texture_atlas_assets);
458        assert_eq!(compute(Vec2::new(0.5, 0.5)), Ok(Vec2::new(2.0, 9.0)));
459        // The pixel is outside the rect, but is still a valid pixel in the image.
460        assert_eq!(compute(Vec2::new(2.0, 2.5)), Err(Vec2::new(3.5, 7.0)));
461    }
462
463    #[test]
464    fn compute_pixel_space_point_for_texture_atlas_sprite() {
465        let mut image_assets = Assets::<Image>::default();
466        let mut texture_atlas_assets = Assets::<TextureAtlasLayout>::default();
467
468        let image = image_assets.add(make_image(UVec2::new(5, 10)));
469        let texture_atlas = texture_atlas_assets.add(TextureAtlasLayout {
470            size: UVec2::new(5, 10),
471            textures: vec![URect::new(1, 1, 4, 4)],
472        });
473
474        let sprite = Sprite {
475            image,
476            anchor: Anchor::BottomLeft,
477            texture_atlas: Some(TextureAtlas {
478                layout: texture_atlas,
479                index: 0,
480            }),
481            ..Default::default()
482        };
483
484        let compute =
485            |point| sprite.compute_pixel_space_point(point, &image_assets, &texture_atlas_assets);
486        assert_eq!(compute(Vec2::new(0.5, 0.5)), Ok(Vec2::new(1.5, 3.5)));
487        // The pixel is outside the texture atlas, but is still a valid pixel in the image.
488        assert_eq!(compute(Vec2::new(4.0, 2.5)), Err(Vec2::new(5.0, 1.5)));
489    }
490
491    #[test]
492    fn compute_pixel_space_point_for_texture_atlas_sprite_with_rect() {
493        let mut image_assets = Assets::<Image>::default();
494        let mut texture_atlas_assets = Assets::<TextureAtlasLayout>::default();
495
496        let image = image_assets.add(make_image(UVec2::new(5, 10)));
497        let texture_atlas = texture_atlas_assets.add(TextureAtlasLayout {
498            size: UVec2::new(5, 10),
499            textures: vec![URect::new(1, 1, 4, 4)],
500        });
501
502        let sprite = Sprite {
503            image,
504            anchor: Anchor::BottomLeft,
505            texture_atlas: Some(TextureAtlas {
506                layout: texture_atlas,
507                index: 0,
508            }),
509            // The rect is relative to the texture atlas sprite.
510            rect: Some(Rect::new(1.5, 1.5, 3.0, 3.0)),
511            ..Default::default()
512        };
513
514        let compute =
515            |point| sprite.compute_pixel_space_point(point, &image_assets, &texture_atlas_assets);
516        assert_eq!(compute(Vec2::new(0.5, 0.5)), Ok(Vec2::new(3.0, 3.5)));
517        // The pixel is outside the texture atlas, but is still a valid pixel in the image.
518        assert_eq!(compute(Vec2::new(4.0, 2.5)), Err(Vec2::new(6.5, 1.5)));
519    }
520
521    #[test]
522    fn compute_pixel_space_point_for_sprite_with_custom_size_and_rect() {
523        let mut image_assets = Assets::<Image>::default();
524        let texture_atlas_assets = Assets::<TextureAtlasLayout>::default();
525
526        let image = image_assets.add(make_image(UVec2::new(5, 10)));
527
528        let sprite = Sprite {
529            image,
530            custom_size: Some(Vec2::new(100.0, 50.0)),
531            rect: Some(Rect::new(0.0, 0.0, 5.0, 5.0)),
532            ..Default::default()
533        };
534
535        let compute =
536            |point| sprite.compute_pixel_space_point(point, &image_assets, &texture_atlas_assets);
537        assert_eq!(compute(Vec2::new(30.0, 15.0)), Ok(Vec2::new(4.0, 1.0)));
538        assert_eq!(compute(Vec2::new(-10.0, -15.0)), Ok(Vec2::new(2.0, 4.0)));
539        // The pixel is outside the texture atlas, but is still a valid pixel in the image.
540        assert_eq!(compute(Vec2::new(0.0, 35.0)), Err(Vec2::new(2.5, -1.0)));
541    }
542}