From 03ec6441a7e508887909a8a3c8afc83ac17e2dcb Mon Sep 17 00:00:00 2001 From: ickshonpe Date: Wed, 5 Feb 2025 19:29:37 +0000 Subject: [PATCH] Basic UI text shadows (#17559) # Objective Basic `TextShadow` support. ## Solution New `TextShadow` component with `offset` and `color` fields. Just insert it on a `Text` node to add a shadow. New system `extract_text_shadows` handles rendering. It's not "real" shadows just the text redrawn with an offset and a different colour. Blur-radius support will need changes to the shaders and be a lot more complicated, whereas this still looks okay and took a couple of minutes to implement. I added the `TextShadow` component to `bevy_ui` rather than `bevy_text` because it only supports the UI atm. We can add a `Text2d` version in a followup but getting the same effect in `Text2d` is trivial even without official support. --- ## Showcase text_shadow g --- crates/bevy_ui/src/lib.rs | 1 + crates/bevy_ui/src/render/mod.rs | 109 ++++++++++++++++++++++++++++++- crates/bevy_ui/src/ui_node.rs | 20 ++++++ examples/ui/button.rs | 1 + examples/ui/text.rs | 1 + 5 files changed, 129 insertions(+), 3 deletions(-) diff --git a/crates/bevy_ui/src/lib.rs b/crates/bevy_ui/src/lib.rs index a5c0313e34..949f022b21 100644 --- a/crates/bevy_ui/src/lib.rs +++ b/crates/bevy_ui/src/lib.rs @@ -167,6 +167,7 @@ impl Plugin for UiPlugin { .register_type::() .register_type::() .register_type::() + .register_type::() .configure_sets( PostUpdate, ( diff --git a/crates/bevy_ui/src/render/mod.rs b/crates/bevy_ui/src/render/mod.rs index 6135ee739a..7f773fead5 100644 --- a/crates/bevy_ui/src/render/mod.rs +++ b/crates/bevy_ui/src/render/mod.rs @@ -10,7 +10,7 @@ mod debug_overlay; use crate::widget::ImageNode; use crate::{ BackgroundColor, BorderColor, BoxShadowSamples, CalculatedClip, ComputedNode, DefaultUiCamera, - Outline, ResolvedBorderRadius, UiAntiAlias, UiTargetCamera, + Outline, ResolvedBorderRadius, TextShadow, UiAntiAlias, UiTargetCamera, }; use bevy_app::prelude::*; use bevy_asset::{load_internal_asset, AssetEvent, AssetId, Assets, Handle}; @@ -105,6 +105,7 @@ pub enum RenderUiSystem { ExtractImages, ExtractTextureSlice, ExtractBorders, + ExtractTextShadows, ExtractText, ExtractDebug, } @@ -134,6 +135,7 @@ pub fn build_ui_render(app: &mut App) { RenderUiSystem::ExtractImages, RenderUiSystem::ExtractTextureSlice, RenderUiSystem::ExtractBorders, + RenderUiSystem::ExtractTextShadows, RenderUiSystem::ExtractText, RenderUiSystem::ExtractDebug, ) @@ -146,6 +148,7 @@ pub fn build_ui_render(app: &mut App) { extract_uinode_background_colors.in_set(RenderUiSystem::ExtractBackgrounds), extract_uinode_images.in_set(RenderUiSystem::ExtractImages), extract_uinode_borders.in_set(RenderUiSystem::ExtractBorders), + extract_text_shadows.in_set(RenderUiSystem::ExtractTextShadows), extract_text_sections.in_set(RenderUiSystem::ExtractText), #[cfg(feature = "bevy_ui_debug")] debug_overlay::extract_debug_overlay.in_set(RenderUiSystem::ExtractDebug), @@ -714,8 +717,8 @@ pub fn extract_text_sections( text_styles: Extract>, camera_map: Extract, ) { - let mut start = 0; - let mut end = 1; + let mut start = extracted_uinodes.glyphs.len(); + let mut end = start + 1; let mut camera_mapper = camera_map.get_mapper(); for ( @@ -743,6 +746,7 @@ pub fn extract_text_sections( let mut color = LinearRgba::WHITE; let mut current_span = usize::MAX; + for ( i, PositionedGlyph { @@ -799,6 +803,105 @@ pub fn extract_text_sections( } } +pub fn extract_text_shadows( + mut commands: Commands, + mut extracted_uinodes: ResMut, + default_ui_camera: Extract, + texture_atlases: Extract>>, + uinode_query: Extract< + Query<( + Entity, + &ComputedNode, + &GlobalTransform, + &InheritedVisibility, + Option<&CalculatedClip>, + Option<&UiTargetCamera>, + &TextLayoutInfo, + &TextShadow, + )>, + >, + mapping: Extract>, +) { + let mut start = extracted_uinodes.glyphs.len(); + let mut end = start + 1; + + let default_ui_camera = default_ui_camera.get(); + for ( + entity, + uinode, + global_transform, + inherited_visibility, + clip, + camera, + text_layout_info, + shadow, + ) in &uinode_query + { + let Some(camera_entity) = camera.map(UiTargetCamera::entity).or(default_ui_camera) else { + continue; + }; + + // Skip if not visible or if size is set to zero (e.g. when a parent is set to `Display::None`) + if !inherited_visibility.get() || uinode.is_empty() { + continue; + } + + let Ok(extracted_camera_entity) = mapping.get(camera_entity) else { + continue; + }; + + let transform = global_transform.affine() + * Mat4::from_translation( + (-0.5 * uinode.size() + shadow.offset / uinode.inverse_scale_factor()).extend(0.), + ); + + let mut current_span = usize::MAX; + for ( + i, + PositionedGlyph { + position, + atlas_info, + span_index, + .. + }, + ) in text_layout_info.glyphs.iter().enumerate() + { + if *span_index != current_span { + current_span = *span_index; + } + + let rect = texture_atlases + .get(&atlas_info.texture_atlas) + .unwrap() + .textures[atlas_info.location.glyph_index] + .as_rect(); + extracted_uinodes.glyphs.push(ExtractedGlyph { + transform: transform * Mat4::from_translation(position.extend(0.)), + rect, + }); + + if text_layout_info.glyphs.get(i + 1).is_none_or(|info| { + info.span_index != current_span || info.atlas_info.texture != atlas_info.texture + }) { + extracted_uinodes.uinodes.push(ExtractedUiNode { + render_entity: commands.spawn(TemporaryRenderEntity).id(), + stack_index: uinode.stack_index, + color: shadow.color.into(), + image: atlas_info.texture.id(), + clip: clip.map(|clip| clip.clip), + extracted_camera_entity, + rect, + item: ExtractedUiItem::Glyphs { range: start..end }, + main_entity: entity.into(), + }); + start = end; + } + + end += 1; + } + } +} + #[repr(C)] #[derive(Copy, Clone, Pod, Zeroable)] struct UiVertex { diff --git a/crates/bevy_ui/src/ui_node.rs b/crates/bevy_ui/src/ui_node.rs index f1e8c3a263..330ad5facd 100644 --- a/crates/bevy_ui/src/ui_node.rs +++ b/crates/bevy_ui/src/ui_node.rs @@ -2762,3 +2762,23 @@ impl Default for BoxShadowSamples { Self(4) } } + +/// Adds a shadow behind text +#[derive(Component, Copy, Clone, Debug, Reflect)] +#[reflect(Component, Default, Debug)] +pub struct TextShadow { + /// Shadow displacement in logical pixels + /// With a value of zero the shadow will be hidden directly behind the text + pub offset: Vec2, + /// Color of the shadow + pub color: Color, +} + +impl Default for TextShadow { + fn default() -> Self { + Self { + offset: Vec2::splat(4.), + color: Color::linear_rgba(0., 0., 0., 0.75), + } + } +} diff --git a/examples/ui/button.rs b/examples/ui/button.rs index 03740f4b4b..bf71ad0881 100644 --- a/examples/ui/button.rs +++ b/examples/ui/button.rs @@ -88,6 +88,7 @@ fn setup(mut commands: Commands, asset_server: Res) { ..default() }, TextColor(Color::srgb(0.9, 0.9, 0.9)), + TextShadow::default(), )); }); } diff --git a/examples/ui/text.rs b/examples/ui/text.rs index e1bb85bc09..8bf34cc96e 100644 --- a/examples/ui/text.rs +++ b/examples/ui/text.rs @@ -38,6 +38,7 @@ fn setup(mut commands: Commands, asset_server: Res) { font_size: 67.0, ..default() }, + TextShadow::default(), // Set the justification of the Text TextLayout::new_with_justify(JustifyText::Center), // Set the style of the Node itself.