SDF Fonts

 from Red Blob Games
1 Jan 2026

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:

Screenshot of five different styles of rendering text

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:

Distance field
Bitmap
Resized bitmap
Resizing a bitmap font

Rendering a resized distance field font leads to smooth edges:

Distance fieldResized distance fieldBitmap
Resizing a distance field font

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

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.

Landscape view of a signed distance field

 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”:

A single glyph’s distance field

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.

100*distance_em at each point

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.

 3.1 Color mapping

The simplest thing is to draw pixels with distance_em < threshold_em:

Threshold:
Distance field rendering for all values under the threshold

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:

Threshold:Width:
Distance field with antialiasing blend at boundary

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:
  1. 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 planeBounds and atlasBounds, 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_width as a uniform.

  2. 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.

SDF
MSDF
Screenshot of SDF contour linesScreenshot of MSDF contour lines
SDF vs MSDF

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:

  1. 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.
  2. Text layout. This calculates the location on screen for each character in the string. Also called “text shaping[13]”.
(interactive!)

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);
}
Thickness:Outline:SDFMSDFContours:(debug)

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:

  1. Text Layout (cpu): calculate where on screen each glyph goes.
  2. Atlas mapping (cpu): calculate where in the atlas to read distances.
  3. Color mapping (gpu): assign a color to use a given distance.
Size:MSDF:

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:

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.

Email me , or comment here: