diff --git a/Cargo.toml b/Cargo.toml index d5b1fa1cb7..c10d0c7acf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -455,6 +455,26 @@ description = "Renders an animated sprite" category = "2D Rendering" wasm = true +[[example]] +name = "sprite_tile" +path = "examples/2d/sprite_tile.rs" + +[package.metadata.example.sprite_tile] +name = "Sprite Tile" +description = "Renders a sprite tiled in a grid" +category = "2D Rendering" +wasm = true + +[[example]] +name = "sprite_slice" +path = "examples/2d/sprite_slice.rs" + +[package.metadata.example.sprite_slice] +name = "Sprite Slice" +description = "Showcases slicing sprites into sections that can be scaled independently via the 9-patch technique" +category = "2D Rendering" +wasm = true + [[example]] name = "text2d" path = "examples/2d/text2d.rs" diff --git a/assets/textures/slice_square.png b/assets/textures/slice_square.png new file mode 100644 index 0000000000..bee873c46b Binary files /dev/null and b/assets/textures/slice_square.png differ diff --git a/assets/textures/slice_square_2.png b/assets/textures/slice_square_2.png new file mode 100644 index 0000000000..b38d6ee666 Binary files /dev/null and b/assets/textures/slice_square_2.png differ diff --git a/crates/bevy_sprite/src/bundle.rs b/crates/bevy_sprite/src/bundle.rs index b2f6394811..16aa7f2b58 100644 --- a/crates/bevy_sprite/src/bundle.rs +++ b/crates/bevy_sprite/src/bundle.rs @@ -1,6 +1,6 @@ use crate::{ texture_atlas::{TextureAtlas, TextureAtlasSprite}, - Sprite, + ImageScaleMode, Sprite, }; use bevy_asset::Handle; use bevy_ecs::bundle::Bundle; @@ -15,6 +15,8 @@ use bevy_transform::components::{GlobalTransform, Transform}; pub struct SpriteBundle { /// Specifies the rendering properties of the sprite, such as color tint and flip. pub sprite: Sprite, + /// Controls how the image is altered when scaled. + pub scale_mode: ImageScaleMode, /// The local transform of the sprite, relative to its parent. pub transform: Transform, /// The absolute transform of the sprite. This should generally not be written to directly. @@ -35,6 +37,8 @@ pub struct SpriteBundle { pub struct SpriteSheetBundle { /// The specific sprite from the texture atlas to be drawn, defaulting to the sprite at index 0. pub sprite: TextureAtlasSprite, + /// Controls how the image is altered when scaled. + pub scale_mode: ImageScaleMode, /// A handle to the texture atlas that holds the sprite images pub texture_atlas: Handle, /// Data pertaining to how the sprite is drawn on the screen diff --git a/crates/bevy_sprite/src/lib.rs b/crates/bevy_sprite/src/lib.rs index e64fb9808d..babef34c3f 100644 --- a/crates/bevy_sprite/src/lib.rs +++ b/crates/bevy_sprite/src/lib.rs @@ -6,6 +6,7 @@ mod render; mod sprite; mod texture_atlas; mod texture_atlas_builder; +mod texture_slice; pub mod collide_aabb; @@ -13,8 +14,9 @@ pub mod prelude { #[doc(hidden)] pub use crate::{ bundle::{SpriteBundle, SpriteSheetBundle}, - sprite::Sprite, + sprite::{ImageScaleMode, Sprite}, texture_atlas::{TextureAtlas, TextureAtlasSprite}, + texture_slice::{BorderRect, SliceScaleMode, TextureSlicer}, ColorMaterial, ColorMesh2dBundle, TextureAtlasBuilder, }; } @@ -26,6 +28,7 @@ pub use render::*; pub use sprite::*; pub use texture_atlas::*; pub use texture_atlas_builder::*; +pub use texture_slice::*; use bevy_app::prelude::*; use bevy_asset::{load_internal_asset, AssetApp, Assets, Handle}; @@ -51,6 +54,7 @@ pub const SPRITE_SHADER_HANDLE: Handle = Handle::weak_from_u128(27633439 #[derive(Debug, Hash, PartialEq, Eq, Clone, SystemSet)] pub enum SpriteSystem { ExtractSprites, + ComputeSlices, } impl Plugin for SpritePlugin { @@ -64,13 +68,22 @@ impl Plugin for SpritePlugin { app.init_asset::() .register_asset_reflect::() .register_type::() + .register_type::() + .register_type::() .register_type::() .register_type::() .register_type::() .add_plugins((Mesh2dRenderPlugin, ColorMaterialPlugin)) .add_systems( PostUpdate, - calculate_bounds_2d.in_set(VisibilitySystems::CalculateBounds), + ( + calculate_bounds_2d.in_set(VisibilitySystems::CalculateBounds), + ( + compute_slices_on_asset_event, + compute_slices_on_sprite_change, + ) + .in_set(SpriteSystem::ComputeSlices), + ), ); if let Ok(render_app) = app.get_sub_app_mut(RenderApp) { diff --git a/crates/bevy_sprite/src/render/mod.rs b/crates/bevy_sprite/src/render/mod.rs index 113cef88e9..a5d89ce0c8 100644 --- a/crates/bevy_sprite/src/render/mod.rs +++ b/crates/bevy_sprite/src/render/mod.rs @@ -2,7 +2,7 @@ use std::ops::Range; use crate::{ texture_atlas::{TextureAtlas, TextureAtlasSprite}, - Sprite, SPRITE_SHADER_HANDLE, + ComputedTextureSlices, Sprite, SPRITE_SHADER_HANDLE, }; use bevy_asset::{AssetEvent, AssetId, Assets, Handle}; use bevy_core_pipeline::{ @@ -333,6 +333,7 @@ pub fn extract_sprite_events( } pub fn extract_sprites( + mut commands: Commands, mut extracted_sprites: ResMut, texture_atlases: Extract>>, sprite_query: Extract< @@ -342,6 +343,7 @@ pub fn extract_sprites( &Sprite, &GlobalTransform, &Handle, + Option<&ComputedTextureSlices>, )>, >, atlas_query: Extract< @@ -356,26 +358,34 @@ pub fn extract_sprites( ) { extracted_sprites.sprites.clear(); - for (entity, view_visibility, sprite, transform, handle) in sprite_query.iter() { + for (entity, view_visibility, sprite, transform, handle, slices) in sprite_query.iter() { if !view_visibility.get() { continue; } - // PERF: we don't check in this function that the `Image` asset is ready, since it should be in most cases and hashing the handle is expensive - extracted_sprites.sprites.insert( - entity, - ExtractedSprite { - color: sprite.color, - transform: *transform, - rect: sprite.rect, - // Pass the custom size - custom_size: sprite.custom_size, - flip_x: sprite.flip_x, - flip_y: sprite.flip_y, - image_handle_id: handle.id(), - anchor: sprite.anchor.as_vec(), - original_entity: None, - }, - ); + if let Some(slices) = slices { + extracted_sprites.sprites.extend( + slices + .extract_sprites(transform, entity, sprite, handle) + .map(|e| (commands.spawn_empty().id(), e)), + ); + } else { + // PERF: we don't check in this function that the `Image` asset is ready, since it should be in most cases and hashing the handle is expensive + extracted_sprites.sprites.insert( + entity, + ExtractedSprite { + color: sprite.color, + transform: *transform, + rect: sprite.rect, + // Pass the custom size + custom_size: sprite.custom_size, + flip_x: sprite.flip_x, + flip_y: sprite.flip_y, + image_handle_id: handle.id(), + anchor: sprite.anchor.as_vec(), + original_entity: None, + }, + ); + } } for (entity, view_visibility, atlas_sprite, transform, texture_atlas_handle) in atlas_query.iter() diff --git a/crates/bevy_sprite/src/sprite.rs b/crates/bevy_sprite/src/sprite.rs index 039f5d722a..5c60d759a5 100644 --- a/crates/bevy_sprite/src/sprite.rs +++ b/crates/bevy_sprite/src/sprite.rs @@ -3,6 +3,8 @@ use bevy_math::{Rect, Vec2}; use bevy_reflect::{std_traits::ReflectDefault, Reflect}; use bevy_render::color::Color; +use crate::TextureSlicer; + /// Specifies the rendering properties of a sprite. /// /// This is commonly used as a component within [`SpriteBundle`](crate::bundle::SpriteBundle). @@ -26,6 +28,27 @@ pub struct Sprite { pub anchor: Anchor, } +/// Controls how the image is altered when scaled. +#[derive(Component, Debug, Default, Clone, Reflect)] +#[reflect(Component, Default)] +pub enum ImageScaleMode { + /// The entire texture stretches when its dimensions change. This is the default option. + #[default] + Stretched, + /// 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` + Tiled { + /// Should the image repeat horizontally + tile_x: bool, + /// Should the image repeat vertically + tile_y: bool, + /// The texture will repeat when the ratio between the *drawing dimensions* of texture and the + /// *original texture size* are above this value. + stretch_value: f32, + }, +} + /// How a sprite is positioned relative to its [`Transform`](bevy_transform::components::Transform). /// It defaults to `Anchor::Center`. #[derive(Component, Debug, Clone, Copy, PartialEq, Default, Reflect)] diff --git a/crates/bevy_sprite/src/texture_slice/border_rect.rs b/crates/bevy_sprite/src/texture_slice/border_rect.rs new file mode 100644 index 0000000000..e32f2891c1 --- /dev/null +++ b/crates/bevy_sprite/src/texture_slice/border_rect.rs @@ -0,0 +1,59 @@ +use bevy_reflect::Reflect; + +/// Struct defining a [`Sprite`](crate::Sprite) border with padding values +#[derive(Default, Copy, Clone, PartialEq, Debug, Reflect)] +pub struct BorderRect { + /// Pixel padding to the left + pub left: f32, + /// Pixel padding to the right + pub right: f32, + /// Pixel padding to the top + pub top: f32, + /// Pixel padding to the bottom + pub bottom: f32, +} + +impl BorderRect { + /// Creates a new border as a square, with identical pixel padding values on every direction + #[must_use] + #[inline] + pub const fn square(value: f32) -> Self { + Self { + left: value, + right: value, + top: value, + bottom: value, + } + } + + /// Creates a new border as a rectangle, with: + /// - `horizontal` for left and right pixel padding + /// - `vertical` for top and bottom pixel padding + #[must_use] + #[inline] + pub const fn rectangle(horizontal: f32, vertical: f32) -> Self { + Self { + left: horizontal, + right: horizontal, + top: vertical, + bottom: vertical, + } + } +} + +impl From for BorderRect { + fn from(v: f32) -> Self { + Self::square(v) + } +} + +impl From<[f32; 4]> for BorderRect { + fn from([left, right, top, bottom]: [f32; 4]) -> Self { + Self { + left, + right, + top, + bottom, + } + } +} diff --git a/crates/bevy_sprite/src/texture_slice/computed_slices.rs b/crates/bevy_sprite/src/texture_slice/computed_slices.rs new file mode 100644 index 0000000000..dd316f7d3c --- /dev/null +++ b/crates/bevy_sprite/src/texture_slice/computed_slices.rs @@ -0,0 +1,150 @@ +use crate::{ExtractedSprite, ImageScaleMode, Sprite}; + +use super::TextureSlice; +use bevy_asset::{AssetEvent, Assets, Handle}; +use bevy_ecs::prelude::*; +use bevy_math::{Rect, Vec2}; +use bevy_render::texture::Image; +use bevy_transform::prelude::*; +use bevy_utils::HashSet; + +/// Component storing texture slices for sprite entities with a tiled or sliced [`ImageScaleMode`] +/// +/// This component is automatically inserted and updated +#[derive(Debug, Clone, Component)] +pub struct ComputedTextureSlices(Vec); + +impl ComputedTextureSlices { + /// Computes [`ExtractedSprite`] iterator from the sprite slices + /// + /// # Arguments + /// + /// * `transform` - the sprite entity global transform + /// * `original_entity` - the sprite entity + /// * `sprite` - The sprite component + /// * `handle` - The sprite texture handle + #[must_use] + pub(crate) fn extract_sprites<'a>( + &'a self, + transform: &'a GlobalTransform, + original_entity: Entity, + sprite: &'a Sprite, + handle: &'a Handle, + ) -> impl ExactSizeIterator + 'a { + self.0.iter().map(move |slice| { + let transform = + transform.mul_transform(Transform::from_translation(slice.offset.extend(0.0))); + ExtractedSprite { + original_entity: Some(original_entity), + color: sprite.color, + transform, + rect: Some(slice.texture_rect), + custom_size: Some(slice.draw_size), + flip_x: sprite.flip_x, + flip_y: sprite.flip_y, + image_handle_id: handle.id(), + anchor: sprite.anchor.as_vec(), + } + }) + } +} + +/// Generates sprite slices for a `sprite` given a `scale_mode`. The slices +/// will be computed according to the `image_handle` dimensions or the sprite rect. +/// +/// Returns `None` if either: +/// - The scale mode is [`ImageScaleMode::Stretched`] +/// - The image asset is not loaded +#[must_use] +fn compute_sprite_slices( + sprite: &Sprite, + scale_mode: &ImageScaleMode, + image_handle: &Handle, + images: &Assets, +) -> Option { + if let ImageScaleMode::Stretched = scale_mode { + return None; + } + let image_size = images.get(image_handle).map(|i| { + Vec2::new( + i.texture_descriptor.size.width as f32, + i.texture_descriptor.size.height as f32, + ) + })?; + let slices = match scale_mode { + ImageScaleMode::Stretched => unreachable!(), + ImageScaleMode::Sliced(slicer) => slicer.compute_slices( + sprite.rect.unwrap_or(Rect { + min: Vec2::ZERO, + max: image_size, + }), + sprite.custom_size, + ), + ImageScaleMode::Tiled { + tile_x, + tile_y, + stretch_value, + } => { + let slice = TextureSlice { + texture_rect: sprite.rect.unwrap_or(Rect { + min: Vec2::ZERO, + max: image_size, + }), + draw_size: sprite.custom_size.unwrap_or(image_size), + offset: Vec2::ZERO, + }; + slice.tiled(*stretch_value, (*tile_x, *tile_y)) + } + }; + Some(ComputedTextureSlices(slices)) +} + +/// System reacting to added or modified [`Image`] handles, and recompute sprite slices +/// on matching sprite entities +pub(crate) fn compute_slices_on_asset_event( + mut commands: Commands, + mut events: EventReader>, + images: Res>, + sprites: Query<(Entity, &ImageScaleMode, &Sprite, &Handle)>, +) { + // We store the asset ids of added/modified image assets + let added_handles: HashSet<_> = events + .read() + .filter_map(|e| match e { + AssetEvent::Added { id } | AssetEvent::Modified { id } => Some(*id), + _ => None, + }) + .collect(); + if added_handles.is_empty() { + return; + } + // We recompute the sprite slices for sprite entities with a matching asset handle id + for (entity, scale_mode, sprite, image_handle) in &sprites { + if !added_handles.contains(&image_handle.id()) { + continue; + } + if let Some(slices) = compute_sprite_slices(sprite, scale_mode, image_handle, &images) { + commands.entity(entity).insert(slices); + } + } +} + +/// System reacting to changes on relevant sprite bundle components to compute the sprite slices +pub(crate) fn compute_slices_on_sprite_change( + mut commands: Commands, + images: Res>, + changed_sprites: Query< + (Entity, &ImageScaleMode, &Sprite, &Handle), + Or<( + Changed, + Changed>, + Changed, + )>, + >, +) { + for (entity, scale_mode, sprite, image_handle) in &changed_sprites { + if let Some(slices) = compute_sprite_slices(sprite, scale_mode, image_handle, &images) { + commands.entity(entity).insert(slices); + } + } +} diff --git a/crates/bevy_sprite/src/texture_slice/mod.rs b/crates/bevy_sprite/src/texture_slice/mod.rs new file mode 100644 index 0000000000..d16e6654ec --- /dev/null +++ b/crates/bevy_sprite/src/texture_slice/mod.rs @@ -0,0 +1,86 @@ +mod border_rect; +mod computed_slices; +mod slicer; + +use bevy_math::{Rect, Vec2}; +pub use border_rect::BorderRect; +pub use slicer::{SliceScaleMode, TextureSlicer}; + +pub(crate) use computed_slices::{ + compute_slices_on_asset_event, compute_slices_on_sprite_change, ComputedTextureSlices, +}; + +#[derive(Debug, Clone)] +pub(crate) struct TextureSlice { + /// texture area to draw + pub texture_rect: Rect, + /// slice draw size + pub draw_size: Vec2, + /// offset of the slice + pub offset: Vec2, +} + +impl TextureSlice { + /// Transforms the given slice in an collection of tiled subdivisions. + /// + /// # Arguments + /// + /// * `stretch_value` - The slice will repeat when the ratio between the *drawing dimensions* of texture and the + /// *original texture size* (rect) are above `stretch_value`. + /// - `tile_x` - should the slice be tiled horizontally + /// - `tile_y` - should the slice be tiled vertically + #[must_use] + pub fn tiled(self, stretch_value: f32, (tile_x, tile_y): (bool, bool)) -> Vec { + if !tile_x && !tile_y { + return vec![self]; + } + let stretch_value = stretch_value.max(0.001); + let rect_size = self.texture_rect.size(); + // Each tile expected size + let expected_size = Vec2::new( + if tile_x { + rect_size.x * stretch_value + } else { + self.draw_size.x + }, + if tile_y { + rect_size.y * stretch_value + } else { + self.draw_size.y + }, + ); + let mut slices = Vec::new(); + let base_offset = Vec2::new( + -self.draw_size.x / 2.0, + self.draw_size.y / 2.0, // Start from top + ); + let mut offset = base_offset; + + let mut remaining_columns = self.draw_size.y; + while remaining_columns > 0.0 { + let size_y = expected_size.y.min(remaining_columns); + offset.x = base_offset.x; + offset.y -= size_y / 2.0; + let mut remaining_rows = self.draw_size.x; + while remaining_rows > 0.0 { + let size_x = expected_size.x.min(remaining_rows); + offset.x += size_x / 2.0; + let draw_size = Vec2::new(size_x, size_y); + let delta = draw_size / expected_size; + slices.push(Self { + texture_rect: Rect { + min: self.texture_rect.min, + max: self.texture_rect.min + self.texture_rect.size() * delta, + }, + draw_size, + offset: self.offset + offset, + }); + offset.x += size_x / 2.0; + remaining_rows -= size_x; + } + offset.y -= size_y / 2.0; + remaining_columns -= size_y; + } + slices + } +} diff --git a/crates/bevy_sprite/src/texture_slice/slicer.rs b/crates/bevy_sprite/src/texture_slice/slicer.rs new file mode 100644 index 0000000000..b302d2e356 --- /dev/null +++ b/crates/bevy_sprite/src/texture_slice/slicer.rs @@ -0,0 +1,267 @@ +use super::{BorderRect, TextureSlice}; +use bevy_math::{vec2, Rect, Vec2}; +use bevy_reflect::Reflect; + +/// Slices a texture using the **9-slicing** technique. This allows to reuse an image at various sizes +/// without needing to prepare multiple assets. The associated texture will be split into nine portions, +/// so that on resize the different portions scale or tile in different ways to keep the texture in proportion. +/// +/// For example, when resizing a 9-sliced texture the corners will remain unscaled while the other +/// sections will be scaled or tiled. +/// +/// See [9-sliced](https://en.wikipedia.org/wiki/9-slice_scaling) textures. +#[derive(Debug, Clone, Reflect)] +pub struct TextureSlicer { + /// The sprite borders, defining the 9 sections of the image + pub border: BorderRect, + /// Defines how the center part of the 9 slices will scale + pub center_scale_mode: SliceScaleMode, + /// Defines how the 4 side parts of the 9 slices will scale + pub sides_scale_mode: SliceScaleMode, + /// Defines the maximum scale of the 4 corner slices (default to `1.0`) + pub max_corner_scale: f32, +} + +/// Defines how a texture slice scales when resized +#[derive(Debug, Copy, Clone, Default, Reflect)] +pub enum SliceScaleMode { + /// The slice will be stretched to fit the area + #[default] + Stretch, + /// The slice will be tiled to fit the area + Tile { + /// The slice will repeat when the ratio between the *drawing dimensions* of texture and the + /// *original texture size* are above `stretch_value`. + /// + /// Example: `1.0` means that a 10 pixel wide image would repeat after 10 screen pixels. + /// `2.0` means it would repeat after 20 screen pixels. + /// + /// Note: The value should be inferior or equal to `1.0` to avoid quality loss. + /// + /// Note: the value will be clamped to `0.001` if lower + stretch_value: f32, + }, +} + +impl TextureSlicer { + /// Computes the 4 corner slices + #[must_use] + fn corner_slices(&self, base_rect: Rect, render_size: Vec2) -> [TextureSlice; 4] { + let coef = render_size / base_rect.size(); + let BorderRect { + left, + right, + top, + bottom, + } = self.border; + let min_coef = coef.x.min(coef.y).min(self.max_corner_scale); + [ + // Top Left Corner + TextureSlice { + texture_rect: Rect { + min: base_rect.min, + max: base_rect.min + vec2(left, top), + }, + draw_size: vec2(left, top) * min_coef, + offset: vec2( + -render_size.x + left * min_coef, + render_size.y - top * min_coef, + ) / 2.0, + }, + // Top Right Corner + TextureSlice { + texture_rect: Rect { + min: vec2(base_rect.max.x - right, base_rect.min.y), + max: vec2(base_rect.max.x, top), + }, + draw_size: vec2(right, top) * min_coef, + offset: vec2( + render_size.x - right * min_coef, + render_size.y - top * min_coef, + ) / 2.0, + }, + // Bottom Left + TextureSlice { + texture_rect: Rect { + min: vec2(base_rect.min.x, base_rect.max.y - bottom), + max: vec2(base_rect.min.x + left, base_rect.max.y), + }, + draw_size: vec2(left, bottom) * min_coef, + offset: vec2( + -render_size.x + left * min_coef, + -render_size.y + bottom * min_coef, + ) / 2.0, + }, + // Bottom Right Corner + TextureSlice { + texture_rect: Rect { + min: vec2(base_rect.max.x - right, base_rect.max.y - bottom), + max: base_rect.max, + }, + draw_size: vec2(right, bottom) * min_coef, + offset: vec2( + render_size.x - right * min_coef, + -render_size.y + bottom * min_coef, + ) / 2.0, + }, + ] + } + + /// Computes the 2 horizontal side slices (left and right borders) + #[must_use] + fn horizontal_side_slices( + &self, + [tl_corner, tr_corner, bl_corner, br_corner]: &[TextureSlice; 4], + base_rect: Rect, + render_size: Vec2, + ) -> [TextureSlice; 2] { + [ + // left + TextureSlice { + texture_rect: Rect { + min: base_rect.min + vec2(0.0, self.border.top), + max: vec2( + base_rect.min.x + self.border.left, + base_rect.max.y - self.border.bottom, + ), + }, + draw_size: vec2( + bl_corner.draw_size.x, + render_size.y - bl_corner.draw_size.y - tl_corner.draw_size.y, + ), + offset: vec2(-render_size.x + bl_corner.draw_size.x, 0.0) / 2.0, + }, + // right + TextureSlice { + texture_rect: Rect { + min: vec2( + base_rect.max.x - self.border.right, + base_rect.min.y + self.border.bottom, + ), + max: vec2(base_rect.max.x, base_rect.max.y - self.border.top), + }, + draw_size: vec2( + br_corner.draw_size.x, + render_size.y - (br_corner.draw_size.y + tr_corner.draw_size.y), + ), + offset: vec2(render_size.x - br_corner.draw_size.x, 0.0) / 2.0, + }, + ] + } + + /// Computes the 2 vertical side slices (bottom and top borders) + #[must_use] + fn vertical_side_slices( + &self, + [tl_corner, tr_corner, bl_corner, br_corner]: &[TextureSlice; 4], + base_rect: Rect, + render_size: Vec2, + ) -> [TextureSlice; 2] { + [ + // Bottom + TextureSlice { + texture_rect: Rect { + min: vec2( + base_rect.min.x + self.border.left, + base_rect.max.y - self.border.bottom, + ), + max: vec2(base_rect.max.x - self.border.right, base_rect.max.y), + }, + draw_size: vec2( + render_size.x - (bl_corner.draw_size.x + br_corner.draw_size.x), + bl_corner.draw_size.y, + ), + offset: vec2(0.0, bl_corner.offset.y), + }, + // Top + TextureSlice { + texture_rect: Rect { + min: base_rect.min + vec2(self.border.left, 0.0), + max: vec2( + base_rect.max.x - self.border.right, + base_rect.min.y + self.border.top, + ), + }, + draw_size: vec2( + render_size.x - (tl_corner.draw_size.x + tr_corner.draw_size.x), + tl_corner.draw_size.y, + ), + offset: vec2(0.0, tl_corner.offset.y), + }, + ] + } + + /// Slices the given `rect` into at least 9 sections. If the center and/or side parts are set to tile, + /// a bigger number of sections will be computed. + /// + /// # Arguments + /// + /// * `rect` - The section of the texture to slice in 9 parts + /// * `render_size` - The optional draw size of the texture. If not set the `rect` size will be used. + #[must_use] + pub(crate) fn compute_slices( + &self, + rect: Rect, + render_size: Option, + ) -> Vec { + let render_size = render_size.unwrap_or_else(|| rect.size()); + let mut slices = Vec::with_capacity(9); + // Corners + let corners = self.corner_slices(rect, render_size); + // Sides + let vertical_sides = self.vertical_side_slices(&corners, rect, render_size); + let horizontal_sides = self.horizontal_side_slices(&corners, rect, render_size); + // Center + let center = TextureSlice { + texture_rect: Rect { + min: rect.min + vec2(self.border.left, self.border.bottom), + max: vec2(rect.max.x - self.border.right, rect.max.y - self.border.top), + }, + draw_size: vec2( + render_size.x - (corners[2].draw_size.x + corners[3].draw_size.x), + render_size.y - (corners[2].draw_size.y + corners[0].draw_size.y), + ), + offset: Vec2::ZERO, + }; + + slices.extend(corners); + match self.center_scale_mode { + SliceScaleMode::Stretch => { + slices.push(center); + } + SliceScaleMode::Tile { stretch_value } => { + slices.extend(center.tiled(stretch_value, (true, true))); + } + } + match self.sides_scale_mode { + SliceScaleMode::Stretch => { + slices.extend(horizontal_sides); + slices.extend(vertical_sides); + } + SliceScaleMode::Tile { stretch_value } => { + slices.extend( + horizontal_sides + .into_iter() + .flat_map(|s| s.tiled(stretch_value, (false, true))), + ); + slices.extend( + vertical_sides + .into_iter() + .flat_map(|s| s.tiled(stretch_value, (true, false))), + ); + } + } + slices + } +} + +impl Default for TextureSlicer { + fn default() -> Self { + Self { + border: Default::default(), + center_scale_mode: Default::default(), + sides_scale_mode: Default::default(), + max_corner_scale: 1.0, + } + } +} diff --git a/examples/2d/sprite_slice.rs b/examples/2d/sprite_slice.rs new file mode 100644 index 0000000000..cf6d44bed3 --- /dev/null +++ b/examples/2d/sprite_slice.rs @@ -0,0 +1,144 @@ +//! Showcases sprite 9 slice scaling +use bevy::prelude::*; + +fn main() { + App::new() + .add_plugins(DefaultPlugins.set(WindowPlugin { + primary_window: Some(Window { + resolution: (1350.0, 700.0).into(), + ..default() + }), + ..default() + })) + .add_systems(Startup, setup) + .run(); +} + +fn spawn_sprites( + commands: &mut Commands, + texture_handle: Handle, + mut position: Vec3, + slice_border: f32, + style: TextStyle, + gap: f32, +) { + let cases = [ + // Reference sprite + ( + "Original texture", + style.clone(), + Vec2::splat(100.0), + ImageScaleMode::default(), + ), + // Scaled regular sprite + ( + "Stretched texture", + style.clone(), + Vec2::new(100.0, 200.0), + ImageScaleMode::default(), + ), + // Stretched Scaled sliced sprite + ( + "Stretched and sliced", + style.clone(), + Vec2::new(100.0, 200.0), + ImageScaleMode::Sliced(TextureSlicer { + border: BorderRect::square(slice_border), + center_scale_mode: SliceScaleMode::Stretch, + ..default() + }), + ), + // Scaled sliced sprite + ( + "Sliced and Tiled", + style.clone(), + Vec2::new(100.0, 200.0), + ImageScaleMode::Sliced(TextureSlicer { + border: BorderRect::square(slice_border), + center_scale_mode: SliceScaleMode::Tile { stretch_value: 0.5 }, + sides_scale_mode: SliceScaleMode::Tile { stretch_value: 0.2 }, + ..default() + }), + ), + // Scaled sliced sprite horizontally + ( + "Sliced and Tiled", + style.clone(), + Vec2::new(300.0, 200.0), + ImageScaleMode::Sliced(TextureSlicer { + border: BorderRect::square(slice_border), + center_scale_mode: SliceScaleMode::Tile { stretch_value: 0.2 }, + sides_scale_mode: SliceScaleMode::Tile { stretch_value: 0.3 }, + ..default() + }), + ), + // Scaled sliced sprite horizontally with max scale + ( + "Sliced and Tiled with corner constraint", + style, + Vec2::new(300.0, 200.0), + ImageScaleMode::Sliced(TextureSlicer { + border: BorderRect::square(slice_border), + center_scale_mode: SliceScaleMode::Tile { stretch_value: 0.1 }, + sides_scale_mode: SliceScaleMode::Tile { stretch_value: 0.2 }, + max_corner_scale: 0.2, + }), + ), + ]; + + for (label, text_style, size, scale_mode) in cases { + position.x += 0.5 * size.x; + commands + .spawn(SpriteBundle { + transform: Transform::from_translation(position), + texture: texture_handle.clone(), + sprite: Sprite { + custom_size: Some(size), + ..default() + }, + scale_mode, + ..default() + }) + .with_children(|builder| { + builder.spawn(Text2dBundle { + text: Text::from_section(label, text_style).with_justify(JustifyText::Center), + transform: Transform::from_xyz(0., -0.5 * size.y - 10., 0.0), + text_anchor: bevy::sprite::Anchor::TopCenter, + ..default() + }); + }); + position.x += 0.5 * size.x + gap; + } +} + +fn setup(mut commands: Commands, asset_server: Res) { + commands.spawn(Camera2dBundle::default()); + let font = asset_server.load("fonts/FiraSans-Bold.ttf"); + let style = TextStyle { + font: font.clone(), + font_size: 16.0, + color: Color::WHITE, + }; + + // Load textures + let handle_1 = asset_server.load("textures/slice_square.png"); + let handle_2 = asset_server.load("textures/slice_square_2.png"); + + spawn_sprites( + &mut commands, + handle_1, + Vec3::new(-600.0, 200.0, 0.0), + 200.0, + style.clone(), + 50., + ); + + spawn_sprites( + &mut commands, + handle_2, + Vec3::new(-600.0, -200.0, 0.0), + 80.0, + style, + 50., + ); +} diff --git a/examples/2d/sprite_tile.rs b/examples/2d/sprite_tile.rs new file mode 100644 index 0000000000..016517505e --- /dev/null +++ b/examples/2d/sprite_tile.rs @@ -0,0 +1,48 @@ +//! Displays a single [`Sprite`] tiled in a grid, with a scaling animation + +use bevy::prelude::*; + +fn main() { + App::new() + .add_plugins(DefaultPlugins) + .add_systems(Startup, setup) + .add_systems(Update, animate) + .run(); +} + +#[derive(Resource)] +struct AnimationState { + min: f32, + max: f32, + current: f32, + speed: f32, +} + +fn setup(mut commands: Commands, asset_server: Res) { + commands.spawn(Camera2dBundle::default()); + commands.insert_resource(AnimationState { + min: 128.0, + max: 512.0, + current: 128.0, + speed: 50.0, + }); + commands.spawn(SpriteBundle { + texture: asset_server.load("branding/icon.png"), + scale_mode: ImageScaleMode::Tiled { + tile_x: true, + tile_y: true, + stretch_value: 0.5, // The image will tile every 128px + }, + ..default() + }); +} + +fn animate(mut sprites: Query<&mut Sprite>, mut state: ResMut, time: Res