bevy/crates/bevy_text/src/font_atlas_set.rs
Marco Buono 8e3db957c5
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()`
2024-09-23 17:28:25 +00:00

278 lines
9.9 KiB
Rust

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;
use crate::{error::TextError, Font, FontAtlas, FontSmoothing, GlyphAtlasInfo};
/// 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<AssetId<Font>, FontAtlasSet>,
}
impl FontAtlasSets {
/// Get a reference to the [`FontAtlasSet`] with the given font asset id.
pub fn get(&self, id: impl Into<AssetId<Font>>) -> Option<&FontAtlasSet> {
let id: AssetId<Font> = 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<AssetId<Font>>) -> Option<&mut FontAtlasSet> {
let id: AssetId<Font> = 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<FontAtlasSets>,
mut font_events: EventReader<AssetEvent<Font>>,
) {
for event in font_events.read() {
if let AssetEvent::Removed { id } = event {
font_atlas_sets.sets.remove(id);
}
}
}
/// 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 FontAtlasKey(pub u32, pub FontSmoothing);
/// 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<FontAtlasKey, Vec<FontAtlas>>,
}
impl Default for FontAtlasSet {
fn default() -> Self {
FontAtlasSet {
font_atlases: HashMap::with_capacity_and_hasher(1, Default::default()),
}
}
}
impl FontAtlasSet {
/// Returns an iterator over the [`FontAtlas`]es in this set
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: &FontAtlasKey) -> bool {
self.font_atlases
.get(font_size)
.map_or(false, |font_atlas| {
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<TextureAtlasLayout>,
textures: &mut Assets<Image>,
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(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,
font_smoothing,
)?;
let mut add_char_to_font_atlas = |atlas: &mut FontAtlas| -> Result<(), TextError> {
atlas.add_glyph(
textures,
texture_atlases,
physical_glyph.cache_key,
&glyph_texture,
offset,
)
};
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
.size
.height
.max(glyph_texture.width());
// Pick the higher of 512 or the smallest power of 2 greater than glyph_max_size
let containing = (1u32 << (32 - glyph_max_size.leading_zeros())).max(512);
font_atlases.push(FontAtlas::new(
textures,
texture_atlases,
UVec2::splat(containing),
font_smoothing,
));
font_atlases.last_mut().unwrap().add_glyph(
textures,
texture_atlases,
physical_glyph.cache_key,
&glyph_texture,
offset,
)?;
}
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(&FontAtlasKey(cache_key.font_size_bits, font_smoothing))
.and_then(|font_atlases| {
font_atlases
.iter()
.find_map(|atlas| {
atlas.get_glyph_index(cache_key).map(|location| {
(
location,
atlas.texture_atlas.clone_weak(),
atlas.texture.clone_weak(),
)
})
})
.map(|(location, texture_atlas, texture)| GlyphAtlasInfo {
texture_atlas,
location,
texture,
})
})
}
/// Returns the number of font atlases in this set
pub fn len(&self) -> usize {
self.font_atlases.len()
}
/// Returns the number of font atlases in this set
pub fn is_empty(&self) -> bool {
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,
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))?;
let cosmic_text::Placement {
left,
top,
width,
height,
} = image.placement;
let data = match image.content {
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
todo!()
}
};
Ok((
Image::new(
Extent3d {
width,
height,
depth_or_array_layers: 1,
},
TextureDimension::D2,
data,
TextureFormat::Rgba8UnormSrgb,
RenderAssetUsages::MAIN_WORLD,
),
IVec2::new(left, top),
))
}
}