mirror of
https://github.com/bevyengine/bevy
synced 2024-11-14 00:47:32 +00:00
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:
parent
2c5be2ef4c
commit
8e3db957c5
11 changed files with 138 additions and 31 deletions
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
})?;
|
||||
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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::*;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)),
|
||||
|
|
|
@ -55,6 +55,7 @@ fn setup(mut commands: Commands) {
|
|||
}],
|
||||
justify: JustifyText::Left,
|
||||
linebreak_behavior: BreakLineOn::AnyCharacter,
|
||||
..default()
|
||||
};
|
||||
|
||||
commands
|
||||
|
|
|
@ -66,6 +66,7 @@ fn spawn(mut commands: Commands, asset_server: Res<AssetServer>) {
|
|||
sections,
|
||||
justify: JustifyText::Center,
|
||||
linebreak_behavior: BreakLineOn::AnyCharacter,
|
||||
..default()
|
||||
},
|
||||
..Default::default()
|
||||
});
|
||||
|
|
|
@ -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 {
|
||||
|
|
Loading…
Reference in a new issue