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
}
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
}
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()
...
}
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)
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";
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];
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;
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];
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')
);
}
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);
The font atlas bitmap will look like this:
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);
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;
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;
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);
}
}
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...
}
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();
}
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.
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;
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:
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:
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;
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
}
}
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];
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
};
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
};
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 }
};
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 },
};
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]];
}
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;
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
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;
}
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)