bevy_sprite/
text2d.rs

1use crate::{Anchor, Sprite};
2use bevy_asset::Assets;
3use bevy_camera::primitives::Aabb;
4use bevy_camera::visibility::{
5    self, NoFrustumCulling, RenderLayers, Visibility, VisibilityClass, VisibleEntities,
6};
7use bevy_camera::Camera;
8use bevy_color::Color;
9use bevy_derive::{Deref, DerefMut};
10use bevy_ecs::entity::EntityHashSet;
11use bevy_ecs::{
12    change_detection::{DetectChanges, Ref},
13    component::Component,
14    entity::Entity,
15    prelude::ReflectComponent,
16    query::{Changed, Without},
17    system::{Commands, Local, Query, Res, ResMut},
18};
19use bevy_image::prelude::*;
20use bevy_math::{FloatOrd, Vec2, Vec3};
21use bevy_reflect::{prelude::ReflectDefault, Reflect};
22use bevy_text::{
23    ComputedTextBlock, CosmicFontSystem, Font, FontAtlasSets, LineBreak, SwashCache, TextBounds,
24    TextColor, TextError, TextFont, TextLayout, TextLayoutInfo, TextPipeline, TextReader, TextRoot,
25    TextSpanAccess, TextWriter,
26};
27use bevy_transform::components::Transform;
28use core::any::TypeId;
29
30/// The top-level 2D text component.
31///
32/// Adding `Text2d` to an entity will pull in required components for setting up 2d text.
33/// [Example usage.](https://github.com/bevyengine/bevy/blob/latest/examples/2d/text2d.rs)
34///
35/// The string in this component is the first 'text span' in a hierarchy of text spans that are collected into
36/// a [`ComputedTextBlock`]. See `TextSpan` for the component used by children of entities with [`Text2d`].
37///
38/// With `Text2d` the `justify` field of [`TextLayout`] only affects the internal alignment of a block of text and not its
39/// relative position, which is controlled by the [`Anchor`] component.
40/// This means that for a block of text consisting of only one line that doesn't wrap, the `justify` field will have no effect.
41///
42///
43/// ```
44/// # use bevy_asset::Handle;
45/// # use bevy_color::Color;
46/// # use bevy_color::palettes::basic::BLUE;
47/// # use bevy_ecs::world::World;
48/// # use bevy_text::{Font, Justify, TextLayout, TextFont, TextColor, TextSpan};
49/// # use bevy_sprite::Text2d;
50/// #
51/// # let font_handle: Handle<Font> = Default::default();
52/// # let mut world = World::default();
53/// #
54/// // Basic usage.
55/// world.spawn(Text2d::new("hello world!"));
56///
57/// // With non-default style.
58/// world.spawn((
59///     Text2d::new("hello world!"),
60///     TextFont {
61///         font: font_handle.clone().into(),
62///         font_size: 60.0,
63///         ..Default::default()
64///     },
65///     TextColor(BLUE.into()),
66/// ));
67///
68/// // With text justification.
69/// world.spawn((
70///     Text2d::new("hello world\nand bevy!"),
71///     TextLayout::new_with_justify(Justify::Center)
72/// ));
73///
74/// // With spans
75/// world.spawn(Text2d::new("hello ")).with_children(|parent| {
76///     parent.spawn(TextSpan::new("world"));
77///     parent.spawn((TextSpan::new("!"), TextColor(BLUE.into())));
78/// });
79/// ```
80#[derive(Component, Clone, Debug, Default, Deref, DerefMut, Reflect)]
81#[reflect(Component, Default, Debug, Clone)]
82#[require(
83    TextLayout,
84    TextFont,
85    TextColor,
86    TextBounds,
87    Anchor,
88    Visibility,
89    VisibilityClass,
90    Transform
91)]
92#[component(on_add = visibility::add_visibility_class::<Sprite>)]
93pub struct Text2d(pub String);
94
95impl Text2d {
96    /// Makes a new 2d text component.
97    pub fn new(text: impl Into<String>) -> Self {
98        Self(text.into())
99    }
100}
101
102impl TextRoot for Text2d {}
103
104impl TextSpanAccess for Text2d {
105    fn read_span(&self) -> &str {
106        self.as_str()
107    }
108    fn write_span(&mut self) -> &mut String {
109        &mut *self
110    }
111}
112
113impl From<&str> for Text2d {
114    fn from(value: &str) -> Self {
115        Self(String::from(value))
116    }
117}
118
119impl From<String> for Text2d {
120    fn from(value: String) -> Self {
121        Self(value)
122    }
123}
124
125/// 2d alias for [`TextReader`].
126pub type Text2dReader<'w, 's> = TextReader<'w, 's, Text2d>;
127
128/// 2d alias for [`TextWriter`].
129pub type Text2dWriter<'w, 's> = TextWriter<'w, 's, Text2d>;
130
131/// Adds a shadow behind `Text2d` text
132///
133/// Use `TextShadow` for text drawn with `bevy_ui`
134#[derive(Component, Copy, Clone, Debug, PartialEq, Reflect)]
135#[reflect(Component, Default, Debug, Clone, PartialEq)]
136pub struct Text2dShadow {
137    /// Shadow displacement
138    /// With a value of zero the shadow will be hidden directly behind the text
139    pub offset: Vec2,
140    /// Color of the shadow
141    pub color: Color,
142}
143
144impl Default for Text2dShadow {
145    fn default() -> Self {
146        Self {
147            offset: Vec2::new(4., -4.),
148            color: Color::BLACK,
149        }
150    }
151}
152
153/// Updates the layout and size information whenever the text or style is changed.
154/// This information is computed by the [`TextPipeline`] on insertion, then stored.
155///
156/// ## World Resources
157///
158/// [`ResMut<Assets<Image>>`](Assets<Image>) -- This system only adds new [`Image`] assets.
159/// It does not modify or observe existing ones.
160pub fn update_text2d_layout(
161    mut target_scale_factors: Local<Vec<(f32, RenderLayers)>>,
162    // Text items which should be reprocessed again, generally when the font hasn't loaded yet.
163    mut queue: Local<EntityHashSet>,
164    mut textures: ResMut<Assets<Image>>,
165    fonts: Res<Assets<Font>>,
166    camera_query: Query<(&Camera, &VisibleEntities, Option<&RenderLayers>)>,
167    mut texture_atlases: ResMut<Assets<TextureAtlasLayout>>,
168    mut font_atlas_sets: ResMut<FontAtlasSets>,
169    mut text_pipeline: ResMut<TextPipeline>,
170    mut text_query: Query<(
171        Entity,
172        Option<&RenderLayers>,
173        Ref<TextLayout>,
174        Ref<TextBounds>,
175        &mut TextLayoutInfo,
176        &mut ComputedTextBlock,
177    )>,
178    mut text_reader: Text2dReader,
179    mut font_system: ResMut<CosmicFontSystem>,
180    mut swash_cache: ResMut<SwashCache>,
181) {
182    target_scale_factors.clear();
183    target_scale_factors.extend(
184        camera_query
185            .iter()
186            .filter(|(_, visible_entities, _)| {
187                !visible_entities.get(TypeId::of::<Sprite>()).is_empty()
188            })
189            .filter_map(|(camera, _, maybe_camera_mask)| {
190                camera.target_scaling_factor().map(|scale_factor| {
191                    (scale_factor, maybe_camera_mask.cloned().unwrap_or_default())
192                })
193            }),
194    );
195
196    let mut previous_scale_factor = 0.;
197    let mut previous_mask = &RenderLayers::none();
198
199    for (entity, maybe_entity_mask, block, bounds, text_layout_info, mut computed) in
200        &mut text_query
201    {
202        let entity_mask = maybe_entity_mask.unwrap_or_default();
203
204        let scale_factor = if entity_mask == previous_mask && 0. < previous_scale_factor {
205            previous_scale_factor
206        } else {
207            // `Text2d` only supports generating a single text layout per Text2d entity. If a `Text2d` entity has multiple
208            // render targets with different scale factors, then we use the maximum of the scale factors.
209            let Some((scale_factor, mask)) = target_scale_factors
210                .iter()
211                .filter(|(_, camera_mask)| camera_mask.intersects(entity_mask))
212                .max_by_key(|(scale_factor, _)| FloatOrd(*scale_factor))
213            else {
214                continue;
215            };
216            previous_scale_factor = *scale_factor;
217            previous_mask = mask;
218            *scale_factor
219        };
220
221        if scale_factor != text_layout_info.scale_factor
222            || computed.needs_rerender()
223            || bounds.is_changed()
224            || (!queue.is_empty() && queue.remove(&entity))
225        {
226            let text_bounds = TextBounds {
227                width: if block.linebreak == LineBreak::NoWrap {
228                    None
229                } else {
230                    bounds.width.map(|width| width * scale_factor)
231                },
232                height: bounds.height.map(|height| height * scale_factor),
233            };
234
235            let text_layout_info = text_layout_info.into_inner();
236            match text_pipeline.queue_text(
237                text_layout_info,
238                &fonts,
239                text_reader.iter(entity),
240                scale_factor as f64,
241                &block,
242                text_bounds,
243                &mut font_atlas_sets,
244                &mut texture_atlases,
245                &mut textures,
246                computed.as_mut(),
247                &mut font_system,
248                &mut swash_cache,
249            ) {
250                Err(TextError::NoSuchFont) => {
251                    // There was an error processing the text layout, let's add this entity to the
252                    // queue for further processing
253                    queue.insert(entity);
254                }
255                Err(e @ (TextError::FailedToAddGlyph(_) | TextError::FailedToGetGlyphImage(_))) => {
256                    panic!("Fatal error when processing text: {e}.");
257                }
258                Ok(()) => {
259                    text_layout_info.scale_factor = scale_factor;
260                    text_layout_info.size *= scale_factor.recip();
261                }
262            }
263        }
264    }
265}
266
267/// System calculating and inserting an [`Aabb`] component to entities with some
268/// [`TextLayoutInfo`] and [`Anchor`] components, and without a [`NoFrustumCulling`] component.
269///
270/// Used in system set [`VisibilitySystems::CalculateBounds`](bevy_camera::visibility::VisibilitySystems::CalculateBounds).
271pub fn calculate_bounds_text2d(
272    mut commands: Commands,
273    mut text_to_update_aabb: Query<
274        (
275            Entity,
276            &TextLayoutInfo,
277            &Anchor,
278            &TextBounds,
279            Option<&mut Aabb>,
280        ),
281        (Changed<TextLayoutInfo>, Without<NoFrustumCulling>),
282    >,
283) {
284    for (entity, layout_info, anchor, text_bounds, aabb) in &mut text_to_update_aabb {
285        let size = Vec2::new(
286            text_bounds.width.unwrap_or(layout_info.size.x),
287            text_bounds.height.unwrap_or(layout_info.size.y),
288        );
289
290        let x1 = (Anchor::TOP_LEFT.0.x - anchor.as_vec().x) * size.x;
291        let x2 = (Anchor::TOP_LEFT.0.x - anchor.as_vec().x + 1.) * size.x;
292        let y1 = (Anchor::TOP_LEFT.0.y - anchor.as_vec().y - 1.) * size.y;
293        let y2 = (Anchor::TOP_LEFT.0.y - anchor.as_vec().y) * size.y;
294        let new_aabb = Aabb::from_min_max(Vec3::new(x1, y1, 0.), Vec3::new(x2, y2, 0.));
295
296        if let Some(mut aabb) = aabb {
297            *aabb = new_aabb;
298        } else {
299            commands.entity(entity).try_insert(new_aabb);
300        }
301    }
302}
303
304#[cfg(test)]
305mod tests {
306
307    use bevy_app::{App, Update};
308    use bevy_asset::{load_internal_binary_asset, Handle};
309    use bevy_camera::{ComputedCameraValues, RenderTargetInfo};
310    use bevy_ecs::schedule::IntoScheduleConfigs;
311    use bevy_math::UVec2;
312    use bevy_text::{detect_text_needs_rerender, TextIterScratch};
313
314    use super::*;
315
316    const FIRST_TEXT: &str = "Sample text.";
317    const SECOND_TEXT: &str = "Another, longer sample text.";
318
319    fn setup() -> (App, Entity) {
320        let mut app = App::new();
321        app.init_resource::<Assets<Font>>()
322            .init_resource::<Assets<Image>>()
323            .init_resource::<Assets<TextureAtlasLayout>>()
324            .init_resource::<FontAtlasSets>()
325            .init_resource::<TextPipeline>()
326            .init_resource::<CosmicFontSystem>()
327            .init_resource::<SwashCache>()
328            .init_resource::<TextIterScratch>()
329            .add_systems(
330                Update,
331                (
332                    detect_text_needs_rerender::<Text2d>,
333                    update_text2d_layout,
334                    calculate_bounds_text2d,
335                )
336                    .chain(),
337            );
338
339        let mut visible_entities = VisibleEntities::default();
340        visible_entities.push(Entity::PLACEHOLDER, TypeId::of::<Sprite>());
341
342        app.world_mut().spawn((
343            Camera {
344                computed: ComputedCameraValues {
345                    target_info: Some(RenderTargetInfo {
346                        physical_size: UVec2::splat(1000),
347                        scale_factor: 1.,
348                    }),
349                    ..Default::default()
350                },
351                ..Default::default()
352            },
353            visible_entities,
354        ));
355
356        // A font is needed to ensure the text is laid out with an actual size.
357        load_internal_binary_asset!(
358            app,
359            Handle::default(),
360            "../../bevy_text/src/FiraMono-subset.ttf",
361            |bytes: &[u8], _path: String| { Font::try_from_bytes(bytes.to_vec()).unwrap() }
362        );
363
364        let entity = app.world_mut().spawn(Text2d::new(FIRST_TEXT)).id();
365
366        (app, entity)
367    }
368
369    #[test]
370    fn calculate_bounds_text2d_create_aabb() {
371        let (mut app, entity) = setup();
372
373        assert!(!app
374            .world()
375            .get_entity(entity)
376            .expect("Could not find entity")
377            .contains::<Aabb>());
378
379        // Creates the AABB after text layouting.
380        app.update();
381
382        let aabb = app
383            .world()
384            .get_entity(entity)
385            .expect("Could not find entity")
386            .get::<Aabb>()
387            .expect("Text should have an AABB");
388
389        // Text2D AABB does not have a depth.
390        assert_eq!(aabb.center.z, 0.0);
391        assert_eq!(aabb.half_extents.z, 0.0);
392
393        // AABB has an actual size.
394        assert!(aabb.half_extents.x > 0.0 && aabb.half_extents.y > 0.0);
395    }
396
397    #[test]
398    fn calculate_bounds_text2d_update_aabb() {
399        let (mut app, entity) = setup();
400
401        // Creates the initial AABB after text layouting.
402        app.update();
403
404        let first_aabb = *app
405            .world()
406            .get_entity(entity)
407            .expect("Could not find entity")
408            .get::<Aabb>()
409            .expect("Could not find initial AABB");
410
411        let mut entity_ref = app
412            .world_mut()
413            .get_entity_mut(entity)
414            .expect("Could not find entity");
415        *entity_ref
416            .get_mut::<Text2d>()
417            .expect("Missing Text2d on entity") = Text2d::new(SECOND_TEXT);
418
419        // Recomputes the AABB.
420        app.update();
421
422        let second_aabb = *app
423            .world()
424            .get_entity(entity)
425            .expect("Could not find entity")
426            .get::<Aabb>()
427            .expect("Could not find second AABB");
428
429        // Check that the height is the same, but the width is greater.
430        approx::assert_abs_diff_eq!(first_aabb.half_extents.y, second_aabb.half_extents.y);
431        assert!(FIRST_TEXT.len() < SECOND_TEXT.len());
432        assert!(first_aabb.half_extents.x < second_aabb.half_extents.x);
433    }
434}