Day 21: Fonts and Text – Part 1

Text is one of the key elements of any game and you can hardly find any game without it. Be it menu labels, instructions, or score, there are almost always pieces of text. Unfortunately graphics engines like OpenGL do not include text manipulation in their core API. Today start creating our own text rendering system to overcome this problem. This discussion is so long that I have no choice but to break this into parts. Today we only create a simple font. Next day we will discuss how we can use this font to draw text.

Background

There are two main approaches to displaying text in a 3D environment.

Outline fonts

In this method, an actual TrueType or another outline font format is used to create text. Glyphs (characters) in the font are actually converted into a series of polygons, and then rendered just like any other 3D object. This method has some advantages, namely that you can manipulate characters and transform them like any polygon, and you can create 3-dimensional embossed version of the text. However, if the characters are very detailed, your polygons can be large and rendering can take longer (unless you give up the details to some extent).

Outline Font
picture attributed to this article

Texture-mapped text

Using this method, glyphs are rendered onto a bitmap, which is later used as a texture to represent text. We talked about texture atlases in day 4. Now imagine an atlas that contains all possible characters of a specific font. Once we load such a texture, we can draw a full text by cutting pieces out of it and drawing them on simple rectangles. This is the basic idea behind this approach.

Design

Our game engine uses the second method above. Fonts in Artenus are specialized textures that take in texture-mapped font information and use it to draw text. Moreover, we use SVG format for fonts. One reason is that SVG can be resized to match any screen density, as we mentioned previously in this tutorial. But the main reason behind this choice is that we need other information for a font, namely the bounding box for each character, and if we use a text format like SVG, we can save this information in simple-text within this file, rather than using a customized binary format that can only be modified using an external application. Remember, we always keep it simple in this tutorial! So, a font texture would look somehow like this:

Font Texture Example

We then want a kind of mark up that identifies individual characters out of this map. For simplicity, we assume that character heights are fixed, and each line of text in a font texture has the same height as any other. It is easy to observe that the above texture satisfies this requirement. A markup can then represent the following cutout:

Marked Font Texture

Looking at the above figure, it is easy to assume such a markup. First of all, the height is fixed. So, we just need to mark x values. In order to account for line breaks, we use a simple technique. Whenever the current x value is less than that of the previous character, we assume that it is meant to be on the next line. Because normally, x values are progressive. For more flexibility, we not only specify x values for character borders (as is done in the figure above), but left and right x value for each individual letter. Now the question is where to place this markup, which brings us back to why we chose SVG in the first place. As SVG is an XML-based format, it can have XML comments! And that is exactly where we can place whatever information we want. You can choose your own markup rules, but here we use the following:

<!--
ARTENUS_FONT {font-height},{h-spacing},{v-spacing}
@{char1} {left-x}, {right-x}
@{char2} {left-x}, {right-x}
...
-->

The above format gives us useful information. First of all we have a starting point at the line with ARTENUS_FONT. Even the simplest parser that uses indexOf can find this line in the XML text. This line gives us general font information: font height (height of the lines in the texture), character horizontal spacing, and character vertical spacing. After this line, whatever comes is character information, until the end of the comment block (which is the line that has -->). Below is a simple example of using this format.

<!--
ARTENUS_FONT 25,-6,0
@A 0,  20
@B 23, 35
@C 35, 49
@D 2, 22
-->

Horizontal spacing is set to -6 in this font, packing characters together to take care of the inevitable character separation in a texture. This font has only three letters, three of which are on the first line of the texture, and the fourth goes to the second line (please notice that 2 < 49, so our parser assumes a line break). This comment block can appear anywhere within the SVG XML, and we can use simple regular expressions to parse it.

Implementation

Preliminaries

Now that everything is determined, let’s begin implementing. Before first let’s organize our existing artenus module a tiny bit. Create a package called graphics under your main library package, and move Texture and TextureManager classes to the new package. This makes it easier to understand which classes work closely together. Now, add a class next to Texture and call it Font. The original code for that will look like this:

public final class Font extends Texture {
	Font(int resourceId) {
		super(resourceId);
	}
}

Up to now, this class is only as good as texture. Next, we add the information we need for a font. Add the following private fields to the class:

// Collection of x-values - two per character
private float[] offsets;

// Horizontal and vertical spacing
private int hspacing = -10, vspacing = 0;

// Character height
private float charH;

// First character
private char firstChar;

The first four fields are what we discussed before. The only tricky bit here is firstChar. This field is used to identify the very first character (the one with the smallest integral code) that appears in the markup, in order to limit memory allocation.

We allocate offsets to cover the whole range of characters from the smallest to the biggest character in the markup. This has an obvious downside. If a font only has two characters, and those two characters are the first and last ones in UTF-8. The list will be as big as 220000 elements (0.8 MB). That’s a lot of memory to allocate for only two characters.

To avoid the above problem, we can use a hash-map. But hash-maps have a more critical downside. While an element in a hash-map can be accessed in constant time, this is not guaranteed. In fact, it might take O(n) time (that is, the time required to probe all n elements) in the worst case. And realistically, the previous problem never happens at this extreme. We might still lose some unnecessary space, but we are prepared to sacrifice some memory for speed, and arrays are guaranteed to have constant access time.

Building the font

We parse the markup and build the font in the load method. This method has been previously declared as final in Texture. So, remove the final modifier there, and override the method in Font:

@Override
public void load(Context context) {
	// TODO: Parse the font
	super.load(context);
}

We use a hash-map to collect font information. The skeletal structure of the parser looks like this:

HashMap<Character, Pair<Float, Float>> map = new HashMap<>();

// Boolean indicating whether the resource represents a valid font
boolean isFont = false;

// Smallest and biggest characters in the markup
char first = Character.MAX_VALUE, last = Character.MIN_VALUE;
		
// Open SVG resource file for reading
Resources res = context.getResources();
BufferedReader reader =
		new BufferedReader(new InputStreamReader(res.openRawResource(resourceId)));

try {
	String line;

	// Read the XML line by line, until we find ARTENUS_FONT
	while ((line = reader.readLine()) != null) {
		int index = line.indexOf("ARTENUS_FONT");

		if (index >= 0) {
			// TODO: Font information found! 
			break;
		}
	}
} catch (IOException ex) {
	isFont = false;
}

try {
	reader.close();
}
catch (IOException ex) {
	// Do nothing
}

if (!isFont)
	throw new IllegalStateException("Error reading font");

// TODO: Create offsets

The code is self-explanatory, and I have added comments to describe parts that are less clear. Once the line ARTENUS_FONT is found (index > 0), we need to extract three numbers from it: character height, hspacing, and vspacing:

// Split the string with commas (ignoring leading and following spaces)
String[] params = line.substring(index + 12).trim().split("\\s*,\\s*");

// It seems like a valid font at this point!
isFont = true;

charH = Integer.parseInt(params[0]);

if (params.length > 1) {
	int hs = Integer.parseInt(params[1]), vs = 0;

	if (params.length > 2)
		vs = Integer.parseInt(params[2]);

	horSpacing = hs;
	verSpacing = vs;
}

// TODO: Read character information
// We are done here, don't read more lines.
break;

This code assumes that hspacing and vspacing are optional. If they are not present, it simply keeps the default values (-10, 0). Once this is done, we will start parsing characters:

while ((line = reader.readLine()) != null) {
	line = line.trim();

	// A line that defines a {char} must start with @
	if (!line.startsWith("@"))
		continue;

	String[] coords = line.substring(2).trim().split("\\s*,\\s*");

	if (coords.length > 1) {
		// The second character is the desired {char}
		char c = line.charAt(1);

		// Update biggest character
		if(c > last)
			last = c;

		// Update smallest character
		if(c < first)
			first = c;

		// Add left-x and right-x values to the hash-map
		map.put(c, new Pair<>(
			Float.parseFloat(coords[0]), Float.parseFloat(coords[1])
		));
	}
}

We now have all the information we need, and we can fill in our private fields (the code below replaces // TODO: Create offsets):

// Create the array twice as big as the total number of characters in the range
offsets = new float[(last - first + 1) << 1];

for(char c = first; c <= last; c++) {
	Pair<Float, Float> result = map.get(c);

	if(result != null) {
		int index = (c - first) << 1;
		// Store left-x
		offsets[index] = result.first;
		// Store right-x
		offsets[index + 1] = result.second;
	}
}

firstChar = first;

Building texture coordinates

We now have a structure that defines a font. But that is not yet enough to draw anything. As you might remember, it all comes to triangles and textures when it comes to OpenGL ES. So, the next step is to prepare texture coordinates based on loaded offset x values. Add the following method and private field to your class:

/**
 * Builds the required OpenGL texture buffers for the characters.
 */
final void buildTextureMapping() {
	// Start at the first line
	float y1 = 0, y2 = charH;

	// Store width and height of the texture locally
	final float sw = width, sh = height;

	...
}

private FloatBuffer[] textureCoords;

We have already discussed texture mapping, and we did it for image sprites in day 12. Having done that, there is only a slight difference here, and that is, instead of columns and rows in a cutout, we now have x values and character height. We continue the method as follows.

// We need as many buffers as we have characters
textureCoords = new FloatBuffer[offsets.length / 2];

for (int index = 0; index < textureCoords.length; index++) {
	// Calculate left-x (x1) and right-x (x2), relative to texture width
	final float x1 = offsets[index * 2] / sw;
	final float x2 = (offsets[index * 2 + 1] - 1f) / sw;

	if (index > 0) {
		if (offsets[index * 2] - (offsets[(index - 1) * 2]) < 0) {
			y1 += charH;
			y2 += charH;
		}
	}

	final float texture[] = {
			x1, y1 / sh,
			x2, y1 / sh,
			x1, y2 / sh,
			x2, y2 / sh,
	};

	final ByteBuffer ibb = ByteBuffer.allocateDirect(texture.length * 4);
	ibb.order(ByteOrder.nativeOrder());
	textureCoords[index] = ibb.asFloatBuffer();
	textureCoords[index].put(texture);
	textureCoords[index].position(0);
}

For the first line, remember that each character has two elements in the offsets array, corresponding to its left-x and right-x. So, we must divide the length of that array by two to get the number of characters. For each character index, index * 2 points to the left-x value, and the following element is the right-x. If you have followed the tutorial so far, the rest of the code should be easy to understand. If not, days 7, 8, and 12 should give you sufficient background.

Next steps

Today we created a format for fonts in our game engine. We managed to read this format and create a structure that can later be used to draw text. We also created texture coordinates for this structure. The only thing left is to draw characters. Next day we will discuss how we do that. We will introduce a new kind of sprite, called TextSprite, which will be used to add text to scenes. You may download today's code here: