diff --git a/CREDITS.md b/CREDITS.md index 1e91afbf4a8e9..8da7203793f52 100644 --- a/CREDITS.md +++ b/CREDITS.md @@ -21,6 +21,7 @@ * Ground tile from [Kenney's Tower Defense Kit](https://www.kenney.nl/assets/tower-defense-kit) (CC0 1.0 Universal) * Game icons from [Kenney's Game Icons](https://www.kenney.nl/assets/game-icons) (CC0 1.0 Universal) * Space ships from [Kenny's Simple Space Kit](https://www.kenney.nl/assets/simple-space) (CC0 1.0 Universal) +* UI borders from [Kenny's Fantasy UI Borders Kit](https://kenney.nl/assets/fantasy-ui-borders) (CC0 1.0 Universal) * glTF animated fox from [glTF Sample Models][fox] * Low poly fox [by PixelMannen] (CC0 1.0 Universal) * Rigging and animation [by @tomkranis on Sketchfab] ([CC-BY 4.0]) diff --git a/Cargo.toml b/Cargo.toml index 0b12af333790d..7d821c32717f6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2482,6 +2482,17 @@ description = "Illustrates how to use TextureAtlases in UI" category = "UI (User Interface)" wasm = true +[[example]] +name = "ui_texture_slice" +path = "examples/ui/ui_texture_slice.rs" +doc-scrape-examples = true + +[package.metadata.example.ui_texture_slice] +name = "UI Texture Slice" +description = "Illustrates how to use 9 Slicing in UI" +category = "UI (User Interface)" +wasm = true + [[example]] name = "viewport_debug" path = "examples/ui/viewport_debug.rs" diff --git a/assets/textures/fantasy_ui_borders/License.txt b/assets/textures/fantasy_ui_borders/License.txt new file mode 100644 index 0000000000000..76976869e69ec --- /dev/null +++ b/assets/textures/fantasy_ui_borders/License.txt @@ -0,0 +1,30 @@ + + + Fantasy UI Borders (1.0) + + Created/distributed by Kenney (www.kenney.nl) + Creation date: 03-12-2023 + + For the sample image the font 'Aoboshi One' was used, OPL (Open Font License) + + ------------------------------ + + License: (Creative Commons Zero, CC0) + http://creativecommons.org/publicdomain/zero/1.0/ + + You can use this content for personal, educational, and commercial purposes. + + Support by crediting 'Kenney' or 'www.kenney.nl' (this is not a requirement) + + ------------------------------ + + • Website : www.kenney.nl + • Donate : www.kenney.nl/donate + + • Patreon : patreon.com/kenney + + Follow on social media for updates: + + • Twitter: twitter.com/KenneyNL + • Instagram: instagram.com/kenney_nl + • Mastodon: mastodon.gamedev.place/@kenney \ No newline at end of file diff --git a/assets/textures/fantasy_ui_borders/panel-border-010.png b/assets/textures/fantasy_ui_borders/panel-border-010.png new file mode 100644 index 0000000000000..afa859521f6fa Binary files /dev/null and b/assets/textures/fantasy_ui_borders/panel-border-010.png differ diff --git a/assets/textures/fantasy_ui_borders/panel-border-015.png b/assets/textures/fantasy_ui_borders/panel-border-015.png new file mode 100644 index 0000000000000..ae7ef04358f94 Binary files /dev/null and b/assets/textures/fantasy_ui_borders/panel-border-015.png differ diff --git a/crates/bevy_sprite/src/bundle.rs b/crates/bevy_sprite/src/bundle.rs index efe3337070299..e6c48ecea036e 100644 --- a/crates/bevy_sprite/src/bundle.rs +++ b/crates/bevy_sprite/src/bundle.rs @@ -39,10 +39,13 @@ pub struct SpriteBundle { /// - [`texture atlas example`](https://github.com/bevyengine/bevy/blob/latest/examples/2d/texture_atlas.rs) #[derive(Bundle, Clone, Default)] pub struct SpriteSheetBundle { + /// 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. pub global_transform: GlobalTransform, /// The sprite sheet base texture pub texture: Handle, @@ -50,6 +53,7 @@ pub struct SpriteSheetBundle { pub atlas: TextureAtlas, /// User indication of whether an entity is visible pub visibility: Visibility, + /// Inherited visibility of an entity. pub inherited_visibility: InheritedVisibility, /// Algorithmically-computed indication of whether an entity is visible and should be extracted for rendering pub view_visibility: ViewVisibility, diff --git a/crates/bevy_sprite/src/lib.rs b/crates/bevy_sprite/src/lib.rs index f4f13ceb75a49..ace0bd0a83e2b 100644 --- a/crates/bevy_sprite/src/lib.rs +++ b/crates/bevy_sprite/src/lib.rs @@ -17,7 +17,7 @@ pub mod prelude { bundle::{SpriteBundle, SpriteSheetBundle}, sprite::{ImageScaleMode, Sprite}, texture_atlas::{TextureAtlas, TextureAtlasLayout}, - texture_slice::{BorderRect, SliceScaleMode, TextureSlicer}, + texture_slice::{BorderRect, SliceScaleMode, TextureSlice, TextureSlicer}, ColorMaterial, ColorMesh2dBundle, TextureAtlasBuilder, }; } @@ -124,7 +124,6 @@ impl Plugin for SpritePlugin { /// System calculating and inserting an [`Aabb`] component to entities with either: /// - a `Mesh2dHandle` component, /// - a `Sprite` and `Handle` components, -/// - a `TextureAtlasSprite` and `Handle` components, /// and without a [`NoFrustumCulling`] component. /// /// Used in system set [`VisibilitySystems::CalculateBounds`]. @@ -137,7 +136,7 @@ pub fn calculate_bounds_2d( sprites_to_recalculate_aabb: Query< (Entity, &Sprite, &Handle, Option<&TextureAtlas>), ( - Or<(Without, Changed)>, + Or<(Without, Changed, Changed)>, Without, ), >, diff --git a/crates/bevy_sprite/src/texture_slice/computed_slices.rs b/crates/bevy_sprite/src/texture_slice/computed_slices.rs index dd316f7d3c759..4e17fd0f2d4e7 100644 --- a/crates/bevy_sprite/src/texture_slice/computed_slices.rs +++ b/crates/bevy_sprite/src/texture_slice/computed_slices.rs @@ -31,17 +31,27 @@ impl ComputedTextureSlices { sprite: &'a Sprite, handle: &'a Handle, ) -> impl ExactSizeIterator + 'a { + let mut flip = Vec2::ONE; + let [mut flip_x, mut flip_y] = [false; 2]; + if sprite.flip_x { + flip.x *= -1.0; + flip_x = true; + } + if sprite.flip_y { + flip.y *= -1.0; + flip_y = true; + } self.0.iter().map(move |slice| { - let transform = - transform.mul_transform(Transform::from_translation(slice.offset.extend(0.0))); + let offset = (slice.offset * flip).extend(0.0); + let transform = transform.mul_transform(Transform::from_translation(offset)); 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, + flip_x, + flip_y, image_handle_id: handle.id(), anchor: sprite.anchor.as_vec(), } diff --git a/crates/bevy_sprite/src/texture_slice/mod.rs b/crates/bevy_sprite/src/texture_slice/mod.rs index d16e6654ec4d0..d617634acd3bd 100644 --- a/crates/bevy_sprite/src/texture_slice/mod.rs +++ b/crates/bevy_sprite/src/texture_slice/mod.rs @@ -10,8 +10,9 @@ pub(crate) use computed_slices::{ compute_slices_on_asset_event, compute_slices_on_sprite_change, ComputedTextureSlices, }; +/// Single texture slice, representing a texture rect to draw in a given area #[derive(Debug, Clone)] -pub(crate) struct TextureSlice { +pub struct TextureSlice { /// texture area to draw pub texture_rect: Rect, /// slice draw size @@ -39,16 +40,19 @@ impl TextureSlice { // Each tile expected size let expected_size = Vec2::new( if tile_x { - rect_size.x * stretch_value + // No slice should be less than 1 pixel wide + (rect_size.x * stretch_value).max(1.0) } else { self.draw_size.x }, if tile_y { - rect_size.y * stretch_value + // No slice should be less than 1 pixel high + (rect_size.y * stretch_value).max(1.0) } else { self.draw_size.y }, - ); + ) + .min(self.draw_size); let mut slices = Vec::new(); let base_offset = Vec2::new( -self.draw_size.x / 2.0, @@ -81,6 +85,9 @@ impl TextureSlice { offset.y -= size_y / 2.0; remaining_columns -= size_y; } + if slices.len() > 1_000 { + bevy_log::warn!("One of your tiled textures has generated {} slices. You might want to use higher stretch values to avoid a great performance cost", slices.len()); + } slices } } diff --git a/crates/bevy_sprite/src/texture_slice/slicer.rs b/crates/bevy_sprite/src/texture_slice/slicer.rs index b302d2e3562cf..f048d6d880a53 100644 --- a/crates/bevy_sprite/src/texture_slice/slicer.rs +++ b/crates/bevy_sprite/src/texture_slice/slicer.rs @@ -199,12 +199,23 @@ impl TextureSlicer { /// * `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 { + pub fn compute_slices(&self, rect: Rect, render_size: Option) -> Vec { let render_size = render_size.unwrap_or_else(|| rect.size()); + let rect_size = rect.size() / 2.0; + if self.border.left >= rect_size.x + || self.border.right >= rect_size.x + || self.border.top >= rect_size.y + || self.border.bottom >= rect_size.y + { + bevy_log::error!( + "TextureSlicer::border has out of bounds values. No slicing will be applied" + ); + return vec![TextureSlice { + texture_rect: rect, + draw_size: render_size, + offset: Vec2::ZERO, + }]; + } let mut slices = Vec::with_capacity(9); // Corners let corners = self.corner_slices(rect, render_size); diff --git a/crates/bevy_ui/src/lib.rs b/crates/bevy_ui/src/lib.rs index 979d4ba6d8b91..2e4306ee598c0 100644 --- a/crates/bevy_ui/src/lib.rs +++ b/crates/bevy_ui/src/lib.rs @@ -21,6 +21,7 @@ mod geometry; mod layout; mod render; mod stack; +mod texture_slice; mod ui_node; pub use focus::*; @@ -39,6 +40,9 @@ pub mod prelude { geometry::*, node_bundles::*, ui_material::*, ui_node::*, widget::Button, widget::Label, Interaction, UiMaterialPlugin, UiScale, }; + // `bevy_sprite` re-exports for texture slicing + #[doc(hidden)] + pub use bevy_sprite::{BorderRect, ImageScaleMode, SliceScaleMode, TextureSlicer}; } use bevy_app::prelude::*; @@ -162,10 +166,17 @@ impl Plugin for UiPlugin { // They run independently since `widget::image_node_system` will only ever observe // its own UiImage, and `widget::text_system` & `bevy_text::update_text2d_layout` // will never modify a pre-existing `Image` asset. - widget::update_image_content_size_system - .before(UiSystem::Layout) - .in_set(AmbiguousWithTextSystem) - .in_set(AmbiguousWithUpdateText2DLayout), + ( + widget::update_image_content_size_system + .before(UiSystem::Layout) + .in_set(AmbiguousWithTextSystem) + .in_set(AmbiguousWithUpdateText2DLayout), + ( + texture_slice::compute_slices_on_asset_event, + texture_slice::compute_slices_on_image_change, + ), + ) + .chain(), ), ); diff --git a/crates/bevy_ui/src/node_bundles.rs b/crates/bevy_ui/src/node_bundles.rs index acce4250942e1..639142e18118d 100644 --- a/crates/bevy_ui/src/node_bundles.rs +++ b/crates/bevy_ui/src/node_bundles.rs @@ -13,7 +13,7 @@ use bevy_render::{ prelude::Color, view::{InheritedVisibility, ViewVisibility, Visibility}, }; -use bevy_sprite::TextureAtlas; +use bevy_sprite::{ImageScaleMode, TextureAtlas}; #[cfg(feature = "bevy_text")] use bevy_text::{BreakLineOn, JustifyText, Text, TextLayoutInfo, TextSection, TextStyle}; use bevy_transform::prelude::{GlobalTransform, Transform}; @@ -95,6 +95,8 @@ pub struct ImageBundle { /// /// This component is set automatically pub image_size: UiImageSize, + /// Controls how the image is altered when scaled. + pub scale_mode: ImageScaleMode, /// Whether this node should block interaction with lower nodes pub focus_policy: FocusPolicy, /// The transform of the node @@ -307,6 +309,8 @@ pub struct ButtonBundle { pub border_color: BorderColor, /// The image of the node pub image: UiImage, + /// Controls how the image is altered when scaled. + pub scale_mode: ImageScaleMode, /// The transform of the node /// /// This component is automatically managed by the UI layout system. @@ -343,6 +347,7 @@ impl Default for ButtonBundle { inherited_visibility: Default::default(), view_visibility: Default::default(), z_index: Default::default(), + scale_mode: ImageScaleMode::default(), } } } diff --git a/crates/bevy_ui/src/render/mod.rs b/crates/bevy_ui/src/render/mod.rs index 613aa728ed9b9..cede7b2f1e5f2 100644 --- a/crates/bevy_ui/src/render/mod.rs +++ b/crates/bevy_ui/src/render/mod.rs @@ -17,8 +17,8 @@ pub use ui_material_pipeline::*; use crate::graph::{LabelsUi, SubGraphUi}; use crate::{ - BackgroundColor, BorderColor, CalculatedClip, ContentSize, DefaultUiCamera, Node, Outline, - Style, TargetCamera, UiImage, UiScale, Val, + texture_slice::ComputedTextureSlices, BackgroundColor, BorderColor, CalculatedClip, + ContentSize, DefaultUiCamera, Node, Outline, Style, TargetCamera, UiImage, UiScale, Val, }; use bevy_app::prelude::*; @@ -62,7 +62,6 @@ pub const UI_SHADER_HANDLE: Handle = Handle::weak_from_u128(130128470471 #[derive(Debug, Hash, PartialEq, Eq, Clone, SystemSet)] pub enum RenderUiSystem { ExtractNode, - ExtractAtlasNode, } pub fn build_ui_render(app: &mut App) { @@ -86,10 +85,10 @@ pub fn build_ui_render(app: &mut App) { extract_default_ui_camera_view::, extract_default_ui_camera_view::, extract_uinodes.in_set(RenderUiSystem::ExtractNode), - extract_uinode_borders.after(RenderUiSystem::ExtractAtlasNode), + extract_uinode_borders, #[cfg(feature = "bevy_text")] - extract_text_uinodes.after(RenderUiSystem::ExtractAtlasNode), - extract_uinode_outlines.after(RenderUiSystem::ExtractAtlasNode), + extract_text_uinodes, + extract_uinode_outlines, ), ) .add_systems( @@ -377,6 +376,7 @@ pub fn extract_uinode_outlines( } pub fn extract_uinodes( + mut commands: Commands, mut extracted_uinodes: ResMut, texture_atlases: Extract>>, default_ui_camera: Extract, @@ -391,11 +391,22 @@ pub fn extract_uinodes( Option<&CalculatedClip>, Option<&TextureAtlas>, Option<&TargetCamera>, + Option<&ComputedTextureSlices>, )>, >, ) { - for (entity, uinode, transform, color, maybe_image, view_visibility, clip, atlas, camera) in - uinode_query.iter() + for ( + entity, + uinode, + transform, + color, + maybe_image, + view_visibility, + clip, + atlas, + camera, + slices, + ) in uinode_query.iter() { let Some(camera_entity) = camera.map(TargetCamera::entity).or(default_ui_camera.get()) else { @@ -406,6 +417,15 @@ pub fn extract_uinodes( continue; } + if let Some((image, slices)) = maybe_image.zip(slices) { + extracted_uinodes.uinodes.extend( + slices + .extract_ui_nodes(transform, uinode, color, image, clip, camera_entity) + .map(|e| (commands.spawn_empty().id(), e)), + ); + continue; + } + let (image, flip_x, flip_y) = if let Some(image) = maybe_image { (image.texture.id(), image.flip_x, image.flip_y) } else { diff --git a/crates/bevy_ui/src/texture_slice.rs b/crates/bevy_ui/src/texture_slice.rs new file mode 100644 index 0000000000000..25aa791be278c --- /dev/null +++ b/crates/bevy_ui/src/texture_slice.rs @@ -0,0 +1,185 @@ +// This module is mostly copied and pasted from `bevy_sprite::texture_slice` +// +// A more centralized solution should be investigated in the future + +use bevy_asset::{AssetEvent, Assets}; +use bevy_ecs::prelude::*; +use bevy_math::{Rect, Vec2}; +use bevy_render::texture::Image; +use bevy_sprite::{ImageScaleMode, TextureSlice}; +use bevy_transform::prelude::*; +use bevy_utils::HashSet; + +use crate::{widget::UiImageSize, BackgroundColor, CalculatedClip, ExtractedUiNode, Node, UiImage}; + +/// Component storing texture slices for image nodes entities with a tiled or sliced [`ImageScaleMode`] +/// +/// This component is automatically inserted and updated +#[derive(Debug, Clone, Component)] +pub struct ComputedTextureSlices { + slices: Vec, + image_size: Vec2, +} + +impl ComputedTextureSlices { + /// Computes [`ExtractedUiNode`] 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_ui_nodes<'a>( + &'a self, + transform: &'a GlobalTransform, + node: &'a Node, + background_color: &'a BackgroundColor, + image: &'a UiImage, + clip: Option<&'a CalculatedClip>, + camera_entity: Entity, + ) -> impl ExactSizeIterator + 'a { + let mut flip = Vec2::new(1.0, -1.0); + let [mut flip_x, mut flip_y] = [false; 2]; + if image.flip_x { + flip.x *= -1.0; + flip_x = true; + } + if image.flip_y { + flip.y *= -1.0; + flip_y = true; + } + self.slices.iter().map(move |slice| { + let offset = (slice.offset * flip).extend(0.0); + let transform = transform.mul_transform(Transform::from_translation(offset)); + let scale = slice.draw_size / slice.texture_rect.size(); + let mut rect = slice.texture_rect; + rect.min *= scale; + rect.max *= scale; + let atlas_size = Some(self.image_size * scale); + ExtractedUiNode { + stack_index: node.stack_index, + color: background_color.0, + transform: transform.compute_matrix(), + rect, + flip_x, + flip_y, + image: image.texture.id(), + atlas_size, + clip: clip.map(|clip| clip.clip), + camera_entity, + } + }) + } +} + +/// 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_texture_slices( + draw_area: Vec2, + scale_mode: &ImageScaleMode, + image_handle: &UiImage, + images: &Assets, +) -> Option { + if let ImageScaleMode::Stretched = scale_mode { + return None; + } + let image_size = images.get(&image_handle.texture).map(|i| { + Vec2::new( + i.texture_descriptor.size.width as f32, + i.texture_descriptor.size.height as f32, + ) + })?; + let texture_rect = Rect { + min: Vec2::ZERO, + max: image_size, + }; + let slices = match scale_mode { + ImageScaleMode::Stretched => unreachable!(), + ImageScaleMode::Sliced(slicer) => slicer.compute_slices(texture_rect, Some(draw_area)), + ImageScaleMode::Tiled { + tile_x, + tile_y, + stretch_value, + } => { + let slice = TextureSlice { + texture_rect, + draw_size: draw_area, + offset: Vec2::ZERO, + }; + slice.tiled(*stretch_value, (*tile_x, *tile_y)) + } + }; + Some(ComputedTextureSlices { slices, image_size }) +} + +/// 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>, + ui_nodes: Query<( + Entity, + &ImageScaleMode, + &Node, + Option<&UiImageSize>, + &UiImage, + )>, +) { + // 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, ui_node, size, image) in &ui_nodes { + if !added_handles.contains(&image.texture.id()) { + continue; + } + let size = size.map(|s| s.size()).unwrap_or(ui_node.size()); + if let Some(slices) = compute_texture_slices(size, scale_mode, image, &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_image_change( + mut commands: Commands, + images: Res>, + changed_nodes: Query< + ( + Entity, + &ImageScaleMode, + &Node, + Option<&UiImageSize>, + &UiImage, + ), + Or<( + Changed, + Changed, + Changed, + Changed, + )>, + >, +) { + for (entity, scale_mode, ui_node, size, image) in &changed_nodes { + let size = size.map(|s| s.size()).unwrap_or(ui_node.size()); + if let Some(slices) = compute_texture_slices(size, scale_mode, image, &images) { + commands.entity(entity).insert(slices); + } + } +} diff --git a/examples/2d/sprite_slice.rs b/examples/2d/sprite_slice.rs index cf6d44bed32f0..34fcfbcb2bdc9 100644 --- a/examples/2d/sprite_slice.rs +++ b/examples/2d/sprite_slice.rs @@ -1,4 +1,5 @@ -//! Showcases sprite 9 slice scaling +//! Showcases sprite 9 slice scaling and tiling features, enabling usage of +//! sprites in multiple resolutions while keeping it in proportion use bevy::prelude::*; fn main() { diff --git a/examples/README.md b/examples/README.md index eb35f67f4b5d3..a39d6e3b7e2cd 100644 --- a/examples/README.md +++ b/examples/README.md @@ -388,6 +388,7 @@ Example | Description [UI Material](../examples/ui/ui_material.rs) | Demonstrates creating and using custom Ui materials [UI Scaling](../examples/ui/ui_scaling.rs) | Illustrates how to scale the UI [UI Texture Atlas](../examples/ui/ui_texture_atlas.rs) | Illustrates how to use TextureAtlases in UI +[UI Texture Slice](../examples/ui/ui_texture_slice.rs) | Illustrates how to use 9 Slicing in UI [UI Z-Index](../examples/ui/z_index.rs) | Demonstrates how to control the relative depth (z-position) of UI elements [Viewport Debug](../examples/ui/viewport_debug.rs) | An example for debugging viewport coordinates [Window Fallthrough](../examples/ui/window_fallthrough.rs) | Illustrates how to access `winit::window::Window`'s `hittest` functionality. diff --git a/examples/ui/ui_texture_slice.rs b/examples/ui/ui_texture_slice.rs new file mode 100644 index 0000000000000..a486bbf258597 --- /dev/null +++ b/examples/ui/ui_texture_slice.rs @@ -0,0 +1,88 @@ +//! This example illustrates how to create a button that has its image sliced +//! and kept in proportion instead of being stretched by the button dimensions + +use bevy::{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), (Changed, With