Rendering Text with SDL3_ttf

Learn to render and manipulate text in SDL3 applications using the SDL3_ttf extension.

Ryan McCombe
Updated

In this lesson, we'll see how we can render text within our programs. We'll use the SDL3_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. To focus on text rendering, we have removed the Image class from our project.

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.

Files

src
Select a file to view its content

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.

Initializing and Quitting SDL3_ttf

To use SDL3_ttf in our application, we #include the SDL3_ttf/SDL_ttf.h header file. We then call TTF_Init() to initialize the library, and TTF_Quit() to close it down:

src/main.cpp

#include <SDL3/SDL.h>
#include <SDL3/SDL_main.h>
#include <SDL3_ttf/SDL_ttf.h>
#include "Window.h"
#include "Text.h"

int main(int, char**) {
  SDL_Init(SDL_INIT_VIDEO);
  TTF_Init();

  Window GameWindow;
  Text TextExample{"Hello World"};

  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;
}

Error Checking TTF_Init()

In SDL3, TTF_Init() returns true if it succeeds and false if it fails. We can check for this error state and call SDL_GetError() for an explanation:

#include <iostream>

// ...
if (!TTF_Init()) {
  std::cout << "Error initializing SDL_ttf: "
    << SDL_GetError();
}

Loading and Freeing Fonts

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 as a float:

TTF_OpenFont("Roboto-Medium.ttf", 50.0f);

Remember, just like with loading images, the font file we're referencing here should be in the same directory as our executable. Our code examples and screenshots are using the Roboto font, which can be downloaded for free from Google Fonts.

On some platforms, these relative paths will not work, so you may need to switch to using absolute paths, using similar techniques we introduced in our image lessons:

const std::string BASE_PATH{SDL_GetBasePath()};
TTF_OpenFont(BASE_PATH + "Roboto-Medium.ttf");

The TTF_OpenFont() 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:

src/Text.h

#pragma once
#include <SDL3/SDL.h>
#include <SDL3_ttf/SDL_ttf.h>
#include <string>

class Text {
 public:
  Text(const std::string& Content) : Font{
    TTF_OpenFont("Roboto-Medium.ttf", 50.0f)
  } {}

  void Render(SDL_Surface* DestinationSurface) {
    // ...
  }

  ~Text() {
    TTF_CloseFont(Font);
  }
  Text(const Text&) = delete;
  Text& operator=(const Text&) = delete;

private:
  TTF_Font* Font{nullptr};
};

Checking Initialization using TTF_WasInit()

If we call TTF_CloseFont() when SDL3_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 ~Text() calls TTF_CloseFont(), SDL3_ttf has already been shut down.

We can test for this scenario using TTF_WasInit(). The TTF_WasInit() function returns a positive integer if SDL3_ttf is currently initialized, so we can make our class more robust like this:

src/Text.h

// ...
class Text {
  // ...
  ~Text() {
    if (TTF_WasInit()) {
      TTF_CloseFont(Font);
    }
  }
  // ...
};

Error Checking TTF_OpenFont()

If TTF_OpenFont() failed, it will return a nullptr. We can check for this, and log out the error for an explanation:

src/Text.h

#pragma once
#include <SDL3/SDL.h>
#include <SDL3_ttf/SDL_ttf.h>
#include <iostream>
#include <string>

class Text {
 public:
  Text(const std::string& Content) : Font{
    TTF_OpenFont("Roboto-Medium.ttf", 50.0f)
  } {
    if (!Font) {
      std::cout << "Error loading font: "
        << SDL_GetError() << '\n';
    }
  }
  // ...
};

Rendering Text

From a high level, the process of rendering text is very similar to rendering images:

  • We generate an SDL_Surface containing the pixel data representing our text.
  • We blit that surface onto another surface, using functions like SDL_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_RenderText_Blended(). This function requires four arguments in SDL3:

  • The font we want to use, as a TTF_Font pointer.
  • The text we want to render, as a C-style string.
  • How many characters we want to render from the string. If we want to render everything, and our string is correctly null-terminated, we can pass 0 here.
  • The color we want to use, as an SDL_Color.

For example:

TTF_RenderText_Blended(
  Font,
  "Hello World",
  0, // Use the null-terminator to find length
  SDL_Color{255, 255, 255, 255} // White
);

SDL will render our text onto an 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_RenderText_Blended(
    Font, "Hello World", 0, {255, 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:

  • Add a TextSurface member to store a pointer to the generated surface.
  • Call SDL_DestroySurface() from the destructor to release the surface when we no longer need it.
  • Call SDL_DestroySurface() 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.
  • Call CreateSurface() from our constructor to ensure our Text objects are initialized with a TextSurface.

src/Text.h

#pragma once
#include <SDL3/SDL.h>
#include <SDL3_ttf/SDL_ttf.h>
#include <iostream>
#include <string>

class Text {
 public:
  Text(const std::string& Content) : Font{
    TTF_OpenFont("Roboto-Medium.ttf", 50.0f)
  } {
    if (!Font) {
      std::cout << "Error loading font: "
        << SDL_GetError() << '\n';
    }
    CreateSurface(Content);
  }

  void Render(SDL_Surface* DestinationSurface) {
    // ...
  }

  ~Text() {
    SDL_DestroySurface(TextSurface);
    if (TTF_WasInit()) {
      TTF_CloseFont(Font);
    }
  }
  Text(const Text&) = delete;
  Text& operator=(const Text&) = delete;

private:
  void CreateSurface(const std::string& Content) {
    SDL_Surface* newSurface = TTF_RenderText_Blended(
      Font, Content.c_str(), 0, {255, 255, 255, 255}
    );
    if (newSurface) {
      SDL_DestroySurface(TextSurface);
      TextSurface = newSurface;
    } else {
      std::cout << "Error creating TextSurface: "
        << SDL_GetError() << '\n';
    }
  }

  TTF_Font* Font{nullptr};
  SDL_Surface* TextSurface{nullptr};
};

Surface Blitting

After creating an SDL_Surface containing our rendered text, we can display it on the screen using the same process we covered earlier. Blitting involves copying the pixel data from one surface (our text surface) to another (the window surface).

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:

src/Text.h

// ...
class Text {
 public:
  // ...
  void Render(SDL_Surface* DestinationSurface) {
    if (!TextSurface) return;
    SDL_BlitSurface(
      TextSurface, nullptr,
      DestinationSurface, nullptr
    );
  }
  // ...
};

Source Rectangle

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 SDL3_ttf. However, we can still crop it if we have some reason to:

src/Text.h

// ...
class Text {
 public:
  // ...
  void Render(SDL_Surface* DestinationSurface) {
    if (!TextSurface) return;
    SDL_BlitSurface(
      TextSurface, &SourceRectangle,
      DestinationSurface, nullptr
    );
  }

private:
  void CreateSurface(const std::string& Content) {
    SDL_Surface* newSurface = TTF_RenderText_Blended(
      Font, Content.c_str(), 0, {255, 255, 255, 255}
    );
    if (newSurface) {
      SDL_DestroySurface(TextSurface);
      TextSurface = newSurface;
    } else {
      std::cout << "Error creating TextSurface: "
        << SDL_GetError() << '\n';
    }
    SourceRectangle = {
      0, 0,
      TextSurface->w - 50,
      TextSurface->h - 20
    };
  }

  TTF_Font* Font{nullptr};
  SDL_Surface* TextSurface{nullptr};
  SDL_Rect SourceRectangle{};
};

Destination Rectangle

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:

src/Text.h

// ...
class Text {
 public:
  // ...
  void Render(SDL_Surface* DestinationSurface) {
    if (!TextSurface) return;
    SDL_BlitSurface(
      TextSurface, nullptr,
      DestinationSurface, &DestinationRectangle
    );
  }

private:
  // ...
  SDL_Rect DestinationRectangle{50, 50, 0, 0}; 
};

As we covered in our image rendering lessons, if our text doesn't fit within the destination surface, it will be clipped.

For example, let's increase the font size so the text is too large for the window:

src/Text.h

// ...
class Text {
 public:
  Text(const std::string& Content) : Font {
    TTF_OpenFont("Roboto-Medium.ttf", 150.0f)
  } {
    if (!Font) {
      std::cout << "Error loading font: "
        << SDL_GetError() << '\n';
    }
    CreateSurface(Content);
  }
  // ...
};

Scaling Text

As with any surface, we can add scaling to the blitting process using SDL_BlitSurfaceScaled(). 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:

src/Text.h

// ...
class Text {
 public:
  Text(const std::string& Content) : Font{
    TTF_OpenFont("Roboto-Medium.ttf", 25.0f)
  } {
    if (!Font) {
      std::cout << "Error loading font: "
        << SDL_GetError() << '\n';
    }
    CreateSurface(Content);
  }

  void Render(SDL_Surface* DestinationSurface) {
    if (!TextSurface) return;
    SDL_BlitSurfaceScaled(
      TextSurface, nullptr,
      DestinationSurface, &DestinationRectangle,
      SDL_SCALEMODE_LINEAR
    );
  }

  ~Text() {
    SDL_DestroySurface(TextSurface);
    if (TTF_WasInit()) {
      TTF_CloseFont(Font);
    }
  }
  Text(const Text&) = delete;
  Text& operator=(const Text&) = delete;

private:
  void CreateSurface(const std::string& Content) {
    SDL_Surface* newSurface = TTF_RenderText_Blended(
      Font, Content.c_str(), 0, {255, 255, 255, 255}
    );
    if (newSurface) {
      SDL_DestroySurface(TextSurface);
      TextSurface = newSurface;
    } else {
      std::cout << "Error creating TextSurface: "
        << SDL_GetError() << '\n';
    }

    DestinationRectangle = {
      50, 50, TextSurface->w * 4, TextSurface->h * 4
    };
  }

  TTF_Font* Font{nullptr};
  SDL_Surface* TextSurface{nullptr};
  SDL_Rect DestinationRectangle{};
};

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. Let's change that to be a constructor argument:

src/Text.h

// ...
class Text {
 public:
  Text(const std::string& Content, float Size = 25.0f)
  : Font {
    TTF_OpenFont("Roboto-Medium.ttf", Size)
  } {
    if (!Font) {
      std::cout << "Error loading font: "
        << SDL_GetError() << '\n';
    }
    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:

src/Text.h

// ...
class Text {
 public:
  // ...
  void SetFontSize(float NewSize) {
    TTF_SetFontSize(Font, NewSize);
  }
  // ...
};

Let's increase our font size, and notice the quality improvement over using SDL_BlitSurfaceScaled():

src/main.cpp

#include <SDL3/SDL.h>
#include <SDL3/SDL_main.h>
#include <SDL3_ttf/SDL_ttf.h>
#include "Window.h"
#include "Text.h"

int main(int, char**) {
  SDL_Init(SDL_INIT_VIDEO);
  TTF_Init();

  Window GameWindow;
  Text TextExample{"Hello World", 100.0f};

  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;
}

We cover more techniques related to scaling in the next lesson.

Complete Code

Complete versions of the files we created in this lesson are available below:

Files

src
Select a file to view its content

Summary

In this lesson, we've explored rendering text in SDL3 using the SDL3_ttf extension. We've covered:

  • Initializing and quitting SDL3_ttf.
  • Loading and freeing fonts.
  • Creating text surfaces.
  • Rendering text to the screen.
  • Handling potential errors in text rendering.
  • Basic text positioning and scaling.

In the next lesson, we'll expand on these concepts further, covering more advanced use cases.

Next Lesson
Lesson 31 of 37

Text Performance, Fitting and Wrapping

Explore advanced techniques for optimizing and controlling text rendering when using SDL3_ttf

Have a question about this lesson?
Answers are generated by AI models and may not be accurate