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