Improved UiImage and Sprite scaling and slicing APIs (#16088)

# Objective

1. UI texture slicing chops and scales an image to fit the size of a
node and isn't meant to place any constraints on the size of the node
itself, but because the required components changes required `ImageSize`
and `ContentSize` for nodes with `UiImage`, texture sliced nodes are
laid out using an `ImageMeasure`.

2. In 0.14 users could spawn a `(UiImage, NodeBundle)` which would
display an image stretched to fill the UI node's bounds ignoring the
image's instrinsic size. Now that `UiImage` requires `ContentSize`,
there's no option to display an image without its size placing
constrains on the UI layout (unless you force the `Node` to a fixed
size, but that's not a solution).

3. It's desirable that the `Sprite` and `UiImage` share similar APIs.

Fixes #16109

## Solution

* Remove the `Component` impl from `ImageScaleMode`.
* Add a `Stretch` variant to `ImageScaleMode`.
* Add a field `scale_mode: ImageScaleMode` to `Sprite`.
* Add a field `mode: UiImageMode` to `UiImage`. 
* Add an enum `UiImageMode` similar to `ImageScaleMode` but with
additional UI specific variants.
* Remove the queries for `ImageScaleMode` from Sprite and UI extraction,
and refer to the new fields instead.
* Change `ui_layout_system` to update measure funcs on any change to
`ContentSize`s to enable manual clearing without removing the component.
* Don't add a measure unless `UiImageMode::Auto` is set in
`update_image_content_size_system`. Mutably deref the `Mut<ContentSize>`
if the `UiImage` is changed to force removal of any existing measure
func.

## Testing
Remove all the constraints from the ui_texture_slice example:

```rust
//! This example illustrates how to create buttons with their textures sliced
//! and kept in proportion instead of being stretched by the button dimensions

use bevy::{
    color::palettes::css::{GOLD, ORANGE},
    prelude::*,
    winit::WinitSettings,
};

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        // Only run the app when there is user input. This will significantly reduce CPU/GPU use.
        .insert_resource(WinitSettings::desktop_app())
        .add_systems(Startup, setup)
        .add_systems(Update, button_system)
        .run();
}

fn button_system(
    mut interaction_query: Query<
        (&Interaction, &Children, &mut UiImage),
        (Changed<Interaction>, With<Button>),
    >,
    mut text_query: Query<&mut Text>,
) {
    for (interaction, children, mut image) in &mut interaction_query {
        let mut text = text_query.get_mut(children[0]).unwrap();
        match *interaction {
            Interaction::Pressed => {
                **text = "Press".to_string();
                image.color = GOLD.into();
            }
            Interaction::Hovered => {
                **text = "Hover".to_string();
                image.color = ORANGE.into();
            }
            Interaction::None => {
                **text = "Button".to_string();
                image.color = Color::WHITE;
            }
        }
    }
}

fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
    let image = asset_server.load("textures/fantasy_ui_borders/panel-border-010.png");

    let slicer = TextureSlicer {
        border: BorderRect::square(22.0),
        center_scale_mode: SliceScaleMode::Stretch,
        sides_scale_mode: SliceScaleMode::Stretch,
        max_corner_scale: 1.0,
    };
    // ui camera
    commands.spawn(Camera2d);
    commands
        .spawn(Node {
            width: Val::Percent(100.0),
            height: Val::Percent(100.0),
            align_items: AlignItems::Center,
            justify_content: JustifyContent::Center,
            ..default()
        })
        .with_children(|parent| {
            for [w, h] in [[150.0, 150.0], [300.0, 150.0], [150.0, 300.0]] {
                parent
                    .spawn((
                        Button,
                        Node {
                            // width: Val::Px(w),
                            // height: Val::Px(h),
                            // horizontally center child text
                            justify_content: JustifyContent::Center,
                            // vertically center child text
                            align_items: AlignItems::Center,
                            margin: UiRect::all(Val::Px(20.0)),
                            ..default()
                        },
                        UiImage::new(image.clone()),
                        ImageScaleMode::Sliced(slicer.clone()),
                    ))
                    .with_children(|parent| {
                        // parent.spawn((
                        //     Text::new("Button"),
                        //     TextFont {
                        //         font: asset_server.load("fonts/FiraSans-Bold.ttf"),
                        //         font_size: 33.0,
                        //         ..default()
                        //     },
                        //     TextColor(Color::srgb(0.9, 0.9, 0.9)),
                        // ));
                    });
            }
        });
}
```

This should result in a blank window, since without any constraints the
texture slice image nodes should be zero-sized. But in main the image
nodes are given the size of the underlying unsliced source image
`textures/fantasy_ui_borders/panel-border-010.png`:

<img width="321" alt="slicing"
src="https://github.com/user-attachments/assets/cbd74c9c-14cd-4b4d-93c6-7c0152bb05ee">

For this PR need to change the lines:
```
                        UiImage::new(image.clone()),
                        ImageScaleMode::Sliced(slicer.clone()),
```
to
```
                        UiImage::new(image.clone()).with_mode(UiImageMode::Sliced(slicer.clone()),
```
and then nothing should be rendered, as desired.

---------

Co-authored-by: Carter Anderson <mcanders1@gmail.com>
This commit is contained in:
ickshonpe 2024-11-04 15:14:03 +00:00 committed by GitHub
parent 1e47604506
commit 4e02d3cdb9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 189 additions and 114 deletions

View file

@ -8,12 +8,6 @@ use bevy_render::{
use bevy_transform::components::{GlobalTransform, Transform}; use bevy_transform::components::{GlobalTransform, Transform};
/// A [`Bundle`] of components for drawing a single sprite from an image. /// A [`Bundle`] of components for drawing a single sprite from an image.
///
/// # Extra behaviors
///
/// You may add one or both of the following components to enable additional behaviors:
/// - [`ImageScaleMode`](crate::ImageScaleMode) to enable either slicing or tiling of the texture
/// - [`TextureAtlas`](crate::TextureAtlas) to draw a specific section of the texture
#[derive(Bundle, Clone, Debug, Default)] #[derive(Bundle, Clone, Debug, Default)]
#[deprecated( #[deprecated(
since = "0.15.0", since = "0.15.0",

View file

@ -30,7 +30,7 @@ pub mod prelude {
#[doc(hidden)] #[doc(hidden)]
pub use crate::{ pub use crate::{
bundle::SpriteBundle, bundle::SpriteBundle,
sprite::{ImageScaleMode, Sprite}, sprite::{Sprite, SpriteImageMode},
texture_atlas::{TextureAtlas, TextureAtlasLayout, TextureAtlasSources}, texture_atlas::{TextureAtlas, TextureAtlasLayout, TextureAtlasSources},
texture_slice::{BorderRect, SliceScaleMode, TextureSlice, TextureSlicer}, texture_slice::{BorderRect, SliceScaleMode, TextureSlice, TextureSlicer},
ColorMaterial, ColorMesh2dBundle, MeshMaterial2d, TextureAtlasBuilder, ColorMaterial, ColorMesh2dBundle, MeshMaterial2d, TextureAtlasBuilder,
@ -106,7 +106,7 @@ impl Plugin for SpritePlugin {
app.init_asset::<TextureAtlasLayout>() app.init_asset::<TextureAtlasLayout>()
.register_asset_reflect::<TextureAtlasLayout>() .register_asset_reflect::<TextureAtlasLayout>()
.register_type::<Sprite>() .register_type::<Sprite>()
.register_type::<ImageScaleMode>() .register_type::<SpriteImageMode>()
.register_type::<TextureSlicer>() .register_type::<TextureSlicer>()
.register_type::<Anchor>() .register_type::<Anchor>()
.register_type::<TextureAtlas>() .register_type::<TextureAtlas>()

View file

@ -34,6 +34,8 @@ pub struct Sprite {
pub rect: Option<Rect>, pub rect: Option<Rect>,
/// [`Anchor`] point of the sprite in the world /// [`Anchor`] point of the sprite in the world
pub anchor: Anchor, pub anchor: Anchor,
/// How the sprite's image will be scaled.
pub image_mode: SpriteImageMode,
} }
impl Sprite { impl Sprite {
@ -79,9 +81,12 @@ impl From<Handle<Image>> for Sprite {
} }
/// Controls how the image is altered when scaled. /// Controls how the image is altered when scaled.
#[derive(Component, Debug, Clone, Reflect)] #[derive(Default, Debug, Clone, Reflect, PartialEq)]
#[reflect(Component, Debug)] #[reflect(Debug)]
pub enum ImageScaleMode { 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 cut in 9 slices, keeping the texture in proportions on resize /// The texture will be cut in 9 slices, keeping the texture in proportions on resize
Sliced(TextureSlicer), Sliced(TextureSlicer),
/// The texture will be repeated if stretched beyond `stretched_value` /// The texture will be repeated if stretched beyond `stretched_value`
@ -96,6 +101,17 @@ pub enum ImageScaleMode {
}, },
} }
impl SpriteImageMode {
/// Returns true if this mode uses slices internally ([`SpriteImageMode::Sliced`] or [`SpriteImageMode::Tiled`])
#[inline]
pub fn uses_slices(&self) -> bool {
matches!(
self,
SpriteImageMode::Sliced(..) | SpriteImageMode::Tiled { .. }
)
}
}
/// How a sprite is positioned relative to its [`Transform`]. /// How a sprite is positioned relative to its [`Transform`].
/// It defaults to `Anchor::Center`. /// It defaults to `Anchor::Center`.
#[derive(Component, Debug, Clone, Copy, PartialEq, Default, Reflect)] #[derive(Component, Debug, Clone, Copy, PartialEq, Default, Reflect)]

View file

@ -1,4 +1,4 @@
use crate::{ExtractedSprite, ImageScaleMode, Sprite, TextureAtlasLayout}; use crate::{ExtractedSprite, Sprite, SpriteImageMode, TextureAtlasLayout};
use super::TextureSlice; use super::TextureSlice;
use bevy_asset::{AssetEvent, Assets}; use bevy_asset::{AssetEvent, Assets};
@ -8,7 +8,7 @@ use bevy_render::texture::Image;
use bevy_transform::prelude::*; use bevy_transform::prelude::*;
use bevy_utils::HashSet; use bevy_utils::HashSet;
/// Component storing texture slices for sprite entities with a [`ImageScaleMode`] /// Component storing texture slices for tiled or sliced sprite entities
/// ///
/// This component is automatically inserted and updated /// This component is automatically inserted and updated
#[derive(Debug, Clone, Component)] #[derive(Debug, Clone, Component)]
@ -69,24 +69,19 @@ impl ComputedTextureSlices {
} }
} }
/// Generates sprite slices for a `sprite` given a `scale_mode`. The slices /// Generates sprite slices for a [`Sprite`] with [`SpriteImageMode::Sliced`] or [`SpriteImageMode::Sliced`]. The slices
/// will be computed according to the `image_handle` dimensions or the sprite rect. /// will be computed according to the `image_handle` dimensions or the sprite rect.
/// ///
/// Returns `None` if the image asset is not loaded /// Returns `None` if the image asset is not loaded
/// ///
/// # Arguments /// # Arguments
/// ///
/// * `sprite` - The sprite component, will be used to find the draw area size /// * `sprite` - The sprite component with the image handle and image mode
/// * `scale_mode` - The image scaling component
/// * `image_handle` - The texture to slice or tile
/// * `images` - The image assets, use to retrieve the image dimensions /// * `images` - The image assets, use to retrieve the image dimensions
/// * `atlas` - Optional texture atlas, if set the slicing will happen on the matching sub section
/// of the texture
/// * `atlas_layouts` - The atlas layout assets, used to retrieve the texture atlas section rect /// * `atlas_layouts` - The atlas layout assets, used to retrieve the texture atlas section rect
#[must_use] #[must_use]
fn compute_sprite_slices( fn compute_sprite_slices(
sprite: &Sprite, sprite: &Sprite,
scale_mode: &ImageScaleMode,
images: &Assets<Image>, images: &Assets<Image>,
atlas_layouts: &Assets<TextureAtlasLayout>, atlas_layouts: &Assets<TextureAtlasLayout>,
) -> Option<ComputedTextureSlices> { ) -> Option<ComputedTextureSlices> {
@ -111,9 +106,9 @@ fn compute_sprite_slices(
(size, rect) (size, rect)
} }
}; };
let slices = match scale_mode { let slices = match &sprite.image_mode {
ImageScaleMode::Sliced(slicer) => slicer.compute_slices(texture_rect, sprite.custom_size), SpriteImageMode::Sliced(slicer) => slicer.compute_slices(texture_rect, sprite.custom_size),
ImageScaleMode::Tiled { SpriteImageMode::Tiled {
tile_x, tile_x,
tile_y, tile_y,
stretch_value, stretch_value,
@ -125,18 +120,21 @@ fn compute_sprite_slices(
}; };
slice.tiled(*stretch_value, (*tile_x, *tile_y)) slice.tiled(*stretch_value, (*tile_x, *tile_y))
} }
SpriteImageMode::Auto => {
unreachable!("Slices should not be computed for SpriteImageMode::Stretch")
}
}; };
Some(ComputedTextureSlices(slices)) Some(ComputedTextureSlices(slices))
} }
/// System reacting to added or modified [`Image`] handles, and recompute sprite slices /// System reacting to added or modified [`Image`] handles, and recompute sprite slices
/// on matching sprite entities with a [`ImageScaleMode`] component /// on sprite entities with a matching [`SpriteImageMode`]
pub(crate) fn compute_slices_on_asset_event( pub(crate) fn compute_slices_on_asset_event(
mut commands: Commands, mut commands: Commands,
mut events: EventReader<AssetEvent<Image>>, mut events: EventReader<AssetEvent<Image>>,
images: Res<Assets<Image>>, images: Res<Assets<Image>>,
atlas_layouts: Res<Assets<TextureAtlasLayout>>, atlas_layouts: Res<Assets<TextureAtlasLayout>>,
sprites: Query<(Entity, &ImageScaleMode, &Sprite)>, sprites: Query<(Entity, &Sprite)>,
) { ) {
// We store the asset ids of added/modified image assets // We store the asset ids of added/modified image assets
let added_handles: HashSet<_> = events let added_handles: HashSet<_> = events
@ -150,29 +148,31 @@ pub(crate) fn compute_slices_on_asset_event(
return; return;
} }
// We recompute the sprite slices for sprite entities with a matching asset handle id // We recompute the sprite slices for sprite entities with a matching asset handle id
for (entity, scale_mode, sprite) in &sprites { for (entity, sprite) in &sprites {
if !sprite.image_mode.uses_slices() {
continue;
}
if !added_handles.contains(&sprite.image.id()) { if !added_handles.contains(&sprite.image.id()) {
continue; continue;
} }
if let Some(slices) = compute_sprite_slices(sprite, scale_mode, &images, &atlas_layouts) { if let Some(slices) = compute_sprite_slices(sprite, &images, &atlas_layouts) {
commands.entity(entity).insert(slices); commands.entity(entity).insert(slices);
} }
} }
} }
/// System reacting to changes on relevant sprite bundle components to compute the sprite slices /// System reacting to changes on the [`Sprite`] component to compute the sprite slices
/// on matching sprite entities with a [`ImageScaleMode`] component
pub(crate) fn compute_slices_on_sprite_change( pub(crate) fn compute_slices_on_sprite_change(
mut commands: Commands, mut commands: Commands,
images: Res<Assets<Image>>, images: Res<Assets<Image>>,
atlas_layouts: Res<Assets<TextureAtlasLayout>>, atlas_layouts: Res<Assets<TextureAtlasLayout>>,
changed_sprites: Query< changed_sprites: Query<(Entity, &Sprite), Changed<Sprite>>,
(Entity, &ImageScaleMode, &Sprite),
Or<(Changed<ImageScaleMode>, Changed<Sprite>)>,
>,
) { ) {
for (entity, scale_mode, sprite) in &changed_sprites { for (entity, sprite) in &changed_sprites {
if let Some(slices) = compute_sprite_slices(sprite, scale_mode, &images, &atlas_layouts) { if !sprite.image_mode.uses_slices() {
continue;
}
if let Some(slices) = compute_sprite_slices(sprite, &images, &atlas_layouts) {
commands.entity(entity).insert(slices); commands.entity(entity).insert(slices);
} }
} }

View file

@ -10,7 +10,7 @@ use bevy_reflect::Reflect;
/// sections will be scaled or tiled. /// sections will be scaled or tiled.
/// ///
/// See [9-sliced](https://en.wikipedia.org/wiki/9-slice_scaling) textures. /// See [9-sliced](https://en.wikipedia.org/wiki/9-slice_scaling) textures.
#[derive(Debug, Clone, Reflect)] #[derive(Debug, Clone, Reflect, PartialEq)]
pub struct TextureSlicer { pub struct TextureSlicer {
/// The sprite borders, defining the 9 sections of the image /// The sprite borders, defining the 9 sections of the image
pub border: BorderRect, pub border: BorderRect,
@ -23,7 +23,7 @@ pub struct TextureSlicer {
} }
/// Defines how a texture slice scales when resized /// Defines how a texture slice scales when resized
#[derive(Debug, Copy, Clone, Default, Reflect)] #[derive(Debug, Copy, Clone, Default, Reflect, PartialEq)]
pub enum SliceScaleMode { pub enum SliceScaleMode {
/// The slice will be stretched to fit the area /// The slice will be stretched to fit the area
#[default] #[default]

View file

@ -221,7 +221,7 @@ pub fn ui_layout_system(
|| node.is_changed() || node.is_changed()
|| content_size || content_size
.as_ref() .as_ref()
.map(|c| c.measure.is_some()) .map(|c| c.is_changed() || c.measure.is_some())
.unwrap_or(false) .unwrap_or(false)
{ {
let layout_context = LayoutContext::new( let layout_context = LayoutContext::new(

View file

@ -62,7 +62,7 @@ pub mod prelude {
Interaction, MaterialNode, UiMaterialPlugin, UiScale, Interaction, MaterialNode, UiMaterialPlugin, UiScale,
}, },
// `bevy_sprite` re-exports for texture slicing // `bevy_sprite` re-exports for texture slicing
bevy_sprite::{BorderRect, ImageScaleMode, SliceScaleMode, TextureSlicer}, bevy_sprite::{BorderRect, SliceScaleMode, SpriteImageMode, TextureSlicer},
}; };
} }

View file

@ -57,12 +57,6 @@ pub struct NodeBundle {
} }
/// A UI node that is an image /// A UI node that is an image
///
/// # Extra behaviors
///
/// You may add one or both of the following components to enable additional behaviors:
/// - [`ImageScaleMode`](bevy_sprite::ImageScaleMode) to enable either slicing or tiling of the texture
/// - [`TextureAtlas`](bevy_sprite::TextureAtlas) to draw a specific section of the texture
#[derive(Bundle, Debug, Default)] #[derive(Bundle, Debug, Default)]
#[deprecated( #[deprecated(
since = "0.15.0", since = "0.15.0",
@ -110,12 +104,6 @@ pub struct ImageBundle {
} }
/// A UI node that is a button /// A UI node that is a button
///
/// # Extra behaviors
///
/// You may add one or both of the following components to enable additional behaviors:
/// - [`ImageScaleMode`](bevy_sprite::ImageScaleMode) to enable either slicing or tiling of the texture
/// - [`TextureAtlas`](bevy_sprite::TextureAtlas) to draw a specific section of the texture
#[derive(Bundle, Clone, Debug)] #[derive(Bundle, Clone, Debug)]
#[deprecated( #[deprecated(
since = "0.15.0", since = "0.15.0",

View file

@ -41,7 +41,7 @@ use bevy_render::{
ExtractSchedule, Render, ExtractSchedule, Render,
}; };
use bevy_sprite::TextureAtlasLayout; use bevy_sprite::TextureAtlasLayout;
use bevy_sprite::{BorderRect, ImageScaleMode, SpriteAssetEvents}; use bevy_sprite::{BorderRect, SpriteAssetEvents};
use crate::{Display, Node}; use crate::{Display, Node};
use bevy_text::{ComputedTextBlock, PositionedGlyph, TextColor, TextLayoutInfo}; use bevy_text::{ComputedTextBlock, PositionedGlyph, TextColor, TextLayoutInfo};
@ -309,18 +309,15 @@ pub fn extract_uinode_images(
texture_atlases: Extract<Res<Assets<TextureAtlasLayout>>>, texture_atlases: Extract<Res<Assets<TextureAtlasLayout>>>,
default_ui_camera: Extract<DefaultUiCamera>, default_ui_camera: Extract<DefaultUiCamera>,
uinode_query: Extract< uinode_query: Extract<
Query< Query<(
( Entity,
Entity, &ComputedNode,
&ComputedNode, &GlobalTransform,
&GlobalTransform, &ViewVisibility,
&ViewVisibility, Option<&CalculatedClip>,
Option<&CalculatedClip>, Option<&TargetCamera>,
Option<&TargetCamera>, &UiImage,
&UiImage, )>,
),
Without<ImageScaleMode>,
>,
>, >,
mapping: Extract<Query<RenderEntity>>, mapping: Extract<Query<RenderEntity>>,
) { ) {
@ -338,6 +335,7 @@ pub fn extract_uinode_images(
if !view_visibility.get() if !view_visibility.get()
|| image.color.is_fully_transparent() || image.color.is_fully_transparent()
|| image.image.id() == TRANSPARENT_IMAGE_HANDLE.id() || image.image.id() == TRANSPARENT_IMAGE_HANDLE.id()
|| image.image_mode.uses_slices()
{ {
continue; continue;
} }

View file

@ -24,7 +24,7 @@ use bevy_render::{
Extract, ExtractSchedule, Render, RenderSet, Extract, ExtractSchedule, Render, RenderSet,
}; };
use bevy_sprite::{ use bevy_sprite::{
ImageScaleMode, SliceScaleMode, SpriteAssetEvents, TextureAtlasLayout, TextureSlicer, SliceScaleMode, SpriteAssetEvents, SpriteImageMode, TextureAtlasLayout, TextureSlicer,
}; };
use bevy_transform::prelude::GlobalTransform; use bevy_transform::prelude::GlobalTransform;
use bevy_utils::HashMap; use bevy_utils::HashMap;
@ -232,7 +232,7 @@ pub struct ExtractedUiTextureSlice {
pub clip: Option<Rect>, pub clip: Option<Rect>,
pub camera_entity: Entity, pub camera_entity: Entity,
pub color: LinearRgba, pub color: LinearRgba,
pub image_scale_mode: ImageScaleMode, pub image_scale_mode: SpriteImageMode,
pub flip_x: bool, pub flip_x: bool,
pub flip_y: bool, pub flip_y: bool,
pub main_entity: MainEntity, pub main_entity: MainEntity,
@ -257,14 +257,11 @@ pub fn extract_ui_texture_slices(
Option<&CalculatedClip>, Option<&CalculatedClip>,
Option<&TargetCamera>, Option<&TargetCamera>,
&UiImage, &UiImage,
&ImageScaleMode,
)>, )>,
>, >,
mapping: Extract<Query<RenderEntity>>, mapping: Extract<Query<RenderEntity>>,
) { ) {
for (entity, uinode, transform, view_visibility, clip, camera, image, image_scale_mode) in for (entity, uinode, transform, view_visibility, clip, camera, image) in &slicers_query {
&slicers_query
{
let Some(camera_entity) = camera.map(TargetCamera::entity).or(default_ui_camera.get()) let Some(camera_entity) = camera.map(TargetCamera::entity).or(default_ui_camera.get())
else { else {
continue; continue;
@ -274,6 +271,22 @@ pub fn extract_ui_texture_slices(
continue; continue;
}; };
let image_scale_mode = match image.image_mode.clone() {
widget::NodeImageMode::Sliced(texture_slicer) => {
SpriteImageMode::Sliced(texture_slicer)
}
widget::NodeImageMode::Tiled {
tile_x,
tile_y,
stretch_value,
} => SpriteImageMode::Tiled {
tile_x,
tile_y,
stretch_value,
},
_ => continue,
};
// Skip invisible images // Skip invisible images
if !view_visibility.get() if !view_visibility.get()
|| image.color.is_fully_transparent() || image.color.is_fully_transparent()
@ -312,7 +325,7 @@ pub fn extract_ui_texture_slices(
clip: clip.map(|clip| clip.clip), clip: clip.map(|clip| clip.clip),
image: image.image.id(), image: image.image.id(),
camera_entity, camera_entity,
image_scale_mode: image_scale_mode.clone(), image_scale_mode,
atlas_rect, atlas_rect,
flip_x: image.flip_x, flip_x: image.flip_x,
flip_y: image.flip_y, flip_y: image.flip_y,
@ -719,10 +732,10 @@ impl<P: PhaseItem> RenderCommand<P> for DrawSlicer {
fn compute_texture_slices( fn compute_texture_slices(
image_size: Vec2, image_size: Vec2,
target_size: Vec2, target_size: Vec2,
image_scale_mode: &ImageScaleMode, image_scale_mode: &SpriteImageMode,
) -> [[f32; 4]; 3] { ) -> [[f32; 4]; 3] {
match image_scale_mode { match image_scale_mode {
ImageScaleMode::Sliced(TextureSlicer { SpriteImageMode::Sliced(TextureSlicer {
border: border_rect, border: border_rect,
center_scale_mode, center_scale_mode,
sides_scale_mode, sides_scale_mode,
@ -775,7 +788,7 @@ fn compute_texture_slices(
], ],
] ]
} }
ImageScaleMode::Tiled { SpriteImageMode::Tiled {
tile_x, tile_x,
tile_y, tile_y,
stretch_value, stretch_value,
@ -784,6 +797,9 @@ fn compute_texture_slices(
let ry = compute_tiled_axis(*tile_y, image_size.y, target_size.y, *stretch_value); let ry = compute_tiled_axis(*tile_y, image_size.y, target_size.y, *stretch_value);
[[0., 0., 1., 1.], [0., 0., 1., 1.], [1., 1., rx, ry]] [[0., 0., 1., 1.], [0., 0., 1., 1.], [1., 1., rx, ry]]
} }
SpriteImageMode::Auto => {
unreachable!("Slices should not be computed for ImageScaleMode::Stretch")
}
} }
} }

View file

@ -5,14 +5,14 @@ use bevy_ecs::prelude::*;
use bevy_math::{Rect, UVec2, Vec2}; use bevy_math::{Rect, UVec2, Vec2};
use bevy_reflect::{std_traits::ReflectDefault, Reflect}; use bevy_reflect::{std_traits::ReflectDefault, Reflect};
use bevy_render::texture::{Image, TRANSPARENT_IMAGE_HANDLE}; use bevy_render::texture::{Image, TRANSPARENT_IMAGE_HANDLE};
use bevy_sprite::{TextureAtlas, TextureAtlasLayout}; use bevy_sprite::{TextureAtlas, TextureAtlasLayout, TextureSlicer};
use bevy_window::{PrimaryWindow, Window}; use bevy_window::{PrimaryWindow, Window};
use taffy::{MaybeMath, MaybeResolve}; use taffy::{MaybeMath, MaybeResolve};
/// The 2D texture displayed for this UI node /// The 2D texture displayed for this UI node
#[derive(Component, Clone, Debug, Reflect)] #[derive(Component, Clone, Debug, Reflect)]
#[reflect(Component, Default, Debug)] #[reflect(Component, Default, Debug)]
#[require(Node, UiImageSize, ContentSize)] #[require(Node, UiImageSize)]
pub struct UiImage { pub struct UiImage {
/// The tint color used to draw the image. /// The tint color used to draw the image.
/// ///
@ -23,11 +23,11 @@ pub struct UiImage {
/// ///
/// This defaults to a [`TRANSPARENT_IMAGE_HANDLE`], which points to a fully transparent 1x1 texture. /// This defaults to a [`TRANSPARENT_IMAGE_HANDLE`], which points to a fully transparent 1x1 texture.
pub image: Handle<Image>, pub image: Handle<Image>,
/// The (optional) texture atlas used to render the image /// The (optional) texture atlas used to render the image.
pub texture_atlas: Option<TextureAtlas>, pub texture_atlas: Option<TextureAtlas>,
/// Whether the image should be flipped along its x-axis /// Whether the image should be flipped along its x-axis.
pub flip_x: bool, pub flip_x: bool,
/// Whether the image should be flipped along its y-axis /// Whether the image should be flipped along its y-axis.
pub flip_y: bool, pub flip_y: bool,
/// An optional rectangle representing the region of the image to render, instead of rendering /// An optional rectangle representing the region of the image to render, instead of rendering
/// the full image. This is an easy one-off alternative to using a [`TextureAtlas`]. /// the full image. This is an easy one-off alternative to using a [`TextureAtlas`].
@ -35,6 +35,8 @@ pub struct UiImage {
/// When used with a [`TextureAtlas`], the rect /// When used with a [`TextureAtlas`], the rect
/// is offset by the atlas's minimal (top-left) corner position. /// is offset by the atlas's minimal (top-left) corner position.
pub rect: Option<Rect>, pub rect: Option<Rect>,
/// Controls how the image is altered to fit within the layout and how the layout algorithm determines the space to allocate for the image.
pub image_mode: NodeImageMode,
} }
impl Default for UiImage { impl Default for UiImage {
@ -56,6 +58,7 @@ impl Default for UiImage {
flip_x: false, flip_x: false,
flip_y: false, flip_y: false,
rect: None, rect: None,
image_mode: NodeImageMode::Auto,
} }
} }
} }
@ -81,6 +84,7 @@ impl UiImage {
flip_y: false, flip_y: false,
texture_atlas: None, texture_atlas: None,
rect: None, rect: None,
image_mode: NodeImageMode::Auto,
} }
} }
@ -119,6 +123,12 @@ impl UiImage {
self.rect = Some(rect); self.rect = Some(rect);
self self
} }
#[must_use]
pub const fn with_mode(mut self, mode: NodeImageMode) -> Self {
self.image_mode = mode;
self
}
} }
impl From<Handle<Image>> for UiImage { impl From<Handle<Image>> for UiImage {
@ -127,6 +137,39 @@ impl From<Handle<Image>> for UiImage {
} }
} }
/// Controls how the image is altered to fit within the layout and how the layout algorithm determines the space in the layout for the image
#[derive(Default, Debug, Clone, Reflect)]
pub enum NodeImageMode {
/// The image will be sized automatically by taking the size of the source image and applying any layout constraints.
#[default]
Auto,
/// The image will be resized to match the size of the node. The image's original size and aspect ratio will be ignored.
Stretch,
/// 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,
},
}
impl NodeImageMode {
/// Returns true if this mode uses slices internally ([`NodeImageMode::Sliced`] or [`NodeImageMode::Tiled`])
#[inline]
pub fn uses_slices(&self) -> bool {
matches!(
self,
NodeImageMode::Sliced(..) | NodeImageMode::Tiled { .. }
)
}
}
/// The size of the image's texture /// The size of the image's texture
/// ///
/// This component is updated automatically by [`update_image_content_size_system`] /// This component is updated automatically by [`update_image_content_size_system`]
@ -216,7 +259,7 @@ pub fn update_image_content_size_system(
textures: Res<Assets<Image>>, textures: Res<Assets<Image>>,
atlases: Res<Assets<TextureAtlasLayout>>, atlases: Res<Assets<TextureAtlasLayout>>,
mut query: Query<(&mut ContentSize, &UiImage, &mut UiImageSize), UpdateImageFilter>, mut query: Query<(&mut ContentSize, Ref<UiImage>, &mut UiImageSize), UpdateImageFilter>,
) { ) {
let combined_scale_factor = windows let combined_scale_factor = windows
.get_single() .get_single()
@ -225,6 +268,14 @@ pub fn update_image_content_size_system(
* ui_scale.0; * ui_scale.0;
for (mut content_size, image, mut image_size) in &mut query { for (mut content_size, image, mut image_size) in &mut query {
if !matches!(image.image_mode, NodeImageMode::Auto) {
if image.is_changed() {
// Mutably derefs, marking the `ContentSize` as changed ensuring `ui_layout_system` will remove the node's measure func if present.
content_size.measure = None;
}
continue;
}
if let Some(size) = match &image.texture_atlas { if let Some(size) = match &image.texture_atlas {
Some(atlas) => atlas.texture_rect(&atlases).map(|t| t.size()), Some(atlas) => atlas.texture_rect(&atlases).map(|t| t.size()),
None => textures.get(&image.image).map(Image::size), None => textures.get(&image.image).map(Image::size),

View file

@ -19,55 +19,65 @@ fn spawn_sprites(
) { ) {
let cases = [ let cases = [
// Reference sprite // Reference sprite
("Original", style.clone(), Vec2::splat(100.0), None), (
"Original",
style.clone(),
Vec2::splat(100.0),
SpriteImageMode::Auto,
),
// Scaled regular sprite // Scaled regular sprite
("Stretched", style.clone(), Vec2::new(100.0, 200.0), None), (
"Stretched",
style.clone(),
Vec2::new(100.0, 200.0),
SpriteImageMode::Auto,
),
// Stretched Scaled sliced sprite // Stretched Scaled sliced sprite
( (
"With Slicing", "With Slicing",
style.clone(), style.clone(),
Vec2::new(100.0, 200.0), Vec2::new(100.0, 200.0),
Some(ImageScaleMode::Sliced(TextureSlicer { SpriteImageMode::Sliced(TextureSlicer {
border: BorderRect::square(slice_border), border: BorderRect::square(slice_border),
center_scale_mode: SliceScaleMode::Stretch, center_scale_mode: SliceScaleMode::Stretch,
..default() ..default()
})), }),
), ),
// Scaled sliced sprite // Scaled sliced sprite
( (
"With Tiling", "With Tiling",
style.clone(), style.clone(),
Vec2::new(100.0, 200.0), Vec2::new(100.0, 200.0),
Some(ImageScaleMode::Sliced(TextureSlicer { SpriteImageMode::Sliced(TextureSlicer {
border: BorderRect::square(slice_border), border: BorderRect::square(slice_border),
center_scale_mode: SliceScaleMode::Tile { stretch_value: 0.5 }, center_scale_mode: SliceScaleMode::Tile { stretch_value: 0.5 },
sides_scale_mode: SliceScaleMode::Tile { stretch_value: 0.2 }, sides_scale_mode: SliceScaleMode::Tile { stretch_value: 0.2 },
..default() ..default()
})), }),
), ),
// Scaled sliced sprite horizontally // Scaled sliced sprite horizontally
( (
"With Tiling", "With Tiling",
style.clone(), style.clone(),
Vec2::new(300.0, 200.0), Vec2::new(300.0, 200.0),
Some(ImageScaleMode::Sliced(TextureSlicer { SpriteImageMode::Sliced(TextureSlicer {
border: BorderRect::square(slice_border), border: BorderRect::square(slice_border),
center_scale_mode: SliceScaleMode::Tile { stretch_value: 0.2 }, center_scale_mode: SliceScaleMode::Tile { stretch_value: 0.2 },
sides_scale_mode: SliceScaleMode::Tile { stretch_value: 0.3 }, sides_scale_mode: SliceScaleMode::Tile { stretch_value: 0.3 },
..default() ..default()
})), }),
), ),
// Scaled sliced sprite horizontally with max scale // Scaled sliced sprite horizontally with max scale
( (
"With Corners Constrained", "With Corners Constrained",
style, style,
Vec2::new(300.0, 200.0), Vec2::new(300.0, 200.0),
Some(ImageScaleMode::Sliced(TextureSlicer { SpriteImageMode::Sliced(TextureSlicer {
border: BorderRect::square(slice_border), border: BorderRect::square(slice_border),
center_scale_mode: SliceScaleMode::Tile { stretch_value: 0.1 }, center_scale_mode: SliceScaleMode::Tile { stretch_value: 0.1 },
sides_scale_mode: SliceScaleMode::Tile { stretch_value: 0.2 }, sides_scale_mode: SliceScaleMode::Tile { stretch_value: 0.2 },
max_corner_scale: 0.2, max_corner_scale: 0.2,
})), }),
), ),
]; ];
@ -77,13 +87,11 @@ fn spawn_sprites(
Sprite { Sprite {
image: texture_handle.clone(), image: texture_handle.clone(),
custom_size: Some(size), custom_size: Some(size),
image_mode: scale_mode,
..default() ..default()
}, },
Transform::from_translation(position), Transform::from_translation(position),
)); ));
if let Some(scale_mode) = scale_mode {
cmd.insert(scale_mode);
}
cmd.with_children(|builder| { cmd.with_children(|builder| {
builder.spawn(( builder.spawn((
Text2d::new(label), Text2d::new(label),

View file

@ -26,14 +26,15 @@ fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
current: 128.0, current: 128.0,
speed: 50.0, speed: 50.0,
}); });
commands.spawn(( commands.spawn(Sprite {
Sprite::from_image(asset_server.load("branding/icon.png")), image: asset_server.load("branding/icon.png"),
ImageScaleMode::Tiled { image_mode: SpriteImageMode::Tiled {
tile_x: true, tile_x: true,
tile_y: true, tile_y: true,
stretch_value: 0.5, // The image will tile every 128px stretch_value: 0.5, // The image will tile every 128px
}, },
)); ..default()
});
} }
fn animate(mut sprites: Query<&mut Sprite>, mut state: ResMut<AnimationState>, time: Res<Time>) { fn animate(mut sprites: Query<&mut Sprite>, mut state: ResMut<AnimationState>, time: Res<Time>) {

View file

@ -9,6 +9,7 @@ use bevy::{
input::mouse::{MouseScrollUnit, MouseWheel}, input::mouse::{MouseScrollUnit, MouseWheel},
picking::focus::HoverMap, picking::focus::HoverMap,
prelude::*, prelude::*,
ui::widget::NodeImageMode,
winit::WinitSettings, winit::WinitSettings,
}; };
@ -280,7 +281,8 @@ fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
// bevy logo (image) // bevy logo (image)
parent parent
.spawn(( .spawn((
UiImage::new(asset_server.load("branding/bevy_logo_dark_big.png")), UiImage::new(asset_server.load("branding/bevy_logo_dark_big.png"))
.with_mode(NodeImageMode::Stretch),
Node { Node {
width: Val::Px(500.0), width: Val::Px(500.0),
height: Val::Px(125.0), height: Val::Px(125.0),

View file

@ -4,6 +4,7 @@
use bevy::{ use bevy::{
color::palettes::css::{GOLD, ORANGE}, color::palettes::css::{GOLD, ORANGE},
prelude::*, prelude::*,
ui::widget::NodeImageMode,
winit::WinitSettings, winit::WinitSettings,
}; };
@ -87,7 +88,8 @@ fn setup(
index: idx, index: idx,
layout: atlas_layout_handle.clone(), layout: atlas_layout_handle.clone(),
}, },
), )
.with_mode(NodeImageMode::Sliced(slicer.clone())),
Node { Node {
width: Val::Px(w), width: Val::Px(w),
height: Val::Px(h), height: Val::Px(h),
@ -98,7 +100,6 @@ fn setup(
margin: UiRect::all(Val::Px(20.0)), margin: UiRect::all(Val::Px(20.0)),
..default() ..default()
}, },
ImageScaleMode::Sliced(slicer.clone()),
)) ))
.with_children(|parent| { .with_children(|parent| {
parent.spawn(( parent.spawn((

View file

@ -4,6 +4,7 @@
use bevy::{ use bevy::{
color::palettes::css::{GOLD, ORANGE}, color::palettes::css::{GOLD, ORANGE},
prelude::*, prelude::*,
ui::widget::NodeImageMode,
winit::WinitSettings, winit::WinitSettings,
}; };
@ -77,20 +78,18 @@ fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
margin: UiRect::all(Val::Px(20.0)), margin: UiRect::all(Val::Px(20.0)),
..default() ..default()
}, },
UiImage::new(image.clone()), UiImage::new(image.clone())
ImageScaleMode::Sliced(slicer.clone()), .with_mode(NodeImageMode::Sliced(slicer.clone())),
)) ))
.with_children(|parent| { .with_child((
parent.spawn(( Text::new("Button"),
Text::new("Button"), TextFont {
TextFont { font: asset_server.load("fonts/FiraSans-Bold.ttf"),
font: asset_server.load("fonts/FiraSans-Bold.ttf"), font_size: 33.0,
font_size: 33.0, ..default()
..default() },
}, TextColor(Color::srgb(0.9, 0.9, 0.9)),
TextColor(Color::srgb(0.9, 0.9, 0.9)), ));
));
});
} }
}); });
} }

View file

@ -3,6 +3,7 @@
use bevy::{ use bevy::{
prelude::*, prelude::*,
render::texture::{ImageLoaderSettings, ImageSampler}, render::texture::{ImageLoaderSettings, ImageSampler},
ui::widget::NodeImageMode,
winit::WinitSettings, winit::WinitSettings,
}; };
@ -61,6 +62,7 @@ fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
image: image.clone(), image: image.clone(),
flip_x, flip_x,
flip_y, flip_y,
image_mode: NodeImageMode::Sliced(slicer.clone()),
..default() ..default()
}, },
Node { Node {
@ -68,7 +70,6 @@ fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
height: Val::Px(height), height: Val::Px(height),
..default() ..default()
}, },
ImageScaleMode::Sliced(slicer.clone()),
)); ));
} }
}); });