In 2024 I wrote a series of blog posts about learning how to use SDF fonts in WebGL. I wanted to implement outline, glow, shadow, and other effects, both for games and for cartography:
This page summarizes what I’ve learned about using them.
1 Why SDF?#
The normal way to render fonts is to read a font file containing vector paths, then render those paths to the screen. These fonts support the full set of characters, sizes, and effects. Many games will pre-render the fonts to bitmap form, but this limits the set of characters, sizes, and effects. A signed distance field (SDF) texture can be used as an intermediate format between the original vector font and fonts pre-rendered to bitmaps. It allows all sizes but not all characters or effects.
Rendering a resized bitmap font leads to blurry or jagged edges:
Rendering a resized distance field font leads to smooth edges:
We don’t have to store the resized distance field. It is generated implicitly by the GPU fragment shader, for “free”. We can use a single low resolution distance field to generate high resolution output at any size. That’s the magic of SDF fonts!
SDF is not always the best choice for fonts. I’ve attempted to summarize the pros and cons:
| Vector | Bitmap | SDF | |
|---|---|---|---|
| all sizes | yes | no | yes |
| all characters | yes | no | no |
| gpu accelerated | no1 | yes | yes |
| fast preprocessing | yes | yes | no |
| hinting | yes | no | no |
| variable fonts | yes | no | no |
| special effects | some | no | yes |
| perspective transform | no | no | yes |
| small runtime code | no | yes | yes |
| color fonts (emoji) | yes | no | no |
- note1: there are newer GPU-accelerated font rendering systems that use the vectors. I have not yet explored these.
SDF fonts are implemented in Unity[1] (Text Mesh Pro), Unreal Engine[2], libgdx[3], Troika/Three.js[4], Use.GPU[5], stb_truetype[6] (c++), tiny-sdf[7] (js), SDFont[8] (c++), and many other libraries. I’m using msdfgen[9] for my projects.
On this page I’ll show how I use msdfgen with WebGL.
2 Understanding SDF#
We can think of a signed distance field as a “height map” in a landscape. The area above the water will be filled in. The area by the coastline will become the border color. The area underwater can be transparent, but could be used for glow, shadow, or other effects.
3 Map distances to colors#
The SDF texture contains distances encoded as 0–255. In msdfgen, 255 is “inside” the font and 0 is “outside”:
To render the distance map to a glyph, we first interpret the 0–255 values as a signed distance. I’ve chosen to use distances in “em” units. I am reversing the direction so that high values are outside and low values are inside, the same as Inigo Quilez’s convention[10]. I have found that the reverse direction makes thickness, outline, and glow calculations easier.
In msdfgen, the --aemrange parameter sets the distance range in “em” units, so I’ll use that to map 0 to the high value (+5%) and 255 to the low value (-5%):
| pixel | distance_em value | interpretation |
|---|---|---|
| 0 | aemrange[1] |
outside |
| 127.5 | 0.0 |
boundary |
| 255 | aemrange[0] |
inside |
We can either remember the values we passed to msdf-atlas-gen --aemrange or we can recover it from msdfgen’s JSON export. I haven’t studied other SDF font libraries to see how they store this information.
-
aemrange[0] =
(atlas.distanceRangeMiddle - atlas.distanceRange/2) / atlas.size -
aemrange[1] =
(atlas.distanceRangeMiddle + atlas.distanceRange/2) / atlas.size
3.1 Color mapping#
The simplest thing is to draw pixels with distance_em < threshold_em:
Here’s an implementation in a fragment shader:
#version 300 es precision mediump float; uniform sampler2D u_atlas; uniform vec2 u_aemrange; // range of distances from lowest to highest uniform float u_threshold_em; in vec2 v_st; out vec4 o_frag_color; void main() { float texel = texture(u_atlas, v_st).r; float distance_em = mix(u_aemrange[1], u_aemrange[0], texel); o_frag_color = vec4(distance_em < u_threshold_em ? 1.0 : 0.0); }
How do we set the uniform values?
- u_aemrange
- Use [aemrange[0], aemrange[1]]. These represent the distances at pixel=0 and pixel=255.
- u_threshold_em:
- I recommend 0.0. Increase this (e.g. by +0.01) to make the font thicker.
We’ll use other threshold values later to place outlines, shadows, and glow effects.
3.2 Antialiasing#
To add antialiasing, we can go smoothly instead of abruptly from 0 to 1 using a transition. Think of the “curves” tool in an image editor. Move the width slider to 0 to see how it looks with a hard threshold:
When distance is threshold - width/2 we want opacity 1.0. When distance is threshold + width/2 we want opacity 0.0. That’s a straight line with slope of -1/width. The formula works out as (threshold - distance) * 1/width + 0.5. Then we clamp opacity to 0.0–1.0. Here’s a shader implementation, avoiding the divide by multiplying by 1/width instead:
#version 300 es precision mediump float; uniform sampler2D u_atlas; uniform vec4 u_color; uniform vec2 u_aemrange; uniform float u_threshold_em; uniform float u_antialias_per_em; uniform float u_screen_px_scale; void main() { float texel = texture(u_atlas, v_st).r; float distance_em = mix(u_aemrange[1], u_aemrange[0], texel); float inverse_width = u_screen_px_scale * u_antialias_per_em; float opacity = clamp((u_threshold_em - distance_em) * inverse_width + 0.5, 0.0, 1.0); o_frag_color = u_color * opacity; // premultiplied alpha }
How do we set the uniform values?
- u_antialias_per_em:
- This value represents how much antialiasing happens per “em” distance. I recommend antialiasing over 1 screen pixel. We need to convert that to “em” units, and we also need to factor in any scaling between the GL pixel size and the screen size (e.g. FSR/DLSS, or render to texture). In msdfgen, set the uniform to atlas.size / canvasToScreenScale.
- u_screen_px_scale:
-
If you know the size of the output text (common in 2D), calculate the width ahead of time on the CPU and pass it in as a uniform. In msdfgen, pick any glyph that contains
planeBoundsandatlasBounds, and calculate:let glyph = atlas.glyphs.find((g) => g.atlasBounds && g.planeBounds); let inputSizePx = glyph.atlasBounds.right - glyph.atlasBounds.left; let outputSizePx = (glyph.planeBounds.right - glyph.planeBounds.left) * gl.canvas.width * fontSize; let screenPxScale = outputSizePx / inputSizePx;
We can further optimize the shader by passing in
inverse_widthas a uniform. -
If the size is going to vary (common in 3D), use GPU screen space derivatives for antialiasing[11]:
float screen_px_scale() { vec2 atlas_size = vec2(textureSize(u_atlas, 0)); vec2 gradient = fwidth(v_st); vec2 product = atlas_size * gradient; return max(0.5 * dot(atlas_size, gradient) / (product.x * product.y), 1.0); }
-
Tweaking antialiasing feels like a “dark art” to me. I’ve collected some notes in the appendix.
3.3 Sharper fonts#
Valve’s 2007 paper about SDF font rendering shows how to use a single distance field to represent fonts. A single distance field will have rounded corners. We can sharpen corners by increasing the resolution of the texture, but a better way is to use multiple signed distance fields (MSDF). The msdfgen library generates three distance fields and stores them in the red, green, and blue channels of the texture. When running msdf-atlas-gen, use --type msdf instead of --type sdf.

In the appendix I show more comparison screenshots at different resolutions, including examples where a single distance field looks nicer than multiple distance fields. I especially prefer the single distance field for glow and shadow effects. Each font + distance range behaves differently at different sizes, so I recommend comparing with your choice of font.
In the shader, SDF and MSDF are similar. Following the msdfgen page[12], replace
void main() { float texel = texture(u_atlas, v_st).r; … }
with:
float median(vec3 rgb) { return max(min(rgb.r, rgb.g), min(max(rgb.r, rgb.g), rgb.b)); } void main() { float texel = median(texture(u_atlas, v_st).rgb); … }
4 Font atlas to layout#
So far we’ve covered how to render a single character. To render a string, we’ll need two more ingredients:
- Font atlas. This is a single “sprite sheet” image that contains all the characters we might want to use in a bitmap or SDF font.
- Text layout. This calculates the location on screen for each character in the string. Also called “text shaping[13]”.
Some libraries will calculate a single character’s SDF, and leave you to generate the atlas using your own sprite sheet / font atlas generator. I used msdf-atlas-gen which generates both the SDF and the atlas at the same time. It stores the atlas texture coordinates in atlasBounds.
The layout in many Western alphabets is left to right on each line. There’s a “baseline” y value and a “cursor” x value. After each character, we advance the cursor to the right. At the end of the line, we move the cursor back to the left an dincrease the y value. But many languages don’t work the same way. For full layout across languages, consider using HarfBuzz[14].
The appendix has more implementation details when using msdfgen.
5 Outlines#
So far we’ve only been thinking about “inside” vs “outside”. For outlines, we can assign a different color between threshold_em and threshold_em + outline_em with this shader:
vec4 paint(vec4 dst, float threshold_em, float distance_em, float inverse_width, vec4 src) { float opacity = clamp(src.a * clamp((threshold_em - distance_em) * inverse_width + 0.5, 0.0, 1.0) - dst.a, 0.0, 1.0); return opacity * src + (1.0 - opacity) * dst; } void main() { … // front to back o_frag_color = vec4(0, 0, 0, 0); o_frag_color = paint(o_frag_color, u_threshold_em, distance_em, inverse_width, u_color); o_frag_color = paint(o_frag_color, u_threshold_em + u_outline_em, distance_em, inverse_width, u_outline_color); }
Why stop there? We can assign any color to any distance. I’ll show examples in the next section.
6 Demo#
Here’s a demo that uses the ingredients from earlier:
- Text Layout (cpu): calculate where on screen each glyph goes.
- Atlas mapping (cpu): calculate where in the atlas to read distances.
- Color mapping (gpu): assign a color to use a given distance.
There are even more effects possible. For example, we can vary the color by angle or texture map or lighting. We can use a round SDF for the glow/shadow while using a sharp MSDF for the main text. We can offset the shadow by some distance to make a drop shadow. We can vary the widths based on noise or any other function.
7 Appendix#
The classic reference for SDF fonts in games is Improved Alpha-Tested Magnification for Vector Textures and Special Effects[15] (Valve / Chris Green, 2007). Distance fields have been also been used for font rendering earlier:
- 2002 A New Framework for Representing, Rendering, Editing, and Animating Type[16] (Perry, Frisken) - uses an “adaptive distance field” that varies in resolution to handle sharp corners and other small features. (CPU rendering)
- 2006 Real-time texture-mapped vector glyphs[17] (Quin, Mccool, Kaplan) - uses multiple distance fields and a voronoi-based lookup to find which distance field to use at any point. (GPU rendering)
The 2007 work is much simpler and easier to implement on GPUs. However it suffers from rounded corners, and recommends multiple channels to get sharp corners. The later work is from 2016: Chlumsky not only wrote an amazing thesis about calculating and storing multiple distance the red/green/blue channels (not adaptive, and no voronoi), he wrote an open source msdfgen[18] library that implemented the algorithm.
This page was getting long so I’ve put some partially organized notes into an appendix page.