Skip to content

Commit 03ec644

Browse files
authored
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" />
1 parent ca6b07c commit 03ec644

File tree

5 files changed

+129
-3
lines changed

5 files changed

+129
-3
lines changed

crates/bevy_ui/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,7 @@ impl Plugin for UiPlugin {
167167
.register_type::<Outline>()
168168
.register_type::<BoxShadowSamples>()
169169
.register_type::<UiAntiAlias>()
170+
.register_type::<TextShadow>()
170171
.configure_sets(
171172
PostUpdate,
172173
(

crates/bevy_ui/src/render/mod.rs

Lines changed: 106 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ mod debug_overlay;
1010
use crate::widget::ImageNode;
1111
use crate::{
1212
BackgroundColor, BorderColor, BoxShadowSamples, CalculatedClip, ComputedNode, DefaultUiCamera,
13-
Outline, ResolvedBorderRadius, UiAntiAlias, UiTargetCamera,
13+
Outline, ResolvedBorderRadius, TextShadow, UiAntiAlias, UiTargetCamera,
1414
};
1515
use bevy_app::prelude::*;
1616
use bevy_asset::{load_internal_asset, AssetEvent, AssetId, Assets, Handle};
@@ -105,6 +105,7 @@ pub enum RenderUiSystem {
105105
ExtractImages,
106106
ExtractTextureSlice,
107107
ExtractBorders,
108+
ExtractTextShadows,
108109
ExtractText,
109110
ExtractDebug,
110111
}
@@ -134,6 +135,7 @@ pub fn build_ui_render(app: &mut App) {
134135
RenderUiSystem::ExtractImages,
135136
RenderUiSystem::ExtractTextureSlice,
136137
RenderUiSystem::ExtractBorders,
138+
RenderUiSystem::ExtractTextShadows,
137139
RenderUiSystem::ExtractText,
138140
RenderUiSystem::ExtractDebug,
139141
)
@@ -146,6 +148,7 @@ pub fn build_ui_render(app: &mut App) {
146148
extract_uinode_background_colors.in_set(RenderUiSystem::ExtractBackgrounds),
147149
extract_uinode_images.in_set(RenderUiSystem::ExtractImages),
148150
extract_uinode_borders.in_set(RenderUiSystem::ExtractBorders),
151+
extract_text_shadows.in_set(RenderUiSystem::ExtractTextShadows),
149152
extract_text_sections.in_set(RenderUiSystem::ExtractText),
150153
#[cfg(feature = "bevy_ui_debug")]
151154
debug_overlay::extract_debug_overlay.in_set(RenderUiSystem::ExtractDebug),
@@ -714,8 +717,8 @@ pub fn extract_text_sections(
714717
text_styles: Extract<Query<&TextColor>>,
715718
camera_map: Extract<UiCameraMap>,
716719
) {
717-
let mut start = 0;
718-
let mut end = 1;
720+
let mut start = extracted_uinodes.glyphs.len();
721+
let mut end = start + 1;
719722

720723
let mut camera_mapper = camera_map.get_mapper();
721724
for (
@@ -743,6 +746,7 @@ pub fn extract_text_sections(
743746

744747
let mut color = LinearRgba::WHITE;
745748
let mut current_span = usize::MAX;
749+
746750
for (
747751
i,
748752
PositionedGlyph {
@@ -799,6 +803,105 @@ pub fn extract_text_sections(
799803
}
800804
}
801805

806+
pub fn extract_text_shadows(
807+
mut commands: Commands,
808+
mut extracted_uinodes: ResMut<ExtractedUiNodes>,
809+
default_ui_camera: Extract<DefaultUiCamera>,
810+
texture_atlases: Extract<Res<Assets<TextureAtlasLayout>>>,
811+
uinode_query: Extract<
812+
Query<(
813+
Entity,
814+
&ComputedNode,
815+
&GlobalTransform,
816+
&InheritedVisibility,
817+
Option<&CalculatedClip>,
818+
Option<&UiTargetCamera>,
819+
&TextLayoutInfo,
820+
&TextShadow,
821+
)>,
822+
>,
823+
mapping: Extract<Query<RenderEntity>>,
824+
) {
825+
let mut start = extracted_uinodes.glyphs.len();
826+
let mut end = start + 1;
827+
828+
let default_ui_camera = default_ui_camera.get();
829+
for (
830+
entity,
831+
uinode,
832+
global_transform,
833+
inherited_visibility,
834+
clip,
835+
camera,
836+
text_layout_info,
837+
shadow,
838+
) in &uinode_query
839+
{
840+
let Some(camera_entity) = camera.map(UiTargetCamera::entity).or(default_ui_camera) else {
841+
continue;
842+
};
843+
844+
// Skip if not visible or if size is set to zero (e.g. when a parent is set to `Display::None`)
845+
if !inherited_visibility.get() || uinode.is_empty() {
846+
continue;
847+
}
848+
849+
let Ok(extracted_camera_entity) = mapping.get(camera_entity) else {
850+
continue;
851+
};
852+
853+
let transform = global_transform.affine()
854+
* Mat4::from_translation(
855+
(-0.5 * uinode.size() + shadow.offset / uinode.inverse_scale_factor()).extend(0.),
856+
);
857+
858+
let mut current_span = usize::MAX;
859+
for (
860+
i,
861+
PositionedGlyph {
862+
position,
863+
atlas_info,
864+
span_index,
865+
..
866+
},
867+
) in text_layout_info.glyphs.iter().enumerate()
868+
{
869+
if *span_index != current_span {
870+
current_span = *span_index;
871+
}
872+
873+
let rect = texture_atlases
874+
.get(&atlas_info.texture_atlas)
875+
.unwrap()
876+
.textures[atlas_info.location.glyph_index]
877+
.as_rect();
878+
extracted_uinodes.glyphs.push(ExtractedGlyph {
879+
transform: transform * Mat4::from_translation(position.extend(0.)),
880+
rect,
881+
});
882+
883+
if text_layout_info.glyphs.get(i + 1).is_none_or(|info| {
884+
info.span_index != current_span || info.atlas_info.texture != atlas_info.texture
885+
}) {
886+
extracted_uinodes.uinodes.push(ExtractedUiNode {
887+
render_entity: commands.spawn(TemporaryRenderEntity).id(),
888+
stack_index: uinode.stack_index,
889+
color: shadow.color.into(),
890+
image: atlas_info.texture.id(),
891+
clip: clip.map(|clip| clip.clip),
892+
extracted_camera_entity,
893+
rect,
894+
item: ExtractedUiItem::Glyphs { range: start..end },
895+
main_entity: entity.into(),
896+
});
897+
start = end;
898+
}
899+
900+
end += 1;
901+
}
902+
}
903+
}
904+
802905
#[repr(C)]
803906
#[derive(Copy, Clone, Pod, Zeroable)]
804907
struct UiVertex {

crates/bevy_ui/src/ui_node.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2762,3 +2762,23 @@ impl Default for BoxShadowSamples {
27622762
Self(4)
27632763
}
27642764
}
2765+
2766+
/// Adds a shadow behind text
2767+
#[derive(Component, Copy, Clone, Debug, Reflect)]
2768+
#[reflect(Component, Default, Debug)]
2769+
pub struct TextShadow {
2770+
/// Shadow displacement in logical pixels
2771+
/// With a value of zero the shadow will be hidden directly behind the text
2772+
pub offset: Vec2,
2773+
/// Color of the shadow
2774+
pub color: Color,
2775+
}
2776+
2777+
impl Default for TextShadow {
2778+
fn default() -> Self {
2779+
Self {
2780+
offset: Vec2::splat(4.),
2781+
color: Color::linear_rgba(0., 0., 0., 0.75),
2782+
}
2783+
}
2784+
}

examples/ui/button.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
8888
..default()
8989
},
9090
TextColor(Color::srgb(0.9, 0.9, 0.9)),
91+
TextShadow::default(),
9192
));
9293
});
9394
}

examples/ui/text.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
3838
font_size: 67.0,
3939
..default()
4040
},
41+
TextShadow::default(),
4142
// Set the justification of the Text
4243
TextLayout::new_with_justify(JustifyText::Center),
4344
// Set the style of the Node itself.

0 commit comments

Comments
 (0)