SDF Fonts

 from Red Blob Games
DRAFT
11 Sep 2024

In 2024 I learned how to use signed distance field fonts in WebGL, and wrote a series of blog posts about it. I’m collecting my notes here for my own reference. They aren’t always the best choice but I picked them because I wanted to apply effects (outlines, glows, soft shadows, etc.) to them.

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 and sizes. Many games will pre-render the fonts to a texture atlas, and then render the bitmaps from the texture to the screen. Using a texture atlas means a limited set of characters and sizes. A signed distance field (SDF) texture can be used as an intermediate format between the original vector font and the fonts pre-rendered to a texture. It allows all sizes but not all characters. The classic references are Improved Alpha-Tested Magnification for Vector Textures and Special Effects[1] (Valve / Chris Green, 2007) and Real-time texture-mapped vector glyphs[2] (Quin, Mccool, Kaplan, 2006). SDF is not always the best choice. I’ve attempted to summarize the pros and cons:

  Vector Atlas 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 font rendering

 1  Starter#

Here’s what worked for me:

 1.1 Install an sdf generator

I tried stb_truetype, tiny-sdf, msdf-bmfont-xml, but ended up using msdfgen[3], a c++ command line tool. It generates a font atlas and a json file with the layout information.

I needed cmake and vcpkg[4] to install msdfgen’s dependencies. I put them into ~/Projects/src on my Mac:

brew install cmake vcpkg
export VCPKG_ROOT="$HOME/Projects/src/vcpkg"
git clone https://github.com/microsoft/vcpkg $VCPKG_ROOT

cd ~/Projects/src
git clone https://github.com/Chlumsky/msdf-atlas-gen.git
cd msdf-atlas-gen
git submodule init
git submodule update

cmake .
make

To update the software later:

cd ~$VCPKG_ROOT
git pull
./bootstrap-vcpkg.sh

cd ~/Projects/src/msdf-atlas-gen
git pull
git submodule init
git submodule update

cmake . --fresh
make

 1.2 Generate a signed distance field

mkdir -p assets
~/Projects/src/msdf-atlas-gen/bin/msdf-atlas-gen \
    -type msdf -aemrange -0.4 0.1 -dimensions 512 512 \
    -font fonts/FiraSans-Regular.otf \
    -imageout assets/FiraSans-Regular.png \
    -json assets/FiraSans-Regular.json

Why -aemrange instead of -emrange? I wrote about that on my blog. How big you want the range to be depends on what effects you want.

I believe that the range you want also depends on what sizes you want to render at and what resolution the font atlas is. Plan to experiment to find a good range for your needs. You might start with the default and see if that works for you.

 1.3 Lay out the glyphs

The JSON file contains information about the size and layout of each character (“glyph”):

{
    unicode: 65,
    advance: 0.57300000000000006,
    planeBounds: {
        left: -0.40678802039082407,
        bottom: -0.42141036533559895,
        right: 0.97978802039082413,
        top: 1.1011045029736617
    },
    atlasBounds: {
        left: 98.5,
        bottom: 207.5,
        right: 149.5,
        top: 263.5
    }
}

What do these fields mean? From msdf-atlas-gen’s documentation[5]:

The planeBounds and advance tell us layout. Use planeBounds to determine the area to draw on the screen; this may be larger than the character itself to leave room for outlines and glows etc. The 0 point is the cursor location. Use advance to tell us how far to advance the cursor position to draw the next character. Visually:

msdfgen’s planeBounds and advance

The planeBounds and advance are expressed in em character units; multiply this by the desired font size. The atlasBounds aren’t need for layout, but will be used for rendering in the next step.

The goal of the layout phase is to generate rectangles where the characters will be drawn:

one rectangle per character layout

Suppose we have a string and a starting x,y position. We can calculate the rectangles needed for each character, as well as the corresponding atlas locations:

/**
 * @typedef {{left: number, top: number, 
               right: number, bottom: number}} Rect
 * @typedef {{distanceRange: number, distanceRangeMiddle: number, 
              size: number, width: number, height: number}} AtlasInfo
 * @typedef {{unicode: number, advance: number, 
              planeBounds: Rect, atlasBounds: Rect}} Glyph
 * @typedef {{atlas: AtlasInfo, glyphs: Glyph[]}} Atlas
 */

/**
 * @param {number} x
 * @param {number} y
 * @param {string} text
 * @param {Atlas} atlas
 * @returns {{x: number, planeCoords: Rect[], atlasCoords: Rect[]}}
 */
function calculateLayout(x, y, text, atlas) {
    let planeCoords = [];
    let atlasCoords = [];
    for (let char of text) {
        let glyph = atlas.glyphs.find(
              (glyph) => glyph.unicode == char.codePointAt(0));
        if (!glyph) continue;
        let {advance, planeBounds, atlasBounds} = glyph;
        if (planeBounds) {
            planeCoords.push({
                left: x + planeBounds.left,
                right: x + planeBounds.right,
                top: y + planeBounds.top,
                bottom: y + planeBounds.bottom,
            });
            atlasCoords.push({
                left: atlasBounds.left / atlas.atlas.width,
                right: atlasBounds.right / atlas.atlas.width,
                top: atlasBounds.top / atlas.atlas.height,
                bottom: atlasBounds.bottom / atlas.atlas.height,
            });
        }
        x += advance;
    }
    return {x, planeCoords, atlasCoords};
}

Let’s try it:

(interactive!)

Note that there’s overlap. The larger your aemrange, the larger the overlap. Here I used -aemrange -0.1 0.1. That extra room is for outlines, glow, and halo effects. Most characters extend slightly below the baseline but descenders like g extend well below the baseline.

 1.4 Render the glyphs

To render the fonts, we need to map a rectangle in the font atlas to a rectangle in the layout. The texture coordinates in atlasBounds represent the input rectangle in the atlas. The layout coordinates in planeBounds represent the output rectangle in the layout.

read from font atlas to render into each rectangle

However, the atlas doesn’t contain colors. Instead, it contains distances storing the distance field. We need to convert distances into colors. We need to know which values are “inside” the font and which are “outside”. There’s no universal convention for this, but msdfgen uses 255 for inside, 0 for outside. In this diagram the boundary is at 128:

character shape stored as a distance field

We’ll map the distances to a color. Here’s: distance < 128? 0 : 1

character shape colored as on/off

When the range is asymmetric (aemrange) we need to use 0.5 + atlas.distanceRangeMiddle / atlas.distanceRange to calculate the boundary.

const vertexShader = `
  uniform vec2 u_offset;
  uniform vec2 u_scale;
  attribute vec2 a_xy; // canvas coordinates
  attribute vec2 a_st; // texture coordinates
  varying vec2 v_st;
  void main() {
    v_st = vec2(a_st.x, 1.0 - a_st.y);
    vec2 xy_0_to_1 = a_xy * u_scale + u_offset; // 0 to 1 coordinates
    vec2 xy_ndc = xy_0_to_1 * 2.0 - 1.0; // ndc coordinates
    gl_Position = vec4(xy_ndc, 0, 1);
  }
`;

const fragmentShader1 = `
  precision mediump float;
  uniform sampler2D u_atlas;
  varying vec2 v_st;
  void main() {
    float distance = texture2D(u_atlas, v_st).r;
    gl_FragColor = distance < 0.5? vec4(0, 0, 0, 0) : vec4(1, 1, 1, 1);
  }
`;

 1.5 Demo

)
function createProgram(gl, vs, fs) {
    function load(type, source) {
        let shader = gl.createShader(type);
        gl.shaderSource(shader, source);
        gl.compileShader(shader);
        if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
            console.error("Error compiling shader", gl.getShaderInfoLog(shader));
            return null;
        }
        return shader;
    }

    let vertShader = load(gl.VERTEX_SHADER, vs);
    let fragShader = load(gl.FRAGMENT_SHADER, fs);
    let program = gl.createProgram();
    gl.attachShader(program, vertShader);
    gl.attachShader(program, fragShader);
    gl.linkProgram(program);
    if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
        console.error("Error linking shader:");
        console.error(gl.getProgramInfoLog(program));
    }
    return program;
}

let texture = null;
function loadImageIntoTexture(gl, image) {
    texture = gl.createTexture();
    gl.bindTexture(gl.TEXTURE_2D, texture);
    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
}

const figureDemo = document.getElementById('diagram-demo');
const canvas = figureDemo.querySelector("canvas");
const gl = canvas.getContext('webgl');

let program = createProgram(gl, vertexShader, fragmentShader1);
let a_xy = gl.createBuffer();
let a_st = gl.createBuffer();

let atlasSdf = new Image();
atlasSdf.onload = () => {
    loadImageIntoTexture(gl, atlasSdf);
    draw();

    const input = figureDemo.querySelector("input");
    input.addEventListener('input', draw);
};
atlasSdf.onerror = (error) => {
    console.error("Error loading image", error);
}
atlasSdf.src = "assets/FiraSans-Regular.png";

function buildBuffers(text) {
    let count = 0, xy = [], st = [];
    let {x, planeCoords, atlasCoords} = calculateLayout(0, 0, text, firaSansAtlas);
    let viewBox = [0, -1.3, 10, 1.7];
    if (planeCoords.length > 0) {
        let top = Math.max(...planeCoords.map((r) => r.top));
        viewBox[0] = planeCoords[0].left;
        viewBox[2] = planeCoords.at(-1).right - planeCoords[0].left;
        for (let i = 0; i < planeCoords.length; i++) {
            let inr = atlasCoords[i], out = planeCoords[i];
            st.push(inr.left, inr.top, inr.left, inr.bottom, inr.right, inr.top,
                    inr.right, inr.top, inr.left, inr.bottom, inr.right, inr.bottom);
            xy.push(out.left, out.top, out.left, out.bottom, out.right, out.top,
                    out.right, out.top, out.left, out.bottom, out.right, out.bottom);
            count += 6;
        }
    }

    return {
        count, xy, st, viewBox,
    };
}

function draw() {
    gl.enable(gl.BLEND);
    gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
    gl.blendEquation(gl.FUNC_ADD);
    gl.clearColor(0.2, 0.3, 0.4, 1.0);
    gl.clear(gl.COLOR_BUFFER_BIT);
    gl.useProgram(program);

    let buffers = buildBuffers(figureDemo.querySelector("input").value);
    console.log(buffers)

    gl.bindBuffer(gl.ARRAY_BUFFER, a_xy);
    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(buffers.xy), gl.DYNAMIC_DRAW);
    gl.vertexAttribPointer(gl.getAttribLocation(program, 'a_xy'), 2, gl.FLOAT, false, 0, 0);
    gl.enableVertexAttribArray(gl.getAttribLocation(program, 'a_xy'));

    gl.bindBuffer(gl.ARRAY_BUFFER, a_st);
    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(buffers.st), gl.DYNAMIC_DRAW);
    gl.vertexAttribPointer(gl.getAttribLocation(program, 'a_st'), 2, gl.FLOAT, false, 0, 0);
    gl.enableVertexAttribArray(gl.getAttribLocation(program, 'a_st'));

    let scale = Math.max(5, buffers.viewBox[2]);
    gl.uniform2f(gl.getUniformLocation(program, 'u_offset'), -buffers.viewBox[0] / scale, 0.4);
    gl.uniform2f(gl.getUniformLocation(program, 'u_scale'), 1/scale, gl.canvas.width/gl.canvas.height/scale);

    gl.uniform1i(gl.getUniformLocation(program, 'u_atlas'), 0); // use TEXTURE0
    gl.activeTexture(gl.TEXTURE0);
    gl.bindTexture(gl.TEXTURE_2D, texture);

    gl.drawArrays(gl.TRIANGLES, 0, buffers.count);
}

TODO: downloadable example

 2  Concepts#

A standalone tool preprocesses vector shapes into raster format, a 2D array of signed distance values. At render time, we read the 2D array and color the pixel based on whether the value is less than zero (inside) or greater than zero (outside):

The numbers are in the input. The colors are the output. Here’s an example for the letter “A”:

A signed distance field for the letter “A”, using SDFs from msdfgen, tiny-sdf, and stb_truetype

 3  Vector to Raster#

SDF fonts are implemented in Unity[6], Unreal Engine[7], libgdx[8], Troika/Three.js[9], and elsewhere, but I wanted small version for my web projects, ideally under 100 lines of code. I wanted a separate tool to preprocess vector fonts into raster distance field textures. My options were:

SDF (signed distance field) contains a single distance value at each point in the array. MSDF (multi-signed distance field) contains three distance values at each point. The main advantage of MSDF is that it can produce sharper corners, as shown on the msdfgen web page and explained in the brilliant msfdgen paper. The original 2007 Valve paper mentions “Sharp corners can be preserved, however, by using more than one channel of the texture to represent different edges intersecting within a texel.”

The main advantage of precalculation (command line tool) is that I can build an image ahead of time, minimizing run time code size. The main advantage of run time generation (library) is that I can use run time values to control the image generation. For example, I can handle unicode, since I know which characters I actually need to generate. And I can apply stylistic variants[15].

Some of these algorithms start with the vector data itself, and some convert the vector to raster, and then convert raster to signed distance field raster. The advantage of using vector directly is that there’s no loss of precision. The advantage of going through a raster first is that it can support many more input shapes.

 3.1 Asymmetric range

For outline/glow effects I want more of the range to be on the outside of the font. I am not sure what effect this has on the shape of the font itself, especially at small font sizes. Something I should test side by side.

 4  Shaders#

At render time, we have a 2D array of signed distances stored in an image. We use a gpu shader that reads the distance out of the image and does two steps:

decode
convert a 0-255 texture value (0.0–1.0 in glsl) to a distance
color
convert a distance to a color

There are different conventions in use for distance.

source inside edge outside
Wikipedia’s SDF page[16] +∞ 0.0 -∞
iquilez’s SDF pages[17] -∞ 0.0 +∞
msdfgen shader[18] 1.0 0.5 0.0
my shader, below 0.0 0.5 1.0

The simplest coloring algorithm is: inside is black, outside is transparent. To that we can add antialiasing, outlines, glow, drop shadow, gradient, and other effects.

Here’s what tinysdf uses:

uniform sampler2D u_texture;
uniform vec4 u_color;
uniform float u_buffer;
uniform float u_gamma;

varying vec2 v_texcoord;

void main() {
    float dist = texture2D(u_texture, v_texcoord).r;
    float alpha = smoothstep(u_buffer - u_gamma, u_buffer + u_gamma, dist);
    gl_FragColor = vec4(u_color.rgb, alpha * u_color.a);
}

The msdfgen page gave me a good shader to start with antialiasing. Others I’ve found:

msdfgen page says “2D” vs “3D” but I think of it as an optimization. If the screen px range is always the same, calculate it once and pass it in as a uniform. If it’s not always the same, use the derivatives and calculate it per pixel.

Antialiasing: msdfgen’s code is from 0 to 1/w, but it might be worth trying -1/2w to +1/2w

many people say you need to have mipmapping off

My 2437 shader (has outlines, halos)

#extension GL_OES_standard_derivatives : enable
precision mediump float;

varying vec2 v_texcoord;

uniform sampler2D u_texture;

uniform vec2 u_unit_range;
uniform vec4 u_inner_color;
uniform vec4 u_outer_color;
uniform vec4 u_halo_color;
uniform float u_bg_alpha; // 0.0 for normal, 0.1 or 0.2 for showing the drawing area
uniform float u_threshold;
uniform float u_out_bias;
uniform float u_outline_width_absolute;
uniform float u_outline_width_relative;
uniform float u_halo_width;
uniform vec2 u_halo_offset;
uniform float u_debug;
uniform float u_edge_blur_px; // how many px to spread the antialiasing gradient over; 2 to 3 matches Chrome

// from https://github.com/Chlumsky/msdfgen MIT licensed
float screenPxRange() {
  vec2 screenTexSize =  vec2(1.0) / fwidth(v_texcoord);
  // For very small text, screenPxRange goes below 1.0, and
  // antialiasing will fail, but at such small sizes the text
  // is too small for antialiasing to matter anyway
  return max(0.5 * dot(u_unit_range, screenTexSize), 1.0);
}

// blend the new color into gl_FragColor, using premultiplied alpha
void blend(vec4 color, float alpha) {
  gl_FragColor = gl_FragColor + vec4(color * clamp(alpha - gl_FragColor.a, 0.0, 1.0));
}

float unlerp(float a, float b, float t) { return (t - a) / (b - a); }
float linearstep(float a, float b, float t) { return clamp(unlerp(a, b, t), 0.0, 1.0); }
float blur(float threshold, float distance, float width) {
  // NOTE: 1/screenPxRange() is ∆distance per pixel, so this function is
  // spreading things out over u_edge_blur_px pixels, half on each side
  // of the threshold, adjusted slightly by u_out_bias
  return linearstep(threshold + (0.5 + u_out_bias) / width,
                    threshold - (0.5 - u_out_bias) / width,
                    distance);
}

void main() {
  // msdfgen's distances are stored with 1.0 meaning "inside"
  // and 0.0 meaning "outside", but I want my calculations to
  // treat 0.0 as "inside" and 1.0 as "outside"
  vec4 distances = texture2D(u_texture, v_texcoord);
  float d_msdf = 1.0 - distances.r;

  // The halo uses a hybrid of sdf for exterior corners and msdf
  // for interior corners. Optionally apply an offset which can
  // be used to make drop shadows.
  vec4 offset_distances = distances;
  if (u_halo_offset.x != 0.0 || u_halo_offset.y != 0.0) offset_distances = texture2D(u_texture, v_texcoord - u_halo_offset);
  float d_halo = 1.0 - offset_distances.g;

  // u_threshold is the "zero" point, in input units, with lower
  // values inside the shape and higher values outside the shape.
  // u_out_bias is an adjustment to the zero point made in output
  // units, mainly to make small fonts slightly thicker. Similarly,
  // u_outline_width_relative is in input units that scale with the
  // font size and u_outline_width_absolute is in output units that
  // do not scale with font size. The non-scaling allows small fonts
  // to have slightly thicker outlines.
  float width = screenPxRange() / u_edge_blur_px;
  float outline_width = width * u_outline_width_relative + u_outline_width_absolute;

  float inner_opacity = blur(u_threshold, d_msdf, width);
  float outer_opacity = blur(u_threshold + u_outline_width_relative, d_msdf, width);

  // Halo is applied around distance d0, using a hybrid of sdf and msdf
  float halo_opacity = clamp(width * (u_threshold - d_halo) + 0.5 + u_out_bias + outline_width + width * u_halo_width, 0.0, 1.0);
  float d0 = u_threshold + u_outline_width_relative;

  gl_FragColor = vec4(0, 0, 0, 0);
  float debug_outline = fract(16.0 * distances.g);
  blend(vec4(0, 0, 0, 1), u_debug * (smoothstep(0.4, 0.5, debug_outline) - smoothstep(0.5, 0.6, debug_outline)));
  blend(u_inner_color, inner_opacity);
  blend(u_outer_color, outer_opacity);
  blend(u_halo_color * linearstep(d0 + u_halo_width, d0 - u_halo_width, d_halo), halo_opacity);
  gl_FragColor.a += u_bg_alpha; // for debugging
  if (distances.g >= 1.0) gl_FragColor = vec4(1, 0.5, 0, 1); // check for distance clipping
}

My 2428 shader (also has linear rgb, background colors, outline blur feature)

#version 300 es
precision mediump float;

in vec2 v_texcoord;
in vec2 v_position;

uniform sampler2D u_image;
uniform sampler2D u_sdf;

uniform vec2 u_unit_range;
uniform vec4 u_inner_color;
uniform vec4 u_outer_color;
uniform vec4 u_halo_color;
uniform float u_bg_alpha; // 0.0 for normal, 0.1 or 0.2 for showing the drawing area
uniform float u_threshold;
uniform float u_out_bias;
uniform float u_outline_width_absolute;
uniform float u_outline_width_relative;
uniform float u_halo_width;
uniform vec2 u_halo_offset;
uniform float u_edge_blur_px; // how many px to spread the antialiasing gradient over; 2 to 3 matches Chrome

out vec4 o_rgba_linear;

// from https://github.com/Chlumsky/msdfgen MIT licensed
float screenPxRange() {
  vec2 screenTexSize =  vec2(1.0) / fwidth(v_texcoord);
  // For very small text, screenPxRange goes below 1.0, and
  // antialiasing will fail, but at such small sizes the text
  // is too small for antialiasing to matter anyway
  return max(0.5 * dot(u_unit_range, screenTexSize), 1.0);
}

// blend the new color into gl_FragColor, using premultiplied alpha
void blend(vec4 color, float alpha) {
  o_rgba_linear = o_rgba_linear + vec4(color * clamp(alpha - o_rgba_linear.a, 0.0, 1.0));
}

float linearrgb_to_luminosity(vec3 color) {
  return dot(color, vec3(0.2126, 0.7152, 0.0722));
}

float unlerp(float a, float b, float t) { return (t - a) / (b - a); }
float linearstep(float a, float b, float t) { return clamp(unlerp(a, b, t), 0.0, 1.0); }
float blur(float threshold, float distance, float width) {
  // NOTE: 1/screenPxRange() is ∆distance per pixel, so this function is
  // spreading things out over u_edge_blur_px pixels, half on each side
  // of the threshold, adjusted slightly by u_out_bias
  return linearstep(threshold + (0.5 + u_out_bias) / width,
                    threshold - (0.5 - u_out_bias) / width,
                    distance);
}

void main() {
  // msdfgen's distances are stored with 1.0 meaning "inside"
  // and 0.0 meaning "outside", but I want my calculations to
  // treat 0.0 as "inside" and 1.0 as "outside"
  vec4 distances = texture(u_sdf, v_texcoord);
  float d_msdf = 1.0 - distances.r;

  // The halo uses a hybrid of sdf for exterior corners and msdf
  // for interior corners. Optionally apply an offset which can
  // be used to make drop shadows.
  vec4 offset_distances = distances;
  if (u_halo_offset.x != 0.0 || u_halo_offset.y != 0.0) offset_distances = texture(u_sdf, v_texcoord - u_halo_offset);
  float d_halo = 1.0 - offset_distances.g;

  // u_threshold is the "zero" point, in input units, with lower
  // values inside the shape and higher values outside the shape.
  // u_out_bias is an adjustment to the zero point made in output
  // units, mainly to make small fonts slightly thicker. Similarly,
  // u_outline_width_relative is in input units that scale with the
  // font size and u_outline_width_absolute is in output units that
  // do not scale with font size. The non-scaling allows small fonts
  // to have slightly thicker outlines.
  float width = screenPxRange() / u_edge_blur_px;
  float outline_width = width * u_outline_width_relative + u_outline_width_absolute;

  float inner_opacity = blur(u_threshold, d_msdf, width);
  float outer_opacity = blur(u_threshold + u_outline_width_relative, d_msdf, width);

  // Halo is applied around distance d0, using a hybrid of sdf and msdf
  float halo_opacity = clamp(width * (u_threshold - d_halo) + 0.5 + u_out_bias + outline_width + width * u_halo_width, 0.0, 1.0);
  float d0 = u_threshold + u_outline_width_relative;
  // TODO: not sure what curve I want here
  float halo_fade = smoothstep(d0 + u_halo_width, d0 - u_halo_width, d_halo);

  vec2 image_texcoord = (v_position + 1.0) * 0.5; // -1:+1 to 0:1
  vec2 step = vec2(dFdx(image_texcoord).x, dFdy(image_texcoord).y);
  const float stddev = 0.84089642; // gaussian blur
  vec4 blurred_bg = vec4(0, 0, 0, 0);
  for (float y = -3.0; y <= 3.0; y += 1.0) {
    for (float x = -3.0; x <= 3.0; x += 1.0) {
      float weight = exp(-(x*x + y*y) / (2.0 * stddev * stddev));
      blurred_bg += weight * texture(u_image, image_texcoord + step * vec2(x, y));
    }
  }
  blurred_bg /= blurred_bg.a;

  // Background for calculating lightness: https://stackoverflow.com/a/56678483

  // I want the brightness of blurred_bg to be similar to halo_color,
  // so first calculate the brightness of both, and then use the bg
  // color when the luminosities are similar, and the halo color when
  // they are not. TODO: want to change the bg luminosity to match the
  // halo color without affecting saturation, I think
  float blurred_bg_luminosity = linearrgb_to_luminosity(blurred_bg.rgb);
  float halo_luminosity = linearrgb_to_luminosity(u_halo_color.rgb);
  float contrast_adjust = 0.0 * pow(abs(blurred_bg_luminosity - halo_luminosity), 1.0);

  vec4 halo_color = u_halo_color;
  // HACK:
  // halo_color.rgb = mix(halo_color.rgb, blurred_bg.rgb / max(blurred_bg.r, max(blurred_bg.g, blurred_bg.b)), 0.75);

  o_rgba_linear = vec4(0, 0, 0, 0);
  blend(u_inner_color, inner_opacity);
  blend(u_outer_color, outer_opacity);
  blend(halo_color * halo_fade, halo_opacity);
  // HACK:
  // blend(mix(blurred_bg, halo_color, contrast_adjust) * halo_fade, halo_opacity);
  o_rgba_linear.a += u_bg_alpha; // for debugging
  if (distances.g >= 1.0)    o_rgba_linear = vec4(1, 0.5, 0, 1); // check for distance clipping
  if (screenPxRange() < 1.0) o_rgba_linear = vec4(1, 0, 1, 1); // check for precision loss
}

DEMO:

 5  Constructing SDFs#

The two main methods I see are:

  1. starting with vectors
  2. starting with bitmaps

For MSDF I’ve only seen the vector approach.

 6  kerning#

Inter should have kerning but don’t know if msdfgen reads that data

https://github.com/Chlumsky/msdf-atlas-gen/issues/126[32]https://github.com/Chlumsky/msdf-atlas-gen/issues/4#issuecomment-792912921[33]

 7  text shaping#

I don’t really do this except left to right for ascii text

But I want to describe how this works for ascii left to right. There’s advance and kerning (only Vegur among my test fonts)

https://wolthera.info/2025/04/going-in-depth-on-font-metrics/[34] - ascender, x-height, cap-height, baseline, descender in european text, line height, font size, platform differences, vertical alignment, em box, underlines, strike through, cursor position, pixels vs points

For example, https://github.com/ShoYamanishi/SDFont?tab=readme-ov-file#rendering-a-word[35] shows how it’s done for that library. I think I’d need to show how it’s done for msdfgen, but maybe separately for tinysdf and stb_truetype

 8  antialiasing#

Getting this right seemed to take some tweaking. And it depends on dark on light vs light on dark, and also on gamma correction.

 9  Gamma#

I’ve read in multiple places that gamma correction is important. However, when I tried it myself, I had a hard time telling an improvement. Maybe my code or math was buggy.

https://acko.net/blog/subpixel-distance-transform/[40]

The “blurry text” that some people associate with anti-aliasing is usually just text blended with the wrong gamma curve, and without an appropriate bleed for the font in question.

In Use.GPU I prefer to use gamma correct, linear RGB color, even for 2D. What surprised me the most is just how unquestionably superior this looks. Text looks rock solid and readable even at small sizes on low-DPI.

https://news.ycombinator.com/item?id=42191709#42192825[41] → says don’t worry about fonts being tuned for non-gamma-correction, and make sure antialiasing is linear not smoothstep

https://blog.johnnovak.net/2016/09/21/what-every-coder-should-know-about-gamma/#antialiasing[42] says Photoshop uses 1.42 instead of 2.2

https://www.puredevsoftware.com/blog/2019/01/22/sub-pixel-gamma-correct-font-rendering/[43] uses 1.43 instead of 2.2

https://hikogui.org/2022/10/24/the-trouble-with-anti-aliasing.html[44] says something different. It says to blend in linear space, but compensate for lightness, not by changing gamma but using something different:

I will show why a pixel wide white line and a black background will visual look like a pixel wide line after anti-aliasing in linear-RGB color space

At this point you may think, okay… anti-alias instead in the sRGB color space. The sRGB’s transfer function (gamma), by design, already approximates the perceived lightness.

This will actually work, up to a point. Many font rendering engines do this, probably after having experimented with linear anti-aliasing and not getting the desired results. And even image editors used to compose in a similar non-linear color spaces in the past.

However instead of a uniform gradient between the foreground and the background the colors may get seriously distorted.

However I think the code is just converting back to something close to srgb.

https://github.com/kovidgoyal/kitty/pull/5969#issuecomment-1426707968[45] the Kitty terminal also has a thickness adjustment based on the contrast between the background luminance (underL) and foreground luminance (overL):

// Apply additional gamma-adjustment scaled by the luminance difference, the darker the foreground the more adjustment we apply.
// A multiplicative contrast is also available to increase sasturation.
over.a = clamp(mix(over.a, pow(over.a, text_gamma_adjustment), (1 - overL + underL) * text_gamma_scaling) * text_contrast);

Patrick Walton @pcwalton says on Twitter[46] :

For posterity: Seems that default glyph dilation on macOS is min(vec2(0.3px), vec2(0.015125, 0.0121) * S) where S is the font size in px

I had a lot of trouble getting things to look right, and I am not the only one https://x.com/rygorous/status/512371399542202368[47]

 9.1 TODO visual diff

if I wanted to study this, I could extract the pixels with gamma correction, and extract with various values of out_bias, and find an out_bias value that compares. maybe do a visual diff?

 9.2 TODO optimization

https://iquilezles.org/articles/gpuconditionals/[48]

 10  Contrast#

The goal of these is to increase the contrast between the text and the map https://somethingaboutmaps.wordpress.com/2021/06/01/on-edges/[49]

Cartography community calls several of these “halos”

overlap problem with adjacent characters - /blog/2024-09-27-sdf-combining-distance-fields/

 11  My pages#

 12  Other pages#