A Swift package for high-quality Signed Distance Field (SDF) and Multi-channel Signed Distance Field (MSDF/MTSDF) generation from CGPath on Apple platforms.
Built on top of msdfgen and Skia PathOps, SwiftSDF provides a clean, idiomatic Swift/Objective-C API that takes any CGPath and produces a packed, Metal-ready texture buffer.
- Overview
- Features
- Architecture
- Requirements
- Installation
- Quick Start
- API Reference
- SDF vs MSDF — When to Use What
- Path Simplification with Skia PathOps
- Metal Rendering (Demo)
- Performance
- Licensing
- Acknowledgements
SwiftSDF bridges the gap between Apple's CoreGraphics/CoreText path world and GPU-accelerated, resolution-independent rendering via Metal. The library takes a CGPath — regardless of where it came from (a font glyph, a vector shape, a UIBezierPath, a drawn path) — and produces a compact texture buffer encoding a signed distance field.
This buffer can be directly uploaded to a MTLTexture and rendered with a tiny Metal fragment shader, giving you:
- Infinite scalability — render at any size from a single small texture
- Sharp edges at all scales — MSDF mode preserves sharp corners
- Cheap stroke and drop shadow — controlled by a single shader threshold
The library focuses entirely on generation. It has no Metal dependency, no rendering pipeline, and no UIKit coupling. It simply takes a path and returns data.
- ✅ SDF and MSDF (MTSDF) generation from any
CGPath - ✅ Auto mode — automatically selects SDF or MSDF based on path complexity
- ✅ Two precision modes —
unorm8(1 byte/channel) andfloat16(2 bytes/channel) - ✅ Skia PathOps integration — optional path simplification to resolve self-intersections and overlapping contours before generation
- ✅ Configurable padding and pixel range — fine-grained control over the SDF margin
- ✅ Y-axis flip control — matches Metal's top-left texture origin out of the box
- ✅ Clean three-layer Swift Package — C++ core, ObjC++ bridge, public Swift API
- ✅ iOS 14+ and macOS 11+
- ✅ No rendering code — bring your own Metal pipeline
- ✅ Stroke and shadow for free — pure shader-side, zero extra generation cost
SwiftSDF is structured as three SPM targets that form a strict dependency chain, keeping the C++ internals completely hidden from Swift consumers.
┌─────────────────────────────────────────────────────┐
│ Your App / Renderer │
└──────────────────────────┬──────────────────────────┘
│ import SwiftSDF
┌──────────────────────────▼──────────────────────────┐
│ SwiftSDF (Swift) │
│ Public API extensions, Metal pixel format helpers │
│ @_exported import SDFFoundation │
└──────────────────────────┬──────────────────────────┘
│
┌──────────────────────────▼──────────────────────────┐
│ SDFFoundation (Objective-C++) │
│ SDFGenerator · SDFResult · SDFConfiguration │
│ Validation · Precision packing · Error mapping │
└──────────────────────────┬──────────────────────────┘
│
┌──────────────────────────▼──────────────────────────┐
│ SDFCore (C++17) │
│ msdfgen · Skia PathOps · Generation core │
└─────────────────────────────────────────────────────┘
Clients only ever import SwiftSDF. The lower two layers are private implementation details and are never exposed.
| Platform | Minimum Version |
|---|---|
| iOS | 14.0 |
| macOS | 11.0 |
Add SwiftSDF to your Package.swift dependencies:
dependencies: [
.package(url: "https://github.com/YOUR_USERNAME/SwiftSDF.git", from: "1.0.0")
],
targets: [
.target(
name: "YourTarget",
dependencies: ["SwiftSDF"]
)
]Or add it directly in Xcode via File → Add Package Dependencies and paste the repository URL.
The entire API surface is one call. Give it a path, a mode, and a configuration:
import SwiftSDF
import CoreText
import Metal
// 1. Get a CGPath from anywhere — CoreText, UIBezierPath, your vector data, etc.
let font = UIFont.systemFont(ofSize: 64)
let path = GlyphUtils.createPath(for: "A", font: font)!
// 2. Configure generation
let config = SDFConfiguration(
outputWidth: 128,
outputHeight: 128,
padding: 8.0,
range: 8.0,
precision: .float16,
flipY: true // flip for Metal's top-left origin
)
// 3. Generate
let result = try SDFGenerator.generate(from: path, requestMode: .msdf, config: config)
// 4. Upload to Metal
let pixelFormat = config.metalPixelFormat(channelFormat: result.channelFormat)
let descriptor = MTLTextureDescriptor.texture2DDescriptor(
pixelFormat: pixelFormat, width: 128, height: 128, mipmapped: false
)
let texture = device.makeTexture(descriptor: descriptor)!
let bytesPerRow = 128 * result.channelFormat.channelCount * result.precision.bytesPerChannel
result.data.withUnsafeBytes { ptr in
texture.replace(region: MTLRegionMake2D(0, 0, 128, 128),
mipmapLevel: 0,
withBytes: ptr.baseAddress!,
bytesPerRow: bytesPerRow)
}
// 5. Bind texture in your Metal render pass and draw with an MSDF fragment shaderThat's it. The result.data is packed and Metal-ready.
SDFConfiguration controls every aspect of the generation process.
// Full designated initializer
SDFConfiguration(
outputWidth: Int, // Output texture width in pixels
outputHeight: Int, // Output texture height in pixels
padding: CGFloat, // Empty margin around the shape (pixels)
range: CGFloat, // SDF gradient range in pixels
precision: SDFPrecision,
flipY: Bool,
angleThreshold: CGFloat, // Corner detection threshold (default: 3.0)
simplifyPath: Bool // Run Skia PathOps before generation
)
// Convenience — hides angleThreshold (defaults to 3.0)
SDFConfiguration(outputWidth:outputHeight:padding:range:precision:flipY:simplifyPath:)
// Convenience — hides angleThreshold + simplifyPath (defaults: 3.0 / true)
SDFConfiguration(outputWidth:outputHeight:padding:range:precision:flipY:)| Property | Type | Description |
|---|---|---|
outputWidth |
Int |
Width of the generated texture in pixels |
outputHeight |
Int |
Height of the generated texture in pixels |
padding |
CGFloat |
Space (px) between the shape boundary and the texture edge. Must be >= 0 and < min(width, height) / 2 |
range |
CGFloat |
The pixel range over which the distance field gradient falls off. Controls the usable distance for stroke/glow effects |
precision |
SDFPrecision |
.unorm8 (1 byte/channel, compact) or .float16 (2 bytes/channel, high fidelity) |
flipY |
Bool |
Flips the output vertically. Set true when uploading to a Metal texture (top-left origin) |
angleThreshold |
CGFloat |
msdfgen corner detection sensitivity. Higher values detect more corners as sharp. Default 3.0 |
simplifyPath |
Bool |
If true, passes the path through Skia PathOps before generation to resolve overlaps and self-intersections. Default true |
The immutable result object returned by the generator.
result.data // NSData — packed, Metal-ready pixel buffer
result.sdfMode // .sdf or .msdf — the mode actually used
result.channelFormat // .r (SDF) or .rgba (MSDF)
result.precision // .unorm8 or .float16result.channelFormat.channelCount // Int: 1 for .r, 4 for .rgba
result.precision.bytesPerChannel // Int: 1 for unorm8, 2 for float16
// Bytes per row for Metal texture upload:
let bytesPerRow = width * result.channelFormat.channelCount * result.precision.bytesPerChannel// Swift throwing API
let result = try SDFGenerator.generate(from: path, requestMode: .msdf, config: config)
// Objective-C NSError API
var error: NSError?
let result = SDFGenerator.generate(from: path, requestMode: .msdf, config: config, error: &error)| Mode | Behaviour |
|---|---|
.sdf |
Always generates a single-channel SDF (SDFChannelFormat.r) |
.msdf |
Always generates a 4-channel MTSDF (SDFChannelFormat.rgba) |
.auto |
The C++ core decides based on path complexity |
Note on MSDF: SwiftSDF generates MTSDF (Multi-channel True Signed Distance Field) for all MSDF requests. This is a 4-channel variant that stores three independent distance channels plus an alpha, improving robustness for complex paths with sharp corners over standard 3-channel MSDF.
| Code | Meaning |
|---|---|
SDFGeneratorError.invalidSize |
outputWidth or outputHeight ≤ 0 |
SDFGeneratorError.invalidPadding |
padding is negative or ≥ half the minimum dimension |
SDFGeneratorError.internalFailure |
The C++ generation core returned a non-success code |
The SwiftSDF layer extends SDFConfiguration with a Metal convenience helper:
// Resolves the correct MTLPixelFormat for the combination of channel format and precision
let pixelFormat: MTLPixelFormat = config.metalPixelFormat(channelFormat: result.channelFormat)| Channel Format | Precision | MTLPixelFormat |
|---|---|---|
.r |
.unorm8 |
.r8Unorm |
.r |
.float16 |
.r16Float |
.rgba |
.unorm8 |
.rgba8Unorm |
.rgba |
.float16 |
.rgba16Float |
| Scenario | Recommended Mode |
|---|---|
| Simple icons, circular shapes, blobs | .sdf |
| Text glyphs at any size | .msdf |
| Vector art with sharp corners | .msdf |
| Tight memory budget, soft shapes only | .sdf + .unorm8 |
| High-fidelity text at large scale | .msdf + .float16 |
| Unknown path complexity | .auto |
SDF encodes the shortest distance to the shape boundary in a single channel. It scales well but softens sharp corners.
MSDF (MTSDF) encodes three independent distance channels across RGB, using them together in the fragment shader to reconstruct true sharp corners at any scale. The fourth channel (alpha) stores a conventional SDF for fallback and masking purposes. The standard median-of-three reconstruction in the fragment shader is:
float median(float r, float g, float b) {
return max(min(r, g), min(max(r, g), b));
}
float sigDist = median(sample.r, sample.g, sample.b) - 0.5;
float alpha = clamp(sigDist / fwidth(sigDist) + 0.5, 0.0, 1.0);Font outlines and complex vector paths sometimes contain self-intersecting contours, overlapping subpaths, or winding ambiguities. These cause msdfgen to produce incorrect or artifacted distance fields.
SwiftSDF embeds Skia's PathOps engine to resolve this before generation. When simplifyPath = true (the default), the path is run through a boolean union operation that:
- Resolves all self-intersections
- Merges overlapping contours
- Normalises winding direction
This is especially important for composite glyphs, ligatures, and complex vector illustrations.
// Explicitly disable if you know your path is clean — saves processing time
let config = SDFConfiguration(
outputWidth: 128, outputHeight: 128,
padding: 8, range: 8,
precision: .unorm8, flipY: true,
simplifyPath: false // skip Skia PathOps
)The repository includes a demo app that demonstrates the full pipeline: CoreText glyph → SwiftSDF generation → Metal texture → on-screen rendering.
// GlyphUtils.swift (demo)
import CoreText
static func createPath(for character: Character, font: UIFont) -> CGPath? {
let attrString = NSAttributedString(string: String(character), attributes: [.font: font])
let line = CTLineCreateWithAttributedString(attrString)
guard let run = (CTLineGetGlyphRuns(line) as? [CTRun])?.first else { return nil }
var glyph = CGGlyph()
CTRunGetGlyphs(run, CFRangeMake(0, 1), &glyph)
return CTFontCreatePathForGlyph(font as CTFont, glyph, nil)
}// SDFMetalView.swift (demo) — full MTKView integration
struct SDFMetalView: UIViewRepresentable {
let cgPath: CGPath
// ...generates texture via SwiftSDF, sets up render pipeline,
// binds MSDF texture, draws full-screen quad with triangle strip
}// Shaders.metal (demo)
fragment float4 fragmentMain(VertexOut in [[stage_in]],
texture2d<float> sdfTexture [[texture(0)]]) {
constexpr sampler s(mag_filter::linear, min_filter::linear);
float4 tex = sdfTexture.sample(s, in.uv);
float sigDist = median(tex.r, tex.g, tex.b) - 0.5;
float alpha = clamp(sigDist / fwidth(sigDist) + 0.5, 0.0, 1.0);
return float4(1.0, 1.0, 1.0, alpha);
}The demo renders text at arbitrary scale with crisp, anti-aliased edges at all sizes — from a single 256×256 MSDF texture.
SwiftSDF runs the entire generation pipeline on the CPU via the msdfgen C++ core. This is well-suited for:
- Pre-generation at load time — generate a glyph atlas once and reuse across frames
- Static vector assets — icons, logos, UI shapes generated once
- Low-frequency updates — occasional path changes between frames
Generation time scales with path complexity and output resolution. A single 128×128 glyph typically completes in under a millisecond on modern Apple Silicon. A full ASCII glyph atlas at 64×64 per glyph can be generated on a background thread and uploaded to Metal textures in a single batch.
A GPU-accelerated SDF/MSDF generation pipeline exists and runs significantly faster than the CPU path — making it suitable for real-time and runtime glyph generation. This pipeline is part of a private text rendering engine and is not included in this library.
If your use case requires real-time SDF generation (e.g. animating vector paths or generating glyphs on demand every frame), consider:
- Pre-generating and caching at startup on a background
DispatchQueue - Maintaining a
MTLTextureglyph atlas, updating only newly-encountered glyphs - Reaching out if GPU generation becomes a priority feature for this library
SwiftSDF is released under the MIT License — free for personal and commercial use.
SwiftSDF is built on two open-source libraries:
| Library | License |
|---|---|
| msdfgen by Viktor Chlumský | MIT |
| Skia PathOps by Google | BSD 3-Clause |
Both MIT and BSD-3-Clause are permissive licenses. You may use SwiftSDF in personal and commercial projects freely.
There is no copyleft requirement. You are not required to open-source your own application or renderer.
A NOTICES file in the repository root contains all third-party attributions in one place.