diff --git a/crates/bevy_text/src/font_atlas.rs b/crates/bevy_text/src/font_atlas.rs index b5e0dfb575..31c0fec9e8 100644 --- a/crates/bevy_text/src/font_atlas.rs +++ b/crates/bevy_text/src/font_atlas.rs @@ -3,12 +3,12 @@ use bevy_math::{IVec2, UVec2}; use bevy_render::{ render_asset::RenderAssetUsages, render_resource::{Extent3d, TextureDimension, TextureFormat}, - texture::Image, + texture::{Image, ImageSampler}, }; use bevy_sprite::{DynamicTextureAtlasBuilder, TextureAtlasLayout}; use bevy_utils::HashMap; -use crate::{GlyphAtlasLocation, TextError}; +use crate::{FontSmoothing, GlyphAtlasLocation, TextError}; /// Rasterized glyphs are cached, stored in, and retrieved from, a `FontAtlas`. /// @@ -39,8 +39,9 @@ impl FontAtlas { textures: &mut Assets, texture_atlases_layout: &mut Assets, size: UVec2, + font_smoothing: FontSmoothing, ) -> FontAtlas { - let texture = textures.add(Image::new_fill( + let mut image = Image::new_fill( Extent3d { width: size.x, height: size.y, @@ -51,7 +52,11 @@ impl FontAtlas { TextureFormat::Rgba8UnormSrgb, // Need to keep this image CPU persistent in order to add additional glyphs later on RenderAssetUsages::MAIN_WORLD | RenderAssetUsages::RENDER_WORLD, - )); + ); + if font_smoothing == FontSmoothing::None { + image.sampler = ImageSampler::nearest(); + } + let texture = textures.add(image); let texture_atlas = texture_atlases_layout.add(TextureAtlasLayout::new_empty(size)); Self { texture_atlas, diff --git a/crates/bevy_text/src/font_atlas_set.rs b/crates/bevy_text/src/font_atlas_set.rs index e4d11b60c0..9cd7b64379 100644 --- a/crates/bevy_text/src/font_atlas_set.rs +++ b/crates/bevy_text/src/font_atlas_set.rs @@ -13,7 +13,7 @@ use bevy_render::{ use bevy_sprite::TextureAtlasLayout; use bevy_utils::HashMap; -use crate::{error::TextError, Font, FontAtlas, GlyphAtlasInfo}; +use crate::{error::TextError, Font, FontAtlas, FontSmoothing, GlyphAtlasInfo}; /// A map of font faces to their corresponding [`FontAtlasSet`]s. #[derive(Debug, Default, Resource)] @@ -47,17 +47,11 @@ pub fn remove_dropped_font_atlas_sets( } } -/// Identifies a font size in a [`FontAtlasSet`]. +/// Identifies a font size and smoothing method 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) - } -} +pub struct FontAtlasKey(pub u32, pub FontSmoothing); /// A map of font sizes to their corresponding [`FontAtlas`]es, for a given font face. /// @@ -77,7 +71,7 @@ impl From for FontSizeKey { /// It is used by [`TextPipeline::queue_text`](crate::TextPipeline::queue_text). #[derive(Debug, TypePath, Asset)] pub struct FontAtlasSet { - font_atlases: HashMap>, + font_atlases: HashMap>, } impl Default for FontAtlasSet { @@ -90,12 +84,12 @@ impl Default for FontAtlasSet { impl FontAtlasSet { /// Returns an iterator over the [`FontAtlas`]es in this set - pub fn iter(&self) -> impl Iterator)> { + pub fn iter(&self) -> impl Iterator)> { self.font_atlases.iter() } /// 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 { + pub fn has_glyph(&self, cache_key: cosmic_text::CacheKey, font_size: &FontAtlasKey) -> bool { self.font_atlases .get(font_size) .map_or(false, |font_atlas| { @@ -111,16 +105,31 @@ impl FontAtlasSet { font_system: &mut cosmic_text::FontSystem, swash_cache: &mut cosmic_text::SwashCache, layout_glyph: &cosmic_text::LayoutGlyph, + font_smoothing: FontSmoothing, ) -> Result { let physical_glyph = layout_glyph.physical((0., 0.), 1.0); let font_atlases = self .font_atlases - .entry(physical_glyph.cache_key.font_size_bits.into()) - .or_insert_with(|| vec![FontAtlas::new(textures, texture_atlases, UVec2::splat(512))]); + .entry(FontAtlasKey( + physical_glyph.cache_key.font_size_bits, + font_smoothing, + )) + .or_insert_with(|| { + vec![FontAtlas::new( + textures, + texture_atlases, + UVec2::splat(512), + font_smoothing, + )] + }); - let (glyph_texture, offset) = - Self::get_outlined_glyph_texture(font_system, swash_cache, &physical_glyph)?; + let (glyph_texture, offset) = Self::get_outlined_glyph_texture( + font_system, + swash_cache, + &physical_glyph, + font_smoothing, + )?; let mut add_char_to_font_atlas = |atlas: &mut FontAtlas| -> Result<(), TextError> { atlas.add_glyph( textures, @@ -146,6 +155,7 @@ impl FontAtlasSet { textures, texture_atlases, UVec2::splat(containing), + font_smoothing, )); font_atlases.last_mut().unwrap().add_glyph( @@ -157,16 +167,19 @@ impl FontAtlasSet { )?; } - Ok(self.get_glyph_atlas_info(physical_glyph.cache_key).unwrap()) + Ok(self + .get_glyph_atlas_info(physical_glyph.cache_key, font_smoothing) + .unwrap()) } /// Generates the [`GlyphAtlasInfo`] for the given subpixel-offset glyph. pub fn get_glyph_atlas_info( &mut self, cache_key: cosmic_text::CacheKey, + font_smoothing: FontSmoothing, ) -> Option { self.font_atlases - .get(&FontSizeKey(cache_key.font_size_bits)) + .get(&FontAtlasKey(cache_key.font_size_bits, font_smoothing)) .and_then(|font_atlases| { font_atlases .iter() @@ -201,7 +214,16 @@ impl FontAtlasSet { font_system: &mut cosmic_text::FontSystem, swash_cache: &mut cosmic_text::SwashCache, physical_glyph: &cosmic_text::PhysicalGlyph, + font_smoothing: FontSmoothing, ) -> Result<(Image, IVec2), TextError> { + // NOTE: Ideally, we'd ask COSMIC Text to honor the font smoothing setting directly. + // However, since it currently doesn't support that, we render the glyph with antialiasing + // and apply a threshold to the alpha channel to simulate the effect. + // + // This has the side effect of making regular vector fonts look quite ugly when font smoothing + // is turned off, but for fonts that are specifically designed for pixel art, it works well. + // + // See: https://github.com/pop-os/cosmic-text/issues/279 let image = swash_cache .get_image_uncached(font_system, physical_glyph.cache_key) .ok_or(TextError::FailedToGetGlyphImage(physical_glyph.cache_key))?; @@ -214,11 +236,22 @@ impl FontAtlasSet { } = 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::Mask => { + if font_smoothing == FontSmoothing::None { + image + .data + .iter() + // Apply a 50% threshold to the alpha channel + .flat_map(|a| [255, 255, 255, if *a > 127 { 255 } else { 0 }]) + .collect() + } else { + image + .data + .iter() + .flat_map(|a| [255, 255, 255, *a]) + .collect() + } + } cosmic_text::SwashContent::Color => image.data, cosmic_text::SwashContent::SubpixelMask => { // TODO: implement diff --git a/crates/bevy_text/src/pipeline.rs b/crates/bevy_text/src/pipeline.rs index 103b37d4ea..4f9dc11c54 100644 --- a/crates/bevy_text/src/pipeline.rs +++ b/crates/bevy_text/src/pipeline.rs @@ -16,8 +16,8 @@ use bevy_utils::HashMap; use cosmic_text::{Attrs, Buffer, Family, Metrics, Shaping, Wrap}; use crate::{ - error::TextError, BreakLineOn, CosmicBuffer, Font, FontAtlasSets, JustifyText, PositionedGlyph, - TextBounds, TextSection, YAxisOrientation, + error::TextError, BreakLineOn, CosmicBuffer, Font, FontAtlasSets, FontSmoothing, JustifyText, + PositionedGlyph, TextBounds, TextSection, YAxisOrientation, }; /// A wrapper around a [`cosmic_text::FontSystem`] @@ -173,6 +173,7 @@ impl TextPipeline { scale_factor: f64, text_alignment: JustifyText, linebreak_behavior: BreakLineOn, + font_smoothing: FontSmoothing, bounds: TextBounds, font_atlas_sets: &mut FontAtlasSets, texture_atlases: &mut Assets, @@ -209,6 +210,24 @@ impl TextPipeline { .map(move |layout_glyph| (layout_glyph, run.line_y)) }) .try_for_each(|(layout_glyph, line_y)| { + let mut temp_glyph; + + let layout_glyph = if font_smoothing == FontSmoothing::None { + // If font smoothing is disabled, round the glyph positions and sizes, + // effectively discarding all subpixel layout. + temp_glyph = layout_glyph.clone(); + temp_glyph.x = temp_glyph.x.round(); + temp_glyph.y = temp_glyph.y.round(); + temp_glyph.w = temp_glyph.w.round(); + temp_glyph.x_offset = temp_glyph.x_offset.round(); + temp_glyph.y_offset = temp_glyph.y_offset.round(); + temp_glyph.line_height_opt = temp_glyph.line_height_opt.map(f32::round); + + &temp_glyph + } else { + layout_glyph + }; + let section_index = layout_glyph.metadata; let font_handle = sections[section_index].style.font.clone_weak(); @@ -217,7 +236,7 @@ impl TextPipeline { let physical_glyph = layout_glyph.physical((0., 0.), 1.); let atlas_info = font_atlas_set - .get_glyph_atlas_info(physical_glyph.cache_key) + .get_glyph_atlas_info(physical_glyph.cache_key, font_smoothing) .map(Ok) .unwrap_or_else(|| { font_atlas_set.add_glyph_to_atlas( @@ -226,6 +245,7 @@ impl TextPipeline { font_system, swash_cache, layout_glyph, + font_smoothing, ) })?; diff --git a/crates/bevy_text/src/text.rs b/crates/bevy_text/src/text.rs index 0982592c84..0c84ab8a0f 100644 --- a/crates/bevy_text/src/text.rs +++ b/crates/bevy_text/src/text.rs @@ -36,6 +36,8 @@ pub struct Text { pub justify: JustifyText, /// How the text should linebreak when running out of the bounds determined by `max_size` pub linebreak_behavior: BreakLineOn, + /// The antialiasing method to use when rendering text. + pub font_smoothing: FontSmoothing, } impl Text { @@ -124,6 +126,12 @@ impl Text { self.linebreak_behavior = BreakLineOn::NoWrap; self } + + /// Returns this [`Text`] with the specified [`FontSmoothing`]. + pub const fn with_font_smoothing(mut self, font_smoothing: FontSmoothing) -> Self { + self.font_smoothing = font_smoothing; + self + } } /// Contains the value of the text in a section and how it should be styled. @@ -260,3 +268,27 @@ pub enum BreakLineOn { /// Hard wrapping, where text contains an explicit linebreak such as the escape sequence `\n`, is still enabled. NoWrap, } + +/// Determines which antialiasing method to use when rendering text. By default, text is +/// rendered with grayscale antialiasing, but this can be changed to achieve a pixelated look. +/// +/// **Note:** Subpixel antialiasing is not currently supported. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Reflect, Serialize, Deserialize)] +#[reflect(Serialize, Deserialize)] +#[doc(alias = "antialiasing")] +#[doc(alias = "pixelated")] +pub enum FontSmoothing { + /// No antialiasing. Useful for when you want to render text with a pixel art aesthetic. + /// + /// Combine this with `UiAntiAlias::Off` and `Msaa::Off` on your 2D camera for a fully pixelated look. + /// + /// **Note:** Due to limitations of the underlying text rendering library, + /// this may require specially-crafted pixel fonts to look good, especially at small sizes. + None, + /// The default grayscale antialiasing. Produces text that looks smooth, + /// even at small font sizes and low resolutions with modern vector fonts. + #[default] + AntiAliased, + // TODO: Add subpixel antialias support + // SubpixelAntiAliased, +} diff --git a/crates/bevy_text/src/text2d.rs b/crates/bevy_text/src/text2d.rs index 2c0570836b..c514cabae3 100644 --- a/crates/bevy_text/src/text2d.rs +++ b/crates/bevy_text/src/text2d.rs @@ -191,6 +191,7 @@ pub fn update_text2d_layout( scale_factor.into(), text.justify, text.linebreak_behavior, + text.font_smoothing, text_bounds, &mut font_atlas_sets, &mut texture_atlases, diff --git a/crates/bevy_ui/src/ui_node.rs b/crates/bevy_ui/src/ui_node.rs index 00e68b2e7d..9df4478eb6 100644 --- a/crates/bevy_ui/src/ui_node.rs +++ b/crates/bevy_ui/src/ui_node.rs @@ -2451,6 +2451,8 @@ impl<'w, 's> DefaultUiCamera<'w, 's> { /// Marker for controlling whether Ui is rendered with or without anti-aliasing /// in a camera. By default, Ui is always anti-aliased. /// +/// **Note:** This does not affect text anti-aliasing. For that, use the `font_smoothing` property of the [`bevy_text::Text`] component. +/// /// ``` /// use bevy_core_pipeline::prelude::*; /// use bevy_ecs::prelude::*; diff --git a/crates/bevy_ui/src/widget/text.rs b/crates/bevy_ui/src/widget/text.rs index 66de4f01b5..c7942a19d9 100644 --- a/crates/bevy_ui/src/widget/text.rs +++ b/crates/bevy_ui/src/widget/text.rs @@ -250,6 +250,7 @@ fn queue_text( scale_factor.into(), text.justify, text.linebreak_behavior, + text.font_smoothing, physical_node_size, font_atlas_sets, texture_atlases, diff --git a/examples/2d/text2d.rs b/examples/2d/text2d.rs index cab32980de..f69abc3bed 100644 --- a/examples/2d/text2d.rs +++ b/examples/2d/text2d.rs @@ -10,7 +10,7 @@ use bevy::{ math::ops, prelude::*, sprite::Anchor, - text::{BreakLineOn, TextBounds}, + text::{BreakLineOn, FontSmoothing, TextBounds}, }; fn main() { @@ -97,6 +97,7 @@ fn setup(mut commands: Commands, asset_server: Res) { )], justify: JustifyText::Left, linebreak_behavior: BreakLineOn::WordBoundary, + ..default() }, // Wrap text in the rectangle text_2d_bounds: TextBounds::from(box_size), @@ -127,6 +128,7 @@ fn setup(mut commands: Commands, asset_server: Res) { )], justify: JustifyText::Left, linebreak_behavior: BreakLineOn::AnyCharacter, + ..default() }, // Wrap text in the rectangle text_2d_bounds: TextBounds::from(other_box_size), @@ -136,6 +138,14 @@ fn setup(mut commands: Commands, asset_server: Res) { }); }); + // Demonstrate font smoothing off + commands.spawn(Text2dBundle { + text: Text::from_section("FontSmoothing::None", slightly_smaller_text_style.clone()) + .with_font_smoothing(FontSmoothing::None), + transform: Transform::from_translation(Vec3::new(-400.0, -250.0, 0.0)), + ..default() + }); + for (text_anchor, color) in [ (Anchor::TopLeft, Color::Srgba(RED)), (Anchor::TopRight, Color::Srgba(LIME)), diff --git a/examples/stress_tests/many_glyphs.rs b/examples/stress_tests/many_glyphs.rs index 51a31f8cd6..45c18459e2 100644 --- a/examples/stress_tests/many_glyphs.rs +++ b/examples/stress_tests/many_glyphs.rs @@ -55,6 +55,7 @@ fn setup(mut commands: Commands) { }], justify: JustifyText::Left, linebreak_behavior: BreakLineOn::AnyCharacter, + ..default() }; commands diff --git a/examples/stress_tests/text_pipeline.rs b/examples/stress_tests/text_pipeline.rs index d0911d1002..a8d351f67b 100644 --- a/examples/stress_tests/text_pipeline.rs +++ b/examples/stress_tests/text_pipeline.rs @@ -66,6 +66,7 @@ fn spawn(mut commands: Commands, asset_server: Res) { sections, justify: JustifyText::Center, linebreak_behavior: BreakLineOn::AnyCharacter, + ..default() }, ..Default::default() }); diff --git a/examples/ui/text_wrap_debug.rs b/examples/ui/text_wrap_debug.rs index 3378a8cbc1..0f5fdacb3f 100644 --- a/examples/ui/text_wrap_debug.rs +++ b/examples/ui/text_wrap_debug.rs @@ -129,6 +129,7 @@ fn spawn(mut commands: Commands, asset_server: Res) { }], justify: JustifyText::Left, linebreak_behavior, + ..default() }; let text_id = commands .spawn(TextBundle {