Proportional scaling for the sprite's texture. (#17258)

# Objective

Bevy sprite image mode lacks proportional scaling for the underlying
texture. In many cases, it's required. For example, if it is desired to
support a wide variety of screens with a single texture, it's okay to
cut off some portion of the original texture.

## Solution

I added scaling of the texture during the preparation step. To fill the
sprite with the original texture, I scaled UV coordinates accordingly to
the sprite size aspect ratio and texture size aspect ratio. To fit
texture in a sprite the original `quad` is scaled and then the
additional translation is applied to place the scaled quad properly.


## Testing

For testing purposes could be used `2d/sprite_scale.rs`. Also, I am
thinking that it would be nice to have some tests for a
`crates/bevy_sprite/src/render/mod.rs:sprite_scale`.

---

## Showcase

<img width="1392" alt="image"
src="https://github.com/user-attachments/assets/c2c37b96-2493-4717-825f-7810d921b4bc"
/>
This commit is contained in:
Predko Silvestr 2025-01-24 20:24:02 +02:00 committed by GitHub
parent 39a1e2b488
commit deb135c25c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 531 additions and 15 deletions

View File

@ -663,6 +663,17 @@ description = "Animates a sprite in response to an event"
category = "2D Rendering"
wasm = true
[[example]]
name = "sprite_scale"
path = "examples/2d/sprite_scale.rs"
doc-scrape-examples = true
[package.metadata.example.sprite_scale]
name = "Sprite Scale"
description = "Shows how a sprite can be scaled into a rectangle while keeping the aspect ratio"
category = "2D Rendering"
wasm = true
[[example]]
name = "sprite_flipping"
path = "examples/2d/sprite_flipping.rs"

View File

@ -25,7 +25,7 @@ pub mod prelude {
pub use crate::{
sprite::{Sprite, SpriteImageMode},
texture_slice::{BorderRect, SliceScaleMode, TextureSlice, TextureSlicer},
ColorMaterial, MeshMaterial2d,
ColorMaterial, MeshMaterial2d, ScalingMode,
};
}

View File

@ -1,6 +1,6 @@
use core::ops::Range;
use crate::{ComputedTextureSlices, Sprite, SPRITE_SHADER_HANDLE};
use crate::{ComputedTextureSlices, ScalingMode, Sprite, SPRITE_SHADER_HANDLE};
use bevy_asset::{AssetEvent, AssetId, Assets};
use bevy_color::{ColorToComponents, LinearRgba};
use bevy_core_pipeline::{
@ -339,6 +339,7 @@ pub struct ExtractedSprite {
/// For cases where additional [`ExtractedSprites`] are created during extraction, this stores the
/// entity that caused that creation for use in determining visibility.
pub original_entity: Option<Entity>,
pub scaling_mode: Option<ScalingMode>,
}
#[derive(Resource, Default)]
@ -430,6 +431,7 @@ pub fn extract_sprites(
image_handle_id: sprite.image.id(),
anchor: sprite.anchor.as_vec(),
original_entity: Some(original_entity),
scaling_mode: sprite.image_mode.scale(),
},
);
}
@ -700,21 +702,43 @@ pub fn prepare_sprite_image_bind_groups(
// By default, the size of the quad is the size of the texture
let mut quad_size = batch_image_size;
// Calculate vertex data for this item
let mut uv_offset_scale: Vec4;
// Texture size is the size of the image
let mut texture_size = batch_image_size;
// If a rect is specified, adjust UVs and the size of the quad
if let Some(rect) = extracted_sprite.rect {
let mut uv_offset_scale = if let Some(rect) = extracted_sprite.rect {
let rect_size = rect.size();
uv_offset_scale = Vec4::new(
quad_size = rect_size;
// Update texture size to the rect size
// It will help scale properly only portion of the image
texture_size = rect_size;
Vec4::new(
rect.min.x / batch_image_size.x,
rect.max.y / batch_image_size.y,
rect_size.x / batch_image_size.x,
-rect_size.y / batch_image_size.y,
);
quad_size = rect_size;
)
} else {
uv_offset_scale = Vec4::new(0.0, 1.0, 1.0, -1.0);
Vec4::new(0.0, 1.0, 1.0, -1.0)
};
// Override the size if a custom one is specified
if let Some(custom_size) = extracted_sprite.custom_size {
quad_size = custom_size;
}
// Used for translation of the quad if `TextureScale::Fit...` is specified.
let mut quad_translation = Vec2::ZERO;
// Scales the texture based on the `texture_scale` field.
if let Some(scaling_mode) = extracted_sprite.scaling_mode {
apply_scaling(
scaling_mode,
texture_size,
&mut quad_size,
&mut quad_translation,
&mut uv_offset_scale,
);
}
if extracted_sprite.flip_x {
@ -726,15 +750,13 @@ pub fn prepare_sprite_image_bind_groups(
uv_offset_scale.w *= -1.0;
}
// Override the size if a custom one is specified
if let Some(custom_size) = extracted_sprite.custom_size {
quad_size = custom_size;
}
let transform = extracted_sprite.transform.affine()
* Affine3A::from_scale_rotation_translation(
quad_size.extend(1.0),
Quat::IDENTITY,
(quad_size * (-extracted_sprite.anchor - Vec2::splat(0.5))).extend(0.0),
((quad_size + quad_translation)
* (-extracted_sprite.anchor - Vec2::splat(0.5)))
.extend(0.0),
);
// Store the vertex data and add the item to the render phase
@ -875,3 +897,89 @@ impl<P: PhaseItem> RenderCommand<P> for DrawSpriteBatch {
RenderCommandResult::Success
}
}
/// Scales a texture to fit within a given quad size with keeping the aspect ratio.
fn apply_scaling(
scaling_mode: ScalingMode,
texture_size: Vec2,
quad_size: &mut Vec2,
quad_translation: &mut Vec2,
uv_offset_scale: &mut Vec4,
) {
let quad_ratio = quad_size.x / quad_size.y;
let texture_ratio = texture_size.x / texture_size.y;
let tex_quad_scale = texture_ratio / quad_ratio;
let quad_tex_scale = quad_ratio / texture_ratio;
match scaling_mode {
ScalingMode::FillCenter => {
if quad_ratio > texture_ratio {
// offset texture to center by y coordinate
uv_offset_scale.y += (uv_offset_scale.w - uv_offset_scale.w * tex_quad_scale) * 0.5;
// sum up scales
uv_offset_scale.w *= tex_quad_scale;
} else {
// offset texture to center by x coordinate
uv_offset_scale.x += (uv_offset_scale.z - uv_offset_scale.z * quad_tex_scale) * 0.5;
uv_offset_scale.z *= quad_tex_scale;
};
}
ScalingMode::FillStart => {
if quad_ratio > texture_ratio {
uv_offset_scale.y += uv_offset_scale.w - uv_offset_scale.w * tex_quad_scale;
uv_offset_scale.w *= tex_quad_scale;
} else {
uv_offset_scale.z *= quad_tex_scale;
}
}
ScalingMode::FillEnd => {
if quad_ratio > texture_ratio {
uv_offset_scale.w *= tex_quad_scale;
} else {
uv_offset_scale.x += uv_offset_scale.z - uv_offset_scale.z * quad_tex_scale;
uv_offset_scale.z *= quad_tex_scale;
}
}
ScalingMode::FitCenter => {
if texture_ratio > quad_ratio {
// Scale based on width
quad_size.y *= quad_tex_scale;
} else {
// Scale based on height
quad_size.x *= tex_quad_scale;
}
}
ScalingMode::FitStart => {
if texture_ratio > quad_ratio {
// The quad is scaled to match the image ratio, and the quad translation is adjusted
// to start of the quad within the original quad size.
let scale = Vec2::new(1.0, quad_tex_scale);
let new_quad = *quad_size * scale;
let offset = *quad_size - new_quad;
*quad_translation = Vec2::new(0.0, -offset.y);
*quad_size = new_quad;
} else {
let scale = Vec2::new(tex_quad_scale, 1.0);
let new_quad = *quad_size * scale;
let offset = *quad_size - new_quad;
*quad_translation = Vec2::new(offset.x, 0.0);
*quad_size = new_quad;
}
}
ScalingMode::FitEnd => {
if texture_ratio > quad_ratio {
let scale = Vec2::new(1.0, quad_tex_scale);
let new_quad = *quad_size * scale;
let offset = *quad_size - new_quad;
*quad_translation = Vec2::new(0.0, offset.y);
*quad_size = new_quad;
} else {
let scale = Vec2::new(tex_quad_scale, 1.0);
let new_quad = *quad_size * scale;
let offset = *quad_size - new_quad;
*quad_translation = Vec2::new(-offset.x, 0.0);
*quad_size = new_quad;
}
}
}
}

View File

@ -162,6 +162,9 @@ pub enum SpriteImageMode {
/// The sprite will take on the size of the image by default, and will be stretched or shrunk if [`Sprite::custom_size`] is set.
#[default]
Auto,
/// The texture will be scaled to fit the rect bounds defined in [`Sprite::custom_size`].
/// Otherwise no scaling will be applied.
Scale(ScalingMode),
/// The texture will be cut in 9 slices, keeping the texture in proportions on resize
Sliced(TextureSlicer),
/// The texture will be repeated if stretched beyond `stretched_value`
@ -185,6 +188,59 @@ impl SpriteImageMode {
SpriteImageMode::Sliced(..) | SpriteImageMode::Tiled { .. }
)
}
/// Returns [`ScalingMode`] if scale is presented or [`Option::None`] otherwise.
#[inline]
#[must_use]
pub const fn scale(&self) -> Option<ScalingMode> {
if let SpriteImageMode::Scale(scale) = self {
Some(*scale)
} else {
None
}
}
}
/// Represents various modes for proportional scaling of a texture.
///
/// Can be used in [`SpriteImageMode::Scale`].
#[derive(Debug, Clone, Copy, PartialEq, Default, Reflect)]
#[reflect(Debug)]
pub enum ScalingMode {
/// Scale the texture uniformly (maintain the texture's aspect ratio)
/// so that both dimensions (width and height) of the texture will be equal
/// to or larger than the corresponding dimension of the target rectangle.
/// Fill sprite with a centered texture.
#[default]
FillCenter,
/// Scales the texture to fill the target rectangle while maintaining its aspect ratio.
/// One dimension of the texture will match the rectangle's size,
/// while the other dimension may exceed it.
/// The exceeding portion is aligned to the start:
/// * Horizontal overflow is left-aligned if the width exceeds the rectangle.
/// * Vertical overflow is top-aligned if the height exceeds the rectangle.
FillStart,
/// Scales the texture to fill the target rectangle while maintaining its aspect ratio.
/// One dimension of the texture will match the rectangle's size,
/// while the other dimension may exceed it.
/// The exceeding portion is aligned to the end:
/// * Horizontal overflow is right-aligned if the width exceeds the rectangle.
/// * Vertical overflow is bottom-aligned if the height exceeds the rectangle.
FillEnd,
/// Scaling the texture will maintain the original aspect ratio
/// and ensure that the original texture fits entirely inside the rect.
/// At least one axis (x or y) will fit exactly. The result is centered inside the rect.
FitCenter,
/// Scaling the texture will maintain the original aspect ratio
/// and ensure that the original texture fits entirely inside rect.
/// At least one axis (x or y) will fit exactly.
/// Aligns the result to the left and top edges of rect.
FitStart,
/// Scaling the texture will maintain the original aspect ratio
/// and ensure that the original texture fits entirely inside rect.
/// At least one axis (x or y) will fit exactly.
/// Aligns the result to the right and bottom edges of rect.
FitEnd,
}
/// How a sprite is positioned relative to its [`Transform`].

View File

@ -53,6 +53,7 @@ impl ComputedTextureSlices {
flip_y,
image_handle_id: sprite.image.id(),
anchor: Self::redepend_anchor_from_sprite_to_slice(sprite, slice),
scaling_mode: sprite.image_mode.scale(),
}
})
}
@ -123,6 +124,9 @@ fn compute_sprite_slices(
SpriteImageMode::Auto => {
unreachable!("Slices should not be computed for SpriteImageMode::Stretch")
}
SpriteImageMode::Scale(_) => {
unreachable!("Slices should not be computed for SpriteImageMode::Scale")
}
};
Some(ComputedTextureSlices(slices))
}

View File

@ -213,6 +213,7 @@ pub fn extract_text2d_sprite(
flip_y: false,
anchor: Anchor::Center.as_vec(),
original_entity: Some(original_entity),
scaling_mode: None,
},
);
}

View File

@ -814,7 +814,10 @@ fn compute_texture_slices(
[[0., 0., 1., 1.], [0., 0., 1., 1.], [1., 1., rx, ry]]
}
SpriteImageMode::Auto => {
unreachable!("Slices should not be computed for ImageScaleMode::Stretch")
unreachable!("Slices can not be computed for SpriteImageMode::Stretch")
}
SpriteImageMode::Scale(_) => {
unreachable!("Slices can not be computed for SpriteImageMode::Scale")
}
}
}

332
examples/2d/sprite_scale.rs Normal file
View File

@ -0,0 +1,332 @@
//! Shows how to use sprite scaling to fill and fit textures into the sprite.
use bevy::prelude::*;
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_systems(
Startup,
(setup_sprites, setup_texture_atlas).after(setup_camera),
)
.add_systems(Update, animate_sprite)
.run();
}
fn setup_camera(mut commands: Commands) {
commands.spawn(Camera2d);
}
fn setup_sprites(mut commands: Commands, asset_server: Res<AssetServer>) {
let square = asset_server.load("textures/slice_square_2.png");
let banner = asset_server.load("branding/banner.png");
let rects = [
Rect {
size: Vec2::new(100., 225.),
text: "Stretched".to_string(),
transform: Transform::from_translation(Vec3::new(-570., 230., 0.)),
texture: square.clone(),
image_mode: SpriteImageMode::Auto,
},
Rect {
size: Vec2::new(100., 225.),
text: "Fill Center".to_string(),
transform: Transform::from_translation(Vec3::new(-450., 230., 0.)),
texture: square.clone(),
image_mode: SpriteImageMode::Scale(ScalingMode::FillCenter),
},
Rect {
size: Vec2::new(100., 225.),
text: "Fill Start".to_string(),
transform: Transform::from_translation(Vec3::new(-330., 230., 0.)),
texture: square.clone(),
image_mode: SpriteImageMode::Scale(ScalingMode::FillStart),
},
Rect {
size: Vec2::new(100., 225.),
text: "Fill End".to_string(),
transform: Transform::from_translation(Vec3::new(-210., 230., 0.)),
texture: square.clone(),
image_mode: SpriteImageMode::Scale(ScalingMode::FillEnd),
},
Rect {
size: Vec2::new(300., 100.),
text: "Fill Start Horizontal".to_string(),
transform: Transform::from_translation(Vec3::new(10., 290., 0.)),
texture: square.clone(),
image_mode: SpriteImageMode::Scale(ScalingMode::FillStart),
},
Rect {
size: Vec2::new(300., 100.),
text: "Fill End Horizontal".to_string(),
transform: Transform::from_translation(Vec3::new(10., 155., 0.)),
texture: square.clone(),
image_mode: SpriteImageMode::Scale(ScalingMode::FillEnd),
},
Rect {
size: Vec2::new(200., 200.),
text: "Fill Center".to_string(),
transform: Transform::from_translation(Vec3::new(280., 230., 0.)),
texture: banner.clone(),
image_mode: SpriteImageMode::Scale(ScalingMode::FillCenter),
},
Rect {
size: Vec2::new(200., 100.),
text: "Fill Center".to_string(),
transform: Transform::from_translation(Vec3::new(500., 230., 0.)),
texture: square.clone(),
image_mode: SpriteImageMode::Scale(ScalingMode::FillCenter),
},
Rect {
size: Vec2::new(100., 100.),
text: "Stretched".to_string(),
transform: Transform::from_translation(Vec3::new(-570., -40., 0.)),
texture: banner.clone(),
image_mode: SpriteImageMode::Auto,
},
Rect {
size: Vec2::new(200., 200.),
text: "Fit Center".to_string(),
transform: Transform::from_translation(Vec3::new(-400., -40., 0.)),
texture: banner.clone(),
image_mode: SpriteImageMode::Scale(ScalingMode::FitCenter),
},
Rect {
size: Vec2::new(200., 200.),
text: "Fit Start".to_string(),
transform: Transform::from_translation(Vec3::new(-180., -40., 0.)),
texture: banner.clone(),
image_mode: SpriteImageMode::Scale(ScalingMode::FitStart),
},
Rect {
size: Vec2::new(200., 200.),
text: "Fit End".to_string(),
transform: Transform::from_translation(Vec3::new(40., -40., 0.)),
texture: banner.clone(),
image_mode: SpriteImageMode::Scale(ScalingMode::FitEnd),
},
Rect {
size: Vec2::new(100., 200.),
text: "Fit Center".to_string(),
transform: Transform::from_translation(Vec3::new(210., -40., 0.)),
texture: banner.clone(),
image_mode: SpriteImageMode::Scale(ScalingMode::FitCenter),
},
];
for rect in rects {
let mut cmd = commands.spawn((
Sprite {
image: rect.texture,
custom_size: Some(rect.size),
image_mode: rect.image_mode,
..default()
},
rect.transform,
));
cmd.with_children(|builder| {
builder.spawn((
Text2d::new(rect.text),
TextLayout::new_with_justify(JustifyText::Center),
TextFont::from_font_size(15.),
Transform::from_xyz(0., -0.5 * rect.size.y - 10., 0.),
bevy::sprite::Anchor::TopCenter,
));
});
}
}
fn setup_texture_atlas(
mut commands: Commands,
asset_server: Res<AssetServer>,
mut texture_atlas_layouts: ResMut<Assets<TextureAtlasLayout>>,
) {
commands.spawn(Camera2d);
let gabe = asset_server.load("textures/rpg/chars/gabe/gabe-idle-run.png");
let animation_indices_gabe = AnimationIndices { first: 0, last: 6 };
let gabe_atlas = TextureAtlas {
layout: texture_atlas_layouts.add(TextureAtlasLayout::from_grid(
UVec2::splat(24),
7,
1,
None,
None,
)),
index: animation_indices_gabe.first,
};
let sprite_sheets = [
SpriteSheet {
size: Vec2::new(120., 50.),
text: "Stretched".to_string(),
transform: Transform::from_translation(Vec3::new(-570., -200., 0.)),
texture: gabe.clone(),
image_mode: SpriteImageMode::Auto,
atlas: gabe_atlas.clone(),
indices: animation_indices_gabe.clone(),
timer: AnimationTimer(Timer::from_seconds(0.1, TimerMode::Repeating)),
},
SpriteSheet {
size: Vec2::new(120., 50.),
text: "Fill Center".to_string(),
transform: Transform::from_translation(Vec3::new(-570., -300., 0.)),
texture: gabe.clone(),
image_mode: SpriteImageMode::Scale(ScalingMode::FillCenter),
atlas: gabe_atlas.clone(),
indices: animation_indices_gabe.clone(),
timer: AnimationTimer(Timer::from_seconds(0.1, TimerMode::Repeating)),
},
SpriteSheet {
size: Vec2::new(120., 50.),
text: "Fill Start".to_string(),
transform: Transform::from_translation(Vec3::new(-430., -200., 0.)),
texture: gabe.clone(),
image_mode: SpriteImageMode::Scale(ScalingMode::FillStart),
atlas: gabe_atlas.clone(),
indices: animation_indices_gabe.clone(),
timer: AnimationTimer(Timer::from_seconds(0.1, TimerMode::Repeating)),
},
SpriteSheet {
size: Vec2::new(120., 50.),
text: "Fill End".to_string(),
transform: Transform::from_translation(Vec3::new(-430., -300., 0.)),
texture: gabe.clone(),
image_mode: SpriteImageMode::Scale(ScalingMode::FillEnd),
atlas: gabe_atlas.clone(),
indices: animation_indices_gabe.clone(),
timer: AnimationTimer(Timer::from_seconds(0.1, TimerMode::Repeating)),
},
SpriteSheet {
size: Vec2::new(50., 120.),
text: "Fill Center".to_string(),
transform: Transform::from_translation(Vec3::new(-300., -250., 0.)),
texture: gabe.clone(),
image_mode: SpriteImageMode::Scale(ScalingMode::FillCenter),
atlas: gabe_atlas.clone(),
indices: animation_indices_gabe.clone(),
timer: AnimationTimer(Timer::from_seconds(0.1, TimerMode::Repeating)),
},
SpriteSheet {
size: Vec2::new(50., 120.),
text: "Fill Start".to_string(),
transform: Transform::from_translation(Vec3::new(-190., -250., 0.)),
texture: gabe.clone(),
image_mode: SpriteImageMode::Scale(ScalingMode::FillStart),
atlas: gabe_atlas.clone(),
indices: animation_indices_gabe.clone(),
timer: AnimationTimer(Timer::from_seconds(0.1, TimerMode::Repeating)),
},
SpriteSheet {
size: Vec2::new(50., 120.),
text: "Fill End".to_string(),
transform: Transform::from_translation(Vec3::new(-90., -250., 0.)),
texture: gabe.clone(),
image_mode: SpriteImageMode::Scale(ScalingMode::FillEnd),
atlas: gabe_atlas.clone(),
indices: animation_indices_gabe.clone(),
timer: AnimationTimer(Timer::from_seconds(0.1, TimerMode::Repeating)),
},
SpriteSheet {
size: Vec2::new(120., 50.),
text: "Fit Center".to_string(),
transform: Transform::from_translation(Vec3::new(20., -200., 0.)),
texture: gabe.clone(),
image_mode: SpriteImageMode::Scale(ScalingMode::FitCenter),
atlas: gabe_atlas.clone(),
indices: animation_indices_gabe.clone(),
timer: AnimationTimer(Timer::from_seconds(0.1, TimerMode::Repeating)),
},
SpriteSheet {
size: Vec2::new(120., 50.),
text: "Fit Start".to_string(),
transform: Transform::from_translation(Vec3::new(20., -300., 0.)),
texture: gabe.clone(),
image_mode: SpriteImageMode::Scale(ScalingMode::FitStart),
atlas: gabe_atlas.clone(),
indices: animation_indices_gabe.clone(),
timer: AnimationTimer(Timer::from_seconds(0.1, TimerMode::Repeating)),
},
SpriteSheet {
size: Vec2::new(120., 50.),
text: "Fit End".to_string(),
transform: Transform::from_translation(Vec3::new(160., -200., 0.)),
texture: gabe.clone(),
image_mode: SpriteImageMode::Scale(ScalingMode::FitEnd),
atlas: gabe_atlas.clone(),
indices: animation_indices_gabe.clone(),
timer: AnimationTimer(Timer::from_seconds(0.1, TimerMode::Repeating)),
},
];
for sprite_sheet in sprite_sheets {
let mut cmd = commands.spawn((
Sprite {
image_mode: sprite_sheet.image_mode,
custom_size: Some(sprite_sheet.size),
..Sprite::from_atlas_image(sprite_sheet.texture.clone(), sprite_sheet.atlas.clone())
},
sprite_sheet.indices,
sprite_sheet.timer,
sprite_sheet.transform,
));
cmd.with_children(|builder| {
builder.spawn((
Text2d::new(sprite_sheet.text),
TextLayout::new_with_justify(JustifyText::Center),
TextFont::from_font_size(15.),
Transform::from_xyz(0., -0.5 * sprite_sheet.size.y - 10., 0.),
bevy::sprite::Anchor::TopCenter,
));
});
}
}
struct Rect {
size: Vec2,
text: String,
transform: Transform,
texture: Handle<Image>,
image_mode: SpriteImageMode,
}
struct SpriteSheet {
size: Vec2,
text: String,
transform: Transform,
texture: Handle<Image>,
image_mode: SpriteImageMode,
atlas: TextureAtlas,
indices: AnimationIndices,
timer: AnimationTimer,
}
#[derive(Component, Clone)]
struct AnimationIndices {
first: usize,
last: usize,
}
#[derive(Component, Deref, DerefMut)]
struct AnimationTimer(Timer);
fn animate_sprite(
time: Res<Time>,
mut query: Query<(&AnimationIndices, &mut AnimationTimer, &mut Sprite)>,
) {
for (indices, mut timer, mut sprite) in &mut query {
timer.tick(time.delta());
if timer.just_finished() {
if let Some(atlas) = &mut sprite.texture_atlas {
atlas.index = if atlas.index == indices.last {
indices.first
} else {
atlas.index + 1
};
}
}
}
}

View File

@ -120,6 +120,7 @@ Example | Description
[Sprite](../examples/2d/sprite.rs) | Renders a sprite
[Sprite Animation](../examples/2d/sprite_animation.rs) | Animates a sprite in response to an event
[Sprite Flipping](../examples/2d/sprite_flipping.rs) | Renders a sprite flipped along an axis
[Sprite Scale](../examples/2d/sprite_scale.rs) | Shows how a sprite can be scaled into a rectangle while keeping the aspect ratio
[Sprite Sheet](../examples/2d/sprite_sheet.rs) | Renders an animated sprite
[Sprite Slice](../examples/2d/sprite_slice.rs) | Showcases slicing sprites into sections that can be scaled independently via the 9-patch technique
[Sprite Tile](../examples/2d/sprite_tile.rs) | Renders a sprite tiled in a grid