Text Performance, Fitting and Wrapping
Explore advanced techniques for optimizing and controlling text rendering when using SDL3_ttf
In this lesson, we'll build on our text rendering capabilities with some new tools:
- The ability to adjust the rasterizing process to optimize performance when appropriate
- Dynamically choosing a font size to fit a target pixel size for our rasterized surface
- Calculating how much text we can fit within a designated space
- Rendering multi-line text areas using word wrapping, and controlling the alignment of the lines
Starting Point
We'll begin with the code from our previous lesson, but with a few adjustments to better suit this lesson's topics. We have re-introduced the GetWidth()
method to our Window
class, which will be useful for scaling our text to fit the window.
Additionally, we've modified the Text
class to support inheritance. The private
section is now protected
, and we've added a protected
constructor. This will allow us to create derived classes for specific text rendering behaviors, such as scaling and wrapping.
Files
Text Rendering Performance
Rendering text is surprisingly expensive in terms of performance cost, and is something we should be mindful of when our program is using a large amount of dynamic text. This performance impact comes in two types:
- Rasterization - how expensive it is to generate pixel data from the provided font and text
- Blitting - how expensive it is to blit that pixel data onto other surfaces, such as the window surface to display to users
We're currently using TTF_RenderText_Blended()
. "Blended" rendering maximizes quality and flexibility at the cost of performance:

Faster Rasterization - Solid
Much of the cost of rasterization comes from calculating the smooth edges of our font, sometimes called anti-aliasing. If we don't need anti-aliasing, we can use "solid" rendering functions, such as TTF_RenderText_Solid()
.
It uses the same arguments as a blended function but does not use anti-aliasing. This is faster to render, but leaves the edges of our text looking more jagged:
src/Text.h
// ...
class Text {
// ...
protected:
// ...
void CreateSurface(const std::string& Content) {
SDL_DestroySurface(TextSurface);
TextSurface = TTF_RenderText_Solid(
Font, Content.c_str(),
0, {255, 255, 255, 255}
);
if (!TextSurface) {
std::cout << "Error creating TextSurface: "
<< SDL_GetError() << '\n';
}
}
// ...
};

Faster Blitting - Shaded
Most of the performance cost of blitting involves working with transparency. The SDL_Surface
generated by functions like TTF_RenderText_Blended()
and TTF_RenderText_Solid()
has a transparent background.
This maximizes flexibility - the text can be blitted onto any surface and will maintain the background color of that destination surface.
However, this comes at a cost. If we don't want to pay for it, we can create our text surface with an opaque background, which makes blitting much faster.
The TTF_RenderText_Shaded()
function anti-aliases our text in the same way as TTF_RenderText_Blended()
, except it rasterizes onto an opaque background. We pass the SDL_Color
we want the background to be as an additional argument:
src/Text.h
// ...
class Text {
// ...
protected:
// ...
void CreateSurface(const std::string& Content) {
SDL_DestroySurface(TextSurface);
TextSurface = TTF_RenderText_Shaded(
Font,
Content.c_str(),
0,
{255, 255, 255, 255}, // Text Color (White)
{0, 0, 90, 255} // Background Color (Blue)
);
if (!TextSurface) {
std::cout << "Error creating TextSurface: "
<< SDL_GetError() << '\n';
}
}
// ...
};

In this program, our text is being blitted onto a solid color anyway - the gray of our window surface.
In scenarios like this, TTF_RenderText_Shaded()
could ultimately generate the exact same visuals as TTF_RenderText_Blended()
without the performance cost. We'd simply need to pass the correct background color to TTF_RenderText_Shaded()
- that is, the same background color our Window
uses.
Faster Rasterization and Blitting - LCD
Finally, we can fully prioritize performance by combining the rough edges of "solid" rendering with the opaque background of "shaded" rendering. SDL_ttf calls this option LCD:
src/Text.h
// ...
class Text {
// ...
protected:
// ...
void CreateSurface(const std::string& Content) {
SDL_DestroySurface(TextSurface);
TextSurface = TTF_RenderText_LCD(
Font,
Content.c_str(),
0,
{255, 255, 255, 255}, // Text Color (White)
{0, 0, 90, 255} // Background Color (Blue)
);
if (!TextSurface) {
std::cout << "Error creating TextSurface: "
<< SDL_GetError() << '\n';
}
}
// ...
};

Font Caching / Glyph Caching
To ensure our program can generate high-quality text with minimal performance impact, a font cache is often used.
This means any time we rasterize a character (sometimes called a glyph) we store the calculated pixels in memory so we can reuse them later.
Often, these glyphs are packed into a large image file called a texture atlas, which is a single image containing multiple smaller images (in this case, glyphs) arranged efficiently:

Nicolas P. Rougier - CC BY-SA 4.0
These caching systems include additional bookkeeping to keep track of which glyphs we have cached, where they are in our atlas, which font they used, and the font size.
When a request is made to render the same glyph, using the same font, at the same size, we can reuse the data from our texture atlas rather than recalculate it from scratch.
SDL_ttf includes a basic implementation of glyph caching, which is enough for our needs in this course.
There are open-source examples of more powerful implementations for those interested in pursuing the topic further, such as SDL_FontCache and VEFontCache.
Text Scaling
Before we render text, we often need to perform some calculations relating to size. For example, we might want to dynamically set the font size such that our surface has some specific pixel size. Or, we may want to find out how much text we can fit within a specific area.
SDL_ttf has some utilities that can help us with these tasks.
Using TTF_Text
and TTF_GetTextSize()
TTF_GetTextSize()
helps us understand the size of the surface that would be created if we were to rasterize a piece of content. In SDL3, this is done by first creating a TTF_Text
object via the TTF_CreateText()
function.
This accepts four arguments:
- A pointer to a
TTF_TextEngine
, which controls how the text will be rendered. We're not rendering thisTTF_Text
object - we're just using it to calculate layout information - so we can pass anullptr
here. - A pointer to the
TTF_Font
we're using - The text we want to use
- The size of the text in bytes. As usual, we can pass
0
here if our text is null-terminated.
TTF_Text* TextObject = TTF_CreateText(
nullptr, Font, "Hello World", 0);
Finally, to get the size that this text would be if we were to render it, we use TTF_GetTextSize()
.
The function accepts the TTF_Text
pointer, and two int
pointers that will be updated with the width and height respectively.
When we're done with a TTF_Text
object, we should destroy it by passing the pointer to TTF_DestroyText()
:
int Width, Height;
TTF_Text* TextObject = TTF_CreateText(
nullptr, Font, "Hello World", 0);
TTF_GetTextSize(
TextObject,
&Width, &Height
);
TTF_DestroyText(TextObject);
The results from doing this are equivalent to simply rendering the text and then accessing the w
and h
of the generated SDL_Surface
.
However, TTF_GetTextSize()
can calculate the dimensions that the surface would be without needing to perform the rasterization, meaning it is significantly more performant.
#include <iostream>
#include <SDL3/SDL.h>
#include <SDL3/SDL_main.h>
#include <SDL3_ttf/SDL_ttf.h>
int main(int, char**){
TTF_Init();
TTF_Font* Font{
TTF_OpenFont("Roboto-Medium.ttf", 25.0f)};
TTF_Text* TextObject = TTF_CreateText(
nullptr, Font, "Hello World", 0
);
int Width, Height;
TTF_GetTextSize(
TextObject, &Width, &Height
);
std::cout << "Width: " << Width
<< ", Height: " << Height;
TTF_DestroyText(TextObject);
TTF_CloseFont(Font);
TTF_Quit();
return 0;
};
Width: 128, Height: 30
If we only care about the width or height of the text, we can pass a nullptr
in the other position.
Let's use this to create a new ScaledText
class that dynamically sets the font size to make our rendered text hit a target width.
We'll arbitrarily initialize our font with a size of 24, and then calculate how wide the surface would be if we rendered our content at that size.
We then determine the ratio between the calculated width and target width, and adjust our font size accordingly before creating the surface for real:
src/ScaledText.h
#pragma once
#include <SDL3/SDL.h>
#include <SDL3_ttf/SDL_ttf.h>
#include "Text.h"
class ScaledText : public Text {
public:
ScaledText(
const std::string& Content, int TargetWidth)
: Text{BaseFontSize} {
int Width;
TTF_Text* TempText = TTF_CreateText(
nullptr, Font, Content.c_str(), 0
);
TTF_GetTextSize(
TempText,
&Width, nullptr
);
TTF_DestroyText(TempText);
float Ratio{static_cast<float>(
TargetWidth
) / Width};
SetFontSize(BaseFontSize * Ratio);
CreateSurface(Content);
}
private:
static constexpr float BaseFontSize{24.0f};
};
Over in main()
, let's update TextExample
to use this type, and pass the window width as our target:
src/main.cpp
#include <SDL3/SDL.h>
#include <SDL3/SDL_main.h>
#include <SDL3_ttf/SDL_ttf.h>
#include "Window.h"
#include "Text.h"
#include "ScaledText.h"
int main(int, char**) {
SDL_Init(SDL_INIT_VIDEO);
TTF_Init();
Window GameWindow;
ScaledText TextExample{
"Hello World",
GameWindow.GetWidth()
};
bool IsRunning = true;
SDL_Event Event;
while (IsRunning) {
while (SDL_PollEvent(&Event)) {
if (Event.type == SDL_EVENT_QUIT) {
IsRunning = false;
}
}
GameWindow.Render();
TextExample.Render(GameWindow.GetSurface());
GameWindow.Update();
}
TTF_Quit();
SDL_Quit();
return 0;
}

Using TTF_MeasureString()
Another requirement we're likely to have is to determine how much text can fit within a desired width. The TTF_MeasureString()
can help us determine how many characters can fit within a space. It accepts 6 arguments:
- The
TTF_Font
pointer - The text we want to render
- The length of the text in bytes, or
0
for a null-terminated string - The maximum width we have available
- A pointer to an
int
, which will be updated with the pixel width of the characters from our string that can fit within that space (the extent) - A pointer to an
int
, which will be updated with the quantity of characters from our string that can fit within our space (the count)
#include <SDL3/SDL.h>
#include <SDL3/SDL_main.h>
#include <SDL3_ttf/SDL_ttf.h>
#include <iostream>
int main(int, char**) {
TTF_Init();
TTF_Font* Font{
TTF_OpenFont("Roboto-Medium.ttf", 25.0f)};
int Extent, Count;
TTF_MeasureString(
Font,
"The quick brown fox jumps over the lazy dog",
0, 300, &Extent, &Count);
std::cout << "Extent: " << Extent <<
", Count: " << Count;
TTF_CloseFont(Font);
TTF_Quit();
return 0;
};
Extent: 288, Count: 24
We can pass a nullptr
to either the extent or the count if we don't care about that result.
In the following example, we create a class that uses this feature. If the entire string won't fit within a space, it will remove characters from the end of the string until it does fit. We also add an ellipsis (...) to indicate it has been truncated.
If TTF_MeasureString()
reports that the number of characters that can fit within the space is less than the size of our Content
string, we use the std::string
's resize()
method to remove the excess characters.
We remove 3
additional characters to make room for the ...
which we add using the std::string
's append()
method:
src/TruncatedText.h
#pragma once
#include <SDL3/SDL.h>
#include <SDL3_ttf/SDL_ttf.h>
#include "Text.h"
class TruncatedText : public Text {
public:
TruncatedText(
std::string Content,
float FontSize,
int MaxWidth
) : Text{FontSize} {
size_t MaxCharacters;
TTF_MeasureString(
Font, Content.c_str(), 0,
MaxWidth, nullptr, &MaxCharacters
);
if (MaxCharacters < Content.size()) {
Content.resize(MaxCharacters - 3);
Content.append("...");
}
CreateSurface(Content);
}
};
Let's update TextExample
in main()
to use this new type:
src/main.cpp
#include <SDL3/SDL.h>
#include <SDL3/SDL_main.h>
#include <SDL3_ttf/SDL_ttf.h>
#include "Window.h"
#include "Text.h"
#include "TruncatedText.h"
int main(int, char**) {
SDL_Init(SDL_INIT_VIDEO);
TTF_Init();
Window GameWindow;
TruncatedText TextExample{
"The quick brown fox jumps over the lazy dog",
36.0f,
GameWindow.GetWidth()
};
bool IsRunning = true;
SDL_Event Event;
while (IsRunning) {
while (SDL_PollEvent(&Event)) {
if (Event.type == SDL_EVENT_QUIT) {
IsRunning = false;
}
}
GameWindow.Render();
TextExample.Render(GameWindow.GetSurface());
GameWindow.Update();
}
TTF_Quit();
SDL_Quit();
return 0;
}

Multi-Line Text (Wrapping)
Often, we'll want to create larger blocks of text that extend across multiple lines. If the text exceeds a specific width in pixels, we want to move the rest of the text onto a new line, without splitting a word across multiple lines. This is typically called word wrapping.
All the font rendering functions have variations that accept an additional argument as a max width and will output a multiple-line surface if our text exceeds that length.
These functions have a _Wrapped
suffix. For example, the wrapped version of TTF_RenderText_Blended()
is TTF_RenderText_Blended_Wrapped()
.
Let's create a WrappedText
class that uses it:
src/WrappedText.h
#pragma once
#include <SDL3/SDL.h>
#include <SDL3_ttf/SDL_ttf.h>
#include "Text.h"
class WrappedText : public Text {
public:
WrappedText(
const std::string& Content,
float FontSize,
int MaxWidth
) : Text{FontSize}, MaxWidth{MaxWidth} {
CreateSurface(Content);
}
private:
void CreateSurface(const std::string& Content) {
SDL_DestroySurface(TextSurface);
TextSurface = TTF_RenderText_Blended_Wrapped(
Font, Content.c_str(), 0,
{255, 255, 255, 255}, MaxWidth
);
if (!TextSurface) {
std::cout << "Error creating TextSurface: "
<< SDL_GetError() << '\n';
}
}
int MaxWidth;
};
Over in main()
, let's update our TextExample
to use this WrappedText
type:
src/main.cpp
#include <SDL3/SDL.h>
#include <SDL3/SDL_main.h>
#include <SDL3_ttf/SDL_ttf.h>
#include "Window.h"
#include "Text.h"
#include "WrappedText.h"
int main(int, char**) {
SDL_Init(SDL_INIT_VIDEO);
TTF_Init();
Window GameWindow;
WrappedText TextExample{
"The quick brown fox jumps over the lazy dog",
36.0f,
GameWindow.GetWidth()
};
bool IsRunning = true;
SDL_Event Event;
while (IsRunning) {
while (SDL_PollEvent(&Event)) {
if (Event.type == SDL_EVENT_QUIT) {
IsRunning = false;
}
}
GameWindow.Render();
TextExample.Render(GameWindow.GetSurface());
GameWindow.Update();
}
TTF_Quit();
SDL_Quit();
return 0;
}

Text Alignment
When rendering wrapped text, we can control its alignment. To do this, we pass the TTF_Font
pointer and the alignment we want to use to TTF_SetFontWrapAlignment()
.
The alignment is an enum called TTF_HorizontalAlignment
, for which SDL_ttf provides named variables:
TTF_HORIZONTAL_ALIGN_LEFT
(default)TTF_HORIZONTAL_ALIGN_CENTER
TTF_HORIZONTAL_ALIGN_RIGHT
Let's add an alignment option as a constructor argument to our WrappedText
, and pass it to TTF_SetFontWrapAlignment()
before creating the surface:
src/WrappedText.h
#pragma once
#include <SDL3/SDL.h>
#include <SDL3_ttf/SDL_ttf.h>
#include "Text.h"
class WrappedText : public Text {
public:
WrappedText(
const std::string& Content,
float FontSize, int MaxWidth,
TTF_HorizontalAlignment Alignment
= TTF_HORIZONTAL_ALIGN_CENTER
) : Text{FontSize}, MaxWidth{MaxWidth} {
TTF_SetFontWrapAlignment(Font, Alignment);
CreateSurface(Content);
}
private:
void CreateSurface(const std::string& Content) {
SDL_DestroySurface(TextSurface);
TextSurface = TTF_RenderText_Blended_Wrapped(
Font, Content.c_str(), 0,
{255, 255, 255, 255}, MaxWidth
);
if (!TextSurface) {
std::cout << "Error creating TextSurface: "
<< SDL_GetError() << '\n';
}
}
int MaxWidth;
};

Complete Code
Here is the complete code after all the changes in this lesson. We've created several specialized text classes (ScaledText
, TruncatedText
, WrappedText
) that inherit from our base Text
class to handle various rendering scenarios.
Files
Summary
In this lesson, we explored advanced text rendering techniques. We covered performance optimization methods, including solid, shaded, and LCD rendering.
We also learned how to dynamically scale text, truncate it to fit within specified dimensions, and implement word wrapping with alignment options. within specified dimensions, and implement word wrapping with alignment options.
Engine Overview
An introduction to the generic engine classes we'll use to create the game