Rendering Text (with as little effort as possible)
10 July, 2025
Introduction
I wrote a post about a technique for rendering text (Rendering Text - SDF) in the before-times, and while it’s still super interesting and well worth a look, it’s definitely on the more complex side. This time around I wanted something that was simple to understand, implement and use.
Initially I just rendered the string to a canvas with the Canvas API and used that as a texture to draw the string on the screen. It was quick and relatively simple to implement but lacks the flexibility I was looking for with regards to string styling and animation. Instead I looked for a way to maintain the simplicity of using the canvas’ text rendering capabilities but render the string using individual quads for each glyph.
Preparing Font
The CSS Font Loading API makes it easy to load a font face and add it to the DOM for use with a canvas.
let font = new FontFace(family, src);
try {
await font.load();
document.fonts.add(font);
} catch (e) {
console.error(e);
}
// generate font
document.fonts.delete(font);
Rendering Glyphs
The Canvas API allows us to create an OffscreenCanvas of our desired texture dimensions, and with that a CanvasRenderingContext2D via the ‘getContext’ method. After setting the font and fillStyle of that context to our previously loaded font, we can use it (‘fillText’ method) — and a simple packing algorithm — to render our individual glyphs; the canvas can then be used as an image source for a SolastJS Texture.
In practice we have an array of canvases, and for any glyph that doesn’t fit on any of the current canvases we create another. Then we create a 2D texture array using our canvas array.
For the packing algorithm I went with a really simple ‘shelf-based’ approach:
A shelf has a maximum height and a remaining width. Each shelf also has a slight tolerance that allows a glyph with a smaller height to sit on it, but not larger.
If a shelf exists that the current glyph fits on (in both height and width) then it is placed there, otherwise it is placed above the existing shelves on a new one. If no space exists above then a new canvas layer is created.
Glyph Metrics
Using an OffscreenCanvas we can get metrics for our glyphs. To do so we only need a temporary 1 by 1 canvas and the ‘measureText’ method (we could also use one of our existing render canvases). After ensuring the font size and family is properly set, we can measure a single character and use the actual left/right and actual top/bottom values to get the visual bounds of the glyph (and from this calculate the width and height); this also includes other metrics such as drop and advance values.
We can also use this method to get kerning data for our glyphs. Instead of measuring a single character we measure two characters together and subtract the sum of their widths from the combined width. The leftover is the kerning value, positive or negative.
A single glyph has to be kerned with itself. Adding in a second glyph requires kerning with itself and with the existing glyph, in both directions (‘AB’ and ‘BA’).
1 glyph = 1 kern = 1 (AA)
2 glyphs = +3 kerns = 4 (BB, AB, BA)
3 glyphs = +5 kerns = 9 (CC, AC, BC, CA, CB)
4 glyphs = +7 kerns = 16 (DD, AD, BD, CD, DA, DB, DC)
Conclusion
Using Canvas API offloads a lot of the busy work to its font and text engine. One useful artefact of this process is the ability to easily mix glyphs from various fonts to essentially construct a new one, though kerning must be carefully considered.
As for improvements, the packing algorithm is not the most efficient (for example, order of insertion matters and glyphs could be packed more tightly by pre-sorting by height) but any improvements come at the cost of complexity (in implementation).
The kerning map can also get quite large. One way to reduce its size would be to keep track of the most popular kerning gap, set that as a default and remove any matching entry. Then, anytime a kerning search returns undefined, we can just use the default gap value instead.