Add the ability to control font smoothing (#15368)

# Objective

- Fixes #10720
- Adds the ability to control font smoothing of rendered text

## Solution

- Introduce the `FontSmoothing` enum, with two possible variants
(`FontSmoothing::None` and `FontSmoothing::AntiAliased`):
- This is based on `-webkit-font-smoothing`, in line with our practice
of adopting CSS-like properties/names for UI;
- I could have gone instead for the [`font-smooth`
property](https://developer.mozilla.org/en-US/docs/Web/CSS/font-smooth)
that's also supported by browsers, but didn't since it's also
non-standard, has an uglier name, and doesn't allow controlling the type
of antialias applied.
- Having an enum instead of e.g. a boolean, leaves the path open for
adding `FontSmoothing::SubpixelAntiAliased` in the future, without a
breaking change;
- Add all the necessary plumbing to get the `FontSmoothing` information
to where we rasterize the glyphs and store them in the atlas;
- Change the font atlas key to also take into account the smoothing
setting, not only font and font size;
- Since COSMIC Text [doesn't support controlling font
smoothing](https://github.com/pop-os/cosmic-text/issues/279), we roll
out our own threshold-based “implementation”:
- This has the downside of **looking ugly for “regular” vector fonts**
⚠️, since it doesn't properly take the hinting information into account
like a proper implementation on the rasterizer side would.
- However, **for fonts that have been specifically authored to be pixel
fonts, (a common use case in games!) this is not as big of a problem**,
since all lines are vertical/horizontal, and close to the final pixel
boundaries (as long as the font is used at a multiple of the size
originally intended by the author)
- Once COSMIC exposes this functionality, we can switch to using it
directly, and get better results;
- Use a nearest neighbor sampler for atlases with font smoothing
disabled, so that you can scale the text via transform and still get the
pixelated look;
- Add a convenience method to `Text` for setting the font smoothing;
- Add a demonstration of using the `FontSmoothing` property to the
`text2d` example.

## Testing

- Did you test these changes? If so, how?
  - Yes. Via the `text2d`example, and also in my game.
- Are there any parts that need more testing?
- I'd like help from someone for testing this on devices/OSs with
fractional scaling (Android/Windows)
- How can other people (reviewers) test your changes? Is there anything
specific they need to know?
- Both via the `text2d` example and also by using it directly on your
projects.
- If relevant, what platforms did you test these changes on, and are
there any important ones you can't test?
  - macOS

---

## Showcase

```rust
commands.spawn(Text2dBundle {
    text: Text::from_section("Hello, World!", default())
        .with_font_smoothing(FontSmoothing::None),
    ..default()
});
```
![Screenshot 2024-09-22 at 12 33
39](https://github.com/user-attachments/assets/93e19672-b8c0-4cba-a8a3-4525fe2ae1cb)

<img width="740" alt="image"
src="https://github.com/user-attachments/assets/b881b02c-4e43-410b-902f-6985c25140fc">

## Migration Guide

- `Text` now contains a `font_smoothing: FontSmoothing` property, make
sure to include it or add `..default()` when using the struct directly;
- `FontSizeKey` has been renamed to `FontAtlasKey`, and now also
contains the `FontSmoothing` setting;
- The following methods now take an extra `font_smoothing:
FontSmoothing` argument:
  - `FontAtlas::new()`
  - `FontAtlasSet::add_glyph_to_atlas()`
  - `FontAtlasSet::get_glyph_atlas_info()`
  - `FontAtlasSet::get_outlined_glyph_texture()`
This commit is contained in:
Marco Buono 2024-09-23 14:28:25 -03:00 committed by GitHub
parent 2c5be2ef4c
commit 8e3db957c5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 138 additions and 31 deletions

View file

@ -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<Image>,
texture_atlases_layout: &mut Assets<TextureAtlasLayout>,
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,

View file

@ -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<u32> 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<u32> for FontSizeKey {
/// It is used by [`TextPipeline::queue_text`](crate::TextPipeline::queue_text).
#[derive(Debug, TypePath, Asset)]
pub struct FontAtlasSet {
font_atlases: HashMap<FontSizeKey, Vec<FontAtlas>>,
font_atlases: HashMap<FontAtlasKey, Vec<FontAtlas>>,
}
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<Item = (&FontSizeKey, &Vec<FontAtlas>)> {
pub fn iter(&self) -> impl Iterator<Item = (&FontAtlasKey, &Vec<FontAtlas>)> {
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<GlyphAtlasInfo, TextError> {
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<GlyphAtlasInfo> {
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

View file

@ -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<TextureAtlasLayout>,
@ -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,
)
})?;

View file

@ -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,
}

View file

@ -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,

View file

@ -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::*;

View file

@ -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,

View file

@ -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<AssetServer>) {
)],
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<AssetServer>) {
)],
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<AssetServer>) {
});
});
// 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)),

View file

@ -55,6 +55,7 @@ fn setup(mut commands: Commands) {
}],
justify: JustifyText::Left,
linebreak_behavior: BreakLineOn::AnyCharacter,
..default()
};
commands

View file

@ -66,6 +66,7 @@ fn spawn(mut commands: Commands, asset_server: Res<AssetServer>) {
sections,
justify: JustifyText::Center,
linebreak_behavior: BreakLineOn::AnyCharacter,
..default()
},
..Default::default()
});

View file

@ -129,6 +129,7 @@ fn spawn(mut commands: Commands, asset_server: Res<AssetServer>) {
}],
justify: JustifyText::Left,
linebreak_behavior,
..default()
};
let text_id = commands
.spawn(TextBundle {