WebGL text using a Canvas Texture
Text and numbers are something that is very useful to render, it's very often both the easiest and best way of conveying information. WebGL does not have any in-build text rendering functions, based as it is on the OpenGL ES 2.0 specification. There are two straight-forward options available to us when we wish to render text, using HTML and overlaying it on top of the canvas or rendering the text as a texture within WebGL (it's also possible to render text usings shaders too).
If you're making a UI / menu system, it would be fairly crazy not use the HTML method as coupled with CSS that's one of it's main strengths. However if you want to have the text in the game world, a HUD or an interface that isn't going to take the user's full attention you will probably want to render the text in WebGL.
Note: If you try overlaying a HUD written in HTML you will probably encounter issues when trying to click and drag on the canvas element, you will get the text cursor even if you've overridden it as described in my controlling the cursor article.
For the purposes of this article I will assume you are familiar with the basics of WebGL, up to and including creating textures from images. If you are not familiar with the basics required to render textured 3D shapes WebGL Fundamentals is a great resource.
There are three main, but fairly small, hurdles to rendering your text as a texture.
- Drawing text with the canvas element
- Sizing the canvas/texture appropriately
- Binding the canvas to a WebGL texture
I'll go through each of these and then at the bottom will have a texture generator and a WebGL display of this texture.
Drawing text using the canvas element
There are plenty of tutorials on how to render text using the canvas element, and you can figure out most of it from the MDN's drawing text using a canvas reference page. I'm going to repeat the basics of what we need here for completeness, first we need a canvas element and to get it's 2D context.
<canvas id="textureCanvas"></canvas>
<script>
var canvas document.getElementById('textureCanvas');
var ctx = canvas.getContext('2d');
// More code to go here!
</script>
When it comes time to combine this with WebGL this should be a separate canvas to the one we are using for WebGL as it will be using a different context, but don't worry you can "display: none;" the canvas you are using for the texture with CSS and the method will still work.
Next we need to set up the properties of the text we wish to render, these include colour, size, font, alignment and vertical alignment, see the MDN reference for a full list of options and their descriptions, we'll stick the simple ones for now.
var canvas document.getElementById('textureCanvas');
var ctx = canvas.getContext('2d');
ctx.fillStyle = "#333333"; // This determines the text colour, it can take a hex value or rgba value (e.g. rgba(255,0,0,0.5))
ctx.textAlign = "center"; // This determines the alignment of text, e.g. left, center, right
ctx.textBaseline = "middle"; // This determines the baseline of the text, e.g. top, middle, bottom
ctx.font = "12px monospace"; // This determines the size of the text and the font family used
Finally we render our text to the canvas, we provide an x and y coordinate (this is in pixels relative to the top-left of the canvas) at which to render the text. Where the text then appears relative to these coordinates is determined by the textAlign
and textBaseline
we specified. Baseline works as you expect and the baseline as specified is rendered at the y-coordinate. However textAlign
is slightly more complex:
- "left" or equivalent will cause the text to appear to the right of the specified x-coordinate
- "center" will cause the text to appear centered on the specified x-coordinate
- "right" or equivalent will cause the text to appear to the left of the specified x-coordinate
So for left aligned text we would probably specify 0 as the x-coordinate, and the full canvas width as the x-coordinate for right aligned text. As we have chosen center aligned we will specify the middle of the canvas.
ctx.fillText("HTML5 Rocks!", canvas.width/2, canvas.height/2);
You can also give the text an outline using ctx.strokeStyle
, ctx.strokeText
, but we are not going to cover that for our texture generator.
Sizing the canvas and multi-line text
We need to ensure that the canvas is larger than the text we are rendering and for some implementations of WebGL textures are required to be a power of two. We'll start by automatically working out the size we need the for text rendered as a single line, then we will look at specifying a maximum width and spliting the text into multiple lines to be rendered. To do this we'll use the measureText
function, this returns an object who's width property is the width in pixels of the text specified if drawn with the current settings. We also need a function to get the first power of 2 greater than or equal to this value.
function getPowerOfTwo(value, pow) {
var pow = pow || 1;
while(pow<value) {
pow *= 2;
}
return pow;
}
var canvas = document.getElementById('textureCanvas');
var ctx = canvas.getContext('2d');
var textToWrite = "HTML5 Rocks!";
var textSize = 12;
ctx.font = textSize+"px monospace"; // Set the font of the text before measuring the width!
var canvas.width = getPowerOfTwo(ctx.measureText(textToWrite).width);
var canvas.height = getPowerOfTwo(2*textSize);
// Omitted: Set all properties of the text again (including font)
// Omitted: Draw the text
I've omitted the code for setting the properties and drawing the text as it won't have really changed. As well as setting the width of the canvas to the first power of two greater than the width of the text, we've also set the height to the first power of two greater than twice the height of the text we'll be drawing (to allow space for larger characters and to ensure a bit of padding).
Note that you must set the font property of the context a second time after measuring the text or you'll get the default font and size rendering our calculation inaccurate!
Now for the possiblity of splitting up long text into multiple lines. We'll do this by specifying a maximum width, and breaking the text into an array of words and compare the specified width to text constructed from a decreasing number of words from that array of words, then from that we can build up an array of text lines which are smaller than the maximum width. The function below does this, the array to write the lines of text to is passed as an argument so that the maximum width of the calculated lines can be returned, it also replaces any newline characters with a space, this is because the drawText
function ignores them and we are using the position of spaces to create our array of words.
function createMultilineText(ctx, textToWrite, maxWidth, text) {
textToWrite = textToWrite.replace("\n"," ");
var currentText = textToWrite;
var futureText;
var subWidth = 0;
var maxLineWidth = 0;
var wordArray = textToWrite.split(" ");
var wordsInCurrent, wordArrayLength;
wordsInCurrent = wordArrayLength = wordArray.length;
// Reduce currentText until it is less than maxWidth or is a single word
// futureText var keeps track of text not yet written to a text line
while (measureText(ctx, currentText) > maxWidth && wordsInCurrent > 1) {
wordsInCurrent--;
var linebreak = false;
currentText = futureText = "";
for(var i = 0; i < wordArrayLength; i++) {
if (i < wordsInCurrent) {
currentText += wordArray[i];
if (i+1 < wordsInCurrent) { currentText += " "; }
}
else {
futureText += wordArray[i];
if(i+1 < wordArrayLength) { futureText += " "; }
}
}
}
text.push(currentText); // Write this line of text to the array
maxLineWidth = measureText(ctx, currentText);
// If there is any text left to be written call the function again
if(futureText) {
subWidth = createMultilineText(ctx, futureText, maxWidth, text);
if (subWidth > maxLineWidth) {
maxLineWidth = subWidth;
}
}
// Return the maximum line width
return maxLineWidth;
}
The eagle eyed amoung you will have noticed that the maximum width can be greater than the maximum width specified, this will happen if a single word is greater than the maximum width (e.g. supercalifragilistic...), if you want to make sure that the maximum width specified truly is you'll have to further split words into characters. For now we'll just feed the maximum line width returned by the function into the calculation of the required canvas width.
Now we'll loop over the array to draw our text, splitting the text does complicate choosing the y-coordinate to draw each line at slightly, but it's just some relatively straight forward maths.
// Omitted: helper functions
var text = [];
var textX, textY;
var textToWrite = "HTML5 Rocks! HTML5 Rocks! HTML5 Rocks!";
var textHeight = 32;
var maxWidth = 128;
// Omitted: Set up the canvas and get the context
ctx.font = textHeight + "px monospace";
maxWidth = createMultilineText(ctx, textToWrite, maxWidth, text);
canvasX = nextPowerOfTwo(maxWidth);
canvasY = nextPowerOfTwo(textHeight * (text.length + 1));
// Omitted: Set canvas width / height
// Omitted: Set text properties
textX = canvasX / 2;
var offset = (canvasY - textHeight*(text.length+1)) * 0.5;
for(var i = 0; i < text.length; i++) {
textY = (i + 1) * textHeight + offset;
ctx.fillText(text[i], textX, textY);
}
Creating the WebGL texture
After all that creating the WebGL texture is relatively straight forward! When loading the texture one must simply pass the DOM object of the canvas where before one would pass the DOM object for the image. Then bind the created texture before rendering our object as normal using gl.bindTexture(gl.TEXTURE_2D, canvasTexture);
where gl is the WebGL context.
gl = canvas.getContext("webgl");
var canvasTexture;
function initTexture() {
canvasTexture = gl.createTexture();
handleLoadedTexture(canvasTexture, document.getElementById('textureCanvas'));
}
function handleLoadedTexture(texture, textureCanvas) {
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, textureCanvas); // This is the important line!
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_NEAREST);
gl.generateMipmap(gl.TEXTURE_2D);
gl.bindTexture(gl.TEXTURE_2D, null);
}
I have combined the above techniques with a quick web form to generate canvas textures and then display them on a cube in WebGL. There is also an option to enforce a square texture (as we're placing it on a cube). You can play around with it below, all the JavaScript is inline so if you view source you can see how it works, it still needs a bit of work to be able to be used in full project but it's a reasonable proof of concept.
I hope this tutorial was helpful! If you have any comments or feedback you can contact me via delphic.bsky.social.