Multi-Channel Signed Distance Field Fonts with MonoGame

When developing a game, we will invariably need to use fonts in some fashion, be it as part of the world’s graphics, user interface, journal entries, etc. For the best user experience, all our text should be clean, crisp, and easily readable at various sizes.

The built-in technology for rendering text with MonoGame is provided by its SpriteFont class. It works by taking a standard, vector-based font and turning it into a bitmapped font that can be rendered quickly.

The problem with sprite fonts is that they exhibit many artifacts as we scale them to sizes smaller or larger than the bitmapped font’s source size. Using multi-channel signed distance field fonts is an alternative way to render fonts that don’t suffer from this defect.

The SpriteFont Problem

Sprite fonts are defined by a *.spritefont file, which specifies the size to base the generated bitmap texture on.

Lato.spritefont
<XnaContent xmlns:Graphics="Microsoft.Xna.Framework.Content.Pipeline.Graphics">
  <Asset Type="Graphics:FontDescription">
    <FontName>Lato-Regular</FontName>
    <Size>90</Size>
    <Spacing>0</Spacing>
    <UseKerning>true</UseKerning>
    <Style>Regular</Style>
    <CharacterRegions>
      <CharacterRegion>
        <Start> </Start>
        <End>~</End>
      </CharacterRegion>
    </CharacterRegions>
  </Asset>
</XnaContent>

This will generate a bitmap for the Lato font with a size of 90 as the source size. Text rendered at this source size will look great; however, we’ll notice a significant degradation in quality if we deviate much from that initial size.

Shows the visual artifacting evident when scaling sprite fonts.
The text looks fine at the source size; however, its quality degrades as the size changes.

The above is a Lato sprite font rendered in various sizes. As we can see, there is some noticeable degradation in quality as we increase or decrease the size. Depending on the font, the observed artifacts may even be worse than the depiction above.

If we use multi-channel signed distance field fonts instead of sprite fonts, we will notice a marked increase in quality.

Shows the superior scaling capability MSDF fonts.
Text rendered with MSDF fonts exhibit no degradation in quality as the size changes.

The rendered text looks insanely better. Adding support for MSDF fonts to MonoGame is a bit of work, however. This article and its follow-up will go over everything you need to do, but first, let’s take a brief look at what precisely multi-channel signed distance fields are.

Multi-Channel Signed Distance Field Fonts

None of this would be possible without the work of Viktor Chlumský. He is the author of the msdfgen and msdf-atlas-gen libraries and the principal researcher on multi-channel signed distance field fonts.

There is probably no better source regarding how MSDF fonts work than Viktor’s thesis paper on the subject. If you want a proper understanding of the topic, I suggest you study that paper. For a very cursory overview, read on.

Single-Channel Signed Distance Fields

Before we dive into multi-channel signed distance fields, let’s start with the more usual variety: signed distance fields using a single channel.

A signed distance field is essentially a visual representation of the distance between a given point and the edge of an object. It is the result of applying a signed distance transformation to a subset of N-dimensional space.

But how are the distance fields represented visually? By encoding the distance value in the form of an RGBA color channel.

Shows a single-channel signed distance field font of the letter 'H'.
A single-channel SDF.

Above, we can see the SDF for an ‘H’ glyph. As only one channel is involved, it is essentially monochrome. Pixels with values of 0.5 are precisely on the edge of the original glyph, decreasing to 0.0 as we move further away and increasing to 1.0 as we move towards the inside.

When processing the SDF, a shader will sample the particular pixel being rendered and only output it when the distance field value is greater than 0.5.

So why is this useful? Because a distance field image is much better suited to scaling via nearest-neighbor interpolation than a traditional font image. Information will be lost in a scaled traditional image that would’ve otherwise been maintained with a distance field image processed by a shader.

Single-channel signed distance fields aren’t perfect however, and they will fail to maintain the integrity of some glyph characteristics with enough scaling applied.

Multi-Channel Signed Distance Fields

A major failing of single-channel signed distance fields is the inability to represent parts of a shape that are meant to be sharp corners, which cause irregularities in the distance field. The interpolation employed by the shader provides accurate reconstruction only where the rate of change is more or less constant; therefore, we’ll be left with rounded corners instead of sharp ones.

Using multiple channels to create additional distance fields is one possible solution to this problem.

Shows a multi-channel signed distance field font of the letter 'H'.
A multi-channel SDF.

Here is a distance field (this time using multiple channels) for the same ‘H’ glyph. Instead of a monochrome encoding of distance fields, we use the RGB and alpha channels.

With multiple distance fields blended, we will only output pixels that pass the minimum threshold in more than one channel. By doing this, we should see an improvement in the quality of our rendered corners.

The shader needed to process an MSDF is much the same as the one we’d use for processing an SDF. The main difference is that after sampling the distance field, the shader needs to compute the median of the RGB channels (the alpha channel only needs to be considered for “specialized use cases” that fall outside this article’s scope).

Computing the median value is a very simple operation, which means processing an MSDF should be as performant as processing an SDF.

I think that summarizes the most critical aspects of multi-channel signed distance fields; for further information, I suggest the reader consult the thesis paper on the topic linked above.

With this brief overview now complete, let’s move on to the primary purpose of these articles: how to add MSDF support to our C# MonoGame projects. But before we can even add support for this new type of content, we need to be able to generate said content from our code.

A C++/CLI Wrapper for msdf-atlas-gen

Like MonoGame’s built-in sprite fonts, our MSDF font will also be generated and packaged by MonoGame’s content pipeline system.

The goal is for our MSDF font content to be as simple to add to our project as sprite font content, namely, through a single configuration file that points to the font vector data and the desired character set (among other options).

Generating MSDF Textures: An Involved Process

The generation of MSDF font texture data is a highly complex affair. Luckily, the msdfgen and msdf-atlas-gen utilities take care of this heavy concern.

msdfgen is a library capable of generating individual MSDF font glyphs, while msdf-atlas-gen, which itself uses msdfgen, provides the means to create entire compact MSDF font atlases.

While msdf-atlas-gen may be more known as a standalone .exe utility, it can also be compiled as a library, which our code can then consume to generate font atlases on the fly.

Unfortunately, as is the case with much of the software out there that performs brainy, complicated work, both of the libraries mentioned above consist of entirely unmanaged, native C++ code.

From managed code, we typically interface with unmanaged libraries through P/Invoke function invocations. P/Invoke works great for simple C-style exports, but it is no longer an option when we need to do a bunch of complicated calls using native data structures.

Sadly, generating an MSDF font atlas using msdf-atlas-gen requires much more code than a single function call.

BadEcho.MsdfGenerator

To provide a bridge between our managed content pipeline code and the unmanaged MSDF generation code, I created the BadEcho.MsdfGenerator C++/CLI library.

This project uses vcpkg to retrieve and build its C/C++ dependencies. Because the msdf-atlas-gen library is not on the official vcpkg registry, we have to use a custom vcpkg registry hosting a port (package definition) that will build msdf-atlas-gen as a linkable library correctly for us.

The BadEcho.MsdfGenerator project consists of a single header and .cpp file — fairly barebones functionality for now; I will probably add more features in the future.

DistanceFieldFontAtlas.h
#pragma once

#include <msdf-atlas-gen/msdf-atlas-gen.h>

namespace BadEcho::MsdfGenerator {

	/// <summary>
	/// Represents configuration settings for a multi-channel signed distance field font atlas to
	/// generate.
	/// </summary>
	public value struct FontConfiguration sealed
	{
		/// <summary>
		/// The path to the font file (.ttf/.otf) to create an atlas for.
		/// </summary>
		System::String^ FontPath;
		/// <summary>
		/// The path to the file containing the character set to include in the atlas; defaults
		/// to ASCII if unset.
		/// </summary>
		System::String^ CharsetPath;		
		/// <summary>
		/// The path to the JSON file to write the atlas's layout data to.
		/// </summary>
		System::String^ JsonPath;
		/// <summary>
		/// The path to the image file to write the atlas to.
		/// </summary>
		System::String^ OutputPath;
		/// <summary>
		/// The size of the glyphs in the atlas, in pixels-per-em.
		/// </summary>
		unsigned int Resolution;
		/// <summary>
		/// The distance field range in output pixels, which affects how far the distance field
		/// extends beyond the glyphs.
		/// </summary>
		unsigned int Range;
	};

	/// <summary>
	/// Provides multi-channel signed distance field font atlas generation methods.
	/// </summary>
	public ref class DistanceFieldFontAtlas abstract sealed
	{
	public:
		/// <summary>
		/// Generates a MSDF atlas using the specified settings.
		/// </summary>
		/// <param name="configuration">
		/// The configuration settings for the font atlas to generate.
		/// </param>
		static void Generate(FontConfiguration configuration);

	private:
		static void Generate(msdfgen::FontHandle* font,
							 const msdf_atlas::Charset& charset,
							 FontConfiguration configuration);
	};
}

The FontConfiguration struct is our main configuration object used to mold the MSDF atlas output. It is the sole parameter to the all-important Generate method, whose implementation is where all the magic lives.

DistanceFieldFontAtlas.cpp
#include "DistanceFieldFontAtlas.h"
#include <msclr/marshal.h>
#include <msclr/marshal_cppstd.h>

using namespace System;
using namespace msdfgen;
using namespace msdf_atlas;
using namespace msclr::interop;
using namespace BadEcho::MsdfGenerator;

void DistanceFieldFontAtlas::Generate(FontConfiguration configuration)
{
	FreetypeHandle* freeType = initializeFreetype();

	if (freeType == nullptr)
		return;

	marshal_context^ context = gcnew marshal_context();
	const char* fontPath = context->marshal_as<const char*>(configuration.FontPath);

	if (FontHandle* font = loadFont(freeType, fontPath))
	{
		Charset charset;
		bool charsetLoaded;	

		if (String::IsNullOrEmpty(configuration.CharsetPath))
		{
			charset = Charset::ASCII;
			charsetLoaded = true;
		}
		else
		{
			const char* charsetPath = context->marshal_as<const char*>(configuration.CharsetPath);
			charsetLoaded = charset.load(charsetPath, false);
		}

		if (charsetLoaded)
			Generate(font, charset, configuration);

		destroyFont(font);
	}

	delete context;
	deinitializeFreetype(freeType);
}

void DistanceFieldFontAtlas::Generate(
	FontHandle* font, const Charset& charset, FontConfiguration configuration)
{
	std::vector<GlyphGeometry> glyphs;
	FontGeometry fontGeometry(&glyphs);

	if (!fontGeometry.loadCharset(font, 1, charset, true, true))
		return;

	int glyphCount = static_cast<int>(glyphs.size());

	for (GlyphGeometry &glyph : glyphs)
	{
		glyph.edgeColoring(&edgeColoringInkTrap, 3.0, 0);
	}

	TightAtlasPacker atlasPacker;

	atlasPacker.setDimensionsConstraint(
		TightAtlasPacker::DimensionsConstraint::MULTIPLE_OF_FOUR_SQUARE);
	atlasPacker.setScale(configuration.Resolution);
	atlasPacker.setPixelRange(configuration.Range);
	atlasPacker.setUnitRange(0);
	atlasPacker.setMiterLimit(0);
	atlasPacker.setPadding(0);
	atlasPacker.pack(glyphs.data(), glyphCount);

	int width = 0, height = 0;

	atlasPacker.getDimensions(width, height);

	double scale = atlasPacker.getScale();
	double range = atlasPacker.getPixelRange();

	ImmediateAtlasGenerator<float, 4, mtsdfGenerator, BitmapAtlasStorage<byte, 4>>
		generator(width, height);

	GeneratorAttributes attributes;
	generator.setAttributes(attributes);
	generator.setThreadCount(4);
	generator.generate(glyphs.data(), glyphCount);

	marshal_context^ context = gcnew marshal_context();
	const char* outputPath = context->marshal_as<const char*>(configuration.OutputPath);
	const char* jsonPath = context->marshal_as<const char*>(configuration.JsonPath);

	savePng(generator.atlasStorage(), outputPath);
	exportJSON(	&fontGeometry,
				1, 
				scale, 
				range, 
				width, 
				height, 
				ImageType::MTSDF, 
				YDirection::TOP_DOWN, 
				jsonPath, 
				true);

	delete context;
}

The above code was more or less put together by piecing together the code that would executed by the msdf-atlas-gen standalone when generating an MTSDF (MSDF with true signed distance in the alpha) atlas type using the specified resolution and range parameters, with the image being output as a PNG file and layout data output as a JSON file.

With this complete, we can now generate MSDF font atlases within our content pipeline extension.

Content Pipeline Extension

To add support for MSDF content, we need to add a content pipeline extension for it. Refer to the previous articles I wrote on the subject for more general-purpose information regarding extending the content pipeline.

Support for packing MSDF font data into the content pipeline was added to the Bad Echo Content Pipeline Extension library; we’ll review how that was done in this next section.

Distance Field Font File Format

The content fed into the pipeline typically has a corresponding file type that defines its data. For our MSDF font content, we use the .sdfont file format, a format I made up and whose schema can be found here.

Example .sdfont File
{
  "fontPath": "Lato-Regular.ttf",
  "characterSet": [
    {
      "start": 32,
      "end": 127
    },
    {
      "start": 128,
      "end": 255
    }
  ],
  "resolution": 128,
  "range": 16
}

When processed by our pipeline extension, this asset file should produce an MSDF font atlas containing characters belonging to the Extended ASCII character set for the Lato font.

Distance Field Font Content Types

We now create types needed to represent .sdfont files in managed code.

DistanceFieldFontAsset.cs
/// <summary>
/// Provides configuration data for a multi-channel signed distance field font asset.
/// </summary>
public sealed class DistanceFieldFontAsset
{
    /// <summary>
    /// Gets or sets the path to the font file (.ttf/.otf) to create an atlas for.
    /// </summary>
    public string FontPath
    { get; set; } = string.Empty;

    /// <summary>
    /// Gets the character set that will be included in the font atlas.
    /// </summary>
    [JsonConverter(typeof(JsonRangeConverter<char>))]
    public IEnumerable<char>? CharacterSet
    { get; init; }
    
    /// <summary>
    /// Gets the size of the glyphs in the atlas, in pixels-per-em.
    /// </summary>
    public int Resolution
    { get; init; }

    /// <summary>
    /// Gets the distance field range in output pixels, which affects how far the distance field
    /// extends beyond the glyphs.
    /// </summary>
    public int Range
    { get; init; }
}

The above type is suitable for deserializing .sdfont JSON data into. The JsonRangeConverter decorating the CharacterSet property will convert the character ranges specified in the .sdfont file into an actual range of character values (which is required by msdf-atlas-gen).

DistanceFieldFontContent.cs
/// <summary>
/// Provides the raw data for a multi-channel signed distance field font asset.
/// </summary>
public sealed class DistanceFieldFontContent : ContentItem<DistanceFieldFontAsset>
{
    /// <summary>
    /// Initializes a new instance of the <see cref="DistanceFieldFontContent"/> class.
    /// </summary>
    public DistanceFieldFontContent(DistanceFieldFontAsset asset) 
        : base(asset)
    { }

    /// <summary>
    /// Gets or sets the path to the generated atlas image file.
    /// </summary>
    public string AtlasPath
    { get; set; } = string.Empty;

    /// <summary>
    /// Gets or sets the font's characteristics parsed from the generated layout data.
    /// </summary>
    public FontCharacteristics Characteristics
    { get; set; } = new();

    /// <summary>
    /// Gets a mapping between unicode characters and their typographic representations,
    /// parsed from the generated layout data.
    /// </summary>
    public Dictionary<char, FontGlyph> Glyphs
    { get; init; } = new();

    /// <summary>
    /// Gets a mapping between unicode character pairs and the adjustments of space between
    /// them, parsed from the generated layout data.
    /// </summary>
    public Dictionary<CharacterPair, KerningPair> Kernings
    { get; init; } = new();
}

The above class is much like a standard custom content type, save for the additional four properties meant for data not immediately available at the time of asset deserialization.

These properties are set by the content processor, one of the entities we’ll be taking a look at next.

Distance Field Font Importer and Processor

The content importer for our MSDF fonts is a fairly standard one — it deserializes the asset and initializes a new DistanceFieldFontContent instance with it.

DistanceFieldFontImporter.cs
/// <summary>
/// Provides an importer of multi-channel signed distance field font asset data for the
/// content pipeline.
/// </summary>
[ContentImporter(".sdfont", 
                 DisplayName = "Distance Field Font Importer - Bad Echo", 
                 DefaultProcessor = nameof(DistanceFieldFontProcessor))]
public sealed class DistanceFieldFontImporter : ContentImporter<DistanceFieldFontContent>
{
    /// <inheritdoc/>
    public override DistanceFieldFontContent Import(string filename, ContentImporterContext context)
    {
        Require.NotNull(filename, nameof(filename));
        Require.NotNull(context, nameof(context));

        context.Log(Strings.ImportingDistanceFieldFont.InvariantFormat(filename));

        var fileContents = File.ReadAllText(filename);
        var asset = JsonSerializer.Deserialize<DistanceFieldFontAsset?>(
            fileContents,
            new JsonSerializerOptions
            {
                PropertyNameCaseInsensitive = true
            });

        if (asset == null)
        {
            throw new ArgumentException(
                Strings.DistanceFieldFontIsNull.InvariantFormat(filename), nameof(filename));
        }

        context.Log(Strings.ImportingDependency.InvariantFormat(asset.FontPath));

        asset.FontPath
            = Path.Combine(Path.GetDirectoryName(filename) ?? string.Empty, asset.FontPath);

        string name = Path.GetFileNameWithoutExtension(asset.FontPath);

        context.AddDependency(asset.FontPath);
        context.Log(Strings.ImportingFinished.InvariantFormat(filename));

        return new DistanceFieldFontContent(asset)
               {
                   Identity = new ContentIdentity(filename), Name = name
               };
    }
}

Again, not much going on with the importer. The processor is where the fun happens.

DistanceFieldFontProcessor.cs
/// <summary>
/// Provides a processor of multi-channel signed distance field font asset data for the content
/// pipeline.
/// </summary>
[ContentProcessor(DisplayName = "Distance Field Font Processor - Bad Echo")]
public sealed class DistanceFieldFontProcessor 
    : ContentProcessor<DistanceFieldFontContent, DistanceFieldFontContent>
{
    private const string UNICODE_PROPERTY_NAME = "unicode";

    /// <summary>
    /// Acts as a modifier to the initial resolved <see cref="JsonTypeInfo"/> contract which
    /// normalizes the differences between the externally generated JSON layout file and our
    /// object model.
    /// </summary>
    private static Action<JsonTypeInfo> ConvertUnicodeProperties
        => static typeInfo =>
        {
            foreach (var property in typeInfo.Properties)
            {   // "Unicode" would be a confusing property name for our character values.
                if (property.Name.Equals(nameof(FontGlyph.Character),
                                         StringComparison.OrdinalIgnoreCase))
                {
                    property.Name = UNICODE_PROPERTY_NAME;
                }
                // Unicode character values are encoded as JSON numbers in the generated layout
                // file; .NET's own data type for UTF-16 code values is more immediately useful
                // to us.
                if (property.Name.Contains(UNICODE_PROPERTY_NAME,
                                           StringComparison.OrdinalIgnoreCase))
                {
                    property.CustomConverter 
                        = new JsonIntFuncConverter<char>(i => (char) i, c => c);
                }
            }
        };

    /// <inheritdoc/>
    public override DistanceFieldFontContent Process(
        DistanceFieldFontContent input, ContentProcessorContext context)
    {
        Require.NotNull(input, nameof(input));
        Require.NotNull(context, nameof(context));

        context.Log(
            Strings.ProcessingDistanceFieldFont.InvariantFormat(input.Identity.SourceFilename));

        string intermediatePath = context.IntermediateDirectory;
        string charsetPath = CreateCharacterSetFile(input.Asset, input.Name, intermediatePath);
        string atlasPath = Path.Combine(intermediatePath, $"{input.Name}-atlas.png");
        string jsonPath = Path.Combine(intermediatePath, $"{input.Name}-layout.json");

        var fontConfiguration = new FontConfiguration
                                {
                                    FontPath = input.Asset.FontPath,
                                    CharsetPath = charsetPath,
                                    OutputPath = atlasPath,
                                    JsonPath = jsonPath,
                                    Range = (uint) input.Asset.Range,
                                    Resolution = (uint) input.Asset.Resolution
                                };

        DistanceFieldFontAtlas.Generate(fontConfiguration);

        if (!File.Exists(atlasPath))
            throw new PipelineException(Strings.DistanceFieldFontNoOutput);

        if (!File.Exists(jsonPath))
            throw new PipelineException(Strings.DistanceFieldFontNoJsonOutput);

        DistanceFieldFontContent output = ProcessOutput(input, jsonPath);

        output.AtlasPath = atlasPath;
        output.AddReference<Texture2DContent>(
            context,
            atlasPath,
            new OpaqueDataDictionary
            {   // The default color key is similar to the colors used for distance fields.
                { nameof(TextureProcessor.ColorKeyEnabled), false },
                // Our atlas image is already in a premultiplied format.
                // Formatting it again would corrupt it.
                { nameof(TextureProcessor.PremultiplyAlpha), false }
            });

        return output;
    }

    private static DistanceFieldFontContent ProcessOutput(DistanceFieldFontContent input, 
                                                          string jsonPath)
    {
        var layoutFileContents = File.ReadAllText(jsonPath);
        var options = new JsonSerializerOptions
                      {
                          PropertyNameCaseInsensitive = true,
                          Converters =
                          {
                              new EdgeRectangleConverter(),
                              // Flatten "atlas": {...}, "metrics": {...}
                              new JsonFlattenedObjectConverter<FontCharacteristics>(2) 
                          },
                          TypeInfoResolver = new DefaultJsonTypeInfoResolver
                                             {
                                                 Modifiers = { ConvertUnicodeProperties }
                                             }
                      };

        var fontLayout = JsonSerializer.Deserialize<FontLayout>(layoutFileContents, options)
                         ?? throw new JsonException(Strings.DistanceFieldFontJsonIsNull);

        return new DistanceFieldFontContent(input.Asset)
               {
                   Name = input.Name,
                   Identity = input.Identity,
                   Characteristics = fontLayout.Characteristics,
                   Glyphs = fontLayout.Glyphs.ToDictionary(kv => kv.Character),
                   Kernings = fontLayout.Kerning.ToDictionary(
                       kv => new CharacterPair(kv.Unicode1, kv.Unicode2),
                       kv => new KerningPair(kv.Unicode1, kv.Unicode2, kv.Advance))
               };
    }
    
    private static string CreateCharacterSetFile(
        DistanceFieldFontAsset asset, string fontName, string intermediatePath)
    {
        if (asset.CharacterSet == null)
            return string.Empty;

        // The character set file consists of a single string of characters, surrounded by quotes.
        // We will want to escape any double quotes found within this string, as well as
        // any \ chars.
        var charset = new string(asset.CharacterSet.ToArray())
                      .Replace("\\", "\\\\", StringComparison.OrdinalIgnoreCase)
                      .Replace("\"", "\\\"", StringComparison.OrdinalIgnoreCase);

        string charsetPath = Path.Combine(intermediatePath, $"{fontName}-charset.txt");

        File.WriteAllText(charsetPath, $"\"{charset}\"");

        return charsetPath;
    }
}

Our content processor’s primary responsibility is the generation of our MSDF font atlas data by leveraging the BadEcho.MsdfGenerator C++/CLI library.

Our MSDF generator not only creates the atlas texture but also generates detailed font layout data in a JSON format — this layout data is almost as critical as the atlas image itself.

The layout data will be required by our functional MSDF font model types to render text correctly; therefore, the other responsibility of the processor is the shaping of the generated JSON layout data to fit in with the larger Bad Echo object model design.

Also, I just can’t abide having numbers in my property names. Sorry!

Distance Field Font Content Writer

Finally, our MSDF content needs to be written to the content pipeline. To finish this article code-wise, here is the MSDF font content writer.

DistanceFieldFontWriter.cs
/// <summary>
/// Provides a writer of raw multi-channel signed distance field font content to the content
/// pipeline.
/// </summary>
[ContentTypeWriter]
public sealed class DistanceFieldFontWriter : ContentTypeWriter<DistanceFieldFontContent>
{
    /// <inheritdoc/>
    public override string GetRuntimeReader(TargetPlatform targetPlatform)
        => typeof(DistanceFieldFontReader).AssemblyQualifiedName ?? string.Empty;

    /// <inheritdoc/>
    protected override void Write(ContentWriter output, DistanceFieldFontContent value)
    {
        Require.NotNull(output, nameof(output));
        Require.NotNull(value, nameof(value));

        ExternalReference<Texture2DContent> atlasReference
            = value.GetReference<Texture2DContent>(value.AtlasPath);

        output.WriteExternalReference(atlasReference);
        output.WriteObject(value.Characteristics);
        output.WriteObject(value.Glyphs);
        output.WriteObject(value.Kernings);
    }
}

A very basic content writer — as most content writers tend to be.

The Remaining Pieces of the Puzzle

This article has covered adding support for MSDF fonts to our content pipeline; however, some critical components still require implementation!

Namely, the functional domain model types responsible for laying out vertex data for our MSDF fonts and the shaders they need to use to render said vertex data.

The next part in this series on using multi-channel signed distance field fonts with MonoGame will cover that and more. See you then!