Skip to content

Conversation

ickshonpe
Copy link
Contributor

@ickshonpe ickshonpe commented Oct 1, 2025

Objective

New text features:

  • Relative font size support.
  • Automatically free unused font atlases.
  • Text style propagation.
  • Descendant text entities use the same primary component (Text or Text2d) as root text entities.
  • Global default text style.
  • New Val::Rem variant, allows setting lengths relative to the default text style's font size.

Fixes #21210, #21207, #21208, #21175, #9525, #5471

Solution

  • TextFont has been removed. There are five new text components in its place: FontFace, FontSize, FontSmoothing, TextColor and LineHeight. These can be placed on any entity and the values are propagated down the tree and used to update another new component, ComputedTextStyle that is required by all Text and Text2d entities.
  • New DefaultTextStyle resource. ComputedTextStyle's fields unset by propagation take the values from DefaultTextStyle.
  • Text no longer requires Node.
  • ComputedFontSize component, holds the computed size of the font, used by the systems that track unused font atlases.
  • New TextRoot component, added automatically to root text entities, holds a list of all the entities making up the text block, updated on changes.
  • FontSize is an enum with Px, Rem, Vw, Vh, VMin, and VMax.
  • The unused font cleanup uses a least recently used cache (suggested by @viridia). It doesn't free unused font atlases immediately, only once the font count is higher than the max fonts limit. Font atlases are never freed while their font is in use.
  • The text access API is removed. It's much less useful with TextSpan's removed and text style propagation support. bsn! should should make it easier to create and update hierarachies hopefully as well.

Testing

The Text example has been changed to demonstrate some text using FontSize::Rem.
Needs lots of testing, only updated a fraction of the examples so far.

  • Merging with main broke interactions in some of the examples due to some incompatability with the UI stack partition changes.
  • Viewport font sizes don't reupdate on changes to the viewport dimensions.
  • Performance with Text2d is ~40% worse than main.
  • Performance with Text seems to be the same, or slightly improved over main.
  • free_unused_font_atlases walks all text entities every update atm, needs a better implementation.
  • The text style propagation implementation is based on bevy_app::propagate. It's not completely terrible but has way too many systems, and I'm not sure about things like the InheritableTextStyle trait.
  • Non-root Text and Text2d entities still require components like TextBlock, TextLayoutInfo, which just sit unused. Could switch to storing the generated layouts in a resource.

* Removed `TextFont` and `TextColor` from the `Text`, `Text2d`, and `TextSpan` requires, replaced with `ComputedTextStyle`.
* `update_text_styles` updates the `ComputedTextStyle`s each frame from the text entities nearest ancestors with `TextFont` or `TextColor` components.
@alice-i-cecile alice-i-cecile added A-Text Rendering and layout for characters S-Waiting-on-Author The author needs to make changes or address concerns before this can be merged M-Needs-Release-Note Work that should be called out in the blog due to impact labels Oct 2, 2025
Copy link
Contributor

github-actions bot commented Oct 2, 2025

It looks like your PR has been selected for a highlight in the next release blog post, but you didn't provide a release note.

Please review the instructions for writing release notes, then expand or revise the content in the release notes directory to showcase your changes.

@alice-i-cecile
Copy link
Member

Seconding @viridia here: I really like this work and the broad direction, but want to try and chunk this down as best as we can.

Seeing the central vision here was really compelling and useful, and gives me the confidence to start merging little chunks of this in.

mut font_atlas_sets: ResMut<FontAtlasSets>,
max_fonts: ResMut<MaxFonts>,
active_font_query: Query<(&ComputedTextStyle, &ComputedFontSize)>,
) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should have unit tests for this algorithm.

/// Wrapper used to differentiate propagated text style compoonents
#[derive(Debug, Component, Clone, PartialEq, Reflect)]
#[reflect(Component, Clone, PartialEq)]
pub struct InheritedTextStyle<S: Clone + PartialEq>(pub S);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we are using the word Style inconsistently here, does it refer to a single styling property or several? Maybe calling this InheritedTextProperty might be clearer.

@mamekoro
Copy link
Contributor

mamekoro commented Oct 3, 2025

If TextSpan is removed, issues arise when displaying long texts that require line breaks or when performing per-span text styling in languages where words are not separated by spaces, such as Japanese or Chinese. In these languages, the text moves to the next line when the character boundary reaches the right edge of the screen, rather than at word boundaries.

Please take a look at the screenshot below. It compares the results when splitting long text into multiple Text or TextSpan elements to apply different colors to each part. The UiDebugOption is enabled, showing the boundaries of GUI elements. The rendering with TextSpans is properly line-broken. On the other hand, with Texts, line breaks are applied to each part, making it difficult to read the text as a whole.

Screenshot

This screenshot is from v0.17.1. I also tested the code from this pull request, but I couldn't find any functionality that performs rendering equivalent to TextSpan.

Code

This example needs a CJK font file.

// Bevy 0.17

use bevy::{color::palettes::tailwind, input::common_conditions::input_just_pressed, prelude::*};

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_systems(Startup, startup)
        // Press Escape to exit.
        .add_systems(
            Update,
            (|mut exit: MessageWriter<AppExit>| _ = exit.write_default())
                .run_if(input_just_pressed(KeyCode::Escape)),
        )
        .insert_resource(UiDebugOptions {
            enabled: true,
            ..default()
        })
        .run();
}

fn startup(mut commands: Commands, asset_server: Res<AssetServer>) {
    commands.spawn(Camera2d);

    let line_breaks = [
        LineBreak::WordBoundary,
        LineBreak::AnyCharacter,
        LineBreak::WordOrCharacter,
        LineBreak::NoWrap,
    ];

    let root_container = commands
        .spawn(Node {
            width: percent(100),
            height: percent(100),
            flex_direction: FlexDirection::Column,
            justify_content: JustifyContent::SpaceEvenly,
            ..default()
        })
        .id();

    let font = asset_server.load("NotoSansJP-Subset-Regular.otf");

    for line_break in &line_breaks {
        commands.spawn((
            Node {
                width: percent(100),
                ..default()
            },
            ChildOf(root_container),
            children![
                Text::new(format!("(Text & LineBreak::{line_break:?}) ")),
                (
                    Text::new("あああああああああああああああ"),
                    TextFont::from(font.clone()),
                    TextLayout::new_with_linebreak(*line_break),
                    TextColor::from(tailwind::CYAN_500),
                ),
                (
                    Text::new("いいいいいいいいいいいいいいい"),
                    TextFont::from(font.clone()),
                    TextLayout::new_with_linebreak(*line_break),
                    TextColor::from(tailwind::BLUE_500),
                ),
                (
                    Text::new("ううううううううううううううう"),
                    TextFont::from(font.clone()),
                    TextLayout::new_with_linebreak(*line_break),
                    TextColor::from(tailwind::INDIGO_500),
                ),
                (
                    Text::new("えええええええええええええええ"),
                    TextFont::from(font.clone()),
                    TextLayout::new_with_linebreak(*line_break),
                    TextColor::from(tailwind::PURPLE_500),
                ),
                (
                    Text::new("おおおおおおおおおおおおおおお"),
                    TextFont::from(font.clone()),
                    TextLayout::new_with_linebreak(*line_break),
                    TextColor::from(tailwind::PINK_500),
                ),
            ],
        ));
    }

    for line_break in &line_breaks {
        commands.spawn((
            Node {
                width: percent(100),
                ..default()
            },
            ChildOf(root_container),
            Text::default(),
            TextLayout::new_with_linebreak(*line_break),
            children![
                TextSpan::new(format!("(TextSpan & LineBreak::{line_break:?}) ")),
                (
                    TextSpan::new("あああああああああああああああ"),
                    TextFont::from(font.clone()),
                    TextColor::from(tailwind::CYAN_500),
                ),
                (
                    TextSpan::new("いいいいいいいいいいいいいいい"),
                    TextFont::from(font.clone()),
                    TextColor::from(tailwind::BLUE_500),
                ),
                (
                    TextSpan::new("ううううううううううううううう"),
                    TextFont::from(font.clone()),
                    TextColor::from(tailwind::INDIGO_500),
                ),
                (
                    TextSpan::new("えええええええええええええええ"),
                    TextFont::from(font.clone()),
                    TextColor::from(tailwind::PURPLE_500),
                ),
                (
                    TextSpan::new("おおおおおおおおおおおおおおお"),
                    TextFont::from(font.clone()),
                    TextColor::from(tailwind::PINK_500),
                ),
            ],
        ));
    }
}

@eckz eckz mentioned this pull request Oct 3, 2025
@ickshonpe
Copy link
Contributor Author

Seconding @viridia here: I really like this work and the broad direction, but want to try and chunk this down as best as we can.

Seeing the central vision here was really compelling and useful, and gives me the confidence to start merging little chunks of this in.

Yeah I guess I've been a bit impatient and getting ahead of myself. The main thing we need I guess is a final decision on whether this TextSpan-less cascading text styles API is the right direction.

@viridia
Copy link
Contributor

viridia commented Oct 4, 2025

OK I'm a bit confused; I didn't notice the removal of TextSpan when I initially read the PR description. Can you elaborate more on how text nodes are intended to be structured?

@ickshonpe
Copy link
Contributor Author

ickshonpe commented Oct 4, 2025

OK I'm a bit confused; I didn't notice the removal of TextSpan when I initially read the PR description. Can you elaborate more on how text nodes are intended to be structured?

It's not much different structurally, just without TextSpan. All text entities have the Text (or Text2d) component, with a new component TextRoot. TextRoot is added automatically. Whenever a Text entity's parent changes, it queries for if the new parent is another Text entity. If isn't a Text entity, you add the TextRoot component, if it is then TextRoot is removed. Internally the implementation is simpler than with TextSpan. The drawback is that non-root Text entities still require components like ComputedNode and UiTransform that aren't used. There might be ways to work around that though, and it's much nicer to use.

@viridia
Copy link
Contributor

viridia commented Oct 4, 2025

OK I'm a bit confused; I didn't notice the removal of TextSpan when I initially read the PR description. Can you elaborate more on how text nodes are intended to be structured?

It's not much different structurally, just without TextSpan. All text entities have the Text (or Text2d) component. Whenever a Text entity's parent changes, it queries for if the new parent is another Text entity. If isn't a Text entity, you add the TextRoot component, if it is then TextRoot is removed. Internally the implementation is simpler than with TextSpan. The drawback is that non-root Text entities still require components like ComputedNode and UiTransform that aren't used. There might be ways to work around that though, and it's much nicer to use.

This seems controversial.

It seems like Text/Text2d is taking over the job formerly done by TextSpan. Is this just a renaming, or something more? And in what way is it nicer to use?

While TextSpan, as it exists today, may not be perfect, I do think:

  • People have mostly gotten used to how it behaves.
  • The user friction that it causes is fairly localized, there aren't a lot of wide-ranging knock-on effects.
  • It's a compromise between two conflicting needs: "I want a simple string that's a single entity" vs "I want multiple concatenated strings".
  • I would place fixing it fairly low on the list of priorities, at least from a user standpoint.

@ickshonpe
Copy link
Contributor Author

It seems like Text/Text2d is taking over the job formerly done by TextSpan. Is this just a renaming, or something more? And in what way is it nicer to use?

Lets say you are displaying a score using:

commands.spawn((Text::new("Score: "), child![(TextSpan::new("0"), ScoreMarker)]));

that is updated by a system that queries for a TextSpan with ScoreMarker. If later you decide to modify it to:

commands.spawn((Text::new("0"), ScoreMarker, child![TextSpan::new(" points")]));

the updater system also needs to be changed.
With a single Text component for all text entities, the updater would still work.

@ickshonpe
Copy link
Contributor Author

ickshonpe commented Oct 4, 2025

I mean, I really, really didn't like the text sections as entities changes, but left my objections too late. It's imo more confusing and difficult to use, internally it's much more complicated, and performance is terrible compared to before. It only starts to make sense with cascading text styles.

@ickshonpe
Copy link
Contributor Author

ickshonpe commented Oct 4, 2025

It seems like Text/Text2d is taking over the job formerly done by TextSpan. Is this just a renaming, or something more? And in what way is it nicer to use?

Oh sorry, actually it wasn't because of usability issues, I completely forgot why I wanted to remove TextSpans, I do understand your objection to making so many changes at once. The reason was that TextSpan's made the relative font sizing and automatic font atlas disposal changes really awkward. bevy_ui handles render targets very differently to bevy_sprite so they need different implementations. But you can't easily and directly identify which type of text hierarchy, Text or Text2d, each TextSpan entity belongs to. Once I got rid of TextSpans so I could just directly query for each type of text entity separately, everything became really simple.

@viridia
Copy link
Contributor

viridia commented Oct 4, 2025

Right now, my reaction to ComputedTextStyles is that it seems like an obvious win, it's the right thing to do - although I am concerned about potential performance impacts of keeping things in sync with their ancestors.

For the changes to TextSpan the win doesn't seem so obvious to me.

As far as "text spans as entities" goes: people have lately been doing some really neat stuff that simply wasn't possible before. And although we still don't have mixed text + non-text (like embedded icons in paragraphs), the goal is to have that ability eventually, and it's hard to see a path for doing that if spans are not entities.

@viridia
Copy link
Contributor

viridia commented Oct 4, 2025

It seems like Text/Text2d is taking over the job formerly done by TextSpan. Is this just a renaming, or something more? And in what way is it nicer to use?

bevy_ui handles render targets very differently to bevy_sprite so they need different implementations.

Is there a way we can embed this distinction inside of ComputedTextStyle? Since it's not meant to be user-facing, it can be extra complex.

@ickshonpe
Copy link
Contributor Author

It seems like Text/Text2d is taking over the job formerly done by TextSpan. Is this just a renaming, or something more? And in what way is it nicer to use?

bevy_ui handles render targets very differently to bevy_sprite so they need different implementations.

Is there a way we can embed this distinction inside of ComputedTextStyle? Since it's not meant to be user-facing, it can be extra complex.

Initially ComputedTextStyle was more complicated, it had the inherited FontSize value, the render target's scalefactor and size, and the final resolved font size in physical pixels, but I decided it should contain the inherited properties only, I think it was so that change detection worked better and to remove redundancy because the UI already propagates the target info through ComputedUiRenderTargetInfo.

…hat resolve font sizes before layout. This fixes text with viewport sizes not immediately updating in response to viewport size changes.

Also fixed panic in `free_unused_font_atlases` where it tried to free more fonts than there are in the buffer.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
A-Text Rendering and layout for characters D-Complex Quite challenging from either a design or technical perspective. Ask for help! M-Needs-Release-Note Work that should be called out in the blog due to impact S-Waiting-on-Author The author needs to make changes or address concerns before this can be merged X-Controversial There is active debate or serious implications around merging this PR
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Free unused font atlases
5 participants