Render crisp text in Metal using multi-channel signed distance field (MSDF) technique.
This method has notable advantges over single-channel signed distance fields: Sharp edges and support for hard-edge visual effects. Learn more at msdfgen Github page.
default_unicode_ranges.txt contains a curated list of hex codepoint ranges for Western scripts. Edit this file or supply additional ranges on the command line to tailor coverage for your app. Large ranges increase atlas size, so scope them to what the UI needs.
Glyph ranges, unlike unicode ranges, are unique to each font. Thus if you have multiple fonts, you would need to generate a unique collection of glyph ranges per font.
msdf-atlas-gen consumes glyph indices, not Unicode codepoints. The helper script resolves the mapping by looking into the source font:
python3 MSDFTextRender/scripts/make_glyph_ranges.py \
/path/to/font.otf \
--ranges-file scripts/default_unicode_ranges.txt \
--output scripts/glyph_ranges.txt \
--include-missing--ranges-fileaccepts the Unicode ranges file you curated.--include-missinglogs codepoints that are absent from the font so you can adjust coverage.- The output file lists inclusive
[0xSTART, 0xEND]glyph index spans; pass this file tomsdf-atlas-genvia the-glyphsetflag.
Note: this step must be done on Windows.
Use Viktor's msdf-atlas-gen, you may generate an efficiently binpacked font atlas for your font. Example invocation:
msdf-atlas-gen.exe ^
-font path\to\SF-Pro-Display-Regular.otf ^
-glyphset scripts\glyph_ranges.txt ^
-type mtsdf ^
-size 64 ^
-pxrange 4 ^
-imageout output\SF-Pro-Display_mtsdf.png ^
-json output\SF-Pro-Display_mtsdf.json
Key parameters:
-type mtsdfencodes the distance field in RGB for higher fidelity. You can also usemsdfformat. Differences are discussed here.-sizesets the atlas resolution; increase for larger glyphs at the cost of memory.-pxrangedefines the distance range stored in the atlas. Keep this in sync with the fragment shader logic (see “Runtime Rendering Pipeline”).
The batch file writes assets into an output/ folder next to the script. Adjust executable paths, font paths, and output names to fit your environment or wrap the call in your own automation.
A subregion of atlas looks like below

Copy the generated .json into MSDFTextRender/MSDFTextRender/Resources and ensure they are part of the application bundle. In this example, Renderer.swift looks for SF-Pro-Display_mtsdf.json and its companion image.
Copy the .png into a Texture Set. Create one in the Assets catalog if not exists. 
The following settings are critical.
- Origin: Bottom-Left. This one is also set programmatically.
- Intepretation: Data. Critical. The png is outputted in 8-bit RGB format without Alpha channel. Setting it otherwise can cause visual artifacts.
- Pixel format. Critical. Set to 8 Bit Normalized RGBA. Otherwise visual artifacts will appear.
MSDFAtlas.load decodes the JSON metadata into MSDFAtlas, capturing texture dimensions, font metrics, glyph quads, and the encoded distanceRange. Renderer loads the matching texture and derives atlasUnitRange from the atlas’ pixel range and texture size; this value keeps shader smoothing in sync with the baked pxrange.
MSDFTextMeshBuilder uses Core Text to typeset attributed strings into lines and runs that obey layout constraints (frame size, margins, and scaling). For each glyph run, the builder:
- Looks up the glyph’s atlas and plane bounds.
- Produces two triangles forming a quad using glyph metrics in em space, translated and scaled into the render frame.
- Emits UV coordinates by normalizing atlas bounds against the atlas texture size.
The builder returns an MSDFTextMesh containing Metal vertex and index buffers plus the logical bounds of the laid-out text. Renderer rebuilds meshes when the drawable size or font selection changes, ensuring crisp spacing and kerning.
The vertex shader multiplies positions by the model-view and projection matrices and forwards UVs. The fragment shader (Shaders.metal) performs bicubic sampling of the MSDF texture, collapses the RGB channels back into a signed distance value, and uses fwidth to adaptively scale the coverage ramp in screen space. This produces clean edges over a wide zoom range without re-rasterizing glyphs. The shader also applies the tint and opacity from the uniform buffer, enabling dynamic color changes.
fragment float4 fragmentShader(ColorInOut in [[stage_in]],
constant Uniforms & uniforms [[ buffer(BufferIndexUniforms) ]],
texture2d<float> colorMap [[ texture(TextureIndexColor) ]])
{
constexpr sampler colorSampler(address::clamp_to_edge,
filter::bicubic);
float3 sample = colorMap.sample(colorSampler, in.texCoord).rgb;
float msdf = median3(sample.r, sample.g, sample.b);
float2 screenTexSize = 1.0f / fwidth(in.texCoord);
float screenPxRange = max(0.5f * dot(uniforms.unitRange, screenTexSize), 1.0f);
float screenPxDistance = screenPxRange * (msdf - 0.5f);
float alpha = clamp(screenPxDistance + 0.5f, 0.0f, 1.0f);
float4 color = uniforms.textColor;
color.a *= alpha;
return color;
}
-
Keep atlas dimensions power-of-two friendly when targeting GPU compression or mipmapping workflows, though MT-SDF rendering works with arbitrary sizes.
-
For multilingual UI, consider building multiple atlases grouped by script, then switch between them at runtime while reusing the mesh builder.
With this workflow you can regenerate glyph coverage, rebuild the MSDF atlas, drop the assets into the project, and immediately render high-fidelity, scalable text in Metal.
On the Paragraph tab, a segmented control toggles between two render styles:
Normal: Standard MSDF fill with adaptive smoothing.
Atlas decoding, glyph typesetting, and rendering have been extracted into a reusable Swift Package MSDFText in Sources/MSDFText with a simple API. The package expects you to supply the atlas MTLTexture (PNG or other) so you can manage asset loading however you prefer.
Key types:
MSDFAtlas: Decodes the JSON metadata for the MSDF atlas and provides glyph descriptors and metrics.MSDFTextMeshBuilder: Lays out text (CoreText) and builds a vertex/index mesh referencing the atlas UVs.MSDFTextRenderer: Owns the Metal pipeline and uniform buffers and encodes draw calls for a mesh and a providedMTLTexture.
Add the local package (this repo) in Xcode via Swift Package Manager. The library product is named MSDFText.
import MetalKit
import CoreText
import MSDFText
// 1) Load atlas metadata and texture (you provide the texture)
let atlasURL = Bundle.main.url(forResource: "SF-Pro-Display_mtsdf", withExtension: "json")!
var atlas = try MSDFAtlas.load(from: atlasURL)
let loader = MTKTextureLoader(device: device)
let texURL = Bundle.main.url(forResource: "SF-Pro-Display_mtsdf", withExtension: "png")!
let atlasTexture = try loader.newTexture(URL: texURL, options: [
.SRGB: false,
.origin: MTKTextureLoader.Origin.topLeft,
.generateMipmaps: false,
])
// 2) Build text mesh for your string
let ctFont: CTFont = /* create from your font */
let meshBuilder = MSDFTextMeshBuilder(device: device, atlas: atlas, font: ctFont)
let mesh = meshBuilder.buildMesh(for: "Hello MSDF!", in: view.bounds.size, margin: 16, scale: view.contentScaleFactor)!
// 3) Create renderer and encode draw
let renderer = try MSDFTextRenderer(device: device,
pixelFormat: mtkView.colorPixelFormat,
sampleCount: mtkView.sampleCount,
atlasPxRange: atlas.atlas.distanceRange)
renderer.setOrthoProjection(width: Float(mtkView.drawableSize.width),
height: Float(mtkView.drawableSize.height))
let style = MSDFTextRenderStyle(textColor: SIMD4<Float>(1,1,1,1))
renderer.encode(encoder: renderEncoder,
mesh: mesh,
atlasTexture: atlasTexture,
style: style)
Notes:
- You must provide the atlas
MTLTexture. The renderer computes the MSDF unit range fromatlas.pxRangeand the texture size. - The package includes the default Metal shaders (fill only). No
ShaderTypes.his required; Swift-side uniforms mirror the shader layout.
You can supply your own Metal shader functions and uniform layouts. If you don’t, the package uses its default fill shader and uniform.
- Provide a custom library and function names when constructing the renderer, or build your own pipeline and pass uniforms directly at draw time.
Example: provide your own uniforms and pipeline per draw
// Build pipeline elsewhere; bind uniforms at buffer index 2
var myUniforms = MyUniforms(
projectionMatrix: renderer.projectionMatrix,
modelViewMatrix: renderer.modelViewMatrix,
unitRange: renderer.unitRange(for: atlasTexture)
)
withUnsafeBytes(of: &myUniforms) { raw in
if let uniformBuffer = device.makeBuffer(bytes: raw.baseAddress!,
length: raw.count,
options: .storageModeShared) {
renderer.encode(encoder: renderEncoder,
mesh: mesh,
atlasTexture: atlasTexture,
uniformBuffer: uniformBuffer,
overridePipeline: myPipeline)
}
}
Contract for custom shaders:
- Vertex and fragment expect uniforms at buffer index
2and the atlas at texture index0. - You can freely define your own uniform struct; the package will not impose any layout when using the custom uniform buffer overload.
- If using
uniformOffset, ensure it is 256-byte aligned (Metal requires constant buffer offsets to be a multiple of 256 bytes).
What moved where:
- Atlas JSON decoding →
Sources/MSDFText/MSDFAtlas.swift - Mesh building/typesetting →
Sources/MSDFText/MSDFTextMesh.swift - Renderer + pipeline/shaders →
Sources/MSDFText/MSDFTextRenderer.swift,Sources/MSDFText/Shaders.metal


