DEV Community

Cover image for How to render TrueType Fonts in OpenGL using stb_truetype.h
Shreyas
Shreyas

Posted on • Edited on

How to render TrueType Fonts in OpenGL using stb_truetype.h

Preface

Rendering text in OpenGL can be tricky, especially when you want to use high-quality TrueType fonts. The stb_truetype.h library makes this task easier by providing a straightforward way to work with TrueType fonts. In this blog, we'll walk through the steps needed to render text using stb_truetype.h and OpenGL. We’ll cover how to load font files, create texture atlases, and draw text on the screen. Whether you’re building a game or an application that needs text rendering, this guide will help you get started with adding stylish fonts to your OpenGL projects.

This post assumes you to have basic knowledge on OpenGL and C++.

The entire demo can be found here

About stb_truetype.h

The stb_truetype.h library is a single-header tool for loading and rasterizing TrueType fonts. It provides two APIs: a basic 3D API intended for debugging purposes and an enhanced 3D API designed for production use. This guide focuses on demonstrating the usage of the improved, more shippable 3D API.

Rendering Text - The Approach

Most low level graphics APIs don't provide a "built in" way to render text, The way to render text using these APIs is to render a quad with the right position, size and texture with the right texture coordinates. But how to render those quads? One approach is to render each character on each draw call.

The pseudocode for the following is:

-> Renderer API setup
for(char ch : text)
{
    -> Calculate the vertices and texture coordinates of each character glyph 'ch'
    -> Upload the vertices to a vertex buffer
    -> Render the vertices
}
Enter fullscreen mode Exit fullscreen mode

This approach might not be the best since text can have many characters and rendering each character with each draw call can impact performance significantly and might not scale well with large paragraphs of text. To reduce the overhead of may draw calls, the batching approach is more preferred where all the quads that represent characters of text will be rendered all together in a single draw call.

The pseudocode for the following is:

RendererBegin(...)
{
    ...
}

AddText(text, position, ...)
{
    for(char ch : text)
    {
        -> Calculate the vertices and texture coordinates of each character glyph 'ch'
        -> Upload the vertices to a vertex buffer
    }
}

RenderFrame()
{
    -> Render the entire vertex buffer
}
Enter fullscreen mode Exit fullscreen mode

The following approach can be used like this in the update loop:

UpdateLoop()
{
    ...

    RendererBegin(...)
    AddText("Text 1", {0.0f, 0.0f, 0.0f}, ...)
    AddText("Text 2", {1.0f, 0.2f, 0.0f}, ...)
    RenderFrame()

    ...
}
Enter fullscreen mode Exit fullscreen mode

Obtaining the data to Render Characters and Rasterizing the Font Atlas Texture

Having discussed the approach to rendering, the first step to render text is to obtain the font atlas texture and data which is used to render a particular character glyph.

Reading the Font Data from a TTF/TTC File

To obtain the font atlas, The font data must be read from the TTF/TTC file. A uint8_t buffer is dynamically allocated based on the size of the TTF/TTC file

std::string fontFilePath = "path/to/file.ttf";

// Include fstream.h
std::ifstream inputFileStream(fontFilePath, std::ios::binary);

// Find the size of the file to allocate memory dynamically
inputFileStream.seekg(0, std::ios::end);
auto&& size = inputFileStream.tellg();
inputFileStream.seekg(0, std::ios::beg);

// Allocate the buffer
uint8_t* fontDataBuf = new uint8_t[static_cast<size_t>(size)];

// Read the font data to the buffer
inputFileStream.read((char*)fontDataBuf, size)
Enter fullscreen mode Exit fullscreen mode

It is crucial to ensure that fontDataBuf remains allocated until the font atlas has been successfully built.

An optional thing to do is to verify the number of fonts present in the font file. .ttf files contain only one font whereas .ttc(TrueType Collection) contain more than one fonts.

// Include stb_truetype.h
int32_t fontCount = stbtt_GetNumberOfFonts(fontDataBuf); 
if(fontCount == -1)
    std::cerr << "The font file doesn't correspond to valid font data\n";
else
    std::cout << "The File " << fontFilepath << " contains " << fontCount << " font(s)\n";
Enter fullscreen mode Exit fullscreen mode

Rasterize the required character glyphs to a bitmap texture

To render a character in a particular font, each font has a particular way to render a character(aka glyphs) like the design, shape and size of the character. Therefore the required characters need to be rasterized to a bitmap to then later use this as a texture to render text.

Allocate the bitmap texture in memory

A buffer that holds the bitmap needs to be allocated in the memory before rendering the font atlas to it.

uint32_t fontAtlasWidth = 1024;  // The width of font atlas texture
uint32_t fontAtlasHeight = 1024; // The height of font atlas texture

uint8_t* fontAtlasBitmap = new uint8_t[fontAtlasWidth * fontAtlasHeight];
Enter fullscreen mode Exit fullscreen mode

The format of the bitmap is 8 bits per pixel, having one grey channel with 8 bits representing a greyscale value.

Render the required character glyphs to the bitmap

Before rendering the font atlas bitmap, we need to determine which characters will be included. Typically, this includes characters from ASCII 32 (Space) to ASCII 126 (~), which cover the most commonly used text characters in english.

We also need to determine the size if the font to be rendered, as declared by the fontSize variable

// There are 95 ASCII characters from ASCII 32(Space) to ASCII 126(~)
// ASCII 32(Space) to ASCII 126(~) are the commonly used characters in text 
const uint32_t codePointOfFirstChar = 32;      
const uint32_t charsToIncludeInFontAtlas = 95; 

// Font pixel height
float fontSize = 64.0f;
Enter fullscreen mode Exit fullscreen mode

In addition to rendering the font atlas bitmap, we must also gather information on how to render each character glyph on the screen. This information is obtained through arrays of stbtt_packedchar and stbtt_aligned_quad structs. The first element in these arrays contains the data needed to render the character corresponding to codePointOfFirstChar, while the subsequent elements provide the data required to render the following ASCII characters incrementally.

// The stbtt_packedchar and stbtt_aligned_quad structures contain the
// data to render a single character
// Therefore to render 'charsToIncludeInFontAtlas' characters, we need
// an array of stbtt_packedchar and stbtt_aligned_quad of length
// 'charsToIncludeInFontAtlas' 
stbtt_packedchar packedChars[charsToIncludeInFontAtlas];
stbtt_aligned_quad alignedQuads[charsToIncludeInFontAtlas];
Enter fullscreen mode Exit fullscreen mode

Here is the code to render the font atlas to a bitmap using the above data:

stbtt_pack_context ctx;

stbtt_PackBegin(
  &ctx,                                     // stbtt_pack_context (this call will initialize it) 
  (unsigned char*)fontAtlasBitmap,          // Font Atlas bitmap data
  fontAtlasWidth,                           // Width of the font atlas texture
  fontAtlasHeight,                          // Height of the font atlas texture
  0,                                        // Stride in bytes
  1,                                        // Padding between the glyphs
  nullptr);

stbtt_PackFontRange(
  &ctx,                                     // stbtt_pack_context
  fontDataBuf,                              // Font Atlas texture data
  0,                                        // Font Index                                 
  fontSize,                                 // Size of font in pixels. (Use STBTT_POINT_SIZE(fontSize) to use points) 
  codePointOfFirstChar,                     // Code point of the first character
  charsToIncludeInFontAtlas,                // No. of charecters to be included in the font atlas 
  packedChars                               // stbtt_packedchar array, this struct will contain the data to render a glyph
);
stbtt_PackEnd(&ctx);

for (int i = 0; i < charsToIncludeInFontAtlas; i++)
{
    float unusedX, unusedY;

    stbtt_GetPackedQuad(
      localState.packedChars,              // Array of stbtt_packedchar
      fontAtlasWidth,                      // Width of the font atlas texture
      fontAtlasHeight,                     // Height of the font atlas texture
      i,                                   // Index of the glyph
      &unusedX, &unusedY,                  // current position of the glyph in screen pixel coordinates, (not required as we have a different corrdinate system)
      &alignedQuads[i],                    // stbtt_alligned_quad struct. (this struct mainly consists of the texture coordinates)
      0                                    // Allign X and Y position to a integer (doesn't matter because we are not using 'unusedX' and 'unusedY')
      );
}
Enter fullscreen mode Exit fullscreen mode

Optionally, we can verify the contents of the font atlas bitmap by writing it to a PNG file using stb_image_write.h

// Optionally write the font atlas texture as a png file.
stbi_write_png("fontAtlas.png", fontAtlasWidth, fontAtlasHeight, 1, fontAtlasBitmap, fontAtlasWidth);
Enter fullscreen mode Exit fullscreen mode

The font atlas bitmap will look like this:
FontAtlas

Create a OpenGL texture using the obtained font atlas bitmap

Now that we have the font atlas bitmap, the font atlas data needs to be uploaded to the GPU.

Assuming that the Rendering surface and OpenGL context is properly setup, the code for the following is:

uint32_t fontAtlasTextureID;

glGenTextures(1, &fontAtlasTextureID)
glBindTexture(GL_TEXTURE_2D, fontAtlasTextureID);

// Upload the data to the GPU.
// Important thing to note down is that the internal format of the
// texture is GL_R8 and the format of the texture data is GL_UNSIGNED_BYTE 
glTexImage2D(GL_TEXTURE_2D, 0, GL_R8, fontAtlasWidth, fontAtlasHeight, 0, GL_RED, GL_UNSIGNED_BYTE, fontAtlasBitmap)

// Set the parameters of the texture
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);

// Unbind the texture
glBindTexture(GL_TEXTURE_2D, 0);
Enter fullscreen mode Exit fullscreen mode

Don't forget to free up resources

Since we allocated some dynamically allocated some memory in the beginning, it is important to free them as we finished uploaded the font atlas to the GPU.

// Free resources
delete[] fontAtlasBitmap;
delete[] fontDataBuf;
Enter fullscreen mode Exit fullscreen mode

Rendering the character glyphs to the screen

Before rendering quads to the screen, it's necessary to set up the Vertex Array Object (VAO) and Vertex Buffer Object (VBO). Additionally, the vertex and fragment shaders must be configured, alpha blending enabled, and the appropriate projection applied. This post will provide an overview of these steps, but will not delve into detailed descriptions.

In the complete demo that I've written, The vertex array is set up like the following:

Each vertex consists of 9 floats where

  • First 3 floats determines the position of the vertex
  • The next 4 floats determines the color of the vertex
  • The next 2 floats determines the texture coordinates of the vertex

To render text, we need to render a quad for each character in the text. Each quad consists of 2 triangles that needs to be represented by 6 vertices.

It's a bit inefficient to render as triangles without index buffers but for the sake of simplicity, the following demo doesn't demonstrate rendering quads using index buffers and the same can be extended either by using index buffers or by using some clever tricks such as Programmable Vertex Pulling.

For the math, I've used glm library

Each vertex is represented by the struct Vertex

// The struct that represents a vertex 
struct Vertex
{
    glm::vec3 position;
    glm::vec4 color;
    glm::vec2 texCoord;
};

// A buffer of vertices, that needs to be calculated 
// and uploaded to the VBO
std::vector<Vertex> vertices;
Enter fullscreen mode Exit fullscreen mode

Here's the code to render vertices taken from the demo:

static void Render(const std::vector<Vertex>& vertices)
{
    // The vertex buffer need to be divided into chunks of size 'VBO_SIZE',
    // Upload them to the VBO and render
    // This is repeated for every divided chunk of the vertex buffer.
    size_t sizeOfVertices = vertices.size() * sizeof(Vertex);
    uint32_t drawCallCount = (sizeOfVertices / VBO_SIZE) + 1; // aka number of chunks.

    // Render each chunk of vertex data.
    for(int i = 0; i < drawCallCount; i++)
    {
        const Vertex* data = vertices.data() + i * VBO_SIZE;

        uint32_t vertexCount = 
            i == drawCallCount - 1 ? 
            (sizeOfVertices % VBO_SIZE) / sizeof(Vertex): 
            VBO_SIZE / (sizeof(Vertex) * 6);

        int uniformLocation = glGetUniformLocation(localState.shaderProgramID, "uViewProjectionMat");
        glUniformMatrix4fv(uniformLocation, 1, GL_TRUE, glm::value_ptr(localState.viewProjectionMat));

        glBindVertexArray(localState.vaoID);
        glBindBuffer(GL_ARRAY_BUFFER, localState.vboID);
        glBufferSubData(GL_ARRAY_BUFFER, 
            0, 
            i == drawCallCount - 1 ? sizeOfVertices % VBO_SIZE : VBO_SIZE,
            data);

        glDrawArrays(GL_TRIANGLES, 0, vertexCount);
    }
}
Enter fullscreen mode Exit fullscreen mode

Now the main part is to calculate the 6 vertices for each character in text to render the text. So let's design a simple renderer that calculates std::vector<Vertex> vertices. The Renderer just takes a few functions. They are:

uint32_t vertexIndex = 0;

void Init()
{
    // VBO and VAO setup
    // Font atlas setup
    // Misc setup
}

void RendererBegin()
{
    vertexIndex = 0;
}

void DrawText(const std::string& text, glm::vec3 position, glm::vec4 color, float size)
{
    // To be continued...
}

Enter fullscreen mode Exit fullscreen mode

The update loop will consist of the following (Note that the variables are arbitary):

while(!WindowShouldClose(windowObject)) 
{
    glClear(GL_COLOR_BUFFER_BIT);
    glClearColor(0.1f, 0.1f, 0.1f, 1.0f);

    // Usage of renderer -----
    RendererBegin();
    DrawText("Text to display", { -1.0f, 0.0f, 0.0f }, { 1.0f, 1.0f, 1.0f, 1.0f }, 0.7f);
    DrawText("The color of text can be changed too!", { -0.5f, -0.4f, 0.0f }, { 0.1f, 0.5f, 1.0f, 1.0f }, 0.5f);
    DrawText("stb_truetype.h example", { -0.8f, 0.4f, 0.0f }, { 0.9f, 0.2f, 0.3f, 1.0f }, 1.0f);

    Render(vertices); // vertices is std::vector<Vertex>

    // -----------------------
    SwapBuffers();
    PollEvents();
}
Enter fullscreen mode Exit fullscreen mode

The main function to focus here is the DrawText() function which calculates and adds the instances of Vertex to vertices.

Understanding glyph metrics and terminologies

Before diving into the calculations, let's understand the actual metrics and terminologies used to render a character glyph.

bounding box

As shown in the above picture,

  • A Bounding Box is a box that tightly packs the glyph.
  • A Baseline is a line in which all the glyphs of a word sit on.
  • The Origin is a point on the Baseline for each character. The metrics given are based of this origin

Now, let's understand the fields in the structs stbtt_packedchar and stbtt_aligned_quad structs

// taken from stb_truetype.h
typedef struct
{
   unsigned short x0,y0,x1,y1; // coordinates of bbox in bitmap
   float xoff,yoff,xadvance;
   float xoff2,yoff2;
} stbtt_packedchar;

typedef struct
{
   float x0,y0,s0,t0; // top-left
   float x1,y1,s1,t1; // bottom-right
} stbtt_aligned_quad;
Enter fullscreen mode Exit fullscreen mode

The required fields are x0, x1, y0, y1, xoff, yoff and xadvance from stbtt_packedchar and s0, t0, s1 and t1 from stbtt_aligned_quad.

  • (x0, y0) and (x1, y1) are the pixel coordinates of the top left and bottom right of the bounding box of a glyph in the font atlas bitmap(the one which we created earlier)
  • (xoff, yoff) are the pixel offsets from the origin to top left of the bounding box of a glyph
  • xadvance is the pixel offset to the next character's origin
  • (s0, t0) and (s1, t1) are the texture coordinates of top left and bottom right of the bounding box of a glyph

The above fields, can be visually shown by the following image:

glyph metrics

In the above image, x1 - x0 and y1 - y0 is the horizontal pixel distance and vertical distance of the bounding box. One important thing to note down is the sign of xoffset and yoffset. Since the all the data is in pixel space and xoffset and yoffset being the distance from the top left of the bounding box to the origin, yoffset will be negative for most of the characters.

The xadvance field is visually described by the following image:

X advance visualization

Using the above data, few calculations are required to calculate the six vertices of a quad.

Calculating vertices

Assuming the projection of the rendering surface is orthographic and setup in a way that the top of the rendering surface is 1.0f, bottom is -1.0f left of the rendering surface is -aspectRatio and the right of the rendering surface is aspectRatio where aspectRatio is the aspect ratio of the rendering surface, The size of 1 pixel is given by:

// surfaceHeight is the height of the rendering surface.
float pixelSize = 2.0 / surfaceHeight;
Enter fullscreen mode Exit fullscreen mode

Therefore, the pixel distances can be converted into units of our assumed projection be simply multiplying the pixel distances to pixelSize.

Now, we need to calculate the position of the 6 vertices for each character in text.

void DrawText(const std::string& text, glm::vec3 position, glm::vec4 color, float size)
{
    for(char ch : text)
    {
        // Calculate the positions of vertices
        // pack the vertices, color and texture coordinates of vertices
    }
}
Enter fullscreen mode Exit fullscreen mode

Firstly, we need to retrieve the stbtt_aligned_quad and stbtt_packedchar data of ch

// Retrive the data that is used to render a glyph of charecter 'ch'
stbtt_packedchar* packedChar = &packedChars[ch - codePointOfFirstChar]; 
stbtt_aligned_quad* alignedQuad = &alignedQuads[ch - codePointOfFirstChar];
Enter fullscreen mode Exit fullscreen mode

Then, the size of the bounding box is calculated by

// The units of the fields of the above structs are in pixels, 
// convert them to a unit of what we want be multilplying to pixelScale  
glm::vec2 glyphSize = 
{
    (packedChar->x1 - packedChar->x0) * pixelScale * size,
    (packedChar->y1 - packedChar->y0) * pixelScale * size
};
Enter fullscreen mode Exit fullscreen mode

The coordinates of the bottom left corner of the bounding box is calculated by:

glm::vec2 glyphBoundingBoxBottomLeft = 
{
    position.x + (packedChar->xoff * pixelScale * size),
    position.y - (packedChar->yoff + packedChar->y1 - packedChar->y0) * pixelScale * size
};
Enter fullscreen mode Exit fullscreen mode

Using the above two, the positions of the vertices can be calculated by:

// The order of vertices of a quad goes top-right, top-left, bottom-left, bottom-right
glm::vec2 glyphVertices[4] = 
{
    { glyphBoundingBoxBottomLeft.x + glyphSize.x, glyphBoundingBoxBottomLeft.y + glyphSize.y },
    { glyphBoundingBoxBottomLeft.x, glyphBoundingBoxBottomLeft.y + glyphSize.y },
    { glyphBoundingBoxBottomLeft.x, glyphBoundingBoxBottomLeft.y },
    { glyphBoundingBoxBottomLeft.x + glyphSize.x, glyphBoundingBoxBottomLeft.y }
};
Enter fullscreen mode Exit fullscreen mode

Note that the order of vertices of quad are top right, top left, bottom left and bottom right.

the texture coordinates of the quad are given by:

glm::vec2 glyphTextureCoords[4] = 
{
    { alignedQuad->s1, alignedQuad->t0 },
    { alignedQuad->s0, alignedQuad->t0 },
    { alignedQuad->s0, alignedQuad->t1 },
    { alignedQuad->s1, alignedQuad->t1 },
};
Enter fullscreen mode Exit fullscreen mode

Using the above data, vertices can be inserted by:

 // We need to fill the vertex buffer by 6 vertices to render a quad as we are rendering a quad as 2 triangles
 // The order used is in the 'order' array
 // order = [0, 1, 2, 0, 2, 3] is meant to represent 2 triangles: 
 // one by glyphVertices[0], glyphVertices[1], glyphVertices[2] and one by glyphVertices[0], glyphVertices[2], glyphVertices[3]
 for(int i = 0; i < 6; i++)
 {
     localState.vertices[vertexIndex + i].position = glm::vec3(glyphVertices[order[i]], position.z);
     localState.vertices[vertexIndex + i].color = color;
     localState.vertices[vertexIndex + i].texCoord = glyphTextureCoords[order[i]];
 }
Enter fullscreen mode Exit fullscreen mode

Note that array order is used to insert the vertices in the right order as 2 triangles for each character ch

Finally, the position's x coordinate needs to be incremented by xadvance

// Update the position to render the next glyph specified by packedChar->xadvance.
position.x += packedChar->xadvance * pixelScale * size;
Enter fullscreen mode Exit fullscreen mode

Conclusion

In conclusion, rendering text in OpenGL can be complex, but the stb_truetype.h library makes it much easier by simplifying the process of working with TrueType fonts. This guide covers the essential steps: loading font files, creating texture atlases, and drawing text on the screen. With this approach, you can add high-quality, stylish text to your OpenGL projects, whether for a game or an application. If you are stuck in between, refer to the following demo.

Screenshot of the demo

Screenshot

Dealing with \n(New Line) Character.

After writing the article, I totally forgot about how to deal with newlines. GitHub user japajoe mentioned about this in the GitHub issue.

Dealing with newline character is pretty straightforward, just reset the x coordinate of position to its original position and subtract some value from the y coordinate of position.

Firstly, we need to create a new variable called localPosition, assigning localPosition to position and using localPosition for all other calculations. This way the original position is unaffected.

Secondly, if a newline character occurs in the text, the following calculations need to be done:

// Handle newlines seperately.
if(ch == '\n')
{
    // advance y by fontSize, reset x-coordinate
    localPosition.y -= fontSize * pixelScale * size;
    localPosition.x = position.x;
}
Enter fullscreen mode Exit fullscreen mode

As the above code states, the y coordinate of localPosition need to be subtracted by fontSize(By scaling it to our coordinate system).

User japajoe also brought up Signed Distance Fields (SDF) for font rendering, which I plan to explore in detail in an upcoming article, as this current piece is already quite large. Stay tuned for more insights on SDF and its applications in font rendering.

Top comments (0)