bevy: Memory leak when changing font sizes

Bevy Release v0.8.0

commit 0149c41 Rust Version: cargo 1.62.1 (a748cf5a3 2022-06-08)

What you did

Just recently completed in a game jam which was my first time using both Bevy and Rust (after which I want to say I really enjoyed both and am looking forward to being a part of the community). That being said please forgive any ignorance in my usage of either. One effect I attempted to implement during the game jam was to have the title text grow and shrink over time. My first approach was to modify the font size over time.

What went wrong

Modifying the font size over time performs the behavior I desired just fine, however I noticed if I left my game title screen running for a little(like 5 minutes or so) my computer would start chugging and when looking at resource usage, the game was eating over 30GB of memory.

I worked around this by modding the scale of the transform on the text entity rather than modifying the text size. Thinking on it this is probably a more “correct” solution to the problem, but seeing my simple game take up 30+ GB of memory just sitting at the title screen of my game was concerning. This also means that dynamically modifying fonts can pose a memory leak issue that might be harder to catch in a more complicated use case. I would expect the font system to cleanup after itself ( or if not better communicate to the user that each change to the font is allocating more memory, since that is not a base expectation)

Additional information

Here is a minimal example to recreate the issue:

use bevy::prelude::*;

const TEST_FONT_SIZE: f32 = 100.0;
const TEST_FONT_VARIATION: f32 = 0.25;

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_startup_system(setup)
        .add_system(leaky_text_updates)
        //.add_system(not_leaky_text_updates)
        .run();
}

#[derive(Component)]
struct TestText;

fn setup(
    mut commands: Commands,
    asset_server: Res<AssetServer>,
) {
    commands.spawn_bundle(Camera2dBundle::default());

    // Text to Test
    commands.spawn_bundle(NodeBundle {
        style: Style {
            size: Size::new(Val::Percent(100.0), Val::Percent(100.0)),
            justify_content: JustifyContent::Center,
            align_items: AlignItems::Center,
            ..default()
        },
        color: Color::NONE.into(),
        ..default()
    }).with_children(|parent| {
        parent.spawn_bundle(
            TextBundle::from_section(
                "Leaky Text?",
                TextStyle {
                    font: asset_server.load("fonts/FiraSans-Bold.ttf"),
                    font_size: TEST_FONT_SIZE,
                    color: Color::WHITE,
                },
            )
        )
        .insert(TestText);
    });
}

fn leaky_text_updates(
    time: Res<Time>,
    mut query: Query<&mut Text, With<TestText>>,
) {
    for mut text in &mut query {

        let seconds = time.seconds_since_startup() as f32;

        // Grow and shrink text
        text.sections[0].style.font_size = TEST_FONT_SIZE +
            TEST_FONT_SIZE * TEST_FONT_VARIATION * (1.25 * seconds).sin();
    }
}

fn not_leaky_text_updates(
    time: Res<Time>,
    mut query: Query<&mut Transform, With<TestText>>,
) {
    for mut transform in &mut query {

        let seconds = time.seconds_since_startup() as f32;

        // Grow and shrink text
        transform.scale = Vec3::ONE +
            TEST_FONT_VARIATION * (1.25 * seconds).sin();
    }
}

Then run the following to see the memory usage continue to grow uncontrolled

cargo run & top -p $!

To test workaround comment out

     //.add_system(leaky_text_updates)

and uncomment

      .add_system(not_leaky_text_updates)
  • theories about what might be going wrong I haven’t had a chance to dig into the Bevy code just yet so I might be speaking out my rear, but my current theory is that when the font size is modified bevy allocates some memory to cache the font pre-rendered to the font size. This allocated memory isn’t cached so or referenced (or if it is, it doesn’t get referenced since the code above references the font at an arbitrary floating point number). As such new pre-rendered font sizes keep getting allocated indefinitely with no cleanup.

  • links to related bugs, PRs or discussions I looked around, but could not find related issue.

About this issue

  • Original URL
  • State: closed
  • Created 2 years ago
  • Comments: 15 (15 by maintainers)

Commits related to this issue

Most upvoted comments

Excellent investigation. My intuition suggests that we should be trying to reuse the handle strategy here. What if we made fonts, as stored in Assets, be specific to a single font size?

Breaking changes are fine, that’s why we have a big experimental warning at the top of the repo README. And yeah, it does seem like the most simplest fix.

Forgive my ignorance, but I am not sure I follow. I think that is what we currently do, but Im going to read through the code to better grasp it.

Writing this down to document my understanding of the font lifecycle. Looking at the TextPlugin we add FontAtlasSet as an asset

.add_asset::<FontAtlasSet>()

A FontAtlasSet holds a HashMap with the Font Size as a key and a list(Vec) of FontAtlas’es as the value

pub struct FontAtlasSet {
    font_atlases: HashMap<FontSizeKey, Vec<FontAtlas>>,
}

During runtime either bevy_ui::widget::text::text_system() or bevy_text::text2d::update_text2d_layout(), (Which both update the layout and size information whenever the text or style is changed) will pass this asset to the text_pipeline.queue_text() which then passes this to the GlyphBrush to process_glyphs()

In GlyphBrush::process_glyphs() we call get_or_insert_with() on the Asset<FontAtlasSet> which will find a FontAtlasSet with an existing font_id or it will generate a default FontAtlasSet for this font_id. Then we will call get_glyph_atlas_info() on this FontAtlasSet which is where we get in trouble since this looks up the value in this HashMap based on the font size.

https://github.com/bevyengine/bevy/blob/55957330351828e84bfc63cf8e151e2805e9d278/crates/bevy_text/src/glyph_brush.rs#L103-L108

Seeing this it looks like we do make fonts as stored in asset specific to a single font size, but the issue come from small incremental changes in the font size, since even a small change would result in new Glyphs being generated (even if the underlying glyph texture is exactly the same for a font size of 12 and 12.000001)

What would would the intended behavior be if the user attempts to modify the font style after it has been loaded?

// repeated every frame
text.sections[0].style.font_size += 0.0000001;

Ultimately the core of the issue is communicated expectations to the user. As a user if you know that each font_size caches generated glyphs in memory, doing the above operation each frame is a bad idea, but if you don’t know that, modifying the font_size each frame seems like a reasonable way to go about making your text dynamically change size. Since the documentation doesn’t mention it, and the code lets you do it we have the potential of leaving the user confused when their RAM fills up, or even worse if that makes it past QA, their players getting confused when their game eats up all their RAM. Proposed solution 1 addresses that by communicating to the user that they are doing something that is unintended and potentially dangerous. Like I cant see a reason why anyone would want to cache over 10,000 generated FontAtlas’es for a single font across 10,000 different font sizes. The other 2 proposed solutions just limit the fallout of a user deciding to take an action like the one above.

This is a great write-up for a nasty bug. Thanks a ton.