msdfgen parameters

 from Red Blob Games
9 Sep 2024

I use the msdf-atlas-gen[1] utility to generate font bitmaps from MSDFgen[2]. It has several parameters I’ve previously experimented with:

However, my earlier experiments were primarily with Fira Sans, and without halos or drop shadows. Since the halos use an extended part of the distance range, I wanted to re-run these experiments to see if I need a different set of parameters. I also want to try the new apxrange / aemrange parameters for asymmetric ranges, as well as other parameters (pxalign , empadding, outerempadding, overlap). And I wanted to update my code to work with MSDFgen 1.12, which changes the glyph coordinate system.

Last time I experimented, it was ad-hoc. I would tweak a parameter and take a screenshot. This time I want to learn how to use a headless rendering library so that I can generate images reproducibly. I had some trouble getting stackgl/headless-gl to work with twgl.js, but just put in workarounds until I got something working. If I’m going to keep using this in the future, I’ll fix it then.

Experiments: display texture size along with rendered output, with outline + halo

The headless renderer here also is useful for testing parameters like antialiasing width, or gamma values, or different font sizes. I can see all the outputs at once instead of interactively seeing one at a time.

 1  Update msdfgen#

I wanted to document the commands I ran, for my own reference. I had tried vcpkg install msdfgen but that installed the library, and I wanted the command line tool. I already had it checked out in git but needed to updated it:

cd ~/Projects/src/vcpkg
git pull

brew install autoconf automake autoconf-archive

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

cmake --fresh .
make

The changelog says that the old coordinate system was buggy and they have a new coordinate system. But it looks like the metrics are roughly the same, so I didn’t have to change my parameters.

 2  Bug: interior corners#

While working on this I got annoyed by an outline artifact. I’ve seen this before but didn’t think about it too much. Now that I’m looking closely at individual letters, it bothered me enough to look closely.

Outline color bleeds into this area

Why does this happen? I am using MSDF, which is a multi-channel signed distance field. This handles sharp corners (except for this bug[6]). But I am also using SDF, a single channel signed distance field, where I want rounded corners. I noticed that all the outline color bleeds are happening with interior corners. I wondered if that correlated with the places msdf and sdf differ, so I added some lines to the shader to override the color:

if (d_msdf > d_sdf * 1.01) gl_FragColor = vec4(1, 0, 1, 1);
if (d_sdf > d_msdf * 1.01) gl_FragColor = vec4(0, 1, 1, 1);

and indeed, the cyan areas are where I want the rounded corners (sdf), and the purple areas are where I want the sharp corners (msdf):

Debug: cyan where sdf > msdf, magenta where sdf < msdf

This is easy to fix! I can take the max of the two:

d_sdf = max(d_sdf, d_msdf);
Fixed outline bleed

However, I later decided that outlines should always follow msdf and never sdf, so I reverted this change. Halos will always use sdf and outlines always use msdf.

 3  Bug: round outlines#

Fixing this made me think about whether I wanted sdf or msdf for the outlines. In the earlier implementation of outlines, I was using the same code for outlines and halos, and I wanted sdf for halos, so I used sdf for outlines too. But my current implementation uses separate halo and outline code, so I could use msdf for outlines and sdf for halos. I compared the output, and found that using msdf for outlines cleans up some other glitches I had noticed, so I decided to use msdf for outlines.

 4  Bug: drop shadow bleed#

Even setting dropShadow to 0, it’s showing up. I noticed this by setting the drop shadow color to red. Hm. It seems to happen when I have both the drop shadow and halo enabled.

Possible fix: merge drop shadow and halo code

I ended up doing just that — I merged the code, so now you can’t have both drop shadows and halos at the same time.

 5  Test: emrange#

All the tests involve running msdf-atlas-gen and then generating output. This is the original command:

cd ~x/2437-msdfgen-parameters/
cd atlas

~/Projects/src/msdf-atlas-gen/bin/msdf-atlas-gen \
    -type mtsdf -emrange 0.5 -dimensions 511 511 \
    -font ~/Library/Fonts/FiraSans-Regular.otf \
    -imageout assets/FiraSans-Regular.png \
    -json assets/FiraSans-Regular.json

I want to test a large halo, a thin outline, a thicker font, a thinner font, and a medium sized drop shadow.

This is how I can test the emrange:

for test in 1 2 3 4 5 6 7 8 9
do
    ~/Projects/src/msdf-atlas-gen/bin/msdf-atlas-gen \
        -type mtsdf -emrange 0.$test -dimensions 511 511 \
        -font ~/Library/Fonts/FiraSans-Regular.otf \
        -imageout assets/FiraSans-Regular.png \
        -json assets/FiraSans-Regular.json
    node msdfgen-parameters.js
    cp _output.png output/test-emrange-$test.png
done

Wow, I learned a lot from this test.

emrange from 0.1 to 0.9
  1. (expected) The halo distance needs emrange to be high
  2. (bug) The outline thickness depends on emrange, and shouldn’t
  3. (bug) The font thickness depends on emrange, and shouldn’t
  4. (bug) The drop shadow distance depends on emrange, and shouldn’t
  5. (suspected) The outline quality goes down when emrange goes up
  6. (unexpected) There’s a bug that renders the red drop shadow even when the shadow is set to 0

I think the asymmetric emrange feature added to msdfgen will be helpful here, as I can keep the halo range high while not losing as much outline quality. However, I need to fix these bugs to make a better comparison.

I think there may be a precision problem with my intermediate texture, but I can’t easily fix this.

 6  Fix: scaling#

The outline thickness and font thickness depend on emrange but shouldn’t. In particular, columns 3, 4, 5 should look the same in each row:

outline and font thickness depends on emrange

I thought about this for a while and now think I understand:

emrange affects the slope

The emrange affects the slope of the mapping from distance to the value in the texture. My outline and font thickness are looking at the y value, the encoded sdf. But I want to be looking at the x value, the distance value.

I need to use the distanceRange value stored in the atlas. In the original atlas, with emrange set to 0.5, I had distanceRange=23.828125,size=47.65625. In one with emrange set to 0.9, I have distanceRange=30.881250000000001,size=34.3125. Looking at these ratios, distanceRange/size is equal to emrange. If I want the thresholds to be based on size then I need to divide that by emrange to get the distance range. I think.

To test this:

for test in 2 5 9
do
    ~/Projects/src/msdf-atlas-gen/bin/msdf-atlas-gen \
        -type mtsdf -emrange 0.$test -dimensions 511 511 \
        -font ~/Library/Fonts/FiraSans-Regular.otf \
        -imageout assets/FiraSans-Regular.png \
        -json assets/FiraSans-Regular.json
    node msdfgen-parameters.js
    cp _output.png output/test-emrange-fixed-$test.png
done

Columns 3, 4, 5 now look roughly the same in each row:

fix outline and font thickness to handle emrange

Drop shadows are still messed up but I may end up reimplementing them a different way, so I don’t want to spend a lot of time tweaking them right now.

 7  Test: atlas size#

I tested emrange of 0.9, which has the worst quality, to see if it would get any better with larger atlas sizes.

for test in 127 128 255 256 511 1023 2047 4095
do
    ~/Projects/src/msdf-atlas-gen/bin/msdf-atlas-gen \
        -type mtsdf -emrange 0.9 -dimensions $test $test \
        -font ~/Library/Fonts/FiraSans-Regular.otf \
        -imageout assets/FiraSans-Regular.png \
        -json assets/FiraSans-Regular.json
    node msdfgen-parameters.js
    cp _output.png output/test-atlassize-$test.png
done
emrange 0.9, atlas size 127, 255, 511, 1023, 2047, 4095
atlas size 127, 128, 255, 256 to see if power of two matters
  1. (unexpected) The drop shadow distance also depends on COMBINED_SCALE, separate from emrange
  2. (unexpected) The drop shadow distance also depends on the atlas size
  3. I had in my notes that powers of two had some issues with twgl, but at least in this test it didn’t matter (although it may be different for browser webgl vs headless gl, and something I will have to double check later)

It’s clear that the drop shadow implementation needs work.

A higher emrange needs a higher resolution atlas, so I also tried at a more reasonable emrange 0.4 to see how well smaller atlases did:

for test in 127 255 511 1023 2047 4095
do
    ~/Projects/src/msdf-atlas-gen/bin/msdf-atlas-gen \
        -type mtsdf -emrange 0.4 -dimensions $test $test \
        -font ~/Library/Fonts/FiraSans-Regular.otf \
        -imageout assets/FiraSans-Regular.png \
        -json assets/FiraSans-Regular.json
    node msdfgen-parameters.js
    cp _output.png output/test-atlassize-$test-emrange-4.png
done
emrange 0.4, atlas size 127, 255, 511, 1023, 2047, 4095

Here 255 was pretty good, and 511 was slightly better. I didn’t see any meaningful improvement past 511.

 8  Fix: drop shadow#

The drop shadow implementation I had put in was a quick & dirty hack, and from the earlier tests it seemed like it didn’t scale well and it didn’t play well with halos.

I decided to remove the drop shadow implementation, and extend halos to support offsets. I changed the halo opacity dropoff to be more like the drop shadow’s. I am not sure this is the right thing to do. But it’s something to investigate later.

I then tested various atlas sizes, COMBINED_SCALE parameters, and emrange parameters, and came up with a way to scale the drop shadow coordinates so that they are independent of those. I tried to make it so that a drop shadow offset of 100 means 1 character width, but right now I don’t know if this worked out because the calculations are correct or just by accident.

In any case, it’s much better than it was before. A drop shadow is a dark halo with an offset. But I think I need to revisit this at some point.

I should have taken before/after screenshots.

 9  Test: aemrange#

The distance field sets 0 at the edge of the font. The interior is <0 and exterior is >0 (or vice versa, depending on convention). I made a visualization of the distance field:

Contour lines showing the distance field

From this we can see that the interior has far fewer contour lines than the exterior. That’s because fonts are relatively thin. If it were a large object like a solid circle, it would have many contour lines on the interior.

The encoding of the distance field maps distances to a value 0.0–1.0, which gets stored in the texture as 0–255. By default, the encoding sets distance=0 to value=0.5 which will be 127.5. Let’s look at how much of the font space is used by each encoded value, with blue being the interior of the font, green the outline, and brown the extended halo:

Distribution of 0-255 values within the font atlas image

From this histogram we can see that a large portion of the 0–255 range is wasted. It’d be nice if we could move the distance=0 point to be somewhere else. And in fact, MSDFgen 1.3 supports this, with the aemrange parameter.

Here’s the original command:

~/Projects/src/msdf-atlas-gen/bin/msdf-atlas-gen \
    -type mtsdf -emrange 0.5 -dimensions 511 511 \
    -font ~/Library/Fonts/FiraSans-Regular.otf \
    -imageout assets/FiraSans-Regular.png \
    -json assets/FiraSans-Regular.json
node msdfgen-parameters.js
cp _distribution.json output/symmetric-distribution.json

and here’s the version that uses aemrange instead of emrange:

~/Projects/src/msdf-atlas-gen/bin/msdf-atlas-gen \
    -type mtsdf -aemrange -0.4 0.1 -dimensions 511 511 \
    -font ~/Library/Fonts/FiraSans-Regular.otf \
    -imageout assets/FiraSans-Regular.png \
    -json assets/FiraSans-Regular.json
node msdfgen-parameters.js
cp _distribution.json output/asymmetric-distribution.json

This uses the available range much better:

Distribution of 0-255 values within the font atlas image

In the asset json is distanceRange=19.3875, distanceRangeMiddle=-6.4625. I need to update my code to handle this. The new zero point is distanceRangeMiddle/distanceRange.

Does this improve the quality? I think it's easier to tell with emrange set to 0.8, or aemrange set to -0.7 +0.1 (which preserves the 0.8 distance):

~/Projects/src/msdf-atlas-gen/bin/msdf-atlas-gen \
    -type mtsdf -emrange 0.8 -dimensions 511 511 \
    -font ~/Library/Fonts/FiraSans-Regular.otf \
    -imageout assets/FiraSans-Regular.png \
    -json assets/FiraSans-Regular.json
node msdfgen-parameters.js
cp _output.png output/test-symmetric-4-4.png
~/Projects/src/msdf-atlas-gen/bin/msdf-atlas-gen \
    -type mtsdf -aemrange -0.7 0.1 -dimensions 511 511 \
    -font ~/Library/Fonts/FiraSans-Regular.otf \
    -imageout assets/FiraSans-Regular.png \
    -json assets/FiraSans-Regular.json
node msdfgen-parameters.js
cp _output.png output/test-asymmetric-7-1.png
Symmetric vs asymmetric use of range

Oh, but it did not improve quality! It's mainly noticeable in the third column. What's going on?

Aha. emrange set to 0.8 is the same as aemrange set to -0.4 +0.4. The first value sets the size in the atlas. When I tried -0.7 +0.1 it made the distance field go out farther, which means less resolution for the letter and more for the halo. I want to keep the halo size constant to make this comparison, so I should only change the second number.

~/Projects/src/msdf-atlas-gen/bin/msdf-atlas-gen \
    -type mtsdf -aemrange -0.4 0.1 -dimensions 511 511 \
    -font ~/Library/Fonts/FiraSans-Regular.otf \
    -imageout assets/FiraSans-Regular.png \
    -json assets/FiraSans-Regular.json
node msdfgen-parameters.js
cp _output.png output/test-asymmetric-4-1.png
Symmetric vs asymmetric use of range

The asymmetric version here does look slightly better. The contour lines are closer together, which means we are getting better resolution. It's hard to notice, but the top left curve of the & is a little bit smoother with the asymmetric version. And the outlines on the third column look quite a bit better.

Asymmetry helps by throwing away the range that is unused. But what if it was really needed? The range depends on the font. If it's set too low, we'll "clip" the useful part of the range. I added a line to the shader to detect this:

if (distances.g >= 1.0) gl_FragColor = vec4(1, 0.5, 0, 1);

and here's what it looks like when clipping:

If the range is too asymmetric, the interior distances get clipped

This is something I'll leave in so that when I'm experimenting with different fonts, it'll let me know if something is wrong.

 10  Test: antialiasing edge width#

I reset my atlas for the next experiments.

~/Projects/src/msdf-atlas-gen/bin/msdf-atlas-gen \
    -type mtsdf -aemrange -0.3 0.1 -dimensions 1024 1024 \
    -font ~/Library/Fonts/FiraSans-Regular.otf \
    -imageout assets/FiraSans-Regular.png \
    -json assets/FiraSans-Regular.json

Although my goal for this project was to experiment with msdfgen's parameters, having a headless renderer has been nice for testing the shader parameters too. Previously I was using interactive sliders to see the effect of one value at a time. But now I can generate output for lots of parameters and see them all side by side. For example, the antialiasing edge width parameter:

labels: [1, 2, 5, 10, 15, 20, 25, 30].map((edge_blur, index) => [
    new Label({text: "&", x: -860 + 245*index, y: -125, size: 400, outline: 1.0, edge_blur: edge_blur}),
    new Label({text: (edge_blur/10).toFixed(1), x: -860 + 245*index, y: -170, size: 30, innerColor: "#ffffff", haloColor: "#000000", halo: 200}),
]).flat(),
The effect of u_edge_blur_px on rendering

I had set it to 1.0 by default, and this confirms that 1.0 is a reasonable choice. Lower values from 0.5 down look a little blocky, and higher values from 2.0 up look a little blurry. I decided to zoom into that range:

labels: [9, 10, 11, 12, 13, 14, 15, 16].map((edge_blur, index) => [
    new Label({text: "&", x: -860 + 245*index, y: -125, size: 400, outline: 1.0, edge_blur: edge_blur}),
    new Label({text: (edge_blur/10).toFixed(1), x: -860 + 245*index, y: -170, size: 30, innerColor: "#ffffff", haloColor: "#000000", halo: 200}),
]).flat(),
The effect of u_edge_blur_px on rendering, zooming in on the promising range

From eyeballing this, I think maybe 1.2 or 1.3 might be the best choice. I don't have any explanation for this. Could be it 1.25? Could it be sqrt(1.5)? I don't know.

{ tech note : use style="image-rendering: pixelated" when looking at this on the web }

This is a reminder that although it's fun to make things interactive, it's often better to show all the outputs at once instead of requiring interaction to see one output at a time.

TODO also test for small text, including out_bias

 11  Test: other fonts#

~/Projects/src/msdf-atlas-gen/bin/msdf-atlas-gen \
    -type mtsdf -aemrange -0.3 0.1 -dimensions 1024 1024 \
    -font ~/Library/Fonts/FiraSans-Regular.otf \
    -imageout assets/FiraSans-Regular.png \
    -json assets/FiraSans-Regular.json
node msdfgen-parameters.js
cp _output.png output/font-firasans-regular.png
Fira Sans Regular
~/Projects/src/msdf-atlas-gen/bin/msdf-atlas-gen \
    -type mtsdf -aemrange -0.3 0.1 -dimensions 1024 1024 \
    -font ~/Library/Fonts/Lora-Italic\[wght\].ttf \
    -imageout assets/Lora-Italic.png \
    -json assets/Lora-Italic.json
node msdfgen-parameters.js
cp _output.png output/font-lora-italic.png
Lora Italic
~/Projects/src/msdf-atlas-gen/bin/msdf-atlas-gen \
    -type mtsdf -aemrange -0.3 0.1 -dimensions 1024 1024 \
    -font ~/Library/Fonts/Merriweather-Regular.ttf \
    -imageout assets/Merriweather-Regular.png \
    -json assets/Merriweather-Regular.json
node msdfgen-parameters.js
cp _output.png output/font-merriweather-regular.png
Merriweather Regular
~/Projects/src/msdf-atlas-gen/bin/msdf-atlas-gen \
    -type mtsdf -aemrange -0.3 0.1 -dimensions 1024 1024 \
    -font ~/Library/Fonts/SourceSerifPro-LightIt.otf \
    -imageout assets/SourceSerifPro-LightIt.png \
    -json assets/SourceSerifPro-LightIt.json
node msdfgen-parameters.js
cp _output.png output/font-sourceserifpro-lightit.png
Source Serif Pro Light Italic
~/Projects/src/msdf-atlas-gen/bin/msdf-atlas-gen \
    -type mtsdf -aemrange -0.3 0.1 -dimensions 1024 1024 \
    -font ~/Library/Fonts/Vegur-R\ 0.601.otf \
    -imageout assets/Vegur-Regular.png \
    -json assets/Vegur-Regular.json
node msdfgen-parameters.js
cp _output.png output/font-vegur-regular.png
Vegur Regular

I wanted to compare the quality loss with Fira Sans and with Lora Italic.

~/Projects/src/msdf-atlas-gen/bin/msdf-atlas-gen \
    -type mtsdf -emrange 0.5 -dimensions 512 512 \
    -font ~/Library/Fonts/FiraSans-Regular.otf \
    -imageout assets/FiraSans-Regular-lo.png \
    -json assets/FiraSans-Regular-lo.json
node msdfgen-parameters.js
cp _output.png output/firasans-regular-lo.png
~/Projects/src/msdf-atlas-gen/bin/msdf-atlas-gen \
    -type mtsdf -emrange 0.5 -dimensions 512 512 \
    -font ~/Library/Fonts/Lora-Italic\[wght\].ttf \
    -imageout assets/Lora-Italic-lo.png \
    -json assets/Lora-Italic-lo.json
node msdfgen-parameters.js
cp _output.png output/font-lora-italic-lo.png
~/Projects/src/msdf-atlas-gen/bin/msdf-atlas-gen \
    -type mtsdf -aemrange -0.2 0.05 -dimensions 2048 2048 \
    -font ~/Library/Fonts/Lora-Italic\[wght\].ttf \
    -imageout assets/Lora-Italic-hi.png \
    -json assets/Lora-Italic-hi.json
node msdfgen-parameters.js
cp _output.png output/font-lora-italic-hi.png

I halved resolution and also removed the asymmetric emrange, and it didn't make things much worse with Fira Sans:

Fira Sans, low vs high resolution atlas

But in Lora Italic it made a difference in the third column especially, in this thin section where the two curves come together:

Lora Italic, low vs high vs higher resolution atlas

So I think that's something to be aware of — thin curved sections of fonts may require higher resolution atlases.

 12  Test: gamma and out_bias#

In a previous gamma correction test, I had noticed out_bias improved the text rendering too. But this depends on the font size, the bias parameter, and the gamma value. This has been hard for me to figure out.

Comparing gamma with out_bias set to 0:

...Array.from({length: 20}, (_, i) => new Label({x: 600, y: -250 + 24 * i, text: "Live and drink, friend. " + (Math.pow(1.5, (i-10)/10)).toFixed(2), size: 18, gamma: Math.pow(1.5, (i-10)/10)})),
...Array.from({length: 20}, (_, i) => new Label({x: 825, y: -250 + 24 * i, text: "Live and drink, friend. " + (Math.pow(1.5, (i-10)/10)).toFixed(2), size: 18, gamma: Math.pow(1.5, (i-10)/10), innerColor: "#ffffff"})),
different gamma

Comparing out_bias with gamma set to 1.4:

...Array.from({length: 20}, (_, i) => new Label({x: 600, y: -250 + 24 * i, text: "Live and drink, friend. " + ((i-10)/20).toFixed(2), size: 18, gamma: 1.4, out_bias: (i-10)/20})),
...Array.from({length: 20}, (_, i) => new Label({x: 825, y: -250 + 24 * i, text: "Live and drink, friend. " + ((i-10)/20).toFixed(2), size: 18, gamma: 1.4, out_bias: (i-10)/20, innerColor: "#ffffff"})),
different out_bias

Comparing font sizes with out_bias set to 1/8 and gamma set to 1.4:

...Array.from({length: 20}, (_, i) => new Label({x: 600, y: -250 + 24 * i, text: "Live and drink, friend. " + (8+i), size: 8+i, gamma: 1.4, out_bias: 1/8})),
...Array.from({length: 20}, (_, i) => new Label({x: 825, y: -250 + 24 * i, text: "Live and drink, friend. " + (8+i), size: 8+i, gamma: 1.4, out_bias: 1/8, innerColor: "#ffffff"})),
different font sizes

Email me , or tweet @redblobgames, or comment: