Map Typography

 from Red Blob Games
9 Jul 2024

{{field}}:{{label[field]}}
{{labels}}

I have always made maps without labels on them, but I wanted to try adding some labels to see how it would look, and what effects I could use.

TODO:

TODO for standalone tool:

TODO if integrated into mapgen4:

 1  Project goal#

Although my end goal is to generate names along with a map, I decided the thing to do now is to focus on the labels. I would like to draw the text, outline, shadow, and halo all in one pass, by using a function from distance to color:

insideoutlinehalooutside{{format(-1)}}{{format(params.thickness/100)}}{{format((params.thickness+params.outline)/100)}}{{format(+1)}}
Mapping from distance to color
Draw all of these at once

To avoid getting into the rabbit hole of generating maps, I started by loading a screenshot of a map. Then I added a label on top. I tried colors and then white text with black outline and black text with white outline:

Initial tests

 2  Rendering text#

 2.1 Bug: outline color

While looking closely at the black text with white outlines, I saw some black is "leaking" outside the white area:

   
Noticed and fixed black leaking outside the outline

At first I thought the text color was leaking out, so I changed it to yellow, and it was still black leaking out. It turns out I had an issue with premultiplied alpha. For proper blending, I need to use premultiplied alpha. And I thought I was doing that. But I was doing something wrong. I tweaked it until it worked. But I don't feel like I understand what I did wrong. I'll have to revisit that at some point.

I wrote more details on my blog.

 2.2 Quality: letter spacing

The other thing I noticed with the initial tests was that the k and s seemed very close together, and the h and e seem a little too far apart. Could it be kerning? Could it be lack of kerning? I rendered k and s with other letters to see if either one was the issue:

Testing spacing of s and k

and it does seem like k is generally close to the letter to the right of it. I checked the font data and this font (Fira Sans) just doesn't have a kerning table in it. So that means the k is just a little bit close to its neighbor. I verified this by checking the rendering in Google Chrome on the Google Fonts web site (second image) and compared to my rendering (first image):

Spacing issues

Sure enough, both the h + e and k + s spacing issues are there. So that's just how the font is. Ok.

BTW these screenshots are from Chrome/Mac. Chrome/Windows[4] has different rendering, and changed it in Feb 2025.

I wrote more details on my blog.

 2.3 Quality: antialiasing

But while I was looking at Fira Sans on Google Fonts[5], I noticed their antialiasing looked nicer than mine. So I started tweaking parameters:

Anti aliasing tweaks

and here you can see what I ended up with after tweaking edge width (from 1.0 to 2.0) and gamma (from 1.0 to 1.5):

My font rendering compared to Google Chrome on Mac

I wasn't sure if I was imagining the gamma making a difference, so I looked really closely at mine (left) and Google's (right). The gray in the middle of the gradient should be half brightness. Both are 10 steps of gray (although it may be hard to tell), but my halfway point looks a little darker than Google's. So I think (but am still not 100% sure) that they are gamma correcting.

Gamma correction comparison

But … I really can't tell at this point. So I decided I had been "pixel peeping" too much, and needed to move on to the bigger issues.

Before and after tweaking gamma

 2.4 Quality: ligatures

Checking Google Fonts reminded me that I don't have support for ligatures. I think ligatures don't work well with what I'm doing — I want to treat the letters as separate sprites so that I can increase spacing or rotate each letter invidually.

 2.5 Bug: shadow overlap

While looking at the text closely, also noticed another issue. The shadows of each letter overlap the previous letter. That's because I'm drawing one letter at a time. So for example in the fl, I draw the f's letter, outline, and shadow, then I draw l's letter, outline, and shadow. So l's shadow is drawn on top of f's letter.

Shadows of each letter overlap the previous letter

To fix this I combined distance fields for a label. I first drew all the letters in a label onto a new texture. I used the blendEquation()[6] function with MAX_EXT instead of the default FUNC_ADD. When I draw the letter f and also the letter l, the overlap will be assigned the higher value, which corresponds to the inside of the letter. The outline will have lower priority. The output texture holds a combined distance field. A secondary bonus is that when translucent outlines overlap, the old approach would draw them twice, but the new approach draws them once.

The MAX_EXT extension seems to have 100% support[7] in WebGL 1, and is always included with WebGL 2.

I then used the distance field shader to draw that combined distance field to the map.

Here's the result, showing that the inside of the letter takes priority over the outline, especially at the bottom left of the second g:

 
Before and after combining distance fields

I could do this once per label or once for the entire map. I decided to do it once per label, because that allows me to use different colors and styles (thickness, outline width, shadow, halo) per label.

 3  Curved text#

I wanted curved text. It's something I see on some maps. I worked out the math on paper first. I'm curving text by preserving the path length on the baseline when curving downwards and the ascender line when curving upwards. Here's an interactive widget showing how that works:

MMMyy curvature:

Here's a screenshot of the three cases:

Three curvature cases: >0, =0, <0

If I always curve at the baseline, the tops of the characters are too close together. That's why I have to curve at the ascender line instead of the baseline when the curvature is negative:

Upwards curvature at baseline makes letters too close together

I'm implementing curved text by first rendering the letters to an uncurved rectangle, and then curving the rectangle ("warping"). The other way to implement it is to rotate and place the individual letters on a curved path ("wrapping"). The recommended way is wrapping (see Google's page[8]), because it doesn't lead to distortions like this:

Distorted letters when curvature is high

But there's more flexibility in curving a rectangle, as I can get things like this (shaped suggested by Rich Gossweiler):

More shapes to fit the text to

However, whenever stretching the label non-uniformly, the distance field gets distorted. In this example the white halo on the left side (The) is much larger vertically than horizontally:

Trapezoid shows the distance field is non-uniform

I think the best solution is to regenerate the distance fields. But since I'm precalculating the distance fields, I don't want to do that. It might also be possible to use shader derivatives to adjust the halo to match the local distortion, like we do for antialiasing. However I don't want to figure that out right now, so I'm going to move on to other parts of this project. If I switch to the wrapping style, I won't need to solve this problem.

 4  Positioning text#

One bug that too way too long to figure out was that I was positioning the text wrong. Only after several days did I find a good visual way to see that there was indeed a problem: the letters change the vertical alignment. Here are two separate strings rendered at y = 0. But they are aligned to the bottom padding, not to the text baseline. This was a surprise to me because I thought I was handling the baseline position correctly.

These two strings should be aligned at the baseline, not at the bottom of the rectangle

I found a way to make it work, but I don't fully understand why my fix works.

Text aligned at the baseline

I will have to revisit it later to understand why this worked. A lot of the code is a mess, as I don't properly annotate variables with whether they're atlas texture coordinates, font coordinates, framebuffer normalized device coordinates, framebuffer texture coordinates, output rectangle coordinates, or output normalized device coordinates. And I think I had been using some values without converting coordinate systems.

 5  Resolution of text#

How big should the intermediate sdf texture be? I initially matched the output label, but for small text that made it lose the distance field resolution, and then the output wasn't smooth. I then matched the input atlas, but for long labels it didn't fit, so the output got truncated. In this diagram pay attention to the shape of the ends of strokes while clicking the resolution number to see the difference:

 
The resolution of the intermediate combined sdf affects small text quality
resolution=

At this size, resolution 1 is too round but 2 is good, and there isn't much to gain past 2. At first I thought it was something do with the Nyquist Theorem[9], which states that we need to sample at twice the frequency of the input. However, for larger text, it does help to make it larger, especially where the two strokes of the r meet:

 
The resolution of the intermediate combined sdf affects large text quality
resolution=

So that means there must be another reason, not the Nyquist Theorem. I realized it's because my original font atlas uses MSDF (multi-channel signed distance fields), which can produce sharp corners[10] at small texture sizes. Regular SDF fonts have rounded corners at small texture sizes. When I combine the MSDF distance fields into the intermediate texture, it turns it into SDF and loses the MSDF sharpening ability. As far as I can tell, there isn't a way to produce a combined MSDF. So I have to compensate by making the intermediate texture larger.

For small text, scale 2 works nicely because it allows for longer labels, up to around 150 characters long with the current choice of font. For large text, the labels can't be too long (because they'll exceed the map size) so that allows me to use larger scales to make the corners sharper.

I decided to set it at 5 for now (maximum label 60 characters), but will have to revisit later, especially after I try serif fonts.

I later discovered that I had a bug that made everything off by a factor of 2, so I was really comparing 0.5, 1.0, 1.5, 2.5, 4.0. And 2.0 was actually pretty good. The bug was that I was measuring in normalized device coordinates that range from -1 to +1, but then comparing to texture coordinates that range from 0 to 1. That's a factor of 2 in my calculations. Oops.

 6  Edit handles#

I have been using lots of sliders to control the various properties of the labels, but I thought it would be nice to control the common ones visually. I added three, one for spacing+curvature, one for position, and one for size+rotation:

Added drag handles to the labels

I thought it'd be easy, and I'd have everything implemented in a few hours. It wasn't as easy as I thought. The first problem was that my code is a mess, and I have lots of different coordinate systems. Drawing the markers mean I need the position portion of the transform matrix but not the scale part. I didn't realize this at first, and was trying to use the transform matrix for everything.

The bigger problem is that it's unclear what movements should turn into. Position is simple. But rotating and resizing the text simultaneously didn't work so well. I don't want horizontal to be size and vertical to be rotation. That didn't feel right. I instead want radial movement to be font size and circular movement to be rotation. So I remembered a vector for each of the markers, and movement along that vector controlled font size and movement perpendicular to that vector controlled rotation.

Vector with the markers

But the vector can change while the dragging is happening! Initially I used the vector as calculated when the drag started, but that didn't feel right. So I changed to use the vector as the drag was happening, and I only applied the change since the last mouse position. That introduced a new bug.

Imagine a mouse drag from x = 0 to x = 100, taken in one large swipe. Maybe this changes the letter spacing from 0 to 25.

Now imagine that same mouse drag taken in 100 steps. Initially the letter spacing is 0. After one step, x = 1, the change in x is +1, and the letter spacing should be increased by +0.25. But the letter spacing is also tied to a slider which rounds the number to the nearest integer. So it sets it to 0. Then after another step, x = 2, the letter spacing is increased from 0 to 0.25 and then rounded back to 0. So if moving the mouse slowly, the value gets stuck at 0.

There are several possible fixes. One is to not round the value during a drag. But this doesn't entirely fix the problem, because the vector changing at each step means you still can get different results with one big update x += 100 vs a hundred separate updates x += 1. Another fix is to do everything in polar coordinates so that r and θ are independent. Another is to write a solver that tries to adjust the parameters iteratively until the marker position is close to the mouse pointer.

It's not at all obvious which one will work best, and I feel like I could get into another rabbit hole exploring this problem.

 7  Halos#

The cartography community seems to use the word "halo" to mean outlines with extra effects:

So these are my ingredients:

 8  More#

Mapbox uses distance field fonts too[16]. They want rotated text on maps, and they also want text halos to increase contrast. Both of these work nicely with distance field fonts.

Source: map-typography.js

Email me , or comment here: