Loading…

Write-Up: How We Improved The Terminal’s Performance in The Peacenet

Video showing the optimizations in action

One of our goals with the MonoGame port of The Peacenet is to improve the performance of the game in general, making it better than both the original Peace Engine version and the UE4 version. Hopefully…with as few bugs as possible.

One of the big issues is our Terminal – it’s always been okay but could always use some work in the performance and bugginess departments. This was especially the case in Peace Engine, and until recently, the actual Greenlight Engine rewrite of the game!

What’s wrong with Greenlight Engine’s terminal?

The big issue with the Greenlight terminal was that, when you first open it, the game hangs for a second. It also hangs for a second as you run the first few commands in that new terminal. After that first bit, everything goes back to normal until you open another terminal.

What could be causing that?

Given that this bug’s behaviour is temporary but persistent, (temporary meaning it goes away after a bit, persistent meaning it comes back to do the same thing), this suggests to me a content loading and caching issue.

The way I wrote the Terminal, I wrote it to load in the fonts it uses to render any time a new instance of the Terminal is created, and unload them when that instance is destroyed. This is perfectly fine in MonoGame from what I can tell, as ContentManager seems to be pretty good at not loading things more than it needs to.

If this is okay, then what’s the issue?

The issue is, while ContentManager is good at what it does, we don’t use native SpriteFonts to render text in the game. We use a library called SpriteFontPlus which allows us to use TTF fonts. This has several benefits:

  • It’s easier to localize the game later on without needing to manually specify the character sets we want to bake into the font.
  • There’s support for setting the font size at run-time, which is not the case with MonoGame SpriteFonts.
  • And probably more that I can’t remember.

We can also use this to allow zooming in the Terminal, which is very important for accessibility reasons. (Your beloved lead dev is blind, remember!)

But while this library is very helpful, it’s not exactly lightweight compared to a SpriteFont. It does a lot of internal magic, and this internal magic takes a little bit more CPU. It’s this magic that causes the game to slow down.

So what’s happening?

From what I was able to gather by looking into the library we use, this is what’s going on in-game which is causing the slowdowns:

  • You open a Terminal, resulting in the game loading in four TTF fonts (Ubuntu Mono in Regular, Bold, BoldItalic and Italic variants).
  • Terminal sets its minimum size to 80×24 characters, requiring the game to measure the size of a character in Ubuntu Mono Regular. Because the font hasn’t been used to measure this character yet, the game has to freshly bake and cache some TTF data before it can measure. First slowdown, but it’s a single character so it’s negligible.
  • The command shell starts, printing a line of text. Now the game has to measure each individual character in that line of text so it can then render it. The font hasn’t been used before to measure/render that text so more TTF data needs to be baked and cached. Since it’s a full render this time, the slowdown is more noticeable.
  • But now the command shell text is baked, so future attempts to render it are nice and fast!
  • Except you run the help command resulting in a METRIC CRAPTON of text being printed, whose TTF data hasn’t yet been baked. So another big slowdown occurs.
  • But now a lot of data has been baked so the terminal goes back to being fast.

So, what’s happening is, when the game uses a font to measure or render a specific character and that font hasn’t been used to do that yet, the CPU needs to spend time baking the TTF data for that character, which is a slow process, resulting in the game hanging at some points in the Terminal. Once enough data has been baked, the terminal is able to run perfectly fine.

And since the terminal reloads the fonts when you open a new one, the same baking issue will occur. Now we understand what’s going on, so it’s time to fix the issue.

Fixing the TTF issue

I had two approaches in mind:

  1. Load the TTF fonts in once, don’t unload them ever until the game exits.
  2. Switch to SpriteFonts.

The first option, I soon realized, would only partially fix the problem. Performance issues would still happen for that first little bit of the first Terminal being opened! We can do better than that, guys!

So I went with plan B. But then I realized… SpriteFonts can’t… be dynamically resized! How will I implement zooming?

Read the freaking manual…

So it turns out that SpriteFontPlus has two APIs. The first one, DynamicSpriteFont, allows you to load in a TTF file and use it as if it were a SpriteFont. You have the ability to adjust the font size on the fly, and you don’t even need to deal with character sets as it’s handled internally. This is the API that the game currently uses for the entire UI.

The second API is meant for creating SpriteFonts from TTF fonts. With this API, you load in a TTF file, specify a font size, a texture atlas size, and the character sets you want, and it bakes a SpriteFont for you which you can use just like any Pipeline Tool-created SpriteFont without MonoGame even knowing.

You sacrifice dynamic font resizing and the ability to be lazy about character sets, but you gain a huge performance boost because you’re essentially doing the Pipeline Tool‘s job but at runtime and you only need to do it once while the game’s loading. Awesome!

But what about zooming?

Well, this is a hard problem depending on who you ask. I had two choices I could make that were dependent on how much RAM I wanted to use in-game and how much I wanted to affect the initial load times:

  • Don’t implement zooming, which defeats the whole purpose of this write-up.
  • Cap the zoom level at a maximum value.

Since I want zooming in the game, I decided to cap the zoom level. Actually, some real-life Terminals will cap the zoom level as well – because once you get to a certain font size, there’s really no point in going any higher.

So here’s how I did it. I essentially just had to add this code:

private const int MAX_ZOOMLEVEL = 10;

public const int TERMINAL_DEFAULT_ROWS = 24;
public const int TERMINAL_DEFAULT_COLUMNS = 80;

private static List<SpriteFont> _regularZoomLevels = new List<SpriteFont>();
private static List<SpriteFont> _boldItalicZoomLevels = new List<SpriteFont>();
private static List<SpriteFont> _boldZoomLevels = new List<SpriteFont>();
private static List<SpriteFont> _italicZoomLevels = new List<SpriteFont>();

public static void LoadTerminalFonts(GraphicsDevice graphics, ContentManager content)
{
    System.Console.WriteLine("Pre-loading console fonts... This is going to take a bit, but it'll dramatically improve in-game performance!");
    var regular = content.Load<byte[]>("Gui/Fonts/Terminal/Regular");
    var bold = content.Load<byte[]>("Gui/Fonts/Terminal/Bold");
    var boldItalic = content.Load<byte[]>("Gui/Fonts/Terminal/BoldItalic");
    var italic = content.Load<byte[]>("Gui/Fonts/Terminal/Italic");

    for(int i = 0; i <= MAX_ZOOMLEVEL; i++)
    {
        float fontSize = 16 + (2 * i);
        int texSize = (int)(1024 + (128 * i));

        System.Console.WriteLine("Baking fonts for zoom level {0} ({1}px size, {2}x{2} texture)...", i, fontSize, texSize);
        var regularBake = TtfFontBaker.Bake(regular, fontSize, texSize, texSize, new[] { CharacterRange.BasicLatin, CharacterRange.Latin1Supplement, CharacterRange.LatinExtendedA, CharacterRange.LatinExtendedB });
        var boldBake = TtfFontBaker.Bake(bold, fontSize, texSize, texSize, new[] { CharacterRange.BasicLatin, CharacterRange.Latin1Supplement, CharacterRange.LatinExtendedA, CharacterRange.LatinExtendedB });
        var italicBake = TtfFontBaker.Bake(italic, fontSize, texSize, texSize, new[] { CharacterRange.BasicLatin, CharacterRange.Latin1Supplement, CharacterRange.LatinExtendedA, CharacterRange.LatinExtendedB });
        var boldItalicBake = TtfFontBaker.Bake(boldItalic, fontSize, texSize, texSize, new[] { CharacterRange.BasicLatin, CharacterRange.Latin1Supplement, CharacterRange.LatinExtendedA, CharacterRange.LatinExtendedB });

        _regularZoomLevels.Add(regularBake.CreateSpriteFont(graphics));
        _boldZoomLevels.Add(boldBake.CreateSpriteFont(graphics));
        _boldItalicZoomLevels.Add(boldItalicBake.CreateSpriteFont(graphics));
        _italicZoomLevels.Add(italicBake.CreateSpriteFont(graphics));
    }
    System.Console.WriteLine("Done preloading fonts!");
}

The first three lines define some constant values just to make my life easier. MAX_ZOOMLEVEL is the important one, because it defines the zoom level cap – and how many fonts I want to load into memory. If you’re curious, a max zoom level of 10 means that, because there are 4 variants of the font and there’s an additional “default” zoom level, the amount of fonts loaded into memory will be 4 * (MAX_ZOOMLEVEL + 1), so, 44.

The LoadTerminalFonts function gets called when the game first loads up (even before Greenlight does its pre-loading :P) and it does this:

  • Loads in the TTF data for the four variants of Ubuntu Mono.
  • For every zoom level between 0 and MAX_ZOOMLEVEL…
    • Calculate the font size for this zoom level, which will be 16 pixels + (zoomLevel * 2).
    • Calculate the texture atlas size, which will be 1024 * (128 * zoomLevel) pixels in both axes.
    • Bake each variant of the font at the calculated sizes, with all of the Latin character sets.
    • Using the baked font data, generate four SpriteFont variants, storing them in Lists, so the game can use them later.

When it comes time to actually use these fonts, I call this function:

private SpriteFont GetFont(bool bold, bool italic)
{
    if (_regularZoomLevels.Count == 0) throw new InvalidOperationException("The game hasn't loaded the console fonts yet.");
    if (_boldZoomLevels.Count == 0) throw new InvalidOperationException("The game hasn't loaded the console fonts yet.");
    if (_italicZoomLevels.Count == 0) throw new InvalidOperationException("The game hasn't loaded the console fonts yet.");
    if (_boldItalicZoomLevels.Count == 0) throw new InvalidOperationException("The game hasn't loaded the console fonts yet.");

    var repo = _regularZoomLevels;
    if (bold && italic)
        repo = _boldItalicZoomLevels;
    else if (bold)
        repo = _boldZoomLevels;
    else if (italic)
        repo = _italicZoomLevels;

    return repo[_zoomLevel];
}

The first four lines simply crash the game if you try to use the terminal before the fonts have been baked, which should never happen. I mean, unless some dude named Michael messes something up sometime in the future.

Next we check if bold and italic are both true, and if so we’ll take from the bolditalic font list. If not, we’ll take from italic’s list if italic is true, and bold’s if bold is true, and regular’s if neither are true.

Then we simply use the current zoom level (_zoomLevel) to index the array we decided to take from, and return the element at that index as the font we want to use.

The result

The result is a very fast terminal, as seen in the video seen above.

Leave a Reply