Rendering text is always a bit of a challenge, and depends on which libraries I have available on the platform I’m
working with. For simple projects I tend to import SDL_TTF
or rusttype
which allow me to bundle a ttf file with the
game, load the font at runtime, and render text as needed. These are heavy weight dependencies, so I’ve done a bit of
digging to see what the alternatives are for web games.
This post presents two ways of rendering text. The first leverages the browser’s ability to load fonts and render text to provide a simple but slow way to render text. The second method involves using a tool to preprocess the font in to a format that is easily rendered with minimal runtime/dependency complexity. My main diving in point for this investigation has been this article on css-tricks.
Simple ’n’ Slow - Canvas
The first way I played with is using a canvas to draw the text that I want to display to an image, using that image as a texture, and rendering that texture to a quad. This lets me lean on the browser to load the font file directly and lay out the text. This builds on the rendering sprite example from the basic 2d rendering post.
To load the font I just create a FontFace
object and add
it to the document’s FontFaceSet
:
async function createTextTexture() {
let font = new FontFace('Roboto', 'url(Roboto-Regular.ttf)', { weight: 500 });
await font.load();
document.fonts.add(font);
/// ...
}
This specifies the name of the font face, the url to load the font from, and waits for the font to finish loading before adding it to the document.
Now that I can rely on the font being available for use I make my target canvas and use the font to draw my text:
async function createTextTexture() {
// .. Load font as above ..
let textCanvas = document.createElement('canvas');
textCanvas.width = 200;
textCanvas.height = 100;
let textCtx = textCanvas.getContext('2d');
textCtx.font = '50px Roboto';
const txt = 'Test';
let textBounds = textCtx.measureText(txt);
textCtx.fillStyle = 'rgba(0, 0, 0, 0)';
textCtx.fillRect(0, 0, 200, 100);
textCtx.fillStyle = 'rgba(255, 255, 255, 1)';
textCtx.fillText(txt, 100 - (textBounds.width / 2), 50 + (textBounds.actualBoundingBoxAscent / 2));
// ...
}
Using canvas with a
CanvasRenderingContext2D
gives me access
to the fillText
method which will draw the string supplied in the current font to the canvas. So I make canvas with
the desired dimensions of my resulting image, and instead of calling getContext('webgl2')
like I normally do I request
a '2d'
context. The measureText
method calculates the bounds of the specific string I want to draw which allows me
to center the text in the image.
And finally I load the canvas in to a texture in my WebGL2 context:
async function createTextTexture() {
// .. Load font ..
// .. Draw text to second canvas ..
texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, textCanvas);
gl.bindTexture(gl.TEXTURE_2D, null);
}
This is pretty much identical to the sprite rendering example mentioned above, except instead of passing an image to
texImage2D
, I pass the canvas element. The rendering of the text is then largely unchanged from the sprite rendering
demo, with the exception that I enable alpha blending:
function draw() {
// ...
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
// ...
}
This approach is very straight forward and lets me use ttf files directly. The big downside is that this is quite an inefficient process. Every time the text changes a new texture need to be built. This might be a usable approach if I wanted to make some static labels at loading time, but for dynamically changing strings I would lean towards the following approach.
Fast ’n’ Fancy - MSDF Fonts
In order to avoid uploading a texture for each label I want to display, I want to use a single texture containing a font atlas. In the atlas each character I may want to display will be stored in a known location on the texture, allowing each character to be rendered to its own quad. I will still need to update a vertex buffer whenever a label changes, but it should be significantly faster than the above canvas based approach.
There are several types of font atlas I could use:
- The simplest, a bitmap font, stores each glyph exactly as it will be rendered. This is quick and easy as long as I don’t need to scale the font. If I do there will either blurriness or jaggies. This is the approach I may use for a pixel art game.
- SDF (Signed-Distance Fields) fonts store a distance to the edge of the font in a grayscale image for each glyph. This can be rendered using an appropriate shader. This has the upside of scaling far better which means the atlas can be smaller than an equivalent bitmap font. It also allows effects like outlining. The downside is that they can lose sharp corners, rounding them off.
- MSDF (Multi-Channel Signed Distance Fields) fonts are similar to SDF fonts, except that by storing more information across multiple channels sharp corners are preseved, without losing the other benefits of SDF.
In this example I’m using an MSDF font, since this is what I’ll probably end up using in practice unless I’m doing a pixel art game (and possibly even then).
First up I need to generate an MSDF font that I can then use to draw text. The project
msdf-atlas-gen
provides a command line tool which will consume a ttf
file and emit the atlas image and metadata in a variety of formats. In my case, I invoked it using:
msdf-atlas-gen -font Roboto-Regular.ttf -json Roboto.json -imageout Roboto.png
This uses the default ascii charset, builds an atlas which is saved to a png, and saves the metadata describing the contents of the atlas to a json file. There are a wide range of other parameters available, but I didn’t need to dig that far in to the tool to get this working.
Loading the atlas is very similar to loading a sprite, except that I also load the metadata as well:
async function loadFontAtlas() {
let atlasResponse = await fetch('Roboto.png');
let atlasBlob = await atlasResponse.blob();
atlas = await createImageBitmap(atlasBlob, { imageOrientation: 'flipY' });
let metaResponse = await fetch('Roboto.json');
atlasMetadata = await metaResponse.json();
texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, atlas);
gl.bindTexture(gl.TEXTURE_2D, null);
}
The reason I had msdf-atlas-gen
output the metadata as json is for the convenience of the above code, which is able
to directly parse the fetch
response as json.
Unlike rendering a sprite, or the previous example where the text to be presented is stored in a texture as it’ll be shown, I have to build the vertex data to create a quad for each character, with texture coordinates pointing each quad at the right part of the texture. This isn’t particularly interesting, so here is an abridged version of this process:
async function generateTextGeom() {
// .. Decls ..
let currentX = 0;
let currentY = fontHeight;
for (let txtChar of txt) {
for (let glyph of atlasMetadata.glyphs) {
if (glyph.unicode == txtChar.charCodeAt(0)) {
let uvTop = glyph.atlasBounds.top / atlas.height;
let uvBottom = glyph.atlasBounds.bottom / atlas.height;
let uvLeft = glyph.atlasBounds.left / atlas.width;
let uvRight = glyph.atlasBounds.right / atlas.width;
let left = currentX + fontHeight * glyph.planeBounds.left;
let right = currentX + fontHeight * glyph.planeBounds.right;
let top = currentY - fontHeight * glyph.planeBounds.top;
let bottom = currentY - fontHeight * glyph.planeBounds.bottom;
// .. Add vertices to arrays ..
textVertCount += 6;
currentX += fontHeight * glyph.advance;
break;
}
}
}
vertBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vertBuffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(textVerts), gl.STATIC_DRAW);
gl.bindBuffer(gl.ARRAY_BUFFER, null);
uvBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, uvBuffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(textUVs), gl.STATIC_DRAW);
gl.bindBuffer(gl.ARRAY_BUFFER, null);
}
It’s worth noting that this is deliberately basic. There is a world of complexity when it comes to text layout that is well outside the scope of one short rendering article like this. If I end up supporting languages like Arabic at some point in the future these functions will gain a lot of complexity. It might be interesting to do an article on updating a simple layout engine like the above to support Arabic, perhaps in the future.
Anyway, I now have the texture loaded and the geometry ready for rendering. I just need to update the fragment shader to properly convert the MSDF data in the atlas texture in to the text I want to render. The shader here is derived from a couple of sources, though mostly the css-tricks article I mentioned in the intro.
#version 300 es
precision highp float;
in vec2 vTexCoord;
layout(location = 0) out vec4 outColor;
uniform sampler2D uTexSampler;
float median(float r, float g, float b) {
return max(min(r, g), min(max(r, g), b));
}
void main() {
vec3 texColor = texture(uTexSampler, vTexCoord).rgb;
float dist = median(texColor.r, texColor.g, texColor.b);
float w = fwidth(dist);
float alpha = smoothstep(0.5 - w, 0.5 + w, dist);
outColor = vec4(1.0, 1.0, 1.0, alpha);
}
The logic here is fairly straightforward, though the math as to how the MSDFs work is a bit more complicated
(paper). I grab the value of whichever rgb component
isn’t the largest or the smallest. Then using fwidth
and smoothstep
I get a nice value that’s 0 or 1 in most
positions, or a smooth quick transition at the edges, and I use that value as the alpha for my white text.
This second approach hasn’t been too tightly tied to web tech specifically, unlike the previous example. The reason I’ve gone through it, is that while I’m sure I could have found a Javascript or wasm library to parse ttf files, I am concerned about total download size of code assets. The more code I need to download to bootstrap the engine and start displaying content the more likely I am to lose players. MSDF fonts let me move the complexity of font generation to a build step, and the layout and rendering code is extremely light.
This is the approach I’m most likely to use in any game that requires dynamic text. If not MSDF itself, at least bitmap fonts which would share the same layout logic.