From c1a2c91d2b74a2cac1905984dd749538bce94a35 Mon Sep 17 00:00:00 2001 From: TotalKrill Date: Thu, 4 Jul 2024 22:41:08 +0200 Subject: [PATCH] Cosmic text (#10193) Fixes #7616. Cosmic-text is a more mature text-rendering library that handles scripts and ligatures better than ab_glyph, it can also handle system fonts which can be implemented in bevy in the future Rebase of https://github.com/bevyengine/bevy/pull/8808 Replaces text renderer ab_glyph with cosmic-text The definition of the font size has changed with the migration to cosmic text. The behavior is now consistent with other platforms (e.g. the web), where the font size in pixels measures the height of the font (the distance between the top of the highest ascender and the bottom of the lowest descender). Font sizes in your app need to be rescaled to approximately 1.2x smaller; for example, if you were using a font size of 60.0, you should now use a font size of 50.0. - `Text2dBounds` has been replaced with `TextBounds`, and it now accepts `Option`s to the bounds, instead of using `f32::INFINITY` to inidicate lack of bounds - Textsizes should be changed, dividing the current size with 1.2 will result in the same size as before. - `TextSettings` struct is removed - Feature `subpixel_alignment` has been removed since cosmic-text already does this automatically - TextBundles and things rendering texts requires the `CosmicBuffer` Component on them as well - TextPipeline: reconstruct byte indices for keeping track of eventual cursors in text input - TextPipeline: (future work) split text entities into section entities - TextPipeline: (future work) text editing - Support line height as an option. Unitless `1.2` is the default used in browsers (1.2x font size). - Support System Fonts and font families - Example showing of animated text styles. Eg. throbbing hyperlinks --------- Co-authored-by: tigregalis Co-authored-by: Nico Burns Co-authored-by: sam edelsten Co-authored-by: Dimchikkk Co-authored-by: Alice Cecile Co-authored-by: Rob Parrett --- Cargo.toml | 3 - crates/bevy_internal/Cargo.toml | 3 - crates/bevy_sprite/src/render/mod.rs | 9 +- crates/bevy_sprite/src/texture_atlas.rs | 40 +- crates/bevy_text/Cargo.toml | 6 +- crates/bevy_text/src/bounds.rs | 70 ++++ crates/bevy_text/src/error.rs | 11 +- crates/bevy_text/src/font.rs | 70 ++-- crates/bevy_text/src/font_atlas.rs | 135 ++++--- crates/bevy_text/src/font_atlas_set.rs | 199 ++++++--- crates/bevy_text/src/font_loader.rs | 10 +- crates/bevy_text/src/glyph.rs | 79 ++++ crates/bevy_text/src/lib.rs | 83 ++-- crates/bevy_text/src/pipeline.rs | 512 ++++++++++++++++-------- crates/bevy_text/src/text.rs | 74 ++-- crates/bevy_text/src/text2d.rs | 86 ++-- crates/bevy_ui/src/layout/mod.rs | 15 +- crates/bevy_ui/src/layout/ui_surface.rs | 21 +- crates/bevy_ui/src/measurement.rs | 52 +-- crates/bevy_ui/src/node_bundles.rs | 6 +- crates/bevy_ui/src/render/mod.rs | 2 +- crates/bevy_ui/src/widget/image.rs | 21 +- crates/bevy_ui/src/widget/text.rs | 83 ++-- docs/cargo_features.md | 1 - examples/2d/text2d.rs | 14 +- examples/3d/tonemapping.rs | 28 +- examples/stress_tests/many_glyphs.rs | 6 +- examples/stress_tests/text_pipeline.rs | 6 +- examples/ui/text.rs | 8 +- examples/ui/text_debug.rs | 40 +- examples/ui/text_wrap_debug.rs | 4 +- examples/ui/ui_scaling.rs | 6 +- 32 files changed, 1088 insertions(+), 615 deletions(-) create mode 100644 crates/bevy_text/src/bounds.rs create mode 100644 crates/bevy_text/src/glyph.rs diff --git a/Cargo.toml b/Cargo.toml index 31ad40696b4b4..b8d70f9cbe7e6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -266,9 +266,6 @@ wayland = ["bevy_internal/wayland"] # X11 display server support x11 = ["bevy_internal/x11"] -# Enable rendering of font glyphs using subpixel accuracy -subpixel_glyph_atlas = ["bevy_internal/subpixel_glyph_atlas"] - # Enable systems that allow for automated testing on CI bevy_ci_testing = ["bevy_internal/bevy_ci_testing"] diff --git a/crates/bevy_internal/Cargo.toml b/crates/bevy_internal/Cargo.toml index 533b96b8a06ad..470615066cded 100644 --- a/crates/bevy_internal/Cargo.toml +++ b/crates/bevy_internal/Cargo.toml @@ -96,9 +96,6 @@ async-io = ["bevy_tasks/async-io"] wayland = ["bevy_winit/wayland"] x11 = ["bevy_winit/x11"] -# enable rendering of font glyphs using subpixel accuracy -subpixel_glyph_atlas = ["bevy_text/subpixel_glyph_atlas"] - # Transmission textures in `StandardMaterial`: pbr_transmission_textures = [ "bevy_pbr?/pbr_transmission_textures", diff --git a/crates/bevy_sprite/src/render/mod.rs b/crates/bevy_sprite/src/render/mod.rs index 0e7efc1f75c23..d60efb0977eb2 100644 --- a/crates/bevy_sprite/src/render/mod.rs +++ b/crates/bevy_sprite/src/render/mod.rs @@ -375,14 +375,15 @@ pub fn extract_sprites( .map(|e| (commands.spawn_empty().id(), e)), ); } else { - let atlas_rect = sheet.and_then(|s| s.texture_rect(&texture_atlases)); + let atlas_rect = + sheet.and_then(|s| s.texture_rect(&texture_atlases).map(|r| r.as_rect())); let rect = match (atlas_rect, sprite.rect) { (None, None) => None, (None, Some(sprite_rect)) => Some(sprite_rect), - (Some(atlas_rect), None) => Some(atlas_rect.as_rect()), + (Some(atlas_rect), None) => Some(atlas_rect), (Some(atlas_rect), Some(mut sprite_rect)) => { - sprite_rect.min += atlas_rect.min.as_vec2(); - sprite_rect.max += atlas_rect.min.as_vec2(); + sprite_rect.min += atlas_rect.min; + sprite_rect.max += atlas_rect.min; Some(sprite_rect) } diff --git a/crates/bevy_sprite/src/texture_atlas.rs b/crates/bevy_sprite/src/texture_atlas.rs index 9af933a43a2cc..ea3c67b640660 100644 --- a/crates/bevy_sprite/src/texture_atlas.rs +++ b/crates/bevy_sprite/src/texture_atlas.rs @@ -31,26 +31,6 @@ pub struct TextureAtlasLayout { pub(crate) texture_handles: Option, usize>>, } -/// Component used to draw a specific section of a texture. -/// -/// It stores a handle to [`TextureAtlasLayout`] and the index of the current section of the atlas. -/// The texture atlas contains various *sections* of a given texture, allowing users to have a single -/// image file for either sprite animation or global mapping. -/// You can change the texture [`index`](Self::index) of the atlas to animate the sprite or display only a *section* of the texture -/// for efficient rendering of related game objects. -/// -/// Check the following examples for usage: -/// - [`animated sprite sheet example`](https://github.com/bevyengine/bevy/blob/latest/examples/2d/sprite_sheet.rs) -/// - [`sprite animation event example`](https://github.com/bevyengine/bevy/blob/latest/examples/2d/sprite_animation.rs) -/// - [`texture atlas example`](https://github.com/bevyengine/bevy/blob/latest/examples/2d/texture_atlas.rs) -#[derive(Component, Default, Debug, Clone, Reflect)] -pub struct TextureAtlas { - /// Texture atlas layout handle - pub layout: Handle, - /// Texture atlas section index - pub index: usize, -} - impl TextureAtlasLayout { /// Create a new empty layout with custom `dimensions` pub fn new_empty(dimensions: UVec2) -> Self { @@ -149,6 +129,26 @@ impl TextureAtlasLayout { } } +/// Component used to draw a specific section of a texture. +/// +/// It stores a handle to [`TextureAtlasLayout`] and the index of the current section of the atlas. +/// The texture atlas contains various *sections* of a given texture, allowing users to have a single +/// image file for either sprite animation or global mapping. +/// You can change the texture [`index`](Self::index) of the atlas to animate the sprite or display only a *section* of the texture +/// for efficient rendering of related game objects. +/// +/// Check the following examples for usage: +/// - [`animated sprite sheet example`](https://github.com/bevyengine/bevy/blob/latest/examples/2d/sprite_sheet.rs) +/// - [`sprite animation event example`](https://github.com/bevyengine/bevy/blob/latest/examples/2d/sprite_animation.rs) +/// - [`texture atlas example`](https://github.com/bevyengine/bevy/blob/latest/examples/2d/texture_atlas.rs) +#[derive(Component, Default, Debug, Clone, Reflect)] +pub struct TextureAtlas { + /// Texture atlas layout handle + pub layout: Handle, + /// Texture atlas section index + pub index: usize, +} + impl TextureAtlas { /// Retrieves the current texture [`URect`] of the sprite sheet according to the section `index` pub fn texture_rect(&self, texture_atlases: &Assets) -> Option { diff --git a/crates/bevy_text/Cargo.toml b/crates/bevy_text/Cargo.toml index e6a693daa3b2b..c5d09c09d6d9f 100644 --- a/crates/bevy_text/Cargo.toml +++ b/crates/bevy_text/Cargo.toml @@ -9,7 +9,6 @@ license = "MIT OR Apache-2.0" keywords = ["bevy"] [features] -subpixel_glyph_atlas = [] default_font = [] [dependencies] @@ -29,10 +28,11 @@ bevy_window = { path = "../bevy_window", version = "0.14.1" } bevy_utils = { path = "../bevy_utils", version = "0.14.1" } # other -ab_glyph = "0.2.6" -glyph_brush_layout = "0.2.1" +cosmic-text = "0.12" thiserror = "1.0" serde = { version = "1", features = ["derive"] } +unicode-bidi = "0.3.13" +sys-locale = "0.3.0" [dev-dependencies] approx = "0.5.1" diff --git a/crates/bevy_text/src/bounds.rs b/crates/bevy_text/src/bounds.rs new file mode 100644 index 0000000000000..57b8d95f1a5d0 --- /dev/null +++ b/crates/bevy_text/src/bounds.rs @@ -0,0 +1,70 @@ +use bevy_ecs::{component::Component, reflect::ReflectComponent}; +use bevy_math::Vec2; +use bevy_reflect::Reflect; + +/// The maximum width and height of text. The text will wrap according to the specified size. +/// Characters out of the bounds after wrapping will be truncated. Text is aligned according to the +/// specified [`JustifyText`](crate::text::JustifyText). +/// +/// Note: only characters that are completely out of the bounds will be truncated, so this is not a +/// reliable limit if it is necessary to contain the text strictly in the bounds. Currently this +/// component is mainly useful for text wrapping only. +#[derive(Component, Copy, Clone, Debug, Reflect)] +#[reflect(Component)] +pub struct TextBounds { + /// The maximum width of text in logical pixels. + /// If `None`, the width is unbounded. + pub width: Option, + /// The maximum height of text in logical pixels. + /// If `None`, the height is unbounded. + pub height: Option, +} + +impl Default for TextBounds { + #[inline] + fn default() -> Self { + Self::UNBOUNDED + } +} + +impl TextBounds { + /// Unbounded text will not be truncated or wrapped. + pub const UNBOUNDED: Self = Self { + width: None, + height: None, + }; + + /// Creates a new `TextBounds`, bounded with the specified width and height values. + #[inline] + pub const fn new(width: f32, height: f32) -> Self { + Self { + width: Some(width), + height: Some(height), + } + } + + /// Creates a new `TextBounds`, bounded with the specified width value and unbounded on height. + #[inline] + pub const fn new_horizontal(width: f32) -> Self { + Self { + width: Some(width), + height: None, + } + } + + /// Creates a new `TextBounds`, bounded with the specified height value and unbounded on width. + #[inline] + pub const fn new_vertical(height: f32) -> Self { + Self { + width: None, + height: Some(height), + } + } +} + +impl From for TextBounds { + #[inline] + fn from(v: Vec2) -> Self { + Self::new(v.x, v.y) + } +} diff --git a/crates/bevy_text/src/error.rs b/crates/bevy_text/src/error.rs index 1bb7cf1253581..ef9f7ea590deb 100644 --- a/crates/bevy_text/src/error.rs +++ b/crates/bevy_text/src/error.rs @@ -1,10 +1,17 @@ -use ab_glyph::GlyphId; +use cosmic_text::CacheKey; use thiserror::Error; #[derive(Debug, PartialEq, Eq, Error)] +/// Errors related to the textsystem pub enum TextError { + /// Font was not found, this could be that the font has not yet been loaded, or + /// that the font failed to load for some other reason #[error("font not found")] NoSuchFont, + /// Failed to add glyph to a newly created atlas for some reason #[error("failed to add glyph to newly-created atlas {0:?}")] - FailedToAddGlyph(GlyphId), + FailedToAddGlyph(u16), + /// Failed to get scaled glyph image for cache key + #[error("failed to get scaled glyph image for cache key: {0:?}")] + FailedToGetGlyphImage(CacheKey), } diff --git a/crates/bevy_text/src/font.rs b/crates/bevy_text/src/font.rs index 780995aa299aa..d24d89eea337d 100644 --- a/crates/bevy_text/src/font.rs +++ b/crates/bevy_text/src/font.rs @@ -1,53 +1,35 @@ -use ab_glyph::{FontArc, FontVec, InvalidFont, OutlinedGlyph}; +use std::sync::Arc; + use bevy_asset::Asset; use bevy_reflect::TypePath; -use bevy_render::{ - render_asset::RenderAssetUsages, - render_resource::{Extent3d, TextureDimension, TextureFormat}, - texture::Image, -}; -#[derive(Asset, TypePath, Debug, Clone)] +/// An [`Asset`] that contains the data for a loaded font, if loaded as an asset. +/// +/// Loaded by [`FontLoader`](crate::FontLoader). +/// +/// # A note on fonts +/// +/// `Font` may differ from the everyday notion of what a "font" is. +/// A font *face* (e.g. Fira Sans Semibold Italic) is part of a font *family* (e.g. Fira Sans), +/// and is distinguished from other font faces in the same family +/// by its style (e.g. italic), its weight (e.g. bold) and its stretch (e.g. condensed). +/// +/// Bevy currently loads a single font face as a single `Font` asset. +#[derive(Debug, TypePath, Clone, Asset)] pub struct Font { - pub font: FontArc, + /// Content of a font file as bytes + pub data: Arc>, } impl Font { - pub fn try_from_bytes(font_data: Vec) -> Result { - let font = FontVec::try_from_vec(font_data)?; - let font = FontArc::new(font); - Ok(Font { font }) - } - - pub fn get_outlined_glyph_texture(outlined_glyph: OutlinedGlyph) -> Image { - let bounds = outlined_glyph.px_bounds(); - // Increase the length of the glyph texture by 2-pixels on each axis to make space - // for a pixel wide transparent border along its edges. - let width = bounds.width() as usize + 2; - let height = bounds.height() as usize + 2; - let mut alpha = vec![0.0; width * height]; - outlined_glyph.draw(|x, y, v| { - // Displace the glyph by 1 pixel on each axis so that it is drawn in the center of the texture. - // This leaves a pixel wide transparent border around the glyph. - alpha[(y + 1) as usize * width + x as usize + 1] = v; - }); - - // TODO: make this texture grayscale - Image::new( - Extent3d { - width: width as u32, - height: height as u32, - depth_or_array_layers: 1, - }, - TextureDimension::D2, - alpha - .iter() - .flat_map(|a| vec![255, 255, 255, (*a * 255.0) as u8]) - .collect::>(), - TextureFormat::Rgba8UnormSrgb, - // This glyph image never needs to reach the render world because it's placed - // into a font texture atlas that'll be used for rendering. - RenderAssetUsages::MAIN_WORLD, - ) + /// Creates a [`Font`] from bytes + pub fn try_from_bytes( + font_data: Vec, + ) -> Result { + use cosmic_text::ttf_parser; + ttf_parser::Face::parse(&font_data, 0)?; + Ok(Self { + data: Arc::new(font_data), + }) } } diff --git a/crates/bevy_text/src/font_atlas.rs b/crates/bevy_text/src/font_atlas.rs index f6dd47d5a6bcd..aa426c67e5550 100644 --- a/crates/bevy_text/src/font_atlas.rs +++ b/crates/bevy_text/src/font_atlas.rs @@ -1,6 +1,5 @@ -use ab_glyph::{GlyphId, Point}; use bevy_asset::{Assets, Handle}; -use bevy_math::UVec2; +use bevy_math::{IVec2, UVec2}; use bevy_render::{ render_asset::RenderAssetUsages, render_resource::{Extent3d, TextureDimension, TextureFormat}, @@ -9,48 +8,36 @@ use bevy_render::{ use bevy_sprite::{DynamicTextureAtlasBuilder, TextureAtlasLayout}; use bevy_utils::HashMap; -#[cfg(feature = "subpixel_glyph_atlas")] -#[derive(Copy, Clone, Eq, PartialEq, Hash, Debug)] -pub struct SubpixelOffset { - x: u16, - y: u16, -} - -#[cfg(feature = "subpixel_glyph_atlas")] -impl From for SubpixelOffset { - fn from(p: Point) -> Self { - fn f(v: f32) -> u16 { - ((v % 1.) * (u16::MAX as f32)) as u16 - } - Self { - x: f(p.x), - y: f(p.y), - } - } -} - -#[cfg(not(feature = "subpixel_glyph_atlas"))] -#[derive(Copy, Clone, Eq, PartialEq, Hash, Debug)] -pub struct SubpixelOffset; - -#[cfg(not(feature = "subpixel_glyph_atlas"))] -impl From for SubpixelOffset { - fn from(_: Point) -> Self { - Self - } -} +use crate::{GlyphAtlasLocation, TextError}; +/// Rasterized glyphs are cached, stored in, and retrieved from, a `FontAtlas`. +/// +/// A `FontAtlas` contains one or more textures, each of which contains one or more glyphs packed into them. +/// +/// A [`FontAtlasSet`](crate::FontAtlasSet) contains a `FontAtlas` for each font size in the same font face. +/// +/// For the same font face and font size, a glyph will be rasterized differently for different subpixel offsets. +/// In practice, ranges of subpixel offsets are grouped into subpixel bins to limit the number of rasterized glyphs, +/// providing a trade-off between visual quality and performance. +/// +/// A [`CacheKey`](cosmic_text::CacheKey) encodes all of the information of a subpixel-offset glyph and is used to +/// find that glyphs raster in a [`TextureAtlas`] through its corresponding [`GlyphAtlasLocation`]. pub struct FontAtlas { + /// Used to update the [`TextureAtlasLayout`]. pub dynamic_texture_atlas_builder: DynamicTextureAtlasBuilder, - pub glyph_to_atlas_index: HashMap<(GlyphId, SubpixelOffset), usize>, + /// A mapping between subpixel-offset glyphs and their [`GlyphAtlasLocation`]. + pub glyph_to_atlas_index: HashMap, + /// The handle to the [`TextureAtlasLayout`] that holds the rasterized glyphs. pub texture_atlas: Handle, + /// The texture where this font atlas is located pub texture: Handle, } impl FontAtlas { + /// Create a new [`FontAtlas`] with the given size, adding it to the appropriate asset collections. pub fn new( textures: &mut Assets, - texture_atlases: &mut Assets, + texture_atlases_layout: &mut Assets, size: UVec2, ) -> FontAtlas { let texture = textures.add(Image::new_fill( @@ -65,50 +52,72 @@ impl FontAtlas { // Need to keep this image CPU persistent in order to add additional glyphs later on RenderAssetUsages::MAIN_WORLD | RenderAssetUsages::RENDER_WORLD, )); - let texture_atlas = TextureAtlasLayout::new_empty(size); + let texture_atlas = texture_atlases_layout.add(TextureAtlasLayout::new_empty(size)); Self { - texture_atlas: texture_atlases.add(texture_atlas), + texture_atlas, glyph_to_atlas_index: HashMap::default(), - dynamic_texture_atlas_builder: DynamicTextureAtlasBuilder::new(size, 0), + dynamic_texture_atlas_builder: DynamicTextureAtlasBuilder::new(size, 1), texture, } } - pub fn get_glyph_index( - &self, - glyph_id: GlyphId, - subpixel_offset: SubpixelOffset, - ) -> Option { - self.glyph_to_atlas_index - .get(&(glyph_id, subpixel_offset)) - .copied() + /// Get the [`GlyphAtlasLocation`] for a subpixel-offset glyph. + pub fn get_glyph_index(&self, cache_key: cosmic_text::CacheKey) -> Option { + self.glyph_to_atlas_index.get(&cache_key).copied() } - pub fn has_glyph(&self, glyph_id: GlyphId, subpixel_offset: SubpixelOffset) -> bool { - self.glyph_to_atlas_index - .contains_key(&(glyph_id, subpixel_offset)) + /// Checks if the given subpixel-offset glyph is contained in this [`FontAtlas`]. + pub fn has_glyph(&self, cache_key: cosmic_text::CacheKey) -> bool { + self.glyph_to_atlas_index.contains_key(&cache_key) } + /// Add a glyph to the atlas, updating both its texture and layout. + /// + /// The glyph is represented by `glyph`, and its image content is `glyph_texture`. + /// This content is copied into the atlas texture, and the atlas layout is updated + /// to store the location of that glyph into the atlas. + /// + /// # Returns + /// + /// Returns `()` if the glyph is successfully added, or [`TextError::FailedToAddGlyph`] otherwise. + /// In that case, neither the atlas texture nor the atlas layout are + /// modified. pub fn add_glyph( &mut self, textures: &mut Assets, - texture_atlases: &mut Assets, - glyph_id: GlyphId, - subpixel_offset: SubpixelOffset, + atlas_layouts: &mut Assets, + cache_key: cosmic_text::CacheKey, texture: &Image, - ) -> bool { - let texture_atlas = texture_atlases.get_mut(&self.texture_atlas).unwrap(); - if let Some(index) = self.dynamic_texture_atlas_builder.add_texture( - texture_atlas, - textures, - texture, - &self.texture, - ) { - self.glyph_to_atlas_index - .insert((glyph_id, subpixel_offset), index); - true + offset: IVec2, + ) -> Result<(), TextError> { + let atlas_layout = atlas_layouts.get_mut(&self.texture_atlas).unwrap(); + let atlas_texture = textures.get_mut(&self.texture).unwrap(); + + if let Some(glyph_index) = + self.dynamic_texture_atlas_builder + .add_texture(atlas_layout, texture, atlas_texture) + { + self.glyph_to_atlas_index.insert( + cache_key, + GlyphAtlasLocation { + glyph_index, + offset, + }, + ); + Ok(()) } else { - false + Err(TextError::FailedToAddGlyph(cache_key.glyph_id)) } } } + +impl std::fmt::Debug for FontAtlas { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("FontAtlas") + .field("glyph_to_atlas_index", &self.glyph_to_atlas_index) + .field("texture_atlas", &self.texture_atlas) + .field("texture", &self.texture) + .field("dynamic_texture_atlas_builder", &"[...]") + .finish() + } +} diff --git a/crates/bevy_text/src/font_atlas_set.rs b/crates/bevy_text/src/font_atlas_set.rs index 25980bdf340e3..e4d11b60c0617 100644 --- a/crates/bevy_text/src/font_atlas_set.rs +++ b/crates/bevy_text/src/font_atlas_set.rs @@ -1,34 +1,45 @@ -use crate::{error::TextError, Font, FontAtlas}; -use ab_glyph::{GlyphId, OutlinedGlyph, Point}; -use bevy_asset::{AssetEvent, AssetId}; -use bevy_asset::{Assets, Handle}; -use bevy_ecs::prelude::*; -use bevy_math::{FloatOrd, UVec2}; -use bevy_reflect::Reflect; -use bevy_render::texture::Image; +use bevy_asset::{Asset, AssetEvent, AssetId, Assets}; +use bevy_ecs::{ + event::EventReader, + system::{ResMut, Resource}, +}; +use bevy_math::{IVec2, UVec2}; +use bevy_reflect::TypePath; +use bevy_render::{ + render_asset::RenderAssetUsages, + render_resource::{Extent3d, TextureDimension, TextureFormat}, + texture::Image, +}; use bevy_sprite::TextureAtlasLayout; use bevy_utils::HashMap; -type FontSizeKey = FloatOrd; +use crate::{error::TextError, Font, FontAtlas, GlyphAtlasInfo}; -#[derive(Default, Resource)] +/// A map of font faces to their corresponding [`FontAtlasSet`]s. +#[derive(Debug, Default, Resource)] pub struct FontAtlasSets { // PERF: in theory this could be optimized with Assets storage ... consider making some fast "simple" AssetMap pub(crate) sets: HashMap, FontAtlasSet>, } impl FontAtlasSets { + /// Get a reference to the [`FontAtlasSet`] with the given font asset id. pub fn get(&self, id: impl Into>) -> Option<&FontAtlasSet> { let id: AssetId = id.into(); self.sets.get(&id) } + /// Get a mutable reference to the [`FontAtlasSet`] with the given font asset id. + pub fn get_mut(&mut self, id: impl Into>) -> Option<&mut FontAtlasSet> { + let id: AssetId = id.into(); + self.sets.get_mut(&id) + } } +/// A system that cleans up [`FontAtlasSet`]s for removed [`Font`]s pub fn remove_dropped_font_atlas_sets( mut font_atlas_sets: ResMut, mut font_events: EventReader>, ) { - // Clean up font atlas sets for removed fonts for event in font_events.read() { if let AssetEvent::Removed { id } = event { font_atlas_sets.sets.remove(id); @@ -36,15 +47,37 @@ pub fn remove_dropped_font_atlas_sets( } } -pub struct FontAtlasSet { - font_atlases: HashMap>, +/// Identifies a font size in a [`FontAtlasSet`]. +/// +/// Allows an `f32` font size to be used as a key in a `HashMap`, by its binary representation. +#[derive(Debug, Hash, PartialEq, Eq)] +pub struct FontSizeKey(pub u32); + +impl From for FontSizeKey { + fn from(val: u32) -> FontSizeKey { + Self(val) + } } -#[derive(Debug, Clone, Reflect)] -pub struct GlyphAtlasInfo { - pub texture_atlas: Handle, - pub texture: Handle, - pub glyph_index: usize, +/// A map of font sizes to their corresponding [`FontAtlas`]es, for a given font face. +/// +/// Provides the interface for adding and retrieving rasterized glyphs, and manages the [`FontAtlas`]es. +/// +/// A `FontAtlasSet` is an [`Asset`]. +/// +/// There is one `FontAtlasSet` for each font: +/// - When a [`Font`] is loaded as an asset and then used in [`Text`](crate::Text), +/// a `FontAtlasSet` asset is created from a weak handle to the `Font`. +/// - ~When a font is loaded as a system font, and then used in [`Text`](crate::Text), +/// a `FontAtlasSet` asset is created and stored with a strong handle to the `FontAtlasSet`.~ +/// (*Note that system fonts are not currently supported by the `TextPipeline`.*) +/// +/// A `FontAtlasSet` contains one or more [`FontAtlas`]es for each font size. +/// +/// It is used by [`TextPipeline::queue_text`](crate::TextPipeline::queue_text). +#[derive(Debug, TypePath, Asset)] +pub struct FontAtlasSet { + font_atlases: HashMap>, } impl Default for FontAtlasSet { @@ -56,46 +89,51 @@ impl Default for FontAtlasSet { } impl FontAtlasSet { + /// Returns an iterator over the [`FontAtlas`]es in this set pub fn iter(&self) -> impl Iterator)> { self.font_atlases.iter() } - pub fn has_glyph(&self, glyph_id: GlyphId, glyph_position: Point, font_size: f32) -> bool { + /// Checks if the given subpixel-offset glyph is contained in any of the [`FontAtlas`]es in this set + pub fn has_glyph(&self, cache_key: cosmic_text::CacheKey, font_size: &FontSizeKey) -> bool { self.font_atlases - .get(&FloatOrd(font_size)) + .get(font_size) .map_or(false, |font_atlas| { - font_atlas - .iter() - .any(|atlas| atlas.has_glyph(glyph_id, glyph_position.into())) + font_atlas.iter().any(|atlas| atlas.has_glyph(cache_key)) }) } + /// Adds the given subpixel-offset glyph to the [`FontAtlas`]es in this set pub fn add_glyph_to_atlas( &mut self, texture_atlases: &mut Assets, textures: &mut Assets, - outlined_glyph: OutlinedGlyph, + font_system: &mut cosmic_text::FontSystem, + swash_cache: &mut cosmic_text::SwashCache, + layout_glyph: &cosmic_text::LayoutGlyph, ) -> Result { - let glyph = outlined_glyph.glyph(); - let glyph_id = glyph.id; - let glyph_position = glyph.position; - let font_size = glyph.scale.y; + let physical_glyph = layout_glyph.physical((0., 0.), 1.0); + let font_atlases = self .font_atlases - .entry(FloatOrd(font_size)) + .entry(physical_glyph.cache_key.font_size_bits.into()) .or_insert_with(|| vec![FontAtlas::new(textures, texture_atlases, UVec2::splat(512))]); - let glyph_texture = Font::get_outlined_glyph_texture(outlined_glyph); - let add_char_to_font_atlas = |atlas: &mut FontAtlas| -> bool { + let (glyph_texture, offset) = + Self::get_outlined_glyph_texture(font_system, swash_cache, &physical_glyph)?; + let mut add_char_to_font_atlas = |atlas: &mut FontAtlas| -> Result<(), TextError> { atlas.add_glyph( textures, texture_atlases, - glyph_id, - glyph_position.into(), + physical_glyph.cache_key, &glyph_texture, + offset, ) }; - if !font_atlases.iter_mut().any(add_char_to_font_atlas) { + if !font_atlases + .iter_mut() + .any(|atlas| add_char_to_font_atlas(atlas).is_ok()) + { // Find the largest dimension of the glyph, either its width or its height let glyph_max_size: u32 = glyph_texture .texture_descriptor @@ -109,48 +147,42 @@ impl FontAtlasSet { texture_atlases, UVec2::splat(containing), )); - if !font_atlases.last_mut().unwrap().add_glyph( + + font_atlases.last_mut().unwrap().add_glyph( textures, texture_atlases, - glyph_id, - glyph_position.into(), + physical_glyph.cache_key, &glyph_texture, - ) { - return Err(TextError::FailedToAddGlyph(glyph_id)); - } + offset, + )?; } - Ok(self - .get_glyph_atlas_info(font_size, glyph_id, glyph_position) - .unwrap()) + Ok(self.get_glyph_atlas_info(physical_glyph.cache_key).unwrap()) } + /// Generates the [`GlyphAtlasInfo`] for the given subpixel-offset glyph. pub fn get_glyph_atlas_info( &mut self, - font_size: f32, - glyph_id: GlyphId, - position: Point, + cache_key: cosmic_text::CacheKey, ) -> Option { self.font_atlases - .get(&FloatOrd(font_size)) + .get(&FontSizeKey(cache_key.font_size_bits)) .and_then(|font_atlases| { font_atlases .iter() .find_map(|atlas| { - atlas - .get_glyph_index(glyph_id, position.into()) - .map(|glyph_index| { - ( - glyph_index, - atlas.texture_atlas.clone_weak(), - atlas.texture.clone_weak(), - ) - }) + atlas.get_glyph_index(cache_key).map(|location| { + ( + location, + atlas.texture_atlas.clone_weak(), + atlas.texture.clone_weak(), + ) + }) }) - .map(|(glyph_index, texture_atlas, texture)| GlyphAtlasInfo { + .map(|(location, texture_atlas, texture)| GlyphAtlasInfo { texture_atlas, + location, texture, - glyph_index, }) }) } @@ -159,9 +191,54 @@ impl FontAtlasSet { pub fn len(&self) -> usize { self.font_atlases.len() } - - /// Returns `true` if the font atlas set contains no elements + /// Returns the number of font atlases in this set pub fn is_empty(&self) -> bool { - self.font_atlases.is_empty() + self.font_atlases.len() == 0 + } + + /// Get the texture of the glyph as a rendered image, and its offset + pub fn get_outlined_glyph_texture( + font_system: &mut cosmic_text::FontSystem, + swash_cache: &mut cosmic_text::SwashCache, + physical_glyph: &cosmic_text::PhysicalGlyph, + ) -> Result<(Image, IVec2), TextError> { + let image = swash_cache + .get_image_uncached(font_system, physical_glyph.cache_key) + .ok_or(TextError::FailedToGetGlyphImage(physical_glyph.cache_key))?; + + let cosmic_text::Placement { + left, + top, + width, + height, + } = image.placement; + + let data = match image.content { + cosmic_text::SwashContent::Mask => image + .data + .iter() + .flat_map(|a| [255, 255, 255, *a]) + .collect(), + cosmic_text::SwashContent::Color => image.data, + cosmic_text::SwashContent::SubpixelMask => { + // TODO: implement + todo!() + } + }; + + Ok(( + Image::new( + Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + TextureDimension::D2, + data, + TextureFormat::Rgba8UnormSrgb, + RenderAssetUsages::MAIN_WORLD, + ), + IVec2::new(left, top), + )) } } diff --git a/crates/bevy_text/src/font_loader.rs b/crates/bevy_text/src/font_loader.rs index a03e96f036862..945987d748b8d 100644 --- a/crates/bevy_text/src/font_loader.rs +++ b/crates/bevy_text/src/font_loader.rs @@ -3,18 +3,19 @@ use bevy_asset::{io::Reader, AssetLoader, AsyncReadExt, LoadContext}; use thiserror::Error; #[derive(Default)] +/// An [`AssetLoader`] for [`Font`]s, for use by the [`AssetServer`] pub struct FontLoader; /// Possible errors that can be produced by [`FontLoader`] #[non_exhaustive] #[derive(Debug, Error)] pub enum FontLoaderError { + /// The contents that could not be parsed + #[error(transparent)] + Content(#[from] cosmic_text::ttf_parser::FaceParsingError), /// An [IO](std::io) Error #[error(transparent)] Io(#[from] std::io::Error), - /// An [`InvalidFont`](ab_glyph::InvalidFont) Error - #[error(transparent)] - FontInvalid(#[from] ab_glyph::InvalidFont), } impl AssetLoader for FontLoader { @@ -29,7 +30,8 @@ impl AssetLoader for FontLoader { ) -> Result { let mut bytes = Vec::new(); reader.read_to_end(&mut bytes).await?; - Ok(Font::try_from_bytes(bytes)?) + let font = Font::try_from_bytes(bytes)?; + Ok(font) } fn extensions(&self) -> &[&str] { diff --git a/crates/bevy_text/src/glyph.rs b/crates/bevy_text/src/glyph.rs new file mode 100644 index 0000000000000..ba949efbfd000 --- /dev/null +++ b/crates/bevy_text/src/glyph.rs @@ -0,0 +1,79 @@ +//! This module exports types related to rendering glyphs. + +use bevy_asset::Handle; +use bevy_math::{IVec2, Vec2}; +use bevy_reflect::Reflect; +use bevy_render::texture::Image; +use bevy_sprite::TextureAtlasLayout; + +/// A glyph of a font, typically representing a single character, positioned in screen space. +/// +/// Contains information about how and where to render a glyph. +/// +/// Used in [`TextPipeline::queue_text`](crate::TextPipeline::queue_text) and [`crate::TextLayoutInfo`] for rendering glyphs. +#[derive(Debug, Clone, Reflect)] +pub struct PositionedGlyph { + /// The position of the glyph in the [`Text`](crate::Text)'s bounding box. + pub position: Vec2, + /// The width and height of the glyph in logical pixels. + pub size: Vec2, + /// Information about the glyph's atlas. + pub atlas_info: GlyphAtlasInfo, + /// The index of the glyph in the [`Text`](crate::Text)'s sections. + pub section_index: usize, + /// TODO: In order to do text editing, we need access to the size of glyphs and their index in the associated String. + /// For example, to figure out where to place the cursor in an input box from the mouse's position. + /// Without this, it's only possible in texts where each glyph is one byte. Cosmic text has methods for this + /// cosmic-texts [hit detection](https://pop-os.github.io/cosmic-text/cosmic_text/struct.Buffer.html#method.hit) + byte_index: usize, +} + +impl PositionedGlyph { + /// Creates a new [`PositionedGlyph`] + pub fn new( + position: Vec2, + size: Vec2, + atlas_info: GlyphAtlasInfo, + section_index: usize, + ) -> Self { + Self { + position, + size, + atlas_info, + section_index, + byte_index: 0, + } + } +} + +/// Information about a glyph in an atlas. +/// +/// Rasterized glyphs are stored as rectangles +/// in one or more [`FontAtlas`](crate::FontAtlas)es. +/// +/// Used in [`PositionedGlyph`] and [`FontAtlasSet`](crate::FontAtlasSet). +#[derive(Debug, Clone, Reflect)] +pub struct GlyphAtlasInfo { + /// A handle to the [`Image`] data for the texture atlas this glyph was placed in. + /// + /// A (weak) clone of the handle held by the [`FontAtlas`]. + pub texture: Handle, + /// A handle to the [`TextureAtlasLayout`] map for the texture atlas this glyph was placed in. + /// + /// A (weak) clone of the handle held by the [`FontAtlas`]. + pub texture_atlas: Handle, + /// Location and offset of a glyph within the texture atlas. + pub location: GlyphAtlasLocation, +} + +/// The location of a glyph in an atlas, +/// and how it should be positioned when placed. +/// +/// Used in [`GlyphAtlasInfo`] and [`FontAtlas`](crate::FontAtlas). +#[derive(Debug, Clone, Copy, Reflect)] +pub struct GlyphAtlasLocation { + /// The index of the glyph in the atlas + pub glyph_index: usize, + /// The required offset (relative positioning) when placed + pub offset: IVec2, +} diff --git a/crates/bevy_text/src/lib.rs b/crates/bevy_text/src/lib.rs index e689e93f9345f..0550883f13830 100644 --- a/crates/bevy_text/src/lib.rs +++ b/crates/bevy_text/src/lib.rs @@ -1,32 +1,61 @@ -// FIXME(3492): remove once docs are ready -#![allow(missing_docs)] -#![cfg_attr(docsrs, feature(doc_auto_cfg))] -#![forbid(unsafe_code)] -#![doc( - html_logo_url = "https://bevyengine.org/assets/icon.png", - html_favicon_url = "https://bevyengine.org/assets/icon.png" -)] +//! This crate provides the tools for positioning and rendering text in Bevy. +//! +//! # `Font` +//! +//! Fonts contain information for drawing glyphs, which are shapes that typically represent a single character, +//! but in some cases part of a "character" (grapheme clusters) or more than one character (ligatures). +//! +//! A font *face* is part of a font family, +//! and is distinguished by its style (e.g. italic), its weight (e.g. bold) and its stretch (e.g. condensed). +//! +//! In Bevy, [`Font`]s are loaded by the [`FontLoader`] as [assets](bevy_asset::AssetPlugin). +//! +//! # `TextPipeline` +//! +//! The [`TextPipeline`] resource does all of the heavy lifting for rendering text. +//! +//! [`Text`] is first measured by creating a [`TextMeasureInfo`] in [`TextPipeline::create_text_measure`], +//! which is called by the `measure_text_system` system of `bevy_ui`. +//! +//! Note that text measurement is only relevant in a UI context. +//! +//! With the actual text bounds defined, the `bevy_ui::widget::text::text_system` system (in a UI context) +//! or [`bevy_text::text2d::update_text2d_layout`] system (in a 2d world space context) +//! passes it into [`TextPipeline::queue_text`], which: +//! +//! 1. creates a [`Buffer`](cosmic_text::Buffer) from the [`TextSection`]s, generating new [`FontAtlasSet`]s if necessary. +//! 2. iterates over each glyph in the [`Buffer`](cosmic_text::Buffer) to create a [`PositionedGlyph`], +//! retrieving glyphs from the cache, or rasterizing to a [`FontAtlas`] if necessary. +//! 3. [`PositionedGlyph`]s are stored in a [`TextLayoutInfo`], +//! which contains all the information that downstream systems need for rendering. +#![allow(clippy::type_complexity)] + +mod bounds; mod error; mod font; mod font_atlas; mod font_atlas_set; mod font_loader; -mod glyph_brush; +mod glyph; mod pipeline; mod text; mod text2d; +pub use cosmic_text; + +pub use bounds::*; pub use error::*; pub use font::*; pub use font_atlas::*; pub use font_atlas_set::*; pub use font_loader::*; -pub use glyph_brush::*; +pub use glyph::*; pub use pipeline::*; pub use text::*; pub use text2d::*; +/// Most commonly used re-exported types. pub mod prelude { #[doc(hidden)] pub use crate::{Font, JustifyText, Text, Text2dBundle, TextError, TextSection, TextStyle}; @@ -41,7 +70,6 @@ use bevy_render::{ camera::CameraUpdateSystem, view::VisibilitySystems, ExtractSchedule, RenderApp, }; use bevy_sprite::SpriteSystem; -use std::num::NonZeroUsize; /// Adds text rendering support to an app. /// @@ -50,31 +78,15 @@ use std::num::NonZeroUsize; #[derive(Default)] pub struct TextPlugin; -/// Settings used to configure the [`TextPlugin`]. -#[derive(Resource)] -pub struct TextSettings { - /// Soft maximum number of font atlases supported in a [`FontAtlasSet`]. When this is exceeded, - /// a warning will be emitted a single time. - pub soft_max_font_atlases: NonZeroUsize, - /// Allows font size to be set dynamically exceeding the amount set in `soft_max_font_atlases`. - /// Note each font size has to be generated which can have a strong performance impact. - pub allow_dynamic_font_size: bool, -} - -impl Default for TextSettings { - fn default() -> Self { - Self { - soft_max_font_atlases: NonZeroUsize::new(16).unwrap(), - allow_dynamic_font_size: false, - } - } -} - -/// Text is rendered for two different view projections, a [`Text2dBundle`] is rendered with a -/// `BottomToTop` y axis, while UI is rendered with a `TopToBottom` y axis. This matters for text because -/// the glyph positioning is different in either layout. +/// Text is rendered for two different view projections; +/// 2-dimensional text ([`Text2dBundle`]) is rendered in "world space" with a `BottomToTop` Y-axis, +/// while UI is rendered with a `TopToBottom` Y-axis. +/// This matters for text because the glyph positioning is different in either layout. +/// For `TopToBottom`, 0 is the top of the text, while for `BottomToTop` 0 is the bottom. pub enum YAxisOrientation { + /// Top to bottom Y-axis orientation, for UI TopToBottom, + /// Bottom to top Y-axis orientation, for 2d world space BottomToTop, } @@ -86,9 +98,8 @@ impl Plugin for TextPlugin { fn build(&self, app: &mut App) { app.init_asset::() .register_type::() - .register_type::() + .register_type::() .init_asset_loader::() - .init_resource::() .init_resource::() .insert_resource(TextPipeline::default()) .add_systems( diff --git a/crates/bevy_text/src/pipeline.rs b/crates/bevy_text/src/pipeline.rs index 298857997e896..7f7004e71ee04 100644 --- a/crates/bevy_text/src/pipeline.rs +++ b/crates/bevy_text/src/pipeline.rs @@ -1,219 +1,397 @@ -use crate::{ - compute_text_bounds, error::TextError, glyph_brush::GlyphBrush, scale_value, BreakLineOn, Font, - FontAtlasSets, JustifyText, PositionedGlyph, Text, TextSection, TextSettings, YAxisOrientation, -}; -use ab_glyph::PxScale; -use bevy_asset::{AssetId, Assets, Handle}; -use bevy_ecs::component::Component; -use bevy_ecs::prelude::ReflectComponent; -use bevy_ecs::system::Resource; -use bevy_math::Vec2; -use bevy_reflect::prelude::ReflectDefault; -use bevy_reflect::Reflect; +use std::sync::Arc; + +use bevy_asset::{AssetId, Assets}; +use bevy_ecs::{component::Component, reflect::ReflectComponent, system::Resource}; +use bevy_math::{UVec2, Vec2}; +use bevy_reflect::{std_traits::ReflectDefault, Reflect}; use bevy_render::texture::Image; use bevy_sprite::TextureAtlasLayout; use bevy_utils::HashMap; -use glyph_brush_layout::{FontId, GlyphPositioner, SectionGeometry, SectionText, ToSectionText}; -#[derive(Default, Resource)] -pub struct TextPipeline { - brush: GlyphBrush, - map_font_id: HashMap, FontId>, +use cosmic_text::{Attrs, Buffer, Family, Metrics, Shaping, Wrap}; + +use crate::{ + error::TextError, BreakLineOn, CosmicBuffer, Font, FontAtlasSets, JustifyText, PositionedGlyph, + TextBounds, TextSection, YAxisOrientation, +}; + +/// A wrapper around a [`cosmic_text::FontSystem`] +struct CosmicFontSystem(cosmic_text::FontSystem); + +impl Default for CosmicFontSystem { + fn default() -> Self { + let locale = sys_locale::get_locale().unwrap_or_else(|| String::from("en-US")); + let db = cosmic_text::fontdb::Database::new(); + // TODO: consider using `cosmic_text::FontSystem::new()` (load system fonts by default) + Self(cosmic_text::FontSystem::new_with_locale_and_db(locale, db)) + } +} + +/// A wrapper around a [`cosmic_text::SwashCache`] +struct SwashCache(cosmic_text::SwashCache); + +impl Default for SwashCache { + fn default() -> Self { + Self(cosmic_text::SwashCache::new()) + } } -/// Render information for a corresponding [`Text`] component. +/// The `TextPipeline` is used to layout and render [`Text`](crate::Text). /// -/// Contains scaled glyphs and their size. Generated via [`TextPipeline::queue_text`]. -#[derive(Component, Clone, Default, Debug, Reflect)] -#[reflect(Component, Default)] -pub struct TextLayoutInfo { - pub glyphs: Vec, - pub logical_size: Vec2, +/// See the [crate-level documentation](crate) for more information. +#[derive(Default, Resource)] +pub struct TextPipeline { + /// Identifies a font [`ID`](cosmic_text::fontdb::ID) by its [`Font`] [`Asset`](bevy_asset::Asset). + map_handle_to_font_id: HashMap, (cosmic_text::fontdb::ID, String)>, + /// The font system is used to retrieve fonts and their information, including glyph outlines. + /// + /// See [`cosmic_text::FontSystem`] for more information. + font_system: CosmicFontSystem, + /// The swash cache rasterizer is used to rasterize glyphs + /// + /// See [`cosmic_text::SwashCache`] for more information. + swash_cache: SwashCache, } impl TextPipeline { - pub fn get_or_insert_font_id(&mut self, handle: &Handle, font: &Font) -> FontId { - let brush = &mut self.brush; - *self - .map_font_id - .entry(handle.id()) - .or_insert_with(|| brush.add_font(handle.id(), font.font.clone())) + /// Utilizes [`cosmic_text::Buffer`] to shape and layout text + /// + /// Negative or 0.0 font sizes will not be laid out. + #[allow(clippy::too_many_arguments)] + pub fn update_buffer( + &mut self, + fonts: &Assets, + sections: &[TextSection], + linebreak_behavior: BreakLineOn, + bounds: TextBounds, + scale_factor: f64, + buffer: &mut CosmicBuffer, + alignment: JustifyText, + ) -> Result<(), TextError> { + let font_system = &mut self.font_system.0; + + // return early if the fonts are not loaded yet + let mut font_size = 0.; + for section in sections { + if section.style.font_size > font_size { + font_size = section.style.font_size; + } + fonts + .get(section.style.font.id()) + .ok_or(TextError::NoSuchFont)?; + } + let line_height = font_size * 1.2; + let metrics = Metrics::new(font_size, line_height).scale(scale_factor as f32); + + // Load Bevy fonts into cosmic-text's font system. + // This is done as as separate pre-pass to avoid borrow checker issues + for section in sections.iter() { + load_font_to_fontdb(section, font_system, &mut self.map_handle_to_font_id, fonts); + } + + // Map text sections to cosmic-text spans, and ignore sections with negative or zero fontsizes, + // since they cannot be rendered by cosmic-text. + // + // The section index is stored in the metadata of the spans, and could be used + // to look up the section the span came from and is not used internally + // in cosmic-text. + let spans: Vec<(&str, Attrs)> = sections + .iter() + .enumerate() + .filter(|(_section_index, section)| section.style.font_size > 0.0) + .map(|(section_index, section)| { + ( + §ion.value[..], + get_attrs( + section, + section_index, + font_system, + &self.map_handle_to_font_id, + scale_factor, + ), + ) + }) + .collect(); + + buffer.set_metrics(font_system, metrics); + buffer.set_size(font_system, bounds.width, bounds.height); + + buffer.set_wrap( + font_system, + match linebreak_behavior { + BreakLineOn::WordBoundary => Wrap::Word, + BreakLineOn::AnyCharacter => Wrap::Glyph, + BreakLineOn::WordOrCharacter => Wrap::WordOrGlyph, + BreakLineOn::NoWrap => Wrap::None, + }, + ); + + buffer.set_rich_text(font_system, spans, Attrs::new(), Shaping::Advanced); + + // PERF: https://github.com/pop-os/cosmic-text/issues/166: + // Setting alignment afterwards appears to invalidate some layouting performed by `set_text` which is presumably not free? + for buffer_line in buffer.lines.iter_mut() { + buffer_line.set_align(Some(alignment.into())); + } + buffer.shape_until_scroll(font_system, false); + + Ok(()) } + /// Queues text for rendering + /// + /// Produces a [`TextLayoutInfo`], containing [`PositionedGlyph`]s + /// which contain information for rendering the text. #[allow(clippy::too_many_arguments)] pub fn queue_text( &mut self, fonts: &Assets, sections: &[TextSection], - scale_factor: f32, + scale_factor: f64, text_alignment: JustifyText, linebreak_behavior: BreakLineOn, - bounds: Vec2, + bounds: TextBounds, font_atlas_sets: &mut FontAtlasSets, texture_atlases: &mut Assets, textures: &mut Assets, - text_settings: &TextSettings, y_axis_orientation: YAxisOrientation, + buffer: &mut CosmicBuffer, ) -> Result { - let mut scaled_fonts = Vec::with_capacity(sections.len()); - let sections = sections - .iter() - .map(|section| { - let font = fonts - .get(§ion.style.font) - .ok_or(TextError::NoSuchFont)?; - let font_id = self.get_or_insert_font_id(§ion.style.font, font); - let font_size = scale_value(section.style.font_size, scale_factor); - - scaled_fonts.push(ab_glyph::Font::as_scaled(&font.font, font_size)); - - let section = SectionText { - font_id, - scale: PxScale::from(font_size), - text: §ion.value, - }; + if sections.is_empty() { + return Ok(TextLayoutInfo::default()); + } + + self.update_buffer( + fonts, + sections, + linebreak_behavior, + bounds, + scale_factor, + buffer, + text_alignment, + )?; + + let box_size = buffer_dimensions(buffer); + let font_system = &mut self.font_system.0; + let swash_cache = &mut self.swash_cache.0; - Ok(section) + let glyphs = buffer + .layout_runs() + .flat_map(|run| { + run.glyphs + .iter() + .map(move |layout_glyph| (layout_glyph, run.line_y)) }) - .collect::, _>>()?; + .map(|(layout_glyph, line_y)| { + let section_index = layout_glyph.metadata; - let section_glyphs = - self.brush - .compute_glyphs(§ions, bounds, text_alignment, linebreak_behavior)?; + let font_handle = sections[section_index].style.font.clone_weak(); + let font_atlas_set = font_atlas_sets.sets.entry(font_handle.id()).or_default(); - if section_glyphs.is_empty() { - return Ok(TextLayoutInfo::default()); - } + let physical_glyph = layout_glyph.physical((0., 0.), 1.); - let size = compute_text_bounds(§ion_glyphs, |index| scaled_fonts[index]).size(); + let atlas_info = font_atlas_set + .get_glyph_atlas_info(physical_glyph.cache_key) + .map(Ok) + .unwrap_or_else(|| { + font_atlas_set.add_glyph_to_atlas( + texture_atlases, + textures, + font_system, + swash_cache, + layout_glyph, + ) + })?; - let h_limit = if bounds.x.is_finite() { - bounds.x - } else { - size.x - }; + let texture_atlas = texture_atlases.get(&atlas_info.texture_atlas).unwrap(); + let location = atlas_info.location; + let glyph_rect = texture_atlas.textures[location.glyph_index]; + let left = location.offset.x as f32; + let top = location.offset.y as f32; + let glyph_size = UVec2::new(glyph_rect.width(), glyph_rect.height()); - let h_anchor = match text_alignment { - JustifyText::Left => 0.0, - JustifyText::Center => h_limit * 0.5, - JustifyText::Right => h_limit * 1.0, - } - .floor(); + // offset by half the size because the origin is center + let x = glyph_size.x as f32 / 2.0 + left + physical_glyph.x as f32; + let y = line_y.round() + physical_glyph.y as f32 - top + glyph_size.y as f32 / 2.0; + let y = match y_axis_orientation { + YAxisOrientation::TopToBottom => y, + YAxisOrientation::BottomToTop => box_size.y - y, + }; - let glyphs = self.brush.process_glyphs( - section_glyphs, - §ions, - font_atlas_sets, - fonts, - texture_atlases, - textures, - text_settings, - y_axis_orientation, - h_anchor, - )?; + let position = Vec2::new(x, y); + + // TODO: recreate the byte index, that keeps track of where a cursor is, + // when glyphs are not limited to single byte representation, relevant for #1319 + let pos_glyph = + PositionedGlyph::new(position, glyph_size.as_vec2(), atlas_info, section_index); + Ok(pos_glyph) + }) + .collect::, _>>()?; Ok(TextLayoutInfo { glyphs, - logical_size: size, + size: box_size, }) } + + /// Queues text for measurement + /// + /// Produces a [`TextMeasureInfo`] which can be used by a layout system + /// to measure the text area on demand. + pub fn create_text_measure( + &mut self, + fonts: &Assets, + sections: &[TextSection], + scale_factor: f64, + linebreak_behavior: BreakLineOn, + buffer: &mut CosmicBuffer, + text_alignment: JustifyText, + ) -> Result { + const MIN_WIDTH_CONTENT_BOUNDS: TextBounds = TextBounds::new_horizontal(0.0); + + self.update_buffer( + fonts, + sections, + linebreak_behavior, + MIN_WIDTH_CONTENT_BOUNDS, + scale_factor, + buffer, + text_alignment, + )?; + + let min_width_content_size = buffer_dimensions(buffer); + + let max_width_content_size = { + let font_system = &mut self.font_system.0; + buffer.set_size(font_system, None, None); + buffer_dimensions(buffer) + }; + + Ok(TextMeasureInfo { + min: min_width_content_size, + max: max_width_content_size, + // TODO: This clone feels wasteful, is there another way to structure TextMeasureInfo + // that it doesn't need to own a buffer? - bytemunch + buffer: buffer.0.clone(), + }) + } + + /// Get a mutable reference to the [`cosmic_text::FontSystem`]. + /// + /// Used internally. + pub fn font_system_mut(&mut self) -> &mut cosmic_text::FontSystem { + &mut self.font_system.0 + } } -#[derive(Debug, Clone)] -pub struct TextMeasureSection { - pub text: Box, - pub scale: f32, - pub font_id: FontId, +/// Render information for a corresponding [`Text`](crate::Text) component. +/// +/// Contains scaled glyphs and their size. Generated via [`TextPipeline::queue_text`]. +#[derive(Component, Clone, Default, Debug, Reflect)] +#[reflect(Component, Default)] +pub struct TextLayoutInfo { + /// Scaled and positioned glyphs in screenspace + pub glyphs: Vec, + /// The glyphs resulting size + pub size: Vec2, } -#[derive(Debug, Clone, Default)] +/// Size information for a corresponding [`Text`](crate::Text) component. +/// +/// Generated via [`TextPipeline::create_text_measure`]. pub struct TextMeasureInfo { - pub fonts: Box<[ab_glyph::FontArc]>, - pub sections: Box<[TextMeasureSection]>, - pub justification: JustifyText, - pub linebreak_behavior: glyph_brush_layout::BuiltInLineBreaker, + /// Minimum size for a text area in pixels, to be used when laying out widgets with taffy pub min: Vec2, + /// Maximum size for a text area in pixels, to be used when laying out widgets with taffy pub max: Vec2, + buffer: cosmic_text::Buffer, } -impl TextMeasureInfo { - pub fn from_text( - text: &Text, - fonts: &Assets, - scale_factor: f32, - ) -> Result { - let sections = &text.sections; - let mut auto_fonts = Vec::with_capacity(sections.len()); - let mut out_sections = Vec::with_capacity(sections.len()); - for (i, section) in sections.iter().enumerate() { - match fonts.get(§ion.style.font) { - Some(font) => { - auto_fonts.push(font.font.clone()); - out_sections.push(TextMeasureSection { - font_id: FontId(i), - scale: scale_value(section.style.font_size, scale_factor), - text: section.value.clone().into_boxed_str(), - }); - } - None => return Err(TextError::NoSuchFont), - } - } - - Ok(Self::new( - auto_fonts, - out_sections, - text.justify, - text.linebreak_behavior.into(), - )) +impl std::fmt::Debug for TextMeasureInfo { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("TextMeasureInfo") + .field("min", &self.min) + .field("max", &self.max) + .field("buffer", &"_") + .field("font_system", &"_") + .finish() } - fn new( - fonts: Vec, - sections: Vec, - justification: JustifyText, - linebreak_behavior: glyph_brush_layout::BuiltInLineBreaker, - ) -> Self { - let mut info = Self { - fonts: fonts.into_boxed_slice(), - sections: sections.into_boxed_slice(), - justification, - linebreak_behavior, - min: Vec2::ZERO, - max: Vec2::ZERO, - }; +} - let min = info.compute_size(Vec2::new(0.0, f32::INFINITY)); - let max = info.compute_size(Vec2::INFINITY); - info.min = min; - info.max = max; - info +impl TextMeasureInfo { + /// Computes the size of the text area within the provided bounds. + pub fn compute_size( + &mut self, + bounds: TextBounds, + font_system: &mut cosmic_text::FontSystem, + ) -> Vec2 { + self.buffer + .set_size(font_system, bounds.width, bounds.height); + buffer_dimensions(&self.buffer) } +} - pub fn compute_size(&self, bounds: Vec2) -> Vec2 { - let sections = &self.sections; - let geom = SectionGeometry { - bounds: (bounds.x, bounds.y), - ..Default::default() - }; - let section_glyphs = glyph_brush_layout::Layout::default() - .h_align(self.justification.into()) - .line_breaker(self.linebreak_behavior) - .calculate_glyphs(&self.fonts, &geom, sections); - - compute_text_bounds(§ion_glyphs, |index| { - let font = &self.fonts[index]; - let font_size = self.sections[index].scale; - ab_glyph::Font::into_scaled(font, font_size) - }) - .size() - } +fn load_font_to_fontdb( + section: &TextSection, + font_system: &mut cosmic_text::FontSystem, + map_handle_to_font_id: &mut HashMap, (cosmic_text::fontdb::ID, String)>, + fonts: &Assets, +) { + let font_handle = section.style.font.clone(); + map_handle_to_font_id + .entry(font_handle.id()) + .or_insert_with(|| { + let font = fonts.get(font_handle.id()).expect( + "Tried getting a font that was not available, probably due to not being loaded yet", + ); + let data = Arc::clone(&font.data); + let ids = font_system + .db_mut() + .load_font_source(cosmic_text::fontdb::Source::Binary(data)); + + // TODO: it is assumed this is the right font face + let face_id = *ids.last().unwrap(); + let face = font_system.db().face(face_id).unwrap(); + let family_name = face.families[0].0.to_owned(); + + (face_id, family_name) + }); } -impl ToSectionText for TextMeasureSection { - #[inline(always)] - fn to_section_text(&self) -> SectionText<'_> { - SectionText { - text: &self.text, - scale: PxScale::from(self.scale), - font_id: self.font_id, - } - } + +/// Translates [`TextSection`] to [`Attrs`](cosmic_text::attrs::Attrs), +/// loading fonts into the [`Database`](cosmic_text::fontdb::Database) if required. +fn get_attrs<'a>( + section: &TextSection, + section_index: usize, + font_system: &mut cosmic_text::FontSystem, + map_handle_to_font_id: &'a HashMap, (cosmic_text::fontdb::ID, String)>, + scale_factor: f64, +) -> Attrs<'a> { + let (face_id, family_name) = map_handle_to_font_id + .get(§ion.style.font.id()) + .expect("Already loaded with load_font_to_fontdb"); + let face = font_system.db().face(*face_id).unwrap(); + + let attrs = Attrs::new() + .metadata(section_index) + .family(Family::Name(family_name)) + .stretch(face.stretch) + .style(face.style) + .weight(face.weight) + .metrics(Metrics::relative(section.style.font_size, 1.2).scale(scale_factor as f32)) + .color(cosmic_text::Color(section.style.color.to_linear().as_u32())); + attrs +} + +/// Calculate the size of the text area for the given buffer. +fn buffer_dimensions(buffer: &Buffer) -> Vec2 { + let width = buffer + .layout_runs() + .map(|run| run.line_w) + .reduce(f32::max) + .unwrap_or(0.0); + let line_height = buffer.metrics().line_height.ceil(); + let height = buffer.layout_runs().count() as f32 * line_height; + + Vec2::new(width.ceil(), height).ceil() } diff --git a/crates/bevy_text/src/text.rs b/crates/bevy_text/src/text.rs index 15532e36c1491..39723a06ee413 100644 --- a/crates/bevy_text/src/text.rs +++ b/crates/bevy_text/src/text.rs @@ -1,15 +1,35 @@ use bevy_asset::Handle; use bevy_color::Color; +use bevy_derive::{Deref, DerefMut}; use bevy_ecs::{prelude::Component, reflect::ReflectComponent}; use bevy_reflect::prelude::*; use bevy_utils::default; +use cosmic_text::{Buffer, Metrics}; use serde::{Deserialize, Serialize}; use crate::Font; +pub use cosmic_text::{ + self, FamilyOwned as FontFamily, Stretch as FontStretch, Style as FontStyle, + Weight as FontWeight, +}; +/// Wrapper for [`cosmic_text::Buffer`] +#[derive(Component, Deref, DerefMut, Debug, Clone)] +pub struct CosmicBuffer(pub Buffer); + +impl Default for CosmicBuffer { + fn default() -> Self { + Self(Buffer::new_empty(Metrics::new(0.0, 0.000001))) + } +} + +/// A component that is the entry point for rendering text. +/// +/// It contains all of the text value and styling information. #[derive(Component, Debug, Clone, Default, Reflect)] #[reflect(Component, Default)] pub struct Text { + /// The text's sections pub sections: Vec, /// The text's internal alignment. /// Should not affect its position within a container. @@ -33,7 +53,7 @@ impl Text { /// // Accepts a String or any type that converts into a String, such as &str. /// "hello world!", /// TextStyle { - /// font: font_handle.clone(), + /// font: font_handle.clone().into(), /// font_size: 60.0, /// color: Color::WHITE, /// }, @@ -42,7 +62,7 @@ impl Text { /// let hello_bevy = Text::from_section( /// "hello world\nand bevy!", /// TextStyle { - /// font: font_handle, + /// font: font_handle.into(), /// font_size: 60.0, /// color: Color::WHITE, /// }, @@ -70,7 +90,7 @@ impl Text { /// TextSection::new( /// "Hello, ", /// TextStyle { - /// font: font_handle.clone(), + /// font: font_handle.clone().into(), /// font_size: 60.0, /// color: BLUE.into(), /// }, @@ -78,7 +98,7 @@ impl Text { /// TextSection::new( /// "World!", /// TextStyle { - /// font: font_handle, + /// font: font_handle.into(), /// font_size: 60.0, /// color: RED.into(), /// }, @@ -106,9 +126,12 @@ impl Text { } } +/// Contains the value of the text in a section and how it should be styled. #[derive(Debug, Default, Clone, Reflect)] pub struct TextSection { + /// The content (in `String` form) of the text in the section. pub value: String, + /// The style of the text in the section, including the font face, font size, and color. pub style: TextStyle, } @@ -168,23 +191,32 @@ pub enum JustifyText { /// Rightmost character is immediately to the left of the render position. /// Bounds start from the render position and advance leftwards. Right, + /// Words are spaced so that leftmost & rightmost characters + /// align with their margins. + /// Bounds start from the render position and advance equally left & right. + Justified, } -impl From for glyph_brush_layout::HorizontalAlign { - fn from(val: JustifyText) -> Self { - match val { - JustifyText::Left => glyph_brush_layout::HorizontalAlign::Left, - JustifyText::Center => glyph_brush_layout::HorizontalAlign::Center, - JustifyText::Right => glyph_brush_layout::HorizontalAlign::Right, +impl From for cosmic_text::Align { + fn from(justify: JustifyText) -> Self { + match justify { + JustifyText::Left => cosmic_text::Align::Left, + JustifyText::Center => cosmic_text::Align::Center, + JustifyText::Right => cosmic_text::Align::Right, + JustifyText::Justified => cosmic_text::Align::Justified, } } } #[derive(Clone, Debug, Reflect)] +/// `TextStyle` determines the style of the text in a section, specifically +/// the font face, the font size, and the color. pub struct TextStyle { - /// If this is not specified, then + /// The specific font face to use, as a `Handle` to a [`Font`] asset. + /// + /// If the `font` is not specified, then /// * if `default_font` feature is enabled (enabled by default in `bevy` crate), - /// `FiraMono-subset.ttf` compiled into the library is used. + /// `FiraMono-subset.ttf` compiled into the library is used. /// * otherwise no text will be rendered. pub font: Handle, /// The vertical height of rasterized glyphs in the font atlas in pixels. @@ -195,6 +227,7 @@ pub struct TextStyle { /// A new font atlas is generated for every combination of font handle and scaled font size /// which can have a strong performance impact. pub font_size: f32, + /// The color of the text for this section. pub color: Color, } @@ -202,7 +235,7 @@ impl Default for TextStyle { fn default() -> Self { Self { font: Default::default(), - font_size: 24.0, + font_size: 20.0, color: Color::WHITE, } } @@ -221,20 +254,9 @@ pub enum BreakLineOn { /// This is closer to the behavior one might expect from text in a terminal. /// However it may lead to words being broken up across linebreaks. AnyCharacter, + /// Wraps at the word level, or fallback to character level if a word can’t fit on a line by itself + WordOrCharacter, /// No soft wrapping, where text is automatically broken up into separate lines when it overflows a boundary, will ever occur. /// Hard wrapping, where text contains an explicit linebreak such as the escape sequence `\n`, is still enabled. NoWrap, } - -impl From for glyph_brush_layout::BuiltInLineBreaker { - fn from(val: BreakLineOn) -> Self { - match val { - // If `NoWrap` is set the choice of `BuiltInLineBreaker` doesn't matter as the text is given unbounded width and soft wrapping will never occur. - // But `NoWrap` does not disable hard breaks where a [`Text`] contains a newline character. - BreakLineOn::WordBoundary | BreakLineOn::NoWrap => { - glyph_brush_layout::BuiltInLineBreaker::UnicodeLineBreaker - } - BreakLineOn::AnyCharacter => glyph_brush_layout::BuiltInLineBreaker::AnyCharLineBreaker, - } - } -} diff --git a/crates/bevy_text/src/text2d.rs b/crates/bevy_text/src/text2d.rs index 0b3ae44dec6f6..8c28fe3079aca 100644 --- a/crates/bevy_text/src/text2d.rs +++ b/crates/bevy_text/src/text2d.rs @@ -1,22 +1,19 @@ use crate::{ - BreakLineOn, Font, FontAtlasSets, PositionedGlyph, Text, TextError, TextLayoutInfo, - TextPipeline, TextSettings, YAxisOrientation, + BreakLineOn, CosmicBuffer, Font, FontAtlasSets, PositionedGlyph, Text, TextBounds, TextError, + TextLayoutInfo, TextPipeline, YAxisOrientation, }; use bevy_asset::Assets; use bevy_color::LinearRgba; use bevy_ecs::{ bundle::Bundle, change_detection::{DetectChanges, Ref}, - component::Component, entity::Entity, event::EventReader, prelude::With, query::{Changed, Without}, - reflect::ReflectComponent, system::{Commands, Local, Query, Res, ResMut}, }; use bevy_math::Vec2; -use bevy_reflect::Reflect; use bevy_render::{ primitives::Aabb, texture::Image, @@ -28,34 +25,6 @@ use bevy_transform::prelude::{GlobalTransform, Transform}; use bevy_utils::HashSet; use bevy_window::{PrimaryWindow, Window, WindowScaleFactorChanged}; -/// The maximum width and height of text. The text will wrap according to the specified size. -/// Characters out of the bounds after wrapping will be truncated. Text is aligned according to the -/// specified [`JustifyText`](crate::text::JustifyText). -/// -/// Note: only characters that are completely out of the bounds will be truncated, so this is not a -/// reliable limit if it is necessary to contain the text strictly in the bounds. Currently this -/// component is mainly useful for text wrapping only. -#[derive(Component, Copy, Clone, Debug, Reflect)] -#[reflect(Component)] -pub struct Text2dBounds { - /// The maximum width and height of text in logical pixels. - pub size: Vec2, -} - -impl Default for Text2dBounds { - #[inline] - fn default() -> Self { - Self::UNBOUNDED - } -} - -impl Text2dBounds { - /// Unbounded text will not be truncated or wrapped. - pub const UNBOUNDED: Self = Self { - size: Vec2::splat(f32::INFINITY), - }; -} - /// The bundle of components needed to draw text in a 2D scene via a 2D `Camera2dBundle`. /// [Example usage.](https://github.com/bevyengine/bevy/blob/latest/examples/2d/text2d.rs) #[derive(Bundle, Clone, Debug, Default)] @@ -66,13 +35,15 @@ pub struct Text2dBundle { /// relative position which is controlled by the `Anchor` component. /// This means that for a block of text consisting of only one line that doesn't wrap, the `alignment` field will have no effect. pub text: Text, + /// Cached buffer for layout with cosmic-text + pub buffer: CosmicBuffer, /// How the text is positioned relative to its transform. /// /// `text_anchor` does not affect the internal alignment of the block of text, only /// its position. pub text_anchor: Anchor, /// The maximum width and height of the text. - pub text_2d_bounds: Text2dBounds, + pub text_2d_bounds: TextBounds, /// The transform of the text. pub transform: Transform, /// The global transform of the text. @@ -124,7 +95,7 @@ pub fn extract_text2d_sprite( } let text_anchor = -(anchor.as_vec() + 0.5); - let alignment_translation = text_layout_info.logical_size * text_anchor; + let alignment_translation = text_layout_info.size * text_anchor; let transform = *global_transform * GlobalTransform::from_translation(alignment_translation.extend(0.)) * scaling; @@ -149,7 +120,7 @@ pub fn extract_text2d_sprite( ExtractedSprite { transform: transform * GlobalTransform::from_translation(position.extend(0.)), color, - rect: Some(atlas.textures[atlas_info.glyph_index].as_rect()), + rect: Some(atlas.textures[atlas_info.location.glyph_index].as_rect()), custom_size: None, image_handle_id: atlas_info.texture.id(), flip_x: false, @@ -175,13 +146,18 @@ pub fn update_text2d_layout( mut queue: Local>, mut textures: ResMut>, fonts: Res>, - text_settings: Res, windows: Query<&Window, With>, mut scale_factor_changed: EventReader, mut texture_atlases: ResMut>, mut font_atlas_sets: ResMut, mut text_pipeline: ResMut, - mut text_query: Query<(Entity, Ref, Ref, &mut TextLayoutInfo)>, + mut text_query: Query<( + Entity, + Ref, + Ref, + &mut TextLayoutInfo, + &mut CosmicBuffer, + )>, ) { // We need to consume the entire iterator, hence `last` let factor_changed = scale_factor_changed.read().last().is_some(); @@ -194,40 +170,43 @@ pub fn update_text2d_layout( let inverse_scale_factor = scale_factor.recip(); - for (entity, text, bounds, mut text_layout_info) in &mut text_query { + for (entity, text, bounds, mut text_layout_info, mut buffer) in &mut text_query { if factor_changed || text.is_changed() || bounds.is_changed() || queue.remove(&entity) { - let text_bounds = Vec2::new( - if text.linebreak_behavior == BreakLineOn::NoWrap { - f32::INFINITY + let text_bounds = TextBounds { + width: if text.linebreak_behavior == BreakLineOn::NoWrap { + None } else { - scale_value(bounds.size.x, scale_factor) + bounds.width.map(|width| scale_value(width, scale_factor)) }, - scale_value(bounds.size.y, scale_factor), - ); + height: bounds + .height + .map(|height| scale_value(height, scale_factor)), + }; + match text_pipeline.queue_text( &fonts, &text.sections, - scale_factor, + scale_factor.into(), text.justify, text.linebreak_behavior, text_bounds, &mut font_atlas_sets, &mut texture_atlases, &mut textures, - text_settings.as_ref(), YAxisOrientation::BottomToTop, + buffer.as_mut(), ) { Err(TextError::NoSuchFont) => { // There was an error processing the text layout, let's add this entity to the // queue for further processing queue.insert(entity); } - Err(e @ TextError::FailedToAddGlyph(_)) => { + Err(e @ (TextError::FailedToAddGlyph(_) | TextError::FailedToGetGlyphImage(_))) => { panic!("Fatal error when processing text: {e}."); } Ok(mut info) => { - info.logical_size.x = scale_value(info.logical_size.x, inverse_scale_factor); - info.logical_size.y = scale_value(info.logical_size.y, inverse_scale_factor); + info.size.x = scale_value(info.size.x, inverse_scale_factor); + info.size.y = scale_value(info.size.y, inverse_scale_factor); *text_layout_info = info; } } @@ -254,11 +233,9 @@ pub fn calculate_bounds_text2d( for (entity, layout_info, anchor, aabb) in &mut text_to_update_aabb { // `Anchor::as_vec` gives us an offset relative to the text2d bounds, by negating it and scaling // by the logical size we compensate the transform offset in local space to get the center. - let center = (-anchor.as_vec() * layout_info.logical_size) - .extend(0.0) - .into(); + let center = (-anchor.as_vec() * layout_info.size).extend(0.0).into(); // Distance in local space from the center to the x and y limits of the text2d bounds. - let half_extents = (layout_info.logical_size / 2.0).extend(0.0).into(); + let half_extents = (layout_info.size / 2.0).extend(0.0).into(); if let Some(mut aabb) = aabb { *aabb = Aabb { center, @@ -291,7 +268,6 @@ mod tests { app.init_resource::>() .init_resource::>() .init_resource::>() - .init_resource::() .init_resource::() .init_resource::>() .insert_resource(TextPipeline::default()) diff --git a/crates/bevy_ui/src/layout/mod.rs b/crates/bevy_ui/src/layout/mod.rs index 11cacda0690e3..a862f5719b495 100644 --- a/crates/bevy_ui/src/layout/mod.rs +++ b/crates/bevy_ui/src/layout/mod.rs @@ -1,3 +1,4 @@ +use bevy_text::TextPipeline; use thiserror::Error; use crate::{ContentSize, DefaultUiCamera, Node, Outline, Style, TargetCamera, UiScale}; @@ -94,6 +95,7 @@ pub fn ui_layout_system( just_children_query: Query<&Children>, mut removed_components: UiLayoutSystemRemovedComponentParam, mut node_transform_query: Query<(&mut Node, &mut Transform)>, + #[cfg(feature = "bevy_text")] mut text_pipeline: ResMut, ) { struct CameraLayoutInfo { size: UVec2, @@ -214,6 +216,8 @@ pub fn ui_layout_system( } } + #[cfg(feature = "bevy_text")] + let font_system = text_pipeline.font_system_mut(); // clean up removed nodes after syncing children to avoid potential panic (invalid SlotMap key used) ui_surface.remove_entities(removed_components.removed_nodes.read()); @@ -227,7 +231,12 @@ pub fn ui_layout_system( for (camera_id, camera) in &camera_layout_info { let inverse_target_scale_factor = camera.scale_factor.recip(); - ui_surface.compute_camera_layout(*camera_id, camera.size); + ui_surface.compute_camera_layout( + *camera_id, + camera.size, + #[cfg(feature = "bevy_text")] + font_system, + ); for root in &camera.root_nodes { update_uinode_geometry_recursive( *root, @@ -408,6 +417,8 @@ mod tests { world.init_resource::>>(); world.init_resource::>(); world.init_resource::(); + #[cfg(feature = "bevy_text")] + world.init_resource::(); // spawn a dummy primary window and camera world.spawn(( @@ -1040,6 +1051,8 @@ mod tests { world.init_resource::>>(); world.init_resource::>(); world.init_resource::(); + #[cfg(feature = "bevy_text")] + world.init_resource::(); // spawn a dummy primary window and camera world.spawn(( diff --git a/crates/bevy_ui/src/layout/ui_surface.rs b/crates/bevy_ui/src/layout/ui_surface.rs index 378cb7b78a616..75b4428af0f9e 100644 --- a/crates/bevy_ui/src/layout/ui_surface.rs +++ b/crates/bevy_ui/src/layout/ui_surface.rs @@ -10,7 +10,7 @@ use bevy_utils::default; use bevy_utils::tracing::warn; use crate::layout::convert; -use crate::{LayoutContext, LayoutError, Measure, NodeMeasure, Style}; +use crate::{LayoutContext, LayoutError, Measure, MeasureArgs, NodeMeasure, Style}; #[derive(Debug, Clone, PartialEq, Eq)] pub struct RootNodePair { @@ -196,7 +196,12 @@ without UI components as a child of an entity with UI components, results may be } /// Compute the layout for each window entity's corresponding root node in the layout. - pub fn compute_camera_layout(&mut self, camera: Entity, render_target_resolution: UVec2) { + pub fn compute_camera_layout( + &mut self, + camera: Entity, + render_target_resolution: UVec2, + #[cfg(feature = "bevy_text")] font_system: &mut bevy_text::cosmic_text::FontSystem, + ) { let Some(camera_root_nodes) = self.camera_roots.get(&camera) else { return; }; @@ -219,10 +224,14 @@ without UI components as a child of an entity with UI components, results may be context .map(|ctx| { let size = ctx.measure( - known_dimensions.width, - known_dimensions.height, - available_space.width, - available_space.height, + MeasureArgs { + width: known_dimensions.width, + height: known_dimensions.height, + available_width: available_space.width, + available_height: available_space.height, + #[cfg(feature = "bevy_text")] + font_system, + }, style, ); taffy::Size { diff --git a/crates/bevy_ui/src/measurement.rs b/crates/bevy_ui/src/measurement.rs index f84391896e426..1d4d7beb8021c 100644 --- a/crates/bevy_ui/src/measurement.rs +++ b/crates/bevy_ui/src/measurement.rs @@ -16,18 +16,20 @@ impl std::fmt::Debug for ContentSize { } } +pub struct MeasureArgs<'a> { + pub width: Option, + pub height: Option, + pub available_width: AvailableSpace, + pub available_height: AvailableSpace, + #[cfg(feature = "bevy_text")] + pub font_system: &'a mut bevy_text::cosmic_text::FontSystem, +} + /// A `Measure` is used to compute the size of a ui node /// when the size of that node is based on its content. pub trait Measure: Send + Sync + 'static { /// Calculate the size of the node given the constraints. - fn measure( - &self, - width: Option, - height: Option, - available_width: AvailableSpace, - available_height: AvailableSpace, - style: &taffy::Style, - ) -> Vec2; + fn measure(&mut self, measure_args: MeasureArgs<'_>, style: &taffy::Style) -> Vec2; } /// A type to serve as Taffy's node context (which allows the content size of leaf nodes to be computed) @@ -43,28 +45,13 @@ pub enum NodeMeasure { } impl Measure for NodeMeasure { - fn measure( - &self, - width: Option, - height: Option, - available_width: AvailableSpace, - available_height: AvailableSpace, - style: &taffy::Style, - ) -> Vec2 { + fn measure(&mut self, measure_args: MeasureArgs, style: &taffy::Style) -> Vec2 { match self { - NodeMeasure::Fixed(fixed) => { - fixed.measure(width, height, available_width, available_height, style) - } + NodeMeasure::Fixed(fixed) => fixed.measure(measure_args, style), #[cfg(feature = "bevy_text")] - NodeMeasure::Text(text) => { - text.measure(width, height, available_width, available_height, style) - } - NodeMeasure::Image(image) => { - image.measure(width, height, available_width, available_height, style) - } - NodeMeasure::Custom(custom) => { - custom.measure(width, height, available_width, available_height, style) - } + NodeMeasure::Text(text) => text.measure(measure_args, style), + NodeMeasure::Image(image) => image.measure(measure_args, style), + NodeMeasure::Custom(custom) => custom.measure(measure_args, style), } } } @@ -77,14 +64,7 @@ pub struct FixedMeasure { } impl Measure for FixedMeasure { - fn measure( - &self, - _: Option, - _: Option, - _: AvailableSpace, - _: AvailableSpace, - _: &taffy::Style, - ) -> Vec2 { + fn measure(&mut self, _: MeasureArgs, _: &taffy::Style) -> Vec2 { self.size } } diff --git a/crates/bevy_ui/src/node_bundles.rs b/crates/bevy_ui/src/node_bundles.rs index 42e7517487803..0610fee35a967 100644 --- a/crates/bevy_ui/src/node_bundles.rs +++ b/crates/bevy_ui/src/node_bundles.rs @@ -15,7 +15,9 @@ use bevy_ecs::bundle::Bundle; use bevy_render::view::{InheritedVisibility, ViewVisibility, Visibility}; use bevy_sprite::TextureAtlas; #[cfg(feature = "bevy_text")] -use bevy_text::{BreakLineOn, JustifyText, Text, TextLayoutInfo, TextSection, TextStyle}; +use bevy_text::{ + BreakLineOn, CosmicBuffer, JustifyText, Text, TextLayoutInfo, TextSection, TextStyle, +}; use bevy_transform::prelude::{GlobalTransform, Transform}; /// The basic UI node. @@ -169,6 +171,8 @@ pub struct TextBundle { pub style: Style, /// Contains the text of the node pub text: Text, + /// Cached cosmic buffer for layout + pub buffer: CosmicBuffer, /// Text layout information pub text_layout_info: TextLayoutInfo, /// Text system flags diff --git a/crates/bevy_ui/src/render/mod.rs b/crates/bevy_ui/src/render/mod.rs index d05c0b541e00e..2a92c5ef8d5b5 100644 --- a/crates/bevy_ui/src/render/mod.rs +++ b/crates/bevy_ui/src/render/mod.rs @@ -857,7 +857,7 @@ pub fn extract_uinode_text( } let atlas = texture_atlases.get(&atlas_info.texture_atlas).unwrap(); - let mut rect = atlas.textures[atlas_info.glyph_index].as_rect(); + let mut rect = atlas.textures[atlas_info.location.glyph_index].as_rect(); rect.min *= inverse_scale_factor; rect.max *= inverse_scale_factor; extracted_uinodes.uinodes.insert( diff --git a/crates/bevy_ui/src/widget/image.rs b/crates/bevy_ui/src/widget/image.rs index 28b50be15c455..4451aed20e8b8 100644 --- a/crates/bevy_ui/src/widget/image.rs +++ b/crates/bevy_ui/src/widget/image.rs @@ -1,6 +1,4 @@ -use crate::{ - measurement::AvailableSpace, ContentSize, Measure, Node, NodeMeasure, UiImage, UiScale, -}; +use crate::{ContentSize, Measure, MeasureArgs, Node, NodeMeasure, UiImage, UiScale}; use bevy_asset::Assets; use bevy_ecs::prelude::*; use bevy_math::{UVec2, Vec2}; @@ -37,14 +35,15 @@ pub struct ImageMeasure { } impl Measure for ImageMeasure { - fn measure( - &self, - width: Option, - height: Option, - available_width: AvailableSpace, - available_height: AvailableSpace, - style: &taffy::Style, - ) -> Vec2 { + fn measure(&mut self, measure_args: MeasureArgs, style: &taffy::Style) -> Vec2 { + let MeasureArgs { + width, + height, + available_width, + available_height, + .. + } = measure_args; + // Convert available width/height into an option let parent_width = available_width.into_option(); let parent_height = available_height.into_option(); diff --git a/crates/bevy_ui/src/widget/text.rs b/crates/bevy_ui/src/widget/text.rs index 2fd25552d2523..b6cfda92d81a6 100644 --- a/crates/bevy_ui/src/widget/text.rs +++ b/crates/bevy_ui/src/widget/text.rs @@ -1,5 +1,6 @@ use crate::{ - ContentSize, DefaultUiCamera, FixedMeasure, Measure, Node, NodeMeasure, TargetCamera, UiScale, + ContentSize, DefaultUiCamera, FixedMeasure, Measure, MeasureArgs, Node, NodeMeasure, + TargetCamera, UiScale, }; use bevy_asset::Assets; use bevy_ecs::{ @@ -15,8 +16,8 @@ use bevy_reflect::{std_traits::ReflectDefault, Reflect}; use bevy_render::{camera::Camera, texture::Image}; use bevy_sprite::TextureAtlasLayout; use bevy_text::{ - scale_value, BreakLineOn, Font, FontAtlasSets, Text, TextError, TextLayoutInfo, - TextMeasureInfo, TextPipeline, TextSettings, YAxisOrientation, + scale_value, BreakLineOn, CosmicBuffer, Font, FontAtlasSets, JustifyText, Text, TextBounds, + TextError, TextLayoutInfo, TextMeasureInfo, TextPipeline, YAxisOrientation, }; use bevy_utils::Entry; use taffy::style::AvailableSpace; @@ -42,20 +43,19 @@ impl Default for TextFlags { } } -#[derive(Clone)] pub struct TextMeasure { pub info: TextMeasureInfo, } impl Measure for TextMeasure { - fn measure( - &self, - width: Option, - height: Option, - available_width: AvailableSpace, - _available_height: AvailableSpace, - _style: &taffy::Style, - ) -> Vec2 { + fn measure(&mut self, measure_args: MeasureArgs, _style: &taffy::Style) -> Vec2 { + let MeasureArgs { + width, + height, + available_width, + font_system, + .. + } = measure_args; let x = width.unwrap_or_else(|| match available_width { AvailableSpace::Definite(x) => { // It is possible for the "min content width" to be larger than @@ -71,7 +71,9 @@ impl Measure for TextMeasure { height .map_or_else( || match available_width { - AvailableSpace::Definite(_) => self.info.compute_size(Vec2::new(x, f32::MAX)), + AvailableSpace::Definite(_) => self + .info + .compute_size(TextBounds::new_horizontal(x), font_system), AvailableSpace::MinContent => Vec2::new(x, self.info.min.y), AvailableSpace::MaxContent => Vec2::new(x, self.info.max.y), }, @@ -81,15 +83,26 @@ impl Measure for TextMeasure { } } +#[allow(clippy::too_many_arguments)] #[inline] fn create_text_measure( fonts: &Assets, - scale_factor: f32, + scale_factor: f64, text: Ref, + text_pipeline: &mut TextPipeline, mut content_size: Mut, mut text_flags: Mut, + buffer: &mut CosmicBuffer, + text_alignment: JustifyText, ) { - match TextMeasureInfo::from_text(&text, fonts, scale_factor) { + match text_pipeline.create_text_measure( + fonts, + &text.sections, + scale_factor, + text.linebreak_behavior, + buffer, + text_alignment, + ) { Ok(measure) => { if text.linebreak_behavior == BreakLineOn::NoWrap { content_size.set(NodeMeasure::Fixed(FixedMeasure { size: measure.max })); @@ -105,7 +118,7 @@ fn create_text_measure( // Try again next frame text_flags.needs_new_measure_func = true; } - Err(e @ TextError::FailedToAddGlyph(_)) => { + Err(e @ (TextError::FailedToAddGlyph(_) | TextError::FailedToGetGlyphImage(_))) => { panic!("Fatal error when processing text: {e}."); } }; @@ -132,13 +145,15 @@ pub fn measure_text_system( &mut ContentSize, &mut TextFlags, Option<&TargetCamera>, + &mut CosmicBuffer, ), With, >, + mut text_pipeline: ResMut, ) { let mut scale_factors: EntityHashMap = EntityHashMap::default(); - for (text, content_size, text_flags, camera) in &mut text_query { + for (text, content_size, text_flags, camera, mut buffer) in &mut text_query { let Some(camera_entity) = camera.map(TargetCamera::entity).or(default_ui_camera.get()) else { continue; @@ -159,7 +174,17 @@ pub fn measure_text_system( || text_flags.needs_new_measure_func || content_size.is_added() { - create_text_measure(&fonts, scale_factor, text, content_size, text_flags); + let text_alignment = text.justify; + create_text_measure( + &fonts, + scale_factor.into(), + text, + &mut text_pipeline, + content_size, + text_flags, + buffer.as_mut(), + text_alignment, + ); } } *last_scale_factors = scale_factors; @@ -173,22 +198,22 @@ fn queue_text( font_atlas_sets: &mut FontAtlasSets, texture_atlases: &mut Assets, textures: &mut Assets, - text_settings: &TextSettings, scale_factor: f32, inverse_scale_factor: f32, text: &Text, node: Ref, mut text_flags: Mut, mut text_layout_info: Mut, + buffer: &mut CosmicBuffer, ) { // Skip the text node if it is waiting for a new measure func if !text_flags.needs_new_measure_func { let physical_node_size = if text.linebreak_behavior == BreakLineOn::NoWrap { // With `NoWrap` set, no constraints are placed on the width of the text. - Vec2::splat(f32::INFINITY) + TextBounds::UNBOUNDED } else { // `scale_factor` is already multiplied by `UiScale` - Vec2::new( + TextBounds::new( node.unrounded_size.x * scale_factor, node.unrounded_size.y * scale_factor, ) @@ -197,26 +222,26 @@ fn queue_text( match text_pipeline.queue_text( fonts, &text.sections, - scale_factor, + scale_factor.into(), text.justify, text.linebreak_behavior, physical_node_size, font_atlas_sets, texture_atlases, textures, - text_settings, YAxisOrientation::TopToBottom, + buffer, ) { Err(TextError::NoSuchFont) => { // There was an error processing the text layout, try again next frame text_flags.needs_recompute = true; } - Err(e @ TextError::FailedToAddGlyph(_)) => { + Err(e @ (TextError::FailedToAddGlyph(_) | TextError::FailedToGetGlyphImage(_))) => { panic!("Fatal error when processing text: {e}."); } Ok(mut info) => { - info.logical_size.x = scale_value(info.logical_size.x, inverse_scale_factor); - info.logical_size.y = scale_value(info.logical_size.y, inverse_scale_factor); + info.size.x = scale_value(info.size.x, inverse_scale_factor); + info.size.y = scale_value(info.size.y, inverse_scale_factor); *text_layout_info = info; text_flags.needs_recompute = false; } @@ -239,7 +264,6 @@ pub fn text_system( fonts: Res>, camera_query: Query<(Entity, &Camera)>, default_ui_camera: DefaultUiCamera, - text_settings: Res, ui_scale: Res, mut texture_atlases: ResMut>, mut font_atlas_sets: ResMut, @@ -250,11 +274,12 @@ pub fn text_system( &mut TextLayoutInfo, &mut TextFlags, Option<&TargetCamera>, + &mut CosmicBuffer, )>, ) { let mut scale_factors: EntityHashMap = EntityHashMap::default(); - for (node, text, text_layout_info, text_flags, camera) in &mut text_query { + for (node, text, text_layout_info, text_flags, camera, mut buffer) in &mut text_query { let Some(camera_entity) = camera.map(TargetCamera::entity).or(default_ui_camera.get()) else { continue; @@ -282,13 +307,13 @@ pub fn text_system( &mut font_atlas_sets, &mut texture_atlases, &mut textures, - &text_settings, scale_factor, inverse_scale_factor, text, node, text_flags, text_layout_info, + buffer.as_mut(), ); } } diff --git a/docs/cargo_features.md b/docs/cargo_features.md index 33e12ee571e06..e8f7e7df136e1 100644 --- a/docs/cargo_features.md +++ b/docs/cargo_features.md @@ -77,7 +77,6 @@ The default feature set enables most of the expected features of a game engine, |serialize|Enable serialization support through serde| |shader_format_glsl|Enable support for shaders in GLSL| |shader_format_spirv|Enable support for shaders in SPIR-V| -|subpixel_glyph_atlas|Enable rendering of font glyphs using subpixel accuracy| |symphonia-aac|AAC audio format support (through symphonia)| |symphonia-all|AAC, FLAC, MP3, MP4, OGG/VORBIS, and WAV audio formats support (through symphonia)| |symphonia-flac|FLAC audio format support (through symphonia)| diff --git a/examples/2d/text2d.rs b/examples/2d/text2d.rs index bb7325ac1f895..a420c16e9cdc5 100644 --- a/examples/2d/text2d.rs +++ b/examples/2d/text2d.rs @@ -9,7 +9,7 @@ use bevy::{ color::palettes::css::*, prelude::*, sprite::Anchor, - text::{BreakLineOn, Text2dBounds}, + text::{BreakLineOn, TextBounds}, }; fn main() { @@ -97,10 +97,8 @@ fn setup(mut commands: Commands, asset_server: Res) { justify: JustifyText::Left, linebreak_behavior: BreakLineOn::WordBoundary, }, - text_2d_bounds: Text2dBounds { - // Wrap text in the rectangle - size: box_size, - }, + // Wrap text in the rectangle + text_2d_bounds: TextBounds::from(box_size), // ensure the text is drawn on top of the box transform: Transform::from_translation(Vec3::Z), ..default() @@ -129,10 +127,8 @@ fn setup(mut commands: Commands, asset_server: Res) { justify: JustifyText::Left, linebreak_behavior: BreakLineOn::AnyCharacter, }, - text_2d_bounds: Text2dBounds { - // Wrap text in the rectangle - size: other_box_size, - }, + // Wrap text in the rectangle + text_2d_bounds: TextBounds::from(other_box_size), // ensure the text is drawn on top of the box transform: Transform::from_translation(Vec3::Z), ..default() diff --git a/examples/3d/tonemapping.rs b/examples/3d/tonemapping.rs index 406de20dc9802..a0af4690175a9 100644 --- a/examples/3d/tonemapping.rs +++ b/examples/3d/tonemapping.rs @@ -408,27 +408,33 @@ fn update_color_grading_settings( } fn update_ui( - mut text: Query<&mut Text, Without>, + mut text_query: Query<&mut Text, Without>, settings: Query<(&Tonemapping, &ColorGrading)>, current_scene: Res, selected_parameter: Res, mut hide_ui: Local, keys: Res>, ) { - let (method, color_grading) = settings.single(); - let method = *method; - - let mut text = text.single_mut(); - let text = &mut text.sections[0].value; - if keys.just_pressed(KeyCode::KeyH) { *hide_ui = !*hide_ui; } - text.clear(); + + let old_text = &text_query.single().sections[0].value; + if *hide_ui { + if !old_text.is_empty() { + // single_mut() always triggers change detection, + // so only access if text actually needs changing + text_query.single_mut().sections[0].value.clear(); + } return; } + let (method, color_grading) = settings.single(); + let method = *method; + + let mut text = String::with_capacity(old_text.len()); + let scn = current_scene.0; text.push_str("(H) Hide UI\n\n"); text.push_str("Test Scene: \n"); @@ -532,6 +538,12 @@ fn update_ui( if current_scene.0 == 1 { text.push_str("(Enter) Reset all to scene recommendation\n"); } + + if text != old_text.as_str() { + // single_mut() always triggers change detection, + // so only access if text actually changed + text_query.single_mut().sections[0].value = text; + } } // ---------------------------------------------------------------------------- diff --git a/examples/stress_tests/many_glyphs.rs b/examples/stress_tests/many_glyphs.rs index ca4bc6db28c53..51a31f8cd622f 100644 --- a/examples/stress_tests/many_glyphs.rs +++ b/examples/stress_tests/many_glyphs.rs @@ -9,7 +9,7 @@ use bevy::{ color::palettes::basic::RED, diagnostic::{FrameTimeDiagnosticsPlugin, LogDiagnosticsPlugin}, prelude::*, - text::{BreakLineOn, Text2dBounds}, + text::{BreakLineOn, TextBounds}, window::{PresentMode, WindowResolution}, winit::{UpdateMode, WinitSettings}, }; @@ -83,9 +83,7 @@ fn setup(mut commands: Commands) { commands.spawn(Text2dBundle { text, text_anchor: bevy::sprite::Anchor::Center, - text_2d_bounds: Text2dBounds { - size: Vec2::new(1000., f32::INFINITY), - }, + text_2d_bounds: TextBounds::new_horizontal(1000.), ..Default::default() }); } diff --git a/examples/stress_tests/text_pipeline.rs b/examples/stress_tests/text_pipeline.rs index 716d867d0d4e5..1dc69af6ed9e2 100644 --- a/examples/stress_tests/text_pipeline.rs +++ b/examples/stress_tests/text_pipeline.rs @@ -6,7 +6,7 @@ use bevy::{ color::palettes::basic::{BLUE, YELLOW}, diagnostic::{FrameTimeDiagnosticsPlugin, LogDiagnosticsPlugin}, prelude::*, - text::{BreakLineOn, Text2dBounds}, + text::{BreakLineOn, TextBounds}, window::{PresentMode, WindowResolution}, winit::{UpdateMode, WinitSettings}, }; @@ -72,9 +72,9 @@ fn spawn(mut commands: Commands, asset_server: Res) { } // changing the bounds of the text will cause a recomputation -fn update_text_bounds(time: Res