Design Patterns and Video Games

OpenGL 2D Facade (15): Text styles

In this post, I present text rendering with different styles (bold, italic, underline, ...). Some of them are straightforward to implement thanks to Pygame; the others are more tricky!

This post is part of the OpenGL 2D Facade series

Objective

We wish to add the following text styles to the facade:

For each style, we compute a dedicated tileset. For instance, for the outline case, we create tiles with outlined characters. Then, during the OpenGL rendering, the process is as before.

Style codes

In the TextLayer class, we define constants for each text style:

class TextLayer(Layer):

    PLAIN = 0
    BOLD = 1
    ITALIC = 2
    UNDERLINE = 4
    SHADOW = 8
    OUTLINE = 16

They are powers of two, so we can combine them using the or operator. For instance, if we wish to use bold, italic, and shadow:

style = TextLayer.BOLD | TextLayer.ITALIC | TextLayer.SHADOW

We can tell if a combination uses a style using to the and operator:

if style & Text.BOLD:
    print("Bold is enabled")
else:
    print("Bold is not enabled")

Bold

The bold style is the easiest to implement because Pygame can render it. We only need to enable it once we created the font:

font = pygame.font.Font(fontName, fontSize)
if style & TextLayer.BOLD:
    font.set_bold(True)

All that we did in the previous post can remain as before.

Italic

Pygame also renders italic characters:

font = pygame.font.Font(fontName, fontSize)
if style & TextLayer.ITALIC:
    font.set_italic(True)

However, this solution is not sufficient: the italic characters are wider, and when placed side by side, they are too far apart.

We can solve this using the character advance, which is given by the Pygame font metrics:

metrics = font.metrics(character)
advance = metrics[0][4]

We save this advance for each character in a new advance attribute in the CharacterTile class.

When we define the quads in the setText() method of the OpenGLTextLayer class, we increase the current x value with the advance rather than with the tile width (see the last line):

x0 = x
# Characters to display
for charIndex, character in enumerate(content):
    if character == "\n":
        x = x0
        y += self.tileHeight
        continue
    tile = self.__tileset.getCharacterTile(character)
    self.__setQuadTile(charIndex, tile.x1, tile.y1, tile.x2, tile.y2)
    self.__setQuadLocation(charIndex, x, y, x + tile.width, y + tile.height)
    x += tile.advance

Underline

Pygame also handles underline text, but the result was not right in my experiments. So, I propose to render it ourselves.

We can get underlined text by drawing a line:

if style & TextLayer.UNDERLINE:
    width = surface.get_width()
    start = (0, underlineShift)
    end = (width - 1, underlineShift)
    pygame.draw.line(surface, color, start, end, underlineThickness)

The unlineShift variable is the vertical location of the line. We could use the font ascent provided by Pygame, but for many fonts, I got terrible results. I propose to compute it using a percentage of the tile height, for instance, 90%:

underlineShift = (tileHeight * 90) // 100

The facade user can also set it, so she/he can get the result she/he wants.

The color variable is the color of the font, as chosen by the facade user.

The underlineThickness variable contains the thickness of the line. We can also compute it using a percentage of the tile:

underlineThickness = (tileHeight * 5) // 100
if underlineThickness < 1:
    underlineThickness = 1

Shadow

For the shadow style, we need to draw each character twice: one with the font color and another with the shadow color. As for the other cases, these drawing are during the tileset creation:

# Normal character drawing
surface = font.render(character, False, color)

# Create a new, larger surface
size = (surface.get_width() + shadowShift, surface.get_height() + shadowShift)
shadowedSurface = pygame.Surface(size, flags=pygame.SRCALPHA)

# Render the character with the shadow color
surface2 = font.render(character, False, shadowColor)

# Blit the shadow and the character
shadowedSurface.blit(surface2, (shadowShift, shadowShift))
shadowedSurface.blit(surface, (0, 0))

Note that we also use the font advance to compute characters' location on the screen (as with italic style).

Also, note that, for the combination of shadow and underline styles, we have to also render the line in the shadow (not shown in this piece of code, have a look at the final program).

Outline

The outline is the most difficult one. There are several ways to get it; I propose one of them.

The idea of this approach is to create new pixels around existing ones:

def outlineStep(surface: pygame.Surface, outlineColor: (int, int, int, int)) -> pygame.Surface:
    result = surface.copy()
    w = surface.get_width()
    h = surface.get_height()
    for y in range(h):
        for x in range(w):
            # Copy opaque pixels
            color = surface.get_at((x, y))
            if color[3] != 0:
                result.set_at((x, y), color)
                continue
            # If any pixel around the current one is opaque, then light it
            if (x > 0 and surface.get_at((x - 1, y))[3] != 0) \
                    or (x < w - 1 and surface.get_at((x + 1, y))[3] != 0) \
                    or (y > 0 and surface.get_at((x, y - 1))[3] != 0) \
                    or (y < h - 1 and surface.get_at((x, y + 1))[3] != 0) \
                    or (x > 0 and y > 0 and surface.get_at((x - 1, y - 1))[3] != 0) \
                    or (x > 0 and y < h - 1 and surface.get_at((x - 1, y + 1))[3] != 0) \
                    or (x < w - 1 and y > 0 and surface.get_at((x + 1, y - 1))[3] != 0) \
                    or (x < w - 1 and y < h - 1 and surface.get_at((x + 1, y + 1))[3] != 0):
                result.set_at((x, y), outlineColor)
    return result

We copy existing pixels (alpha is not zero) on a new surface (lines 9-11). For other pixels, if there is at least one opaque pixel around, we create a new one with the outline color (lines 13-21).

We can repeat this procedure to get larger outlines.

The creation of an outlined character is as follow:

# Normal character drawing
surface = font.render(character, False, color)

# Create a new, larger surface
size = (surface.get_width() + 2 * thickness, surface.get_height() + 2 * thickness)
outlineSurface = pygame.Surface(size, flags=pygame.SRCALPHA)

# Blit the character
outlineSurface.blit(surface, (thickness, thickness))

# Draw the outline
for step in range(thickness):
    outlineSurface = outlineStep(outlineSurface, outlineColor + (255, ))

We render the character (line 2) and create a larger surface (lines 5-6). We blit the character on this surface with a shift that depends on the outline (line 9). Finally, we draw the outline (line 12-13). Each step adds another pixel to the outline.

Final program

Download code and assets

In the next post, we'll see how to render text with mixed styles.