SDL_ttf
SDL_ttf
extensionIn this lesson, we’ll see how we can render text within our programs. We’ll use the official SDL_ttf extension we installed earlier in the course.
We’ll build upon the concepts we introduced in the previous chapters. Our main.cpp
looks like below.
The key thing to note is that we have created a Text
class, and instantiated an object from it called TextExample
. This object is being asked to Render()
onto the window surface every frame:
#include <iostream>
#include <SDL.h>
#include "Text.h"
class Window {/*...*/};
int main(int argc, char** argv) {
SDL_Init(SDL_INIT_VIDEO);
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();
}
SDL_Quit();
return 0;
}
Our Text
class currently looks like this, but we’ll expand it throughout this lesson:
#pragma once
#include <SDL.h>
#include <SDL_ttf.h>
class Text {
public:
Text(std::string Content) {}
void Render(SDL_Surface* DestinationSurface) {
// ...
}
};
We will also need a TrueType (.ttf
) file stored in the same location as our executable. The code examples and screenshots in this lesson use Roboto-Medium.ttf
, available from Google Fonts.
To use SDL_ttf
in our application, we #include
the SDL_ttf.h
header file. We then call TTF_Init()
to initialize the library, and TTF_Quit()
to close it down:
#include <SDL.h>
#include <SDL_ttf.h>
#include "Text.h"
class Window {/*...*/};
int main(int argc, char** argv) {
SDL_Init(SDL_INIT_VIDEO);
TTF_Init();
SDL_Event Event;
bool shouldQuit{false};
Window GameWindow;
Text Example{"Hello World"};
while (!shouldQuit) {/*...*/}
TTF_Quit();
SDL_Quit();
return 0;
}
TTF_Init()
returns 0
if it succeeds, and a negative error code if it fails. We can check for this error state, and call SDL_GetError()
for an explanation:
if (TTF_Init() < 0) {
std::cout << "Error initializing SDL_ttf: "
<< SDL_GetError();
}
To create text in our application, we first need to load a font. To do this, we call TTF_OpenFont()
, passing the path to our font file, and the font size we want to use:
TTF_OpenFont("Roboto-Medium.ttf", 50);
This function creates a TTF_Font
object and returns a pointer to it. To prevent memory leaks, we pass this pointer to TTF_CloseFont()
when we no longer need it.
To simplify memory management, we’ll also prevent our Text
objects from being copied by deleting their copy constructor and copy assignment operator. Our updated Text
class looks like this:
#pragma once
#include <SDL.h>
#include <SDL_ttf.h>
class Text {
public:
Text(std::string Content) : Font {
TTF_OpenFont("Roboto-Medium.ttf", 50)
} {
}
void Render(SDL_Surface* DestinationSurface) {
}
~Text() {
TTF_CloseFont(Font);
}
Text(const Text&) = delete;
Text& operator=(const Text&) = delete;
private:
TTF_Font* Font;
};
TTF_WasInit()
If we call TTF_CloseFont()
when SDL_ttf is not initialized, our program can crash. Issues like this are most common during the "cleanup" phase of our application, where we’re trying to shut everything down cleanly.
In our example, the TextExample
object’s destructor isn’t called until main()
ends. However, before main()
ends, we call TTF_Quit()
, meaning by the time ~TextExample()
calls TTF_CloseFont()
, SDL_ttf has already been shut down.
We can test for this scenario using TTF_WasInit()
. The TTF_WasInit()
function returns a positive integer if SDL_ttf is currently initialized, so we can make our class more robust like this:
// Text.h
class Text {
// ...
~Text() {
if (TTF_WasInit()) {
TTF_CloseFont(Font);
}
}
// ...
};
TTF_Init()
and TTF_Quit()
Multiple TimesAs an aside, SDL_ttf keeps track of how many times TTF_Init()
has been called, and will only shut down when the same number of calls to TTF_Quit()
have been made.
This gives us an alternative approach to working with the library. Instead of calling TTF_Init()
when our application starts, we can call it every time it is needed - for example, in the constructors of objects that use it.
We then ensure each of those initializations is matched with a TTF_Quit()
call, such as in the destructors of those same objects.
The positive integer returned by TTF_WasInit()
represents how many times TTF_Init()
has been called, reduced by how many times SDL_Quit()
has been called. If we manage the lifecycle of all our objects perfectly, the number will be 0
when our application closes.
If TTF_OpenFont()
failed, it will return a nullptr
. We can check for this, and log out the error for an explanation:
// Text.h
class Text {
public:
Text(std::string Content) : Font {
TTF_OpenFont("Roboto-Medium.ttf", 50)
} {
if (!Font) {
std::cout << "Error loading font: "
<< SDL_GetError();
}
}
// ...
};
From a high level, the process of rendering text is very similar to rendering images:
SDL_Surface
containing the pixel data representing our textSDL_BlitSurface()
There are many options we can use to generate our surface. We’ll explain their differences in the next lesson. For now, we’ll use TTF_RenderUTF8_Blended()
. This function requires three arguments:
SDL_Font
pointerSDL_Color
For example:
TTF_RenderUTF8_Blended(
Font,
"Hello World",
SDL_Color{255, 255, 255} // White
)
SDL will render our text onto SDL_Surface
, containing the pixel data we need to display the text. It will return a pointer to that surface, or a nullptr
if the process failed. We can therefore check for errors in the usual way:
SDL_Surface* TextSurface{
TTF_RenderUTF8_Blended(
Font, "Hello World", {255, 255, 255}
)
};
if (!TextSurface) {
std::cout << "Error creating TextSurface: "
<< SDL_GetError();
}
Let’s add this capability to our class as a private CreateSurface()
method. We’ll additionally:
TextSurface
member to store a pointer to the generated surfaceSDL_FreeSurface()
from the destructor to release the surface when we no longer need it.SDL_FreeSurface()
before we replace the existing TextSurface
with a new one. This is not currently necessary as we’re only calling CreateSurface()
once per object lifecycle, but we’ll expand our class later.CreateSurface()
from our constructor to ensure our Text
objects are initialized with a TextSurface
// Text.h
class Text {
public:
Text(std::string Content) : Font {
TTF_OpenFont("Roboto-Medium.ttf", 50)
} {
if (!Font) {
std::cout << "Error loading font: "
<< SDL_GetError();
}
CreateSurface(Content);
}
void Render(SDL_Surface* DestinationSurface) {
}
~Text() {
SDL_FreeSurface(TextSurface);
TTF_CloseFont(Font);
}
Text(const Text&) = delete;
Text& operator=(const Text&) = delete;
private:
void CreateSurface(std::string Content) {
SDL_Surface* newSurface = TTF_RenderUTF8_Blended(
Font, Content.c_str(), {255, 255, 255}
);
if (newSurface) {
SDL_FreeSurface(TextSurface);
TextSurface = newSurface;
} else {
std::cout << "Error creating TextSurface: "
<< SDL_GetError() << '\n';
}
}
TTF_Font* Font{nullptr};
SDL_Surface* TextSurface{nullptr};
};
After creating an SDL_Surface
containing our rendered text, we can display it on the screen using a process called blitting. Blitting involves copying the pixel data from one surface (our text surface) to another (the window surface).
We covered blitting in much more detail when working with images earlier in the chapter.
In our current setup, the Text::Render()
method receives the window surface as a parameter. By blitting our TextSurface
onto this window surface, we draw the text onto the window:
// Text.h
class Text {
public:
// ...
void Render(SDL_Surface* DestinationSurface) {
SDL_BlitSurface(
TextSurface, nullptr,
DestinationSurface, nullptr
);
}
// ...
};
As with any call to SDL_BlitSurface()
, we can pass pointers to the 2nd and 4th arguments. These are pointers to SDL_Rect
objects representing the source and destination rectangles respectively.
When working with text, it is fairly unusual that we would specify a source rectangle. The text we generated fills the entire SDL_Surface
created by SDL_ttf. However, we can still crop it if we have some reason to:
// Text.h
class Text {
public:
// ...
void Render(SDL_Surface* DestinationSurface) {
SDL_BlitSurface(
TextSurface, &SourceRectangle,
DestinationSurface, nullptr
);
}
// ...
private:
void CreateSurface(std::string Content) {
SDL_Surface* newSurface = TTF_RenderUTF8_Blended(
Font, Content.c_str(), {255, 255, 255}
);
if (newSurface) {
SDL_FreeSurface(TextSurface);
TextSurface = newSurface;
} else {
std::cout << "Error creating TextSurface: "
<< SDL_GetError() << '\n';
}
SourceRectangle.w = TextSurface->w - 50;
SourceRectangle.h = TextSurface->h - 20;
}
TTF_Font* Font{nullptr};
SDL_Surface* TextSurface{nullptr};
SDL_Rect SourceRectangle{0, 0, 0, 0};
};
More commonly, we’ll want to provide a destination rectangle with x
and y
values, controlling where the text is placed within the destination surface:
// Text.h
class Text {
public:
// ...
void Render(SDL_Surface* DestinationSurface) {
SDL_BlitSurface(
TextSurface, nullptr,
DestinationSurface, &DestinationRectangle
);
}
private:
// ...
SDL_Rect DestinationRectangle{50, 50, 0, 0};
};
As we covered in our image rendering lessons, SDL_BlitSurface()
will update the w
and h
values of this rectangle with the size of our output within the destination surface.
If these values are smaller than the width and height of our source surface (or source rectangle, if we’re using one) that indicates that our text did not fit within the destination surface at the location we specified:
// Text.h
class Text {
public:
Text(std::string Content) : Font {
TTF_OpenFont("Roboto-Medium.ttf", 150)
} {
if (!Font) {
std::cout << "Error loading font: "
<< SDL_GetError();
}
CreateSurface(Content);
}
void Render(SDL_Surface* DestinationSurface) {
SDL_BlitSurface(
TextSurface, nullptr,
DestinationSurface, &DestinationRectangle
);
if (DestinationRectangle.w < TextSurface->w) {
std::cout << "Clipped horizontally\n";
}
if (DestinationRectangle.h < TextSurface->h) {
std::cout << "Clipped vertically\n";
}
}
// ...
};
Clipped horizontally
Clipped horizontally
Clipped horizontally
...
As with any surface, we can add scaling to the blitting process using SDL_BlitScaled()
. However, we should avoid doing this where possible. Scaling an image causes a loss of quality, and this can be particularly noticeable with text.
Let’s temporarily update Render()
and CreateSurface()
to increase the size of our text using scaled blitting, and note the quality loss:
// Text.h
class Text {
public:
Text(std::string Content) : Font {
TTF_OpenFont("Roboto-Medium.ttf", 25)
} {
if (!Font) {
std::cout << "Error loading font: "
<< SDL_GetError();
}
CreateSurface(Content);
}
void Render(SDL_Surface* DestinationSurface) {
SDL_BlitScaled(
TextSurface, nullptr,
DestinationSurface, &DestinationRectangle
);
}
~Text() {
SDL_FreeSurface(TextSurface);
if (TTF_WasInit()) {
TTF_CloseFont(Font);
}
}
Text(const Text&) = delete;
Text& operator=(const Text&) = delete;
private:
void CreateSurface(std::string Content) {
SDL_Surface* newSurface = TTF_RenderUTF8_Blended(
Font, Content.c_str(), {255, 255, 255}
);
if (newSurface) {
SDL_FreeSurface(TextSurface);
TextSurface = newSurface;
} else {
std::cout << "Error creating TextSurface: "
<< SDL_GetError() << '\n';
}
DestinationRectangle.w = TextSurface->w * 4;
DestinationRectangle.h = TextSurface->h * 4;
}
TTF_Font* Font{nullptr};
SDL_Surface* TextSurface{nullptr};
SDL_Rect DestinationRectangle{50, 50, 0, 0};
};
Instead, we can just render the text at the correct size we want, by setting the font size. Our constructor is currently setting the font size to 20. Let’s change that to be a constructor argument:
// Text.h
class Text {
public:
Text(std::string Content, int Size = 25)
: Font {
TTF_OpenFont("Roboto-Medium.ttf", Size)
} {
if (!Font) {
std::cout << "Error loading font: "
<< SDL_GetError();
}
CreateSurface(Content);
}
// ...
};
The TTF_SetFontSize()
function also lets us change the size of a font we’ve already loaded. Let’s use this in a new SetFontSize()
method, allowing consumers to change the font size without needing to create an entirely new object:
// Text.h
class Text {
public:
// ...
void SetFontSize(int NewSize) {
TTF_SetFontSize(Font, NewSize);
}
// ...
};
Let’s increase our font size, and notice the quality improvement over using SDL_BlitScaled()
:
// main.cpp
class Window {/*...*/};
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", 100};
while (!shouldQuit) {/*...*/}
TTF_Quit();
SDL_Quit();
return 0;
}
We cover more techniques related to scaling in the next lesson.
Font files, such as the .ttf
format, store their characters in a vector format. These formats often involve a set of instructions on how to draw the lines and shapes of each character. A font’s representation of a character is often called a glyph.
However, to store text in an SDL_Surface
, and ultimately show it on our screen, the data needs to be in a raster format. This is the image format we’re likely most familiar with - grids of small, rectangular pixels.
The process of converting vectors and other arbitrary data types to raster images is called rasterization.
The main advantage of a vector format is that it allows any algorithm that understands the format, such as TTF_RenderUTF8_Blended()
, to generate raster data for a specific character by executing the instructions provided by the font.
And, like calling a function, that algorithm can include parameters such as the size we want the raster to be. This allows a font (or any other vector graphic) to be rendered at any size without losing quality.
Complete versions of the files we created in this lesson are available below:
#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, 600, 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;
private:
SDL_Window* SDLWindow;
};
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", 100};
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;
}
#pragma once
#include <SDL.h>
#include <SDL_ttf.h>
class Text {
public:
Text(std::string Content, int Size = 25)
: Font {
TTF_OpenFont("Roboto-Medium.ttf", Size)
} {
if (!Font) {
std::cout << "Error loading font: "
<< SDL_GetError();
}
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;
private:
void CreateSurface(std::string Content) {
SDL_Surface* newSurface = TTF_RenderUTF8_Blended(
Font, Content.c_str(), {255, 255, 255}
);
if (newSurface) {
SDL_FreeSurface(TextSurface);
TextSurface = newSurface;
} else {
std::cout << "Error creating TextSurface: "
<< SDL_GetError() << '\n';
}
}
TTF_Font* Font{nullptr};
SDL_Surface* TextSurface{nullptr};
SDL_Rect DestinationRectangle{0, 0, 0, 0};
};
In this lesson, we've explored the fundamentals of rendering text in SDL2 using the SDL_ttf extension. We've covered:
In the next lesson, we’ll expand on these concepts further, covering more advanced use cases.
SDL_ttf
Learn to render and manipulate text in SDL2 applications using the official SDL_ttf
extension
Learn C++ and SDL development by creating hands on, practical projects inspired by classic retro games