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 |
- note1: there are newer GPU-accelerated font rendering systems that use the vectors. I have not yet explored these.

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.
- The low value (-0.4) depends on the maximum range I want outside the font for outline, halo, and other special effects. Setting it too low (e.g. -1.0 is safe but may reduce quality because of precision). Setting it too high (e.g. -0.1) is unsafe and will clip the special effects. Outlines are thinner so they don’t require as low a value as halo or glow or drop shadow effects.
- The high value (0.1) depends on the maximum distance inside the font, as calculated by
msdfgen. It should be higher for thicker fonts. Setting it too low (e.g. 0.05) may reduce quality because of truncation. Setting it too high (e.g. 0.5) may also reduce quality because of precision.
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]:
-
advanceis the horizontal advance in em’s. -
planeBoundsrepresents the glyph quad’s bounds in em’s relative to the baseline and horizontal cursor position. -
atlasBoundsrepresents the glyph’s bounds in the atlas in pixels.
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:

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:

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

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:

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

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”:
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:
- stb_truetype[10] - c++ library
- tiny-sdf[11] - js library
- msdfgen[12] - c++ command line tool
- msdf-bmfont-xml[13] - javascript command line tool
- https://github.com/ShoYamanishi/SDFont[14]
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:
- https://github.com/Blatko1/awesome-msdf[19] - shaders with thickness, outlines, glow, drop shadow, gamma correction, plus links to discussions and resources
- https://github.com/maltaisn/msdf-gdx/blob/master/lib/src/main/resources/font.frag[20] - shadow
- https://github.com/maplibre/maplibre-gl-js/blob/main/src/shaders/symbol_text_and_icon.fragment.glsl[21] - halo
- https://www.shadertoy.com/view/XtdSRs[22] - border
- https://github.com/qt/qtlocation-mapboxgl/blob/upstream/master/src/mbgl/programs/gl/symbol_sdf_text.cpp[23] - halo
- https://www.reddit.com/r/gamedev/comments/2879jd/comment/cicatot/[24] - supersampling ; I have not noticed a big difference myself, but I need to test again with small text and gamma, with mipmapping off
- https://drewcassidy.me/2020/06/26/sdf-antialiasing/[25] does antialiasing by using the screen space derivative of the distance field but msdf distance fields have discontinuities, so it’s better to use the derivative of the texture coordinate
-
https://blog.frost.kiwi/analytical-anti-aliasing/[26] says that
fwidth()is just an approximation, and that it’s more accurate to getlength(vec2(dFdx(dist), dFdy(dist))); https://bgolus.medium.com/the-best-darn-grid-shader-yet-727f9278b9d8[27] says that too - https://old.reddit.com/r/gamedev/comments/2879jd/just_found_out_about_signed_distance_field_text/ci88v9q/[28] says sdf gradient gets lost with small font sizes, and that setting 0 threshold higher might help (but setting it lower is what we want for glow), and supersample not on pixels but on input space? need to re-read this
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:
-
[ ]turn on/off mipmapping -
[ ]show msdfgen shader vs mine -
[ ]turn on/off gamma correct rendering -
[ ]does premultiplied alpha interfere with the t-msdf format? maybe I should stick to non-t-msdf? I’m thinking it shouldn’t matter because that channel isn’t being used as alpha -
[ ]fwidth() vs length(), only matters when text is rotated I think, so need to show text at different rotations -
[ ]compare to reference? https://opentype.js.org/[29] or <canvas>, or maybe a vector overlay? -
[ ]given these toggles, build the shader function from parts, and then run it to show the output; probably need more than one active at a time, and also some way to compare two different settings, maybe highlight the changed lines in yellow, and also flash the changed pixels
5 Constructing SDFs#
The two main methods I see are:
- starting with vectors
msdfgen-atlasstb_truetype- https://github.com/mapbox/node-fontnik[30]
- starting with bitmaps
tiny-sdf- https://acko.net/blog/subpixel-distance-transform/[31] → says tiny-sdf is simple but subtly wrong
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.
-
[ ]0 to 1/w vs -1/2w to +1/2w - out_bias
- edge_width_px
- there may be more tweaks like using cubic interpolation instead of linear https://mini.gmshaders.com/p/gm-shaders-mini-interpolation-1430549[36]
- https://mapbox.s3.amazonaws.com/kkaefer/sdf/index.html[37] has a shader. Their “gamma” is antialias edge width, not actually gamma correction.
- https://blog.pkh.me/p/44-perfecting-anti-aliasing-on-signed-distance-functions.html[38] - explains with diagrams how we should construct the antialiasing ramp, covers L1 vs L2 norm, ramp width, 2D vs 3D, linear step vs smoothstep, linear vs srgb space
- https://docs.google.com/document/d/1Ci8Ko_qbOthj2i7TIKoqDqJ8E2ibitR9h748HcTrZ2U/edit?tab=t.0[39] - there’s an antialiasing section I need to look at more closely
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
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”
- outline
- fade/feather
- blur https://somethingaboutmaps.wordpress.com/2021/05/19/blurring-backgrounds-to-improve-text-legibility/[50]
- median filter https://ica-proc.copernicus.org/articles/6/4/2024/ica-proc-6-4-2024.pdf[51] - didn’t implement
- selectively applying https://somethingaboutmaps.wordpress.com/2018/10/28/smart-type-halos-in-photoshop-and-illustrator/[52]
- drop shadow
- knockout - remove other high contrast edges (requires these to be in a separate layer so we can mask them)
overlap problem with adjacent characters - /blog/2024-09-27-sdf-combining-distance-fields/
11 My pages#
- /x/2403-distance-field-fonts/
- /x/2404-distance-field-effects/
- /x/2405-text-effects/
- /x/2414-font-distortion/
- /x/2428-map-typography/
- /x/2437-msdfgen-parameters/
- /x/2440-parameter-dragging/
- /x/2445-srgb-webgl/
- /blog/2024-03-21-sdf-fonts/
- /blog/2024-03-27-distance-field-effects/
- /blog/2024-03-30-text-effects/
- /blog/2024-04-13-testing-font-code/
- /blog/2024-05-31-font-distortion/
- /blog/2024-08-20-labels-on-maps/
- /blog/2024-08-27-sdf-font-outlines/
- /blog/2024-09-08-sdf-font-spacing/
- /blog/2024-09-22-sdf-antialiasing/
- /blog/2024-09-27-sdf-combining-distance-fields/
- /blog/2024-10-09-sdf-curved-text/
- /blog/2024-11-08-sdf-headless-tests/
- /blog/2024-11-17-sdf-headless-tests/ - determine what asymmetric emrange I need
- /blog/2024-12-08-sdf-halos/
12 Other pages#
- https://medium.com/@evanwallace/easy-scalable-text-rendering-on-the-gpu-c3f4d782c5ac[53] -
- https://wdobbie.com/post/gpu-text-rendering-with-vector-textures/[54] - reads font beziers in the fragment shader, webgl1 demo
- https://osor.io/text[55] - had been using msdf, switched to rendering from the bezier curves directly, made text sharper (but I didn’t see an obvious comparison screenshot)
- https://infinitecanvas.cc/guide/lesson-015#msdf[56] - plus, references at the end
- https://github.com/servo/pathfinder[57] uses gpu compute shaders, which should be faster than sdfs, and better quality at small sizes
- https://www.youtube.com/watch?v=_sv8K190Zps[58] - Raph Levien’s talk about 2D vector rendering, including fonts; uses more vertex shader time than an atlas but uses less fragment shader time, especially at larger font sizes
- https://harfbuzz.github.io/harfbuzzjs/[59]
- https://github.com/JimmyLefevre/kb[60] - text shaping (given a string, tells you where to place each glyph)
- https://axleos.com/writing-a-truetype-font-renderer[61]
- https://github.com/roy-t/MSDF[62] - C#, uses msdfgen