Skip to content

Commit

Permalink
Proportional scaling for the sprite's texture. (#17258)
Browse files Browse the repository at this point in the history
# 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"
/>
  • Loading branch information
silvestrpredko authored Jan 24, 2025
1 parent 39a1e2b commit deb135c
Show file tree
Hide file tree
Showing 9 changed files with 531 additions and 15 deletions.
11 changes: 11 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion crates/bevy_sprite/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ pub mod prelude {
pub use crate::{
sprite::{Sprite, SpriteImageMode},
texture_slice::{BorderRect, SliceScaleMode, TextureSlice, TextureSlicer},
ColorMaterial, MeshMaterial2d,
ColorMaterial, MeshMaterial2d, ScalingMode,
};
}

Expand Down
134 changes: 121 additions & 13 deletions crates/bevy_sprite/src/render/mod.rs
Original file line number Diff line number Diff line change
@@ -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::{
Expand Down Expand Up @@ -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)]
Expand Down Expand Up @@ -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(),
},
);
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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;
}
}
}
}
56 changes: 56 additions & 0 deletions crates/bevy_sprite/src/sprite.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -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`].
Expand Down
4 changes: 4 additions & 0 deletions crates/bevy_sprite/src/texture_slice/computed_slices.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
}
})
}
Expand Down Expand Up @@ -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))
}
Expand Down
1 change: 1 addition & 0 deletions crates/bevy_text/src/text2d.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
);
}
Expand Down
5 changes: 4 additions & 1 deletion crates/bevy_ui/src/render/ui_texture_slice_pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
}
Expand Down
Loading

0 comments on commit deb135c

Please sign in to comment.