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

<img width="122" alt="text_shadow"
src="https://github.com/user-attachments/assets/0333d167-c507-4262-b93b-b6d39e2cf3a4"
/>
<img width="136" alt="g"
src="https://github.com/user-attachments/assets/9b01d5d9-55c9-4af7-9360-a7b04f55944d"
/>
This commit is contained in:
ickshonpe 2025-02-05 19:29:37 +00:00 committed by GitHub
parent ca6b07c348
commit 03ec6441a7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 129 additions and 3 deletions

View File

@ -167,6 +167,7 @@ impl Plugin for UiPlugin {
.register_type::<Outline>() .register_type::<Outline>()
.register_type::<BoxShadowSamples>() .register_type::<BoxShadowSamples>()
.register_type::<UiAntiAlias>() .register_type::<UiAntiAlias>()
.register_type::<TextShadow>()
.configure_sets( .configure_sets(
PostUpdate, PostUpdate,
( (

View File

@ -10,7 +10,7 @@ mod debug_overlay;
use crate::widget::ImageNode; use crate::widget::ImageNode;
use crate::{ use crate::{
BackgroundColor, BorderColor, BoxShadowSamples, CalculatedClip, ComputedNode, DefaultUiCamera, BackgroundColor, BorderColor, BoxShadowSamples, CalculatedClip, ComputedNode, DefaultUiCamera,
Outline, ResolvedBorderRadius, UiAntiAlias, UiTargetCamera, Outline, ResolvedBorderRadius, TextShadow, UiAntiAlias, UiTargetCamera,
}; };
use bevy_app::prelude::*; use bevy_app::prelude::*;
use bevy_asset::{load_internal_asset, AssetEvent, AssetId, Assets, Handle}; use bevy_asset::{load_internal_asset, AssetEvent, AssetId, Assets, Handle};
@ -105,6 +105,7 @@ pub enum RenderUiSystem {
ExtractImages, ExtractImages,
ExtractTextureSlice, ExtractTextureSlice,
ExtractBorders, ExtractBorders,
ExtractTextShadows,
ExtractText, ExtractText,
ExtractDebug, ExtractDebug,
} }
@ -134,6 +135,7 @@ pub fn build_ui_render(app: &mut App) {
RenderUiSystem::ExtractImages, RenderUiSystem::ExtractImages,
RenderUiSystem::ExtractTextureSlice, RenderUiSystem::ExtractTextureSlice,
RenderUiSystem::ExtractBorders, RenderUiSystem::ExtractBorders,
RenderUiSystem::ExtractTextShadows,
RenderUiSystem::ExtractText, RenderUiSystem::ExtractText,
RenderUiSystem::ExtractDebug, RenderUiSystem::ExtractDebug,
) )
@ -146,6 +148,7 @@ pub fn build_ui_render(app: &mut App) {
extract_uinode_background_colors.in_set(RenderUiSystem::ExtractBackgrounds), extract_uinode_background_colors.in_set(RenderUiSystem::ExtractBackgrounds),
extract_uinode_images.in_set(RenderUiSystem::ExtractImages), extract_uinode_images.in_set(RenderUiSystem::ExtractImages),
extract_uinode_borders.in_set(RenderUiSystem::ExtractBorders), extract_uinode_borders.in_set(RenderUiSystem::ExtractBorders),
extract_text_shadows.in_set(RenderUiSystem::ExtractTextShadows),
extract_text_sections.in_set(RenderUiSystem::ExtractText), extract_text_sections.in_set(RenderUiSystem::ExtractText),
#[cfg(feature = "bevy_ui_debug")] #[cfg(feature = "bevy_ui_debug")]
debug_overlay::extract_debug_overlay.in_set(RenderUiSystem::ExtractDebug), debug_overlay::extract_debug_overlay.in_set(RenderUiSystem::ExtractDebug),
@ -714,8 +717,8 @@ pub fn extract_text_sections(
text_styles: Extract<Query<&TextColor>>, text_styles: Extract<Query<&TextColor>>,
camera_map: Extract<UiCameraMap>, camera_map: Extract<UiCameraMap>,
) { ) {
let mut start = 0; let mut start = extracted_uinodes.glyphs.len();
let mut end = 1; let mut end = start + 1;
let mut camera_mapper = camera_map.get_mapper(); let mut camera_mapper = camera_map.get_mapper();
for ( for (
@ -743,6 +746,7 @@ pub fn extract_text_sections(
let mut color = LinearRgba::WHITE; let mut color = LinearRgba::WHITE;
let mut current_span = usize::MAX; let mut current_span = usize::MAX;
for ( for (
i, i,
PositionedGlyph { PositionedGlyph {
@ -799,6 +803,105 @@ pub fn extract_text_sections(
} }
} }
pub fn extract_text_shadows(
mut commands: Commands,
mut extracted_uinodes: ResMut<ExtractedUiNodes>,
default_ui_camera: Extract<DefaultUiCamera>,
texture_atlases: Extract<Res<Assets<TextureAtlasLayout>>>,
uinode_query: Extract<
Query<(
Entity,
&ComputedNode,
&GlobalTransform,
&InheritedVisibility,
Option<&CalculatedClip>,
Option<&UiTargetCamera>,
&TextLayoutInfo,
&TextShadow,
)>,
>,
mapping: Extract<Query<RenderEntity>>,
) {
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)] #[repr(C)]
#[derive(Copy, Clone, Pod, Zeroable)] #[derive(Copy, Clone, Pod, Zeroable)]
struct UiVertex { struct UiVertex {

View File

@ -2762,3 +2762,23 @@ impl Default for BoxShadowSamples {
Self(4) 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),
}
}
}

View File

@ -88,6 +88,7 @@ fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
..default() ..default()
}, },
TextColor(Color::srgb(0.9, 0.9, 0.9)), TextColor(Color::srgb(0.9, 0.9, 0.9)),
TextShadow::default(),
)); ));
}); });
} }

View File

@ -38,6 +38,7 @@ fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
font_size: 67.0, font_size: 67.0,
..default() ..default()
}, },
TextShadow::default(),
// Set the justification of the Text // Set the justification of the Text
TextLayout::new_with_justify(JustifyText::Center), TextLayout::new_with_justify(JustifyText::Center),
// Set the style of the Node itself. // Set the style of the Node itself.