msdfgen (C++ / standalone library)
A dependency-free C++ core that turns a closed-contour vector Shape into a multi-channel signed distance field, with FreeType reduced to an optional importer in ext/ — the reference implementation of the technique every MSDF-based GPU text renderer (Godot, msdf-atlas-gen, many game engines) uses.
| Field | Value |
|---|---|
| Language | C++ (C++98-compatible core; C++11 move paths behind MSDFGEN_USE_CPP11), optional OpenMP |
| License | MIT (LICENSE.txt) |
| Repository | Chlumsky/msdfgen |
| Documentation | README.md (library API, shader recipe, shape-description syntax), CHANGELOG.md |
| Category | rasterizer |
| Layer(s) covered | outline · raster |
| Version at pin | 1.13.0 (vcpkg.json) |
| Pinned revision | 1c106ed8117893bf943e577f62eb0665fb271e46 (2026-08-29) |
Overview
What it solves
A conventional signed distance field (Valve, 2007) stores one distance per texel and thresholds it in the fragment shader: resolution-independent, but every corner rounds because one bilinearly interpolated channel cannot hold a discontinuous gradient. msdfgen stores three signed distances per texel, each measured against a differently coloured subset of the outline's edges, and the shader takes their median. Where edges of different colour meet the median reproduces the corner exactly; elsewhere the channels agree and degrade to an ordinary SDF. A 32 × 32 texel bitmap then renders a glyph with sharp corners at any magnification, which is why GPU text pipelines reach for it.
Design philosophy
The core states its one algorithmic idea at the top of the public header:
/*
* MULTI-CHANNEL SIGNED DISTANCE FIELD GENERATOR
* ---------------------------------------------
* A utility by Viktor Chlumsky, (c) 2014 - 2025
*
* The technique used to generate multi-channel distance fields in this code
* has been developed by Viktor Chlumsky in 2014 for his master's thesis,
* "Shape Decomposition for Multi-Channel Distance Fields". It provides improved
* quality of sharp corners in glyphs and other 2D shapes compared to monochrome
* distance fields. To reconstruct an image of the shape, apply the median of three
* operation on the triplet of sampled signed distance values.
*
*/The second principle is the split the README names: "It is divided into two parts, core and extensions. The core module has no dependencies and only uses bare C++." (README.md). Fonts, SVG, PNG and Skia live behind msdfgen-ext.h; the core never sees a font file.
How it works
The pipeline is four calls. loadGlyph (or loadSvgShape, or a hand-built Shape) produces geometry; Shape::normalize fixes degenerate and convergent edges; edgeColoringSimple assigns an EdgeColor to every edge; generateMSDF writes floats into a BitmapSection<float, 3> and runs error correction (README.md "Library API").
The generator is a per-texel nearest-distance search, not a scanline rasterizer. generateDistanceField<ContourCombiner> walks the output in serpentine row order, unprojects each texel centre into shape space, and asks a ShapeDistanceFinder for the distance (core/msdfgen.cpp). The finder caches per-edge results and is documented as "Not thread-safe! Is fastest when subsequent queries are close together" (core/ShapeDistanceFinder.h); under MSDFGEN_USE_OPENMP each thread owns one. Two policy templates pick the metric: the EdgeSelector (TrueDistanceSelector, PerpendicularDistanceSelector, MultiDistanceSelector, MultiAndTrueDistanceSelector) and the ContourCombiner (SimpleContourCombiner takes the nearest contour; OverlappingContourCombiner tracks per-contour windings so overlapping contours keep a correct sign) (core/edge-selectors.h, core/contour-combiners.h). GeneratorConfig::overlapSupport toggles the combiner.
SDFTransformation is a Projection (shape → pixel scale/translate) plus a DistanceMapping (a Range of representable distances onto 0..1) (core/SDFTransformation.h, core/Projection.h, core/Range.hpp).
Analysis spine
1. Layering and ownership
Three layers, each with a different ownership model:
| Layer | Types | Ownership |
|---|---|---|
| geometry | Shape → std::vector<Contour> → std::vector<EdgeHolder> | EdgeHolder is a hand-written owning smart pointer to a heap EdgeSegment (virtual base; clone() on copy) (core/EdgeHolder.h) |
| transform | Projection, DistanceMapping, SDFTransformation, Range | plain values |
| pixels | Bitmap<T, N> (owning) vs BitmapRef/BitmapSection (borrowing) | BitmapSection carries pixels, width, height, a signed rowStride and a YAxisOrientation; "Pixel storage not owned or managed by the object" (core/BitmapRef.hpp) |
| font (ext) | FreetypeHandle, FontHandle | opaque classes wrapping FT_Library / FT_Face; FontHandle::ownership records whether destroyFont may call FT_Done_Face (ext/import-font.cpp) |
Every generate* entry point takes a const BitmapSection<float, N> &output, so the caller owns the destination and may hand in a sub-rectangle of a larger atlas or a negatively-strided flipped image (the 1.13 change: "The library now operates on bitmap section references", CHANGELOG.md). Error handling is bool returns and NULL handles; the core never throws. Thread-safety is per-object: a Shape is read-only during generation, the finder is explicitly single-threaded, and parallelism is OpenMP over rows.
2. Face loading and table access
Only through FreeType, and only to the outline. loadFont calls FT_New_Face(…, 0, …) and loadFontData calls FT_New_Memory_Face(…, 0, …) — face index is hard-coded to 0, so a TTC member beyond the first is unreachable; adoptFreetypeFont(FT_Face) is the escape hatch for a caller that opened the face itself (ext/import-font.cpp, ext/import-font.h). There is no table access: getGlyphCount, getGlyphIndex (FT_Get_Char_Index), getFontMetrics and getKerning (FT_Get_Kerning, FT_KERNING_UNSCALED, so the legacy kern table only) are the entire inspection surface.
3. Shaping
Does not apply. msdfgen is addressed by GlyphIndex or a single unicode_t and has no notion of a string, cluster or feature; consumers (msdf-atlas-gen, Godot's TextServerAdvanced) layer HarfBuzz above it. The core is a per-glyph geometry-to-texture function by design.
4. Variation and instances
Present, thin, and FreeType-mediated. listFontVariationAxes fills FontVariationAxis { Tag tag; const char *name; minValue; maxValue; defaultValue } from FT_Get_MM_Var, converting 16.16 fixed with F16DOT16_TO_DOUBLE; setFontVariationAxis (by 4-char tag or by name) reads the current design coordinates with FT_Get_Var_Design_Coordinates, replaces one, and writes the whole vector back with FT_Set_Var_Design_Coordinates (ext/import-font.cpp). Coordinates are user/design, never normalized; avar and STAT are FreeType's business; named instances are not exposed. Because loadGlyph reads face->glyph->outline after FT_Load_Glyph(…, FT_LOAD_NO_SCALE), the varied outline flows into the Shape automatically: variation reaches rasterization purely as changed input geometry. All of it compiles out under MSDFGEN_DISABLE_VARIABLE_FONTS.
5. Rasterization and outlines
Outline API. The Shape is a concrete path object: contours of LinearSegment (Point2 p[2]), QuadraticSegment (p[3]) and CubicSegment (p[4]), each exposing point(t), direction(t), signedDistance(origin, param), scanlineIntersections, bound, reverse, splitInThirds (core/edge-segments.h). Import from FreeType is a callback sink: readFreetypeOutline installs FT_Outline_Funcs (move_to/line_to/conic_to/cubic_to) and the ftLineTo/ftConicTo callbacks append an EdgeHolder per segment, dropping zero-length edges (ext/import-font.cpp). writeShapeDescription serialises a shape back to the README's text syntax (core/shape-description.h).
Units. FontCoordinateScaling selects FONT_SCALING_NONE (raw font units), FONT_SCALING_EM_NORMALIZED (1/units_per_EM, so 1.0 = 1 em) or FONT_SCALING_LEGACY (/64, "the incorrect legacy version … DO NOT USE", ext/import-font.h) — a 26.6 misreading of FT_LOAD_NO_SCALE output that is still the default for compatibility. Shape tracks YAxisOrientation (Y_UPWARD from fonts, Y_DOWNWARD from SVG) and BitmapSection::reorient flips rowStride to match (core/YAxisOrientation.h).
Modes. generateSDF (true distance), generatePSDF (perpendicular distance, extends edges past corners), generateMSDF (3-channel), and generateMTSDF (MSDF + true distance in alpha, for effects such as outlines and soft shadows) (msdfgen.h). rasterize is a separate exact scanline fill with FillRule (FILL_NONZERO/FILL_ODD/FILL_POSITIVE/FILL_NEGATIVE), used by distanceSignCorrection to fix the sign of a field whose contours had inconsistent winding (core/rasterization.h, core/Scanline.h).
Edge colouring is what makes the three channels disagree at corners. edgeColoringSimple walks each contour, detects corners by dotProduct(aDir, bDir) <= 0 || fabs(crossProduct(aDir, bDir)) > sin(angleThreshold), and cycles colours so adjacent edges across a corner share only one channel (core/edge-coloring.cpp). edgeColoringInkTrap and edgeColoringByDistance are alternatives; the latter solves a graph problem over all edge pairs and is "much slower than the rest" (core/edge-coloring.h).
Error correction. Bilinear interpolation of three channels can produce spurious median crossings between texels. MSDFErrorCorrection keeps a per-texel stencil (ERROR, PROTECTED), protectCorners/protectEdges, then findErrors either from the SDF alone or by comparing with the exact shape distance, and apply converts flagged texels to single-channel (core/MSDFErrorCorrection.h). ErrorCorrectionConfig chooses DISABLED/INDISCRIMINATE/EDGE_PRIORITY/EDGE_ONLY and DO_NOT_CHECK_DISTANCE/CHECK_DISTANCE_AT_EDGE/ALWAYS_CHECK_DISTANCE, and accepts a caller-supplied byte *buffer to avoid allocation (core/generator-config.h). estimateSDFError quantifies the remaining misfilled area by comparing analytic scanlines of the shape against scanlines reconstructed from the field (core/sdf-error-estimation.h).
GPU side. The README's shader is the contract: median(r, g, b), then screenPxRange() * (sd - 0.5) clamped to opacity, with the rule "screenPxRange() must never be lower than 1. If it is lower than 2, there is a high probability that the anti-aliasing will fail" (README.md). Channels are sampled as linear, not sRGB. There is no hinting, LCD filtering or gamma model: anti-aliasing is one ramp over a distance. Colour glyphs are out of scope. No atlas or cache lives here; BitmapSection exists so msdf-atlas-gen can target a sub-rectangle directly.
6. Metrics and measurement
FontMetrics { emSize, ascenderY, descenderY, lineHeight, underlineY, underlineThickness } is filled straight from face->units_per_EM, ->ascender, ->descender, ->height, ->underline_position, ->underline_thickness, scaled by the same FontCoordinateScaling (ext/import-font.cpp) — i.e. FreeType's already-resolved hhea/OS/2 choice, with no x-height or cap-height. Per-glyph advance comes out of loadGlyph's outAdvance; bounds come from Shape::getBounds(border, miterLimit, polarity), which can grow the box by a mitered border so the distance range fits (core/Shape.h).
7. Discovery, matching and fallback
Does not apply. The library opens one file or one byte buffer; there is no enumeration, matching or fallback, and no classification data is read.
What it teaches sparkles:font
- Separate the owning bitmap from the borrowed section.
Bitmap<T, N>vsBitmapSection<T, N>with a signedrowStrideand an orientation flag is the shape a D@nogcraster target should take: the generator writes into a slice of the caller's atlas and never allocates pixels. - A path object, not only a sink. msdfgen needs random access to edges (neighbours for colouring,
splitInThirds,reverse), which a pure callback sink cannot give.sparkles:fontshould offer both: a@nogcdecompose sink for rasterizers and a flat segment array for analysis. - Make the unit scaling an explicit enum.
FontCoordinateScalingexists because an implicit/64shipped for a decade. Font units vs em-normalized vs pixels must be a type or a named parameter, never a default. - Error correction and edge colouring are first-class passes with config structs, not flags on the generator — and the config accepts a caller-owned scratch buffer. Same pattern as our
Bufferpolicies. - Variation is just geometry. For a distance-field or outline rasterizer, variation needs no special path: set design coordinates on the face, decompose again. The shaper is where coordinates need separate plumbing.
- Face index 0 is a bug waiting to happen. Any
loadFontDatawrapper must carry the collection index.
Strengths
- Dependency-free core, four-call API, documented shader contract.
EdgeSelector×ContourCombinerpolicies yield SDF, PSDF, MSDF and MTSDF from one generator loop.- Survives real outline defects: overlapping contours, inconsistent winding (
orientContours,distanceSignCorrection), convergent edges (normalize), self-intersections via optional Skia preprocessing (ext/resolve-shape-geometry.h). - Three colouring heuristics plus analytic
estimateSDFErrormake quality measurable.
Weaknesses
- Per-texel nearest-edge search: an offline atlas-build cost, not a per-frame rasterizer.
- Face index hard-coded to 0;
kern-table kerning only; no colour glyphs, hinting, LCD or gamma treatment. - Edge colouring is heuristic and seed-dependent; dense corners still need the slow distance-checked correction.
- The wrong legacy
/64scaling remains the default. EdgeHolderis a bespoke owning pointer over a virtual hierarchy: one heap allocation per edge.
Key design decisions and trade-offs
| Decision | Rationale | Trade-off |
|---|---|---|
| Three distances per texel, median in the shader | Reproduces sharp corners from a tiny bitmap | 3× texture memory vs SDF; interpolation artifacts need an error-correction pass |
Dependency-free core, FreeType only in ext/ | Embeddable anywhere; geometry may come from SVG or hand-built shapes | Core knows nothing about fonts: no cmap, tables, collections or shaping |
Generator writes into a borrowed BitmapSection | Atlas packers target sub-rectangles; flipped images via negative stride | Caller owns allocation and lifetime |
EdgeSelector × ContourCombiner policy templates | One loop serves SDF/PSDF/MSDF/MTSDF and overlap-aware variants | Template-heavy C++ surface; ShapeDistanceFinder is single-threaded per instance |
Explicit FontCoordinateScaling with the wrong legacy value kept | Backward compatibility for a decade of callers | Correct behaviour is opt-in |
| Error correction as a configurable stencil pass | Fast modes for batch builds, exact modes for quality | Flagged texels lose corner sharpness locally |
| Variation through FreeType design coordinates only | No duplicate fvar/avar logic | No named instances, no normalized coordinates, no STAT |
Sources
All paths verified at the pinned revision with git cat-file -e.
- Public surface —
msdfgen.h,msdfgen-ext.h,README.md,CHANGELOG.md,vcpkg.json,LICENSE.txt. - Geometry —
core/Shape.h,core/Contour.h,core/EdgeHolder.h,core/edge-segments.h,core/EdgeColor.h,core/YAxisOrientation.h,core/shape-description.h. - Generator —
core/msdfgen.cpp,core/ShapeDistanceFinder.h,core/edge-selectors.h,core/contour-combiners.h,core/generator-config.h,core/SDFTransformation.h,core/Projection.h,core/Range.hpp,core/DistanceMapping.h. - Colouring, correction, raster —
core/edge-coloring.h,core/edge-coloring.cpp,core/msdf-error-correction.h,core/MSDFErrorCorrection.h,core/sdf-error-estimation.h,core/rasterization.h,core/Scanline.h,core/render-sdf.h,core/Bitmap.h,core/BitmapRef.hpp. - Extensions —
ext/import-font.h,ext/import-font.cpp,ext/import-svg.h,ext/resolve-shape-geometry.h.