Make default behavior for BackgroundColor and BorderColor more intuitive (#14017)

# Objective

In Bevy 0.13, `BackgroundColor` simply tinted the image of any
`UiImage`. This was confusing: in every other case (e.g. Text), this
added a solid square behind the element. #11165 changed this, but
removed `BackgroundColor` from `ImageBundle` to avoid confusion, since
the semantic meaning had changed.

However, this resulted in a serious UX downgrade / inconsistency, as
this behavior was no longer part of the bundle (unlike for `TextBundle`
or `NodeBundle`), leaving users with a relatively frustrating upgrade
path.

Additionally, adding both `BackgroundColor` and `UiImage` resulted in a
bizarre effect, where the background color was seemingly ignored as it
was covered by a solid white placeholder image.

Fixes #13969.

## Solution

Per @viridia's design:

> - if you don't specify a background color, it's transparent.
> - if you don't specify an image color, it's white (because it's a
multiplier).
> - if you don't specify an image, no image is drawn.
> - if you specify both a background color and an image color, they are
independent.
> - the background color is drawn behind the image (in whatever pixels
are transparent)

As laid out by @benfrankel, this involves:

1. Changing the default `UiImage` to use a transparent texture but a
pure white tint.
2. Adding `UiImage::solid_color` to quickly set placeholder images.
3. Changing the default `BorderColor` and `BackgroundColor` to
transparent.
4. Removing the default overrides for these values in the other assorted
UI bundles.
5. Adding `BackgroundColor` back to `ImageBundle` and `ButtonBundle`.
6. Adding a 1x1 `Image::transparent`, which can be accessed from
`Assets<Image>` via the `TRANSPARENT_IMAGE_HANDLE` constant.

Huge thanks to everyone who helped out with the design in the linked
issue and [the Discord
thread](https://discord.com/channels/691052431525675048/1255209923890118697/1255209999278280844):
this was very much a joint design.

@cart helped me figure out how to set the UiImage's default texture to a
transparent 1x1 image, which is a much nicer fix.

## Testing

I've checked the examples modified by this PR, and the `ui` example as
well just to be sure.

## Migration Guide

- `BackgroundColor` no longer tints the color of images in `ImageBundle`
or `ButtonBundle`. Set `UiImage::color` to tint images instead.
- The default texture for `UiImage` is now a transparent white square.
Use `UiImage::solid_color` to quickly draw debug images.
- The default value for `BackgroundColor` and `BorderColor` is now
transparent. Set the color to white manually to return to previous
behavior.
This commit is contained in:
Alice Cecile 2024-06-25 17:50:41 -04:00 committed by GitHub
parent dbffb41e50
commit 336fddb101
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 131 additions and 82 deletions

View file

@ -531,6 +531,38 @@ impl Image {
image
}
/// A transparent white 1x1x1 image.
///
/// Contrast to [`Image::default`], which is opaque.
pub fn transparent() -> Image {
// We rely on the default texture format being RGBA8UnormSrgb
// when constructing a transparent color from bytes.
// If this changes, this function will need to be updated.
let format = TextureFormat::bevy_default();
debug_assert!(format.pixel_size() == 4);
let data = vec![255, 255, 255, 0];
Image {
data,
texture_descriptor: wgpu::TextureDescriptor {
size: Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
format,
dimension: TextureDimension::D2,
label: None,
mip_level_count: 1,
sample_count: 1,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
},
sampler: ImageSampler::Default,
texture_view_descriptor: None,
asset_usage: RenderAssetUsages::default(),
}
}
/// Creates a new image from raw binary data and the corresponding metadata, by filling
/// the image data with the `pixel` data repeated multiple times.
///

View file

@ -43,6 +43,14 @@ use bevy_app::{App, Plugin};
use bevy_asset::{AssetApp, Assets, Handle};
use bevy_ecs::prelude::*;
/// A handle to a 1 x 1 transparent white image.
///
/// Like [`Handle<Image>::default`], this is a handle to a fallback image asset.
/// While that handle points to an opaque white 1 x 1 image, this handle points to a transparent 1 x 1 white image.
// Number randomly selected by fair WolframAlpha query. Totally arbitrary.
pub const TRANSPARENT_IMAGE_HANDLE: Handle<Image> =
Handle::weak_from_u128(154728948001857810431816125397303024160);
// TODO: replace Texture names with Image names?
/// Adds the [`Image`] as an asset and makes sure that they are extracted and prepared for the GPU.
pub struct ImagePlugin {
@ -89,9 +97,11 @@ impl Plugin for ImagePlugin {
.init_asset::<Image>()
.register_asset_reflect::<Image>();
app.world_mut()
.resource_mut::<Assets<Image>>()
.insert(&Handle::default(), Image::default());
let mut image_assets = app.world_mut().resource_mut::<Assets<Image>>();
image_assets.insert(&Handle::default(), Image::default());
image_assets.insert(&TRANSPARENT_IMAGE_HANDLE, Image::transparent());
#[cfg(feature = "basis-universal")]
if let Some(processor) = app
.world()

View file

@ -23,7 +23,7 @@ use bevy_transform::prelude::{GlobalTransform, Transform};
/// Contains the [`Node`] component and other components required to make a container.
///
/// See [`node_bundles`](crate::node_bundles) for more specialized bundles like [`TextBundle`].
#[derive(Bundle, Clone, Debug)]
#[derive(Bundle, Clone, Debug, Default)]
pub struct NodeBundle {
/// Describes the logical size of the node
pub node: Node,
@ -58,26 +58,6 @@ pub struct NodeBundle {
pub z_index: ZIndex,
}
impl Default for NodeBundle {
fn default() -> Self {
NodeBundle {
// Transparent background
background_color: Color::NONE.into(),
border_color: Color::NONE.into(),
border_radius: BorderRadius::default(),
node: Default::default(),
style: Default::default(),
focus_policy: Default::default(),
transform: Default::default(),
global_transform: Default::default(),
visibility: Default::default(),
inherited_visibility: Default::default(),
view_visibility: Default::default(),
z_index: Default::default(),
}
}
}
/// A UI node that is an image
///
/// # Extra behaviours
@ -94,8 +74,12 @@ pub struct ImageBundle {
pub style: Style,
/// The calculated size based on the given image
pub calculated_size: ContentSize,
/// The image of the node
/// The image of the node.
///
/// To tint the image, change the `color` field of this component.
pub image: UiImage,
/// The color of the background that will fill the containing node.
pub background_color: BackgroundColor,
/// The size of the image in pixels
///
/// This component is set automatically
@ -176,7 +160,7 @@ pub struct AtlasImageBundle {
///
/// The positioning of this node is controlled by the UI layout system. If you need manual control,
/// use [`Text2dBundle`](bevy_text::Text2dBundle).
#[derive(Bundle, Debug)]
#[derive(Bundle, Debug, Default)]
pub struct TextBundle {
/// Describes the logical size of the node
pub node: Node,
@ -214,29 +198,6 @@ pub struct TextBundle {
pub background_color: BackgroundColor,
}
#[cfg(feature = "bevy_text")]
impl Default for TextBundle {
fn default() -> Self {
Self {
text: Default::default(),
text_layout_info: Default::default(),
text_flags: Default::default(),
calculated_size: Default::default(),
node: Default::default(),
style: Default::default(),
focus_policy: Default::default(),
transform: Default::default(),
global_transform: Default::default(),
visibility: Default::default(),
inherited_visibility: Default::default(),
view_visibility: Default::default(),
z_index: Default::default(),
// Transparent background
background_color: BackgroundColor(Color::NONE),
}
}
}
#[cfg(feature = "bevy_text")]
impl TextBundle {
/// Create a [`TextBundle`] from a single section.
@ -321,6 +282,8 @@ pub struct ButtonBundle {
pub border_radius: BorderRadius,
/// The image of the node
pub image: UiImage,
/// The background color that will fill the containing node
pub background_color: BackgroundColor,
/// The transform of the node
///
/// This component is automatically managed by the UI layout system.
@ -348,9 +311,10 @@ impl Default for ButtonBundle {
style: Default::default(),
interaction: Default::default(),
focus_policy: FocusPolicy::Block,
border_color: BorderColor(Color::NONE),
border_radius: BorderRadius::default(),
border_color: Default::default(),
border_radius: Default::default(),
image: Default::default(),
background_color: Default::default(),
transform: Default::default(),
global_transform: Default::default(),
visibility: Default::default(),

View file

@ -6,7 +6,7 @@ use bevy_math::{Rect, Vec2};
use bevy_reflect::prelude::*;
use bevy_render::{
camera::{Camera, RenderTarget},
texture::Image,
texture::{Image, TRANSPARENT_IMAGE_HANDLE},
};
use bevy_transform::prelude::GlobalTransform;
use bevy_utils::warn_once;
@ -1693,7 +1693,8 @@ pub enum GridPlacementError {
pub struct BackgroundColor(pub Color);
impl BackgroundColor {
pub const DEFAULT: Self = Self(Color::WHITE);
/// Background color is transparent by default.
pub const DEFAULT: Self = Self(Color::NONE);
}
impl Default for BackgroundColor {
@ -1725,7 +1726,8 @@ impl<T: Into<Color>> From<T> for BorderColor {
}
impl BorderColor {
pub const DEFAULT: Self = BorderColor(Color::WHITE);
/// Border color is transparent by default.
pub const DEFAULT: Self = BorderColor(Color::NONE);
}
impl Default for BorderColor {
@ -1819,12 +1821,17 @@ impl Outline {
}
/// The 2D texture displayed for this UI node
#[derive(Component, Clone, Debug, Reflect, Default)]
#[derive(Component, Clone, Debug, Reflect)]
#[reflect(Component, Default)]
pub struct UiImage {
/// The tint color used to draw the image
/// The tint color used to draw the image.
///
/// This is multiplied by the color of each pixel in the image.
/// The field value defaults to solid white, which will pass the image through unmodified.
pub color: Color,
/// Handle to the texture
/// Handle to the texture.
///
/// This defaults to a [`TRANSPARENT_IMAGE_HANDLE`], which points to a fully transparent 1x1 texture.
pub texture: Handle<Image>,
/// Whether the image should be flipped along its x-axis
pub flip_x: bool,
@ -1832,14 +1839,49 @@ pub struct UiImage {
pub flip_y: bool,
}
impl Default for UiImage {
/// A transparent 1x1 image with a solid white tint.
///
/// # Warning
///
/// This will be invisible by default.
/// To set this to a visible image, you need to set the `texture` field to a valid image handle,
/// or use [`Handle<Image>`]'s default 1x1 solid white texture (as is done in [`UiImage::solid_color`]).
fn default() -> Self {
UiImage {
// This should be white because the tint is multiplied with the image,
// so if you set an actual image with default tint you'd want its original colors
color: Color::WHITE,
// This texture needs to be transparent by default, to avoid covering the background color
texture: TRANSPARENT_IMAGE_HANDLE,
flip_x: false,
flip_y: false,
}
}
}
impl UiImage {
/// Create a new [`UiImage`] with the given texture.
pub fn new(texture: Handle<Image>) -> Self {
Self {
texture,
color: Color::WHITE,
..Default::default()
}
}
/// Create a solid color [`UiImage`].
///
/// This is primarily useful for debugging / mocking the extents of your image.
pub fn solid_color(color: Color) -> Self {
Self {
texture: Handle::default(),
color,
flip_x: false,
flip_y: false,
}
}
/// Set the color tint
#[must_use]
pub const fn with_color(mut self, color: Color) -> Self {

View file

@ -267,7 +267,7 @@ fn add_button_for_value(
},
border_color: BorderColor(Color::WHITE),
border_radius: BorderRadius::MAX,
image: UiImage::default().with_color(Color::BLACK),
background_color: Color::BLACK.into(),
..default()
})
.insert(ColorGradingOptionWidget {

View file

@ -140,7 +140,7 @@ fn setup(
..default()
},
border_color: Color::WHITE.into(),
image: UiImage::default().with_color(Color::srgb(0.25, 0.25, 0.25)),
background_color: Color::srgb(0.25, 0.25, 0.25).into(),
..default()
},
))

View file

@ -456,7 +456,7 @@ mod menu {
.spawn((
ButtonBundle {
style: button_style.clone(),
image: UiImage::default().with_color(NORMAL_BUTTON),
background_color: NORMAL_BUTTON.into(),
..default()
},
MenuButtonAction::Play,
@ -477,7 +477,7 @@ mod menu {
.spawn((
ButtonBundle {
style: button_style.clone(),
image: UiImage::default().with_color(NORMAL_BUTTON),
background_color: NORMAL_BUTTON.into(),
..default()
},
MenuButtonAction::Settings,
@ -498,7 +498,7 @@ mod menu {
.spawn((
ButtonBundle {
style: button_style,
image: UiImage::default().with_color(NORMAL_BUTTON),
background_color: NORMAL_BUTTON.into(),
..default()
},
MenuButtonAction::Quit,
@ -567,7 +567,7 @@ mod menu {
.spawn((
ButtonBundle {
style: button_style.clone(),
image: UiImage::default().with_color(NORMAL_BUTTON),
background_color: NORMAL_BUTTON.into(),
..default()
},
action,
@ -654,7 +654,7 @@ mod menu {
height: Val::Px(65.0),
..button_style.clone()
},
image: UiImage::default().with_color(NORMAL_BUTTON),
background_color: NORMAL_BUTTON.into(),
..default()
},
quality_setting,
@ -675,7 +675,7 @@ mod menu {
.spawn((
ButtonBundle {
style: button_style,
image: UiImage::default().with_color(NORMAL_BUTTON),
background_color: NORMAL_BUTTON.into(),
..default()
},
MenuButtonAction::BackToSettings,
@ -750,7 +750,7 @@ mod menu {
height: Val::Px(65.0),
..button_style.clone()
},
image: UiImage::default().with_color(NORMAL_BUTTON),
background_color: NORMAL_BUTTON.into(),
..default()
},
Volume(volume_setting),
@ -764,7 +764,7 @@ mod menu {
.spawn((
ButtonBundle {
style: button_style,
image: UiImage::default().with_color(NORMAL_BUTTON),
background_color: NORMAL_BUTTON.into(),
..default()
},
MenuButtonAction::BackToSettings,

View file

@ -125,7 +125,6 @@ fn setup_scene(
bottom: Val::Px(50.0),
..default()
},
image: UiImage::default().with_color(Color::NONE),
..default()
},
BackgroundColor(Color::WHITE),

View file

@ -363,7 +363,7 @@ mod ui {
align_items: AlignItems::Center,
..default()
},
image: UiImage::default().with_color(NORMAL_BUTTON),
background_color: NORMAL_BUTTON.into(),
..default()
},
MenuButton::Play,
@ -391,10 +391,11 @@ mod ui {
align_items: AlignItems::Center,
..default()
},
image: UiImage::default().with_color(match tutorial_state.get() {
background_color: match tutorial_state.get() {
TutorialState::Active => ACTIVE_BUTTON,
TutorialState::Inactive => NORMAL_BUTTON,
}),
}
.into(),
..default()
},
MenuButton::Tutorial,

View file

@ -270,7 +270,7 @@ fn setup_menu(mut commands: Commands) {
align_items: AlignItems::Center,
..default()
},
image: UiImage::default().with_color(NORMAL_BUTTON),
background_color: NORMAL_BUTTON.into(),
..default()
})
.with_children(|parent| {

View file

@ -74,7 +74,7 @@ fn setup_menu(mut commands: Commands) {
align_items: AlignItems::Center,
..default()
},
image: UiImage::default().with_color(NORMAL_BUTTON),
background_color: NORMAL_BUTTON.into(),
..default()
})
.with_children(|parent| {

View file

@ -180,7 +180,7 @@ mod ui {
align_items: AlignItems::Center,
..default()
},
image: UiImage::default().with_color(NORMAL_BUTTON),
background_color: NORMAL_BUTTON.into(),
..default()
})
.with_children(|parent| {

View file

@ -246,7 +246,7 @@ fn spawn_button(
border,
..default()
},
image: UiImage::default().with_color(background_color),
background_color: background_color.into(),
border_color,
..default()
},

View file

@ -75,7 +75,7 @@ fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
},
border_color: BorderColor(Color::BLACK),
border_radius: BorderRadius::MAX,
image: UiImage::default().with_color(NORMAL_BUTTON),
background_color: NORMAL_BUTTON.into(),
..default()
})
.with_children(|parent| {

View file

@ -415,7 +415,7 @@ where
padding: UiRect::axes(Val::Px(5.), Val::Px(1.)),
..Default::default()
},
image: UiImage::default().with_color(Color::BLACK.with_alpha(0.5)),
background_color: Color::BLACK.with_alpha(0.5).into(),
..Default::default()
},
Target::<T>::new(target),

View file

@ -245,11 +245,12 @@ fn spawn_button(
margin: UiRect::horizontal(Val::Px(2.)),
..Default::default()
},
image: UiImage::default().with_color(if active {
background_color: if active {
ACTIVE_BORDER_COLOR
} else {
INACTIVE_BORDER_COLOR
}),
}
.into(),
..Default::default()
},
constraint,

View file

@ -37,7 +37,7 @@ fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
align_items: AlignItems::Center,
..default()
},
image: UiImage::default().with_color(Color::srgb(0.1, 0.5, 0.1)),
background_color: Color::srgb(0.1, 0.5, 0.1).into(),
..default()
})
.with_children(|parent| {
@ -63,7 +63,7 @@ fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
align_items: AlignItems::Center,
..default()
},
image: UiImage::default().with_color(Color::srgb(0.5, 0.1, 0.5)),
background_color: Color::srgb(0.5, 0.1, 0.5).into(),
..default()
})
.with_children(|parent| {

View file

@ -54,10 +54,10 @@ fn setup(
..default()
},
image: UiImage::new(texture_handle),
background_color: BackgroundColor(ANTIQUE_WHITE.into()),
..default()
},
TextureAtlas::from(texture_atlas_handle),
BackgroundColor(ANTIQUE_WHITE.into()),
Outline::new(Val::Px(8.0), Val::ZERO, CRIMSON.into()),
));
parent.spawn(TextBundle::from_sections([