Multi-Channel Signed Distance Field Fonts with MonoGame - Part 2

In the first part of this series, we discussed multi-channel signed distance field fonts, how they work, and how to generate them as content.

Today, we’ll look at the object model types in our game or game framework needed to render text using our MSDF content. This article will cover the techniques employed to generate our vertex data as well as the various shaders we use to color the pixels.

At this article’s completion, we’ll have covered everything we need to have fully functional MSDF fonts in our games.

How MSDF Text Models are Generated

Before diving into our code for rendering MSDF font text, let’s take a step back and do a high-level overview of how we will generate the vertex data for the text.

Creating a model for a string of text will require the careful placement of consecutive quad primitives, each with vertices positioned in a manner specific to the glyph being mapped to it. The spacing between each consecutive glyph must be consistent with the font’s design, with any relevant kerning adjustments applied.

Luckily, all the necessary metrics are provided in the MSDF layout JSON file generated by msdf-atlas-gen and have been packed into the content pipeline by our pipeline extension. To understand these metrics, however, we’ll need to first touch on some fundamentals of typography.

Basic Typographic Concepts

Let’s look at some basic typeface characteristics that require consideration if we wish to render text from our generated MSDF font content.

Shows the word 'Flaxy' rendered as MSDF text with some of its measurements pointed out.
A rendered MSDF text string and some of its associated metrics.

Have no illusions, there exist far better places on the net for learning about typography; nevertheless, I shall do my best to illuminate some basic terms and concepts using the above image of some rendered MSDF text as a reference.

Baseline

The baseline is precisely what it sounds like. It is the line upon which most letters rest. It is also where our pen rests when we’re between letters.

The baseline is important in that most of the metrics found in the MSDF layout file generated by msdf-atlas-gen are relative to it.

X-height

The x-height is the height of the lowercase ‘x’ glyph and other lowercase characters with no ascenders or descenders in a typeface.

This metric isn’t all that relevant for us, as it is not present in the generated layout data; it’s just good to know about!

Ascender

Ascenders are upward strokes found in certain (and typically lowercase) letters that extend well beyond the x-height. The ascender line depicts the height of the highest ascender.

The ascender distance, represented by the ascender JSON property in the generated layout data, is the vertical distance between the typeface’s baseline and the ascender line. This is a very important metric for us, as it is necessary for the initial positioning of our cursor/pen.

Descender

Descenders are the opposite of ascenders: downward strokes found in certain (and typically lowercase) letters extending beyond the baseline. The descender line depicts the height of the lowest descender.

The descender distance, represented by the descender JSON property in the generated layout data, is the vertical distance between the typeface’s baseline and the descender line.

Writing with a Vertex Pen

Now that we have a rudimentary understanding of some of the metrics in our MSDF layout data, we can begin to devise a process for generating the model data needed to render strings of text.

We will (loosely speaking) write out our vertex data in much the same way we might write the letters with a pen on paper (OK…very loosely speaking).

Shows the process of plotting vertex data for the text 'Sx', with each glyph being composed of two triangle primitives.
The general process of plotting vertex data for rendering a sequence of glyphs, with each glyph being a pair of triangle primitives (or quad).

We are given an initial position at the outset of model data generation. As is the case with most positional resources, this position specifies the top-left corner of the model.

However, as was stated before, layout metrics are relative to the baseline, which, as you can see in the image above, is nowhere near our initial position. In fact, because the initial position is at the top-left corner of our model, it follows that it rests on the ascender line.

In order to move our cursor or pen to the baseline, we perform a vertical translation of our initial position by multiplying it by the vector normal (perpendicular) to the baseline at a magnitude equal to that of the ascender distance metric.

All of this will shift our cursor’s location to the the proper cursor start point.

Modeling a Glyph

We can now begin modeling the first glyph in the string from our baseline cursor position. Two triangle primitives form a quadrilateral polygon, encompassing a glyph’s shape and texture.

Vertices are created and positioned based on the bounding rectangle found in the planeBounds JSON property for the glyph in the MSDF layout data. They are made in the order shown in the image above (i.e., starting at 0 and ending at 3).

The vertices are then indexed as we index all flat quadrilateral polygons: in a clockwise winding order. We don’t want any part of our glyphs culled!

Advancing Onwards!

Once a glyph is modeled, it’s time to move ever forward to the next one. This is done by adding an advance width to the current position of our cursor.

The advance width is the distance between the current glyph’s origin and the next glyph’s origin; in other words, the distance between two successive cursor positions.

It is a character-specific value, stored in the advance JSON property for the glyph. Adding it to the cursor location will position it such that the next glyph can be drawn. If a kerning adjustment is defined for the character pair in question, then that must also be added to the cursor position.

After advancement is complete, we basically repeat all the above steps until we reach the end of the string. And that’s all she wrote.

Domain Model

We now need to take everything we discussed above, and put it into action with our code.

The previous article in this series covered the classes in our content pipeline extension and how they were responsible for generating our font data and packaging it for later consumption. We’ll now look at our font domain model, which is the object model containing the objects we’ll be using to draw text on the screen.

Our MSDF font domain model comprises the following types:

  • DistanceFieldFont: The primary object type that exposes text rendering methods, produced by the content pipeline.
  • FontCharacteristics: Metrics and statistics applicable to a font atlas as whole, sourced the from msdf-atlas-gen layout output.
  • FontGlyph: A typographic representation for a single unicode character.
  • CharacterPair/KerningPair: Defines space adjustments between character pairings.
  • FontModelData: Generates the vertex data needed to render text using our font.

A number of these types do not warrant coverage in an article write-up; for further information, feel free to peruse the source code of any of the types not covered.

FontModelData – Generating Model Data for Text

This class will generate vertex data required to render a model of text using our MSDF font content. It is an implementation of the vertex generation process described in the previous sections above.

FontModelData.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
/// <summary>
/// Provides the vertex data required to render a 3D model of flat signed distance field font text.
/// </summary>
public sealed class FontModelData : QuadModelData<VertexPositionOutlinedColorTexture>
{
    private readonly DistanceFieldFont _font;
    private readonly Color _fillColor;
    private readonly Color _strokeColor;
 
    /// <summary>
    /// Initializes a new instance of the <see cref="FontModelData"/> class.
    /// </summary>
    /// <param name="font">The multi-channel signed distance font to vertex data for.</param>
    /// <param name="color">The color of the text.</param>
    public FontModelData(DistanceFieldFont font, Color color)
        : this(font, color, default)
    {
        FillOnly = true;
    }
 
    /// <summary>
    /// Initializes a new instance of the <see cref="FontModelData"/> class.
    /// </summary>
    /// <param name="font">The multi-channel signed distance font to vertex data for.</param>
    /// <param name="fillColor">The inner color of the text.</param>
    /// <param name="strokeColor">The outer color of the text.</param>
    public FontModelData(DistanceFieldFont font, Color fillColor, Color strokeColor)
        : base(VertexPositionOutlinedColorTexture.VertexDeclaration)
    {
        Require.NotNull(font, nameof(font));
 
        _font = font;
        _fillColor = fillColor;
        _strokeColor = strokeColor;
    }
 
    /// <summary>
    /// Gets a value indicating if the coloring of the text consists of only a single fill color,
    /// without any outlining.
    /// </summary>
    public bool FillOnly
    { get; }
 
    /// <summary>
    /// Adds 3D modeling data for quadrilateral surfaces that can be mapped to the specified text
    /// using glyphs found in a font atlas texture during rendering.
    /// </summary>
    /// <param name="text">The text to prepare modeling data for.</param>
    /// <param name="position">The position of the top-left corner of the text.</param>
    /// <param name="scale">The amount of scaling to apply to the text.</param>
    /// <remarks>
    /// <para>
    /// In order to model a glyph, we create <see cref="VertexPositionOutlinedColorTexture"/> values
    /// whose texture coordinates are based on the generated atlas coordinates for the glyph.
    /// These coordinates must be normalized such that they are in a range from 0 to 1 where (0, 0)
    /// is the top-left of the texture and (1, 1) is the bottom-right of the texture.
    /// </para>
    /// <para>
    /// Much like what we do with <see cref="QuadTextureModelData"/>, we divide the atlas (source)
    /// rectangle's individual vertex coordinates by the appropriate font atlas texture dimension,
    /// based on the axis that the particular vertex rests on.
    /// </para>
    /// <para>
    /// Unlike what we do with <see cref="QuadTextureModelData"/>, the source rectangle has no
    /// impact on the position of the vertices; rather, we make base the position off of the
    /// generated plane coordinates for the glyph. These coordinates are relative to the baseline
    /// cursor, and allow us to position the glyphs appropriately.
    /// </para>
    /// </remarks>
    public void AddText(string text, Vector2 position, float scale)
    {
        if (string.IsNullOrEmpty(text))
            return;
 
        var advanceDirection = new Vector2(1, 0);
        // The vertical direction is the vector that's perpendicular to our advance direction.
        var verticalDirection = -1 * new Vector2(advanceDirection.Y, -advanceDirection.X);
 
        // The provided position vector value specifies where the top-left corner of the text
        // should be placed. The generated signed distance glyph plane coordinates, however,
        // are meant to be applied relative to the baseline cursor. Therefore, we subtract
        // the distance between the baseline and ascender line from our initial cursor,
        // giving us a cursor now positioned at the baseline.
        Vector2 cursorStart
            = position + verticalDirection * scale * _font.Characteristics.Ascender * -1;
        Vector2 cursor = cursorStart;
        Vector2 scaledAdvance = advanceDirection * scale;
        PointF? offset = position;
 
        for (int i = 0; i < text.Length; i++)
        {
            char character = text[i];
            FontGlyph glyph = _font.FindGlyph(character);
 
            if (!char.IsWhiteSpace(character))
                AddGlyph(glyph, _font.Characteristics, cursor, scaledAdvance, offset);
 
            char? nextCharacter = i < text.Length - 1 ? text[i + 1] : null;
 
            cursor += _font.GetNextAdvance(character, nextCharacter, advanceDirection, scale);
 
            // Only apply an offset the starting quad corner of the text. Another offset may be
            // needed if a line break is inserted.
            offset = null;
        }
    }
 
    /// <inheritdoc/>
    protected override Vector3 GetVertexPosition(VertexPositionOutlinedColorTexture vertex)
        => vertex.Position;
 
    private void AddGlyph(FontGlyph glyph,
                          FontCharacteristics characteristics,
                          Vector2 cursor,
                          Vector2 scaledAdvance,
                          PointF? offset)
    {
        Require.NotNull(glyph, nameof(glyph));
        Require.NotNull(characteristics, nameof(characteristics));
 
        RectangleF atlasBounds = glyph.AtlasBounds;
        float texelLeft = atlasBounds.X / characteristics.Width;
        float texelRight = (atlasBounds.X + atlasBounds.Width) / characteristics.Width;
        float texelTop = atlasBounds.Y / characteristics.Height;
        float texelBottom = (atlasBounds.Y + atlasBounds.Height) / characteristics.Height;
 
        Vector2 verticalAdvance = -1 * new Vector2(scaledAdvance.Y, -scaledAdvance.X);
        RectangleF planeBounds = glyph.PlaneBounds;
        Vector2 positionLeft = scaledAdvance * planeBounds.Left;
        Vector2 positionRight = scaledAdvance * planeBounds.Right;
        Vector2 positionTop = verticalAdvance * planeBounds.Top;
        Vector2 positionBottom = verticalAdvance * planeBounds.Bottom;
 
        var vertexTopLeft = new VertexPositionOutlinedColorTexture(
            new Vector3(cursor + positionTop + positionLeft, 0),
            _fillColor,
            _strokeColor,
            new Vector2(texelLeft, texelTop));
 
        var vertexTopRight = new VertexPositionOutlinedColorTexture(
            new Vector3(cursor + positionTop + positionRight, 0),
            _fillColor,
            _strokeColor,
            new Vector2(texelRight, texelTop));
 
        var vertexBottomLeft = new VertexPositionOutlinedColorTexture(
            new Vector3(cursor + positionBottom + positionLeft, 0),
            _fillColor,
            _strokeColor,
            new Vector2(texelLeft, texelBottom));
 
        var vertexBottomRight = new VertexPositionOutlinedColorTexture(
            new Vector3(cursor + positionBottom + positionRight, 0),
            _fillColor,
            _strokeColor,
            new Vector2(texelRight, texelBottom));
 
        AddVertices(vertexTopLeft, vertexTopRight, vertexBottomLeft, vertexBottomRight);
 
        if (offset != null)
            AddQuadCornerOffset(vertexTopLeft, offset.Value);
    }
}

The code above carries out the vertex data generation process detailed in the previous sections.

You can find the up-to-date source here in the (very likely) event that the code changes following the publishing of this article.

This type can be used on its own, but it is mainly used by the primary model type for MSDF fonts, which we’ll be covering next.

DistanceFieldFont – Provisioner of MSDF Goodness

Here’s the star player of the MSDF font gang. This type serves as the direct output of MSDF font content read from the content pipeline, and it provides the functionality needed to generate model data and then render said data onto the screen.

DistanceFieldFont.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
/// <summary>
/// Provides a multi-channel signed distance field font.
/// </summary>
public sealed class DistanceFieldFont
{
    private readonly GraphicsDevice _device;
    private readonly Dictionary<char, FontGlyph> _glyphs;
    private readonly Dictionary<CharacterPair, KerningPair> _kernings;
 
    /// <summary>
    /// Initializes a new instance of the <see cref="DistanceFieldFont"/> class.
    /// </summary>
    /// <param name="device">The graphics device to use when rendering the font's models.</param>
    /// <param name="atlas">The texture of the font's atlas, generated with distance fields.</param>
    /// <param name="characteristics">The font's characteristics and metrics.</param>
    /// <param name="glyphs">
    /// A mapping between unicode characters and their typographic representations.
    /// </param>
    /// <param name="kernings">
    /// A mapping between unicode character pairs and the adjustments of space between them.
    /// </param>
    public DistanceFieldFont(GraphicsDevice device,
                             Texture2D atlas,
                             FontCharacteristics characteristics,
                             Dictionary<char, FontGlyph> glyphs,
                             Dictionary<CharacterPair, KerningPair> kernings)
    {
        Require.NotNull(device, nameof(device));
        Require.NotNull(atlas, nameof(atlas));
        Require.NotNull(characteristics, nameof(characteristics));
        Require.NotNull(glyphs, nameof(glyphs));
        Require.NotNull(kernings, nameof(kernings));
 
        _device = device;
        Atlas = atlas;
        Characteristics = characteristics;
        _glyphs = glyphs;
        _kernings = kernings;
    }
 
    /// <summary>
    /// Gets the texture of this font's atlas, generated with distance fields.
    /// </summary>
    public Texture2D Atlas
    { get; }
 
    /// <summary>
    /// Gets this font's characteristics and metrics.
    /// </summary>
    public FontCharacteristics Characteristics
    { get; }
 
    /// <summary>
    /// Generates a model required to render the specified text when it is being submitted for
    /// drawing.
    /// </summary>
    /// <param name="text">The text to prepare a model for.</param>
    /// <param name="position">The position of the top-left corner of the text.</param>
    /// <param name="color">The color of the text.</param>
    /// <param name="scale">The amount of scaling to apply to the text.</param>
    /// <returns>A <see cref="IModelRenderer"/> instance that will render the model.</returns>
    public IModelRenderer AddModel(string text, Vector2 position, Color color, float scale)
        => AddModel(text, position, color, scale, false);
 
    /// <summary>
    /// Generates a model required to render the specified text when it is being submitted for
    /// drawing.
    /// </summary>
    /// <param name="text">The text to prepare a model for.</param>
    /// <param name="position">The position of the top-left corner of the text.</param>
    /// <param name="color">The color of the text.</param>
    /// <param name="scale">The amount of scaling to apply to the text.</param>
    /// <param name="optimizeForSmallText">
    /// Value indicating if the text should be rendered using a shader optimized for smaller text,
    /// the use of which helps to avoid the artifacting normally observed when attempting to
    /// render signed distance fonts at small scale.
    /// </param>
    /// <returns>A <see cref="IModelRenderer"/> instance that will render the model.</returns>
    /// <remarks>
    /// If text being rendered with the default shader exhibit artifacts, it is recommended to pass
    /// <c>true</c> for <c>optimizeForSmallText</c>. The small-optimized shader is also able to
    /// render larger text, however the additional thickness applied in order to account for
    /// artifacting will also become more noticeable as the scale increases.
    /// </remarks>
    public IModelRenderer AddModel(
        string text, Vector2 position, Color color, float scale, bool optimizeForSmallText)
    {
        var fontData = new FontModelData(this, color);
 
        return AddModel(fontData, text, position, scale, optimizeForSmallText);
    }
 
    /// <summary>
    /// Generates a model required to render the specified text when it is being submitted for
    /// drawing.
    /// </summary>
    /// <param name="text">The text to prepare a model for.</param>
    /// <param name="position">The position of the top-left corner of the text.</param>
    /// <param name="fillColor">The fill color of the text.</param>
    /// <param name="strokeColor">The stroke color of the text.</param>
    /// <param name="scale">The amount of scaling to apply to the text.</param>
    /// <returns>A <see cref="IModelRenderer"/> instance that will render the model.</returns>
    public IModelRenderer AddModel(string text,
                                   Vector2 position,
                                   Color fillColor,
                                   Color strokeColor,
                                   float scale)
        => AddModel(text, position, fillColor, strokeColor, scale, false);
 
    /// <summary>
    /// Generates a model required to render the specified text when it is being submitted for
    /// drawing.
    /// </summary>
    /// <param name="text">The text to prepare a model for.</param>
    /// <param name="position">The position of the top-left corner of the text.</param>
    /// <param name="fillColor">The fill color of the text.</param>
    /// <param name="strokeColor">The stroke color of the text.</param>
    /// <param name="scale">The amount of scaling to apply to the text.</param>
    /// <param name="optimizeForSmallText">
    /// Value indicating if the text should be rendered using a shader optimized for smaller text,
    /// the use of which helps to avoid the artifacting normally observed when attempting to
    /// render signed distance fonts at small scale.
    /// </param>
    /// <returns>A <see cref="IModelRenderer"/> instance that will render the model.</returns>
    /// <remarks>
    /// If text being rendered with the default shader exhibit artifacts, it is recommended to
    /// pass <c>true</c> for <c>optimizeForSmallText</c>. The small-optimized shader is also
    /// able to render larger text, however the additional thickness applied in order to account
    /// for artifacting will also become more noticeable as the scale increases.
    /// </remarks>
    public IModelRenderer AddModel(string text,
                                   Vector2 position,
                                   Color fillColor,
                                   Color strokeColor,
                                   float scale,
                                   bool optimizeForSmallText)
    {
        var fontData = new FontModelData(this, fillColor, strokeColor);
 
        return AddModel(fontData, text, position, scale, optimizeForSmallText);
    }
 
    /// <summary>
    /// Retrieves the typographic representation of the specified unicode character from this font.
    /// </summary>
    /// <param name="character">
    /// The unique character to retrieve the typographic representation for.
    /// </param>
    /// <returns>The <see cref="FontGlyph"/> representing <c>character</c>.</returns>
    public FontGlyph FindGlyph(char character)
    {
        if (!_glyphs.TryGetValue(character, out FontGlyph? glyph))
        {
            throw new ArgumentException(Strings.GlyphNotInFont.InvariantFormat(character),
                                        nameof(character));
        }
 
        return glyph;
    }
 
    /// <summary>
    /// Determines the next advance width to apply to the cursor given the specified character
    /// sequence and direction.
    /// </summary> 
    /// <param name="current">The character the cursor is currently positioned at.</param>
    /// <param name="next">
    /// The next character to advance the cursor to, or null if the current character was the
    /// last one.
    /// </param>
    /// <param name="advanceDirection">The direction to advance the cursor.</param>
    /// <param name="scale">The amount of scaling to apply to the text.</param>
    /// <returns>The next advance width to apply to the current cursor position.</returns>
    public Vector2 GetNextAdvance(char current, char? next, Vector2 advanceDirection, float scale)
    {
        FontGlyph currentGlyph = FindGlyph(current);
        Vector2 advance = advanceDirection * currentGlyph.Advance * scale;
 
        if (next.HasValue && _kernings.TryGetValue(new CharacterPair(current, next.Value),
                                                   out KerningPair? kerning))
        {
            advance += advanceDirection * kerning.Advance * scale;
        }
 
        return advance;
    }
 
    private IModelRenderer AddModel(FontModelData fontData,
                                    string text,
                                    Vector2 position,
                                    float scale,
                                    bool optimizeForSmallText)
    {
        if (!string.IsNullOrEmpty(text))
            fontData.AddText(text, position, scale);
 
        var model = new StaticModel(_device, Atlas, fontData);
         
        return new DistanceFieldFontRenderer(this, model, optimizeForSmallText, !fontData.FillOnly);
    }
 
    /// <summary>
    /// Provides a renderer of signed distance field font models using shaders appropriate to the
    /// size of the text.
    /// </summary>
    private sealed class DistanceFieldFontRenderer : IModelRenderer
    {
        private readonly DistanceFieldFont _font;
        private readonly StaticModel _model;
        private readonly bool _useSmallShader;
        private readonly bool _useStrokedShader;
 
        /// <summary>
        /// Initializes a new instance of the <see cref="DistanceFieldFontRenderer"/> class.
        /// </summary>
        /// <param name="font">The multi-channel signed distance font to render.</param>
        /// <param name="model">A generated model of static font data for rendering.</param>
        /// <param name="useSmallShader"
        /// >Value indicating whether a shader optimized for rendering small text is used.
        /// </param>
        /// <param name="useStrokedShader">
        /// Value indicating whether a shader that applies an outline is used.
        /// </param>
        public DistanceFieldFontRenderer(DistanceFieldFont font,
                                         StaticModel model,
                                         bool useSmallShader,
                                         bool useStrokedShader)
        {
            _font = font;
            _model = model;
            _useSmallShader = useSmallShader;
            _useStrokedShader = useStrokedShader;
        }
 
        /// <inheritdoc/>
        public void Draw()
            => Draw(Matrix.Identity);
 
        /// <inheritdoc/>
        public void Draw(Matrix view)
        {
            GraphicsDevice device = _font._device;
 
            var projection = Matrix.CreateOrthographicOffCenter(0,
                                                                device.Viewport.Width,
                                                                device.Viewport.Height,
                                                                0,
                                                                0,
                                                                -1);
 
            var effect = new DistanceFieldFontEffect(device)
                         {
                             WorldViewProjection = projection,
                             AtlasSize = new Vector2(_font.Characteristics.Width,
                                                     _font.Characteristics.Height),
                             DistanceRange = _font.Characteristics.DistanceRange,
                             Texture = _font.Atlas
                         };
 
            effect.CurrentTechnique = GetTechnique(effect);
 
            _model.Draw(effect);
        }
 
        private EffectTechnique GetTechnique(Effect effect) => (_useSmallShader, _useStrokedShader)
            switch
            {
                (false, false)
                    => effect.Techniques[DistanceFieldFontEffect.LargeTextTechnique],
                (true, false)
                    => effect.Techniques[DistanceFieldFontEffect.SmallTextTechnique],
                (false, true)
                    => effect.Techniques[DistanceFieldFontEffect.LargeStrokedTextTechnique],
                (true, true)
                    => effect.Techniques[DistanceFieldFontEffect.SmallStrokedTextTechnique]
            };
    }
}

The DistanceFieldFont class contains all information pertaining to the font and offers methods to generate models purposed for rendering text. We’ll have some sample usage code for this type at the end of the article.

I have no doubt that I’ll be adding improvements to this over time, but I do feel that the code as it is now is a rather suitable first incarnation. The up-to-date source is here, as always, on the Bad Echo repo.

As one can see by looking at the code, after we generate the model data, we return an IModelRenderer instance. When asked to draw to the screen, these renderers use a custom effect class type: DistanceFieldFontEffect.

And that’s the next stop on this MSDF tour: our custom shaders.

Shaders

We require the use of specialized shaders in order to translate the multi-channel signed distance fields into beautiful text. We will spend some time covering the various different shaders and the effects class that exposes them for use.

Font Effect Class

Because some of these shaders will need to be supplied with MSDF-related generation parameters, a custom effect class was created to simplify the process.

DistanceFieldFontEffect.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
/// <summary>
/// Provides parameters and the shaders that use them which are needed to render multi-channel
/// signed distance field font text.
/// </summary>
public sealed class DistanceFieldFontEffect : Effect, ITextureEffect
{
    private EffectParameter _worldViewProjectionParam;
    private EffectParameter _atlasSizeParam;
    private EffectParameter _distanceRangeParam;
    private EffectParameter _textureParam;
 
    /// <summary>
    /// Initializes a new instance of the <see cref="DistanceFieldFontEffect"/> class.
    /// </summary>
    /// <param name="device">The graphics device used for rendering.</param>
    public DistanceFieldFontEffect(GraphicsDevice device)
        : base(device, Shaders.DistanceFieldFontEffect)
    {
        CacheEffectParameters();
    }
 
    /// <summary>
    /// Initializes a new instance of the <see cref="DistanceFieldFontEffect"/> class.
    /// </summary>
    /// <param name="cloneSource">
    /// The <see cref="DistanceFieldFontEffect"/> instance to clone.
    /// </param>
    private DistanceFieldFontEffect(DistanceFieldFontEffect cloneSource)
        : base(cloneSource)
    {
        CacheEffectParameters();
    }
 
    /// <summary>
    /// Gets the name of the technique designed to render larger text optimally.
    /// </summary>
    public static string LargeTextTechnique
        => "DistanceFieldFont";
 
    /// <summary>
    /// Gets the name of the technique designed to render smaller text optimally.
    /// </summary>
    public static string SmallTextTechnique
        => "SmallDistanceFieldFont";
 
    /// <summary>
    /// Gets the name of the technique designed to render outlined larger text optimally.
    /// </summary>
    public static string LargeStrokedTextTechnique
        => "StrokedDistanceFieldFont";
 
    /// <summary>
    /// Gets the name of the technique designed to render outlined smaller text optimally.
    /// </summary>
    public static string SmallStrokedTextTechnique
        => "StrokedSmallDistanceFieldFont";
 
    /// <summary>
    /// Gets or sets the world, view, and projection transformation matrix.
    /// </summary>
    public Matrix WorldViewProjection
    {
        get => _worldViewProjectionParam.GetValueMatrix();
        set => _worldViewProjectionParam.SetValue(value);
    }
 
    /// <summary>
    /// Gets or sets the width and height of the atlas texture.
    /// </summary>
    public Vector2 AtlasSize
    {
        get => _atlasSizeParam.GetValueVector2();
        set => _atlasSizeParam.SetValue(value);
    }
 
    /// <summary>
    /// Gets or sets the distance field range in pixels.
    /// </summary>
    public float DistanceRange
    {
        get => _distanceRangeParam.GetValueSingle();
        set => _distanceRangeParam.SetValue(value);
    }
 
    /// <summary>
    /// Gets or sets the font atlas texture.
    /// </summary>
    public Texture2D Texture
    {
        get => _textureParam.GetValueTexture2D();
        set => _textureParam.SetValue(value);
    }
 
    /// <summary>
    /// Creates a clone of the current <see cref="DistanceFieldFontEffect"/> instance.
    /// </summary>
    /// <returns>A cloned <see cref="Effect"/> instance of this.</returns>
    public override Effect Clone()
        => new DistanceFieldFontEffect(this);
 
    [MemberNotNull(nameof(_worldViewProjectionParam),
                   nameof(_atlasSizeParam),
                   nameof(_distanceRangeParam),
                   nameof(_textureParam))]
    private void CacheEffectParameters()
    {
        _worldViewProjectionParam = Parameters[nameof(WorldViewProjection)];
        _atlasSizeParam = Parameters[nameof(AtlasSize)];
        _distanceRangeParam = Parameters[nameof(DistanceRange)];
        _textureParam = Parameters[nameof(Texture)];
    }
}

Four custom parameters can be set here. The world-view-projection matrix and texture parameters are fairly standard; the other two are characteristics pertaining to our MSDF font atlas.

The projection matrix is a standard one geared for 2D rendering, set up by the IModelRenderer instance in DistanceFieldFont. An identity matrix for the world works just fine, and the view matrix will be whatever was passed to IModelRenderer.Draw.

The other thing this effect class does is load the bytecode for our *.fx file containing all our shader HLSL. Let us look at that now!

Shader for Normal-Sized Text

We use two varieties of pixel shaders to draw our MSDF text: a standard one for normal- and large-sized text and one optimized for small-sized text. The reason for this is due to the fact that MSDF struggles to render small-sized text 100% correctly, resulting in visible artifacts if the standard shader is used.

The standard shader is more or less based on the shader suggested by the author of msdfgen in the project’s README. Still, I don’t like to type code blindly, so I attempted to understand the purpose behind it all while documenting my thoughts in the comments.

DistanceFontFieldEffect.fx – Normal-Sized Pixel Shader
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// Returns the channel value that is in the middle of a data set consisting of the provided color's
// RGB values.
float Median(float3 color)
{
    return max(min(color.r, color.g), min(max(color.r, color.g), color.b));
}
 
// A pixel shader to use for normal- and large-sized text.
float4 DistancePixelShader(VSOutput input) : COLOR
{   // Divide the distance field range by the atlas size to give us a distance range applicable to
    // texels.
    float2 texelDistanceRange = DistanceRange / AtlasSize;
    float3 sample = tex2D(AtlasSampler, input.TexCoord).rgb;
         
    // Vertex color values go from 0.0 to 1.0. The center of a pixel is located at (0, 0), however
    // the edges are located at +/- 0.5. We subtract 0.5 to align with the pixels.
    float signedDistance = Median(sample) - 0.5f;
 
    // Calculate the relation between the texel distance range and the changes across the input
    // coordinates, and apply this to our signed distance. This converts the distance to screen
    // pixels.
    signedDistance *= dot(texelDistanceRange, 0.5f / fwidth(input.TexCoord));
 
    // Align the distance value back to a normal vertex color value range by adding 0.5 back to it.
    float opacity = clamp(signedDistance + 0.5f, 0.0f, 1.0f);
 
    return input.FillColor * opacity;
}

The magic here, which separates this shader from the single-channel signed distance field kind, is the computing of the median RGB value, giving us the encoded signed field distance.

Refer to the code comments for more information on what the shader is doing if it pleases you.

Shader for Small-Sized Text

In order to maintain glyph design fidelity, it is recommended that font atlases be generated at a resolution as high as practicable. The large size of the glyphs in the atlas will preserve the most minute details.

Shows tiny MSDF text using a non-optimized shader.

Unfortunately, the typical MSDF shader approach starts to run into trouble when we attempt to render tiny text using large glyph sources. Our once beautiful text now appears aliased and no longer true to the original design.

Shows tiny MSDF text using an optimized shader.

A different approach (and a bunch more math) is required to render small text properly. A separate shader was created for this very purpose. The relevant code for the shader will be provided below, with an attempt at explaining what it is doing following right after.

DistanceFontFieldEffect.fx – Small-Sized Pixel Shader
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// Calculates the opacity to apply with respect to screen-space x- and y-coordinates given the
// specified encoded signed distance.
float GetOpacityFromDistance(float signedDistance, float2 Jdx, float2 Jdy)
{   // Continue reading this article for attempted explanation of this code.
    const float distanceLimit = sqrt(2.0f) / 2.0f; 
    const float thickness = 1.0f / DistanceRange;
 
    float2 gradientDistance = SafeNormalize(float2(ddx(signedDistance), ddy(signedDistance)));
    float2 gradient = float2(gradientDistance.x * Jdx.x + gradientDistance.y * Jdy.x,
                             gradientDistance.x * Jdx.y + gradientDistance.y * Jdy.y);
 
    float scaledDistanceLimit = min(thickness * distanceLimit * length(gradient), 0.5f);
 
    return smoothstep(-scaledDistanceLimit, scaledDistanceLimit, signedDistance);
}
 
// A pixel shader to use for small- and normal-sized text.
// This will render small-sized text more accurately without artifacts.
// As the text size increases however, the text may appear 'phatter' than it should.
// In that case, use the standard DistancePixelShader.
float4 SmallDistancePixelShader(VSOutput input) : COLOR
{  
    float2 pixelCoord = input.TexCoord * AtlasSize;   
    float2 Jdx = ddx(pixelCoord);
    float2 Jdy = ddy(pixelCoord);
    float3 sample = tex2D(AtlasSampler, input.TexCoord).rgb;
    float signedDistance = Median(sample) - 0.5f;
     
    float opacity = GetOpacityFromDistance(signedDistance, Jdx, Jdy);
    float4 color;
 
    // Correct for gamma, 2.2 is a valid gamma for most LCD monitors.
    color.a = pow(abs(input.FillColor.a * opacity), 1.0f / 2.2f);  
    color.rgb = input.FillColor.rgb * color.a;
 
    return color;
}

The above shader employs concepts discussed by James M. Lan Verth in his series of articles on the topic, and it is based on the implementation of said concepts by the Cinder-SdfText project.

Having mentioned that, I shall now attempt to spit out a quick explanation of what the code is doing.

How to Render Tiny Text Optimally

Omni Suspicious...

The distance from the center of a pixel to one of its corners is the square root of 2 divided by 2. This comes from the fact that the length of a diagonal is the length of a side (1) multiplied by the square root of 2; dividing this by 2 gives us the length from the center.

If we use this distance value, which we will refer to as the distance limit, to define the minimum and maximum range in a smoothstep function, along with the signed distance value as the value to be interpolated, we will have found the appropriate amount of pixel coverage (the inverse of which is the opacity that needs to be applied).

This only works, however, if the size of the geometry we’re rendering is the same size as the distance field’s bounding rectangle in the source texture. This is clearly not the case with what we want to do, as our atlas texture will more than likely differ in size from our target rendering area.

Scaling Our Problems Away

Multiplying our distance limit by the partial derivative of non-normalized texture pixel coordinates will provide us with uniform scaling of the distance limit. In order to account for non-uniform scaling and perspective, however, we need to scale our distance limit based on how a vector normal to the outline curve is transformed.

We do this by creating a gradient vector that has been normalized to avoid approximation errors when near the edge of the shape. We take this gradient and then multiply it by a Jacobian matrix containing our coordinate value’s first-order partial derivatives.

This will give us our scaled distance limit, which we can then use in the smoothstep function as described above. And that’s it!

Putting It All Together

Let’s see how we go about using new MSDF font classes to write some text.

Testing Our Distance Fonts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// A one time setup of our text renderers...
DistanceFieldFont lato = Content.Load<DistanceFieldFont>("Fonts\\Lato");
 
IModelRenderer smallText = lato.AddModel("Hello",
                                         new Vector2(225, 100),
                                         Color.White,
                                         11f,
                                         true);
 
IModelRenderer bigText = lato.AddModel("Hello",
                                       new Vector2(220, 175),
                                       Color.White,
                                       90f);
 
IModelRenderer outlinedText = lato.AddModel("Hello",
                                            new Vector2(220, 350),
                                            Color.White,
                                            90);
 
// And then wherever our drawing loop is...
smallText.Draw();
bigText.Draw();
outlinedText.Draw();

The above code will produce the resulting output:

Shows the result of running our sample code.
Rendered tiny, normal, and outlined normal text, respectively.

The outlined text is using a shader that wasn’t covered in our article — but it isn’t too different from the shaders we did cover. You can see how they work as well as the rest of the *.fx file’s contents by looking at the full source code.

Omni Dab!

The MSDF font source code in its entirety is part of the Bad Echo Game Framework.

Thanks for reading! Until next time.