SDL_ttf
In this lesson, we’ll build on our text rendering capabilities with some new tools:
Our main.cpp
is below. Compared to the last lesson, we’ve added a GetWidth()
method to the Window
class.
#include <iostream>
#include <SDL.h>
#include <SDL_ttf.h>
#include "Text.h"
class Window {
public:
Window() {
SDLWindow = SDL_CreateWindow(
"My Program", SDL_WINDOWPOS_UNDEFINED,
SDL_WINDOWPOS_UNDEFINED, GetWidth(), 300, 0);
}
void Render() {
SDL_FillRect(
GetSurface(), nullptr, SDL_MapRGB(
GetSurface()->format, 50, 50, 50
)
);
}
void Update() {
SDL_UpdateWindowSurface(SDLWindow);
}
SDL_Surface* GetSurface() {
return SDL_GetWindowSurface(SDLWindow);
}
~Window() { SDL_DestroyWindow(SDLWindow); }
Window(const Window&) = delete;
Window& operator=(const Window&) = delete;
int GetWidth() const { return WindowWidth; }
private:
SDL_Window* SDLWindow;
int WindowWidth{600};
};
int main(int argc, char** argv) {
SDL_Init(SDL_INIT_VIDEO);
TTF_Init();
SDL_Event Event;
bool shouldQuit{false};
Window GameWindow;
Text TextExample{"Hello World"};
while (!shouldQuit) {
while (SDL_PollEvent(&Event)) {
if (Event.type == SDL_QUIT) {
shouldQuit = true;
}
}
GameWindow.Render();
TextExample.Render(GameWindow.GetSurface());
GameWindow.Update();
}
TTF_Quit();
SDL_Quit();
return 0;
}
Our Text
class is below. Compared to the previous lesson, we’ve changed the private
section to protected
, as we’ll create some derived classes in this section.
We’ve also added a constructor that initializes the Font
, but not the SDL_Surface
needed for blitting. This constructor will be useful to those derived classes, but we don’t want it to be called in any other context. Therefore, we’ve marked it as protected
.
#pragma once
#include <SDL.h>
#include <SDL_ttf.h>
class Text {
public:
Text(std::string Content, int FontSize = 100)
: Font{LoadFont(FontSize)} {
CreateSurface(Content);
}
void SetFontSize(int NewSize) {
TTF_SetFontSize(Font, NewSize);
}
void Render(SDL_Surface* DestinationSurface) {
SDL_BlitSurface(
TextSurface, nullptr,
DestinationSurface, &DestinationRectangle
);
}
~Text() {
SDL_FreeSurface(TextSurface);
if (TTF_WasInit()) {
TTF_CloseFont(Font);
}
}
Text(const Text&) = delete;
Text& operator=(const Text&) = delete;
protected:
Text(int FontSize)
: Font{LoadFont(FontSize)} {}
TTF_Font* LoadFont(int FontSize) {
TTF_Font* LoadedFont{TTF_OpenFont(
"Roboto-Medium.ttf", FontSize)};
if (!LoadedFont) {
std::cout << "Error loading font: "
<< SDL_GetError();
}
return LoadedFont;
}
void CreateSurface(std::string Content) {
SDL_FreeSurface(TextSurface);
TextSurface = TTF_RenderUTF8_Blended(
Font, Content.c_str(), {255, 255, 255}
);
if (!TextSurface) {
std::cout << "Error creating TextSurface: "
<< SDL_GetError();
}
}
TTF_Font* Font{nullptr};
SDL_Surface* TextSurface{nullptr};
SDL_Rect DestinationRectangle{0, 0, 0, 0};
};
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:
We’re currently using TTF_RenderUTF8_Blended()
. "Blended" rendering maximizes quality and flexibility at the cost of performance:
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_RenderUTF8_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:
// Text.h
// ...
class Text {
// ...
protected:
// ...
void CreateSurface(std::string Content) {
SDL_FreeSurface(TextSurface);
TextSurface = TTF_RenderUTF8_Solid(
Font, Content.c_str(), {255, 255, 255}
);
if (!TextSurface) {
std::cout << "Error creating TextSurface: "
<< SDL_GetError();
}
}
// ...
};
Most of the performance cost of blitting involves working with transparency. The SDL_Surface
generated by functions like TTF_RenderUTF8_Blended()
and TTF_RenderUTF8_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_RenderUTF8_Shaded()
function anti-aliases our text in the same way as TTF_RenderUTF8_Blended()
, except it rasterizes onto an opaque background. We pass the SDL_Color
we want the background to be as an additional argument:
// Text.h
// ...
class Text {
// ...
protected:
// ...
void CreateSurface(std::string Content) {
SDL_FreeSurface(TextSurface);
TextSurface = TTF_RenderUTF8_Shaded(
Font,
Content.c_str(),
{255, 255, 255}, // Text Color (White)
{0, 0, 90} // Background Color (Blue)
);
if (!TextSurface) {
std::cout << "Error creating TextSurface: "
<< SDL_GetError();
}
}
// ...
};
In this program, our text is being blitted onto a solid color anyway - the gray of our window surface.
In scenarios like this, TTF_RenderUTF8_Shaded()
could ultimately generate the exact same visuals as TTF_RenderUTF8_Blended()
without the performance cost. We’d simply need to pass the correct background color to TTF_RenderUTF8_Shaded()
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:
// Text.h
// ...
class Text {
// ...
protected:
// ...
void CreateSurface(std::string Content) {
SDL_FreeSurface(TextSurface);
TextSurface = TTF_RenderUTF8_LCD(
Font,
Content.c_str(),
{255, 255, 255}, // Text Color (White)
{0, 0, 90} // Background Color (Blue)
);
if (!TextSurface) {
std::cout << "Error creating TextSurface: "
<< SDL_GetError();
}
}
// ...
};
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.
Character encoding is the process of representing text as binary data that computers can store and transmit. UTF-8 (Unicode Transformation Format 8-bit) is a widely used encoding standard that can represent virtually all characters in use worldwide.
When you see UTF8
in SDL_ttf function names like TTF_RenderUTF8_Blended()
, it indicates that the function expects the input text to be encoded in UTF-8 format.
This is the most common encoding for text in modern systems, making it a safe default choice for handling text in your applications. We cover character encoding in more depth in our advanced course:
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.
TTF_SizeUTF8()
TTF_SizeUTF8()
helps us understand how big a surface would be created if we were to rasterize a piece of content in a given font. It accepts 4 parameters - the font, the content, and two int
pointers that will be updated with the width and height respectively.
int Width, Height;
TTF_SizeUTF8(
Font, "Hello World",
&Width, &Height
);
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_SizeUTF8()
can calculate the dimensions that the source would be without needing to perform the rasterization, meaning it is significantly more performant.
#include <iostream>
#include <SDL_ttf.h>
int main(int argc, char** argv) {
TTF_Init();
TTF_Font* Font{
TTF_OpenFont("Roboto-Medium.ttf", 25)};
int Width, Height;
TTF_SizeUTF8(
Font, "Hello World",
&Width, &Height
);
std::cout << "Width: " << Width
<< ", Height: " << Height;
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 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:
// ScaledText.h
#pragma once
#include <SDL.h>
#include <SDL_ttf.h>
#include "Text.h"
class ScaledText : public Text {
public:
ScaledText(
std::string Content, int TargetWidth)
: Text{BaseFontSize} {
int Width;
TTF_SizeUTF8(
Font, Content.c_str(),
&Width, nullptr
);
float Ratio{static_cast<float>(TargetWidth) / Width};
SetFontSize(BaseFontSize * Ratio);
CreateSurface(Content);
}
private:
static constexpr int BaseFontSize{24};
};
Over in main()
, let’s update TextExample
to use this type, and pass the window width as our target:
// main.cpp
// ...
#include "ScaledText.h"
// ...
int main(int argc, char** argv) {
// ...
ScaledText TextExample{
"Hello World",
GameWindow.GetWidth()
};
// ...
}
TTF_MeasureUTF8()
Another requirement we’re likely to have is to determine how much text can fit within a desired width. The TTF_MeasureUTF8()
can help us determine how many characters can fit within a space. It accepts 5 arguments:
TTF_Font
pointerint
, which will be updated with the pixel width of the characters from our string that can fit within that space (the extent)int
, which will be updated with the quantity of characters from our string that can fit within our space (the count)#include <SDL_ttf.h>
#include <iostream>
int main(int argc, char** argv) {
TTF_Init();
TTF_Font* Font{
TTF_OpenFont("Roboto-Medium.ttf", 25)};
int Extent, Count;
TTF_MeasureUTF8(
Font,
"The quick brown fox jumps over the lazy dog",
300, &Extent, &Count);
std::cout << "Extent: " << Extent <<
", Count: " << Count;
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_MeasureUTF8()
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:
// TruncatedText.h
#pragma once
#include <SDL.h>
#include <SDL_ttf.h>
#include "Text.h"
class TruncatedText : public Text {
public:
TruncatedText(
std::string Content,
int FontSize,
int MaxWidth
) : Text{FontSize} {
int MaxCharacters;
TTF_MeasureUTF8(
Font, Content.c_str(),
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:
// main.cpp
// ...
#include "TruncatedText.h"
// ...
int main(int argc, char** argv) {
// ...
TruncatedText TextExample{
"The quick brown fox jumps over the lazy dog",
36,
GameWindow.GetWidth()
};
// ...
}
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_RenderUTF8_Blended()
is TTF_RenderUTF8_Blended_Wrapped()
.
Let’s create a WrappedText
class that uses it:
// WrappedText.h
#pragma once
#include <SDL.h>
#include <SDL_ttf.h>
#include "Text.h"
class WrappedText : public Text {
public:
WrappedText(std::string Content, int FontSize, int MaxWidth)
: Text{FontSize}, MaxWidth{MaxWidth} {
CreateSurface(Content);
}
private:
void CreateSurface(std::string Content) {
SDL_FreeSurface(TextSurface);
TextSurface = TTF_RenderUTF8_Blended_Wrapped(
Font, Content.c_str(), {255, 255, 255}, MaxWidth
);
if (!TextSurface) {
std::cout << "Error creating TextSurface: " << SDL_GetError();
}
}
int MaxWidth;
};
Over in main()
, let’s update our TextExample
to use this WrappedText
type:
// main.cpp
// ...
#include "WrappedText.h"
// ...
int main(int argc, char** argv) {
// ...
WrappedText TextExample{
"The quick brown fox jumps over the lazy dog",
36,
GameWindow.GetWidth()
};
// ...
}
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_SetFontWrappedAlign()
.
The alignment is a simple integer, for which SDL_ttf provides named variables:
TTF_WRAPPED_ALIGN_LEFT
(default)TTF_WRAPPED_ALIGN_CENTER
TTF_WRAPPED_ALIGN_RIGHT
Let’s add an alignment option as a constructor argument to our WrappedText
, and pass it to TTF_SetFontWrappedAlign()
before creating the surface:
// WrappedText.h
#pragma once
#include <SDL.h>
#include <SDL_ttf.h>
#include "Text.h"
class WrappedText : public Text {
public:
WrappedText(
std::string Content,
int FontSize, int MaxWidth,
int Alignment = TTF_WRAPPED_ALIGN_CENTER
) : Text{FontSize}, MaxWidth{MaxWidth} {
TTF_SetFontWrappedAlign(Font, Alignment);
CreateSurface(Content);
}
// ...
};
In this lesson, we explored advanced text rendering techniques using SDL and SDL_ttf. 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.
Explore advanced techniques for optimizing and controlling text rendering when using SDL_ttf
Learn C++ and SDL development by creating hands on, practical projects inspired by classic retro games