Skip to content

ZeroOneZeroR/SwiftSDF

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

3 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

SwiftSDF

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.


Table of Contents


Overview

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.


Features

  • SDF and MSDF (MTSDF) generation from any CGPath
  • Auto mode — automatically selects SDF or MSDF based on path complexity
  • Two precision modesunorm8 (1 byte/channel) and float16 (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

Architecture

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.


Requirements

Platform Minimum Version
iOS 14.0
macOS 11.0

Installation

Swift Package Manager

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.


Quick Start

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 shader

That's it. The result.data is packed and Metal-ready.


API Reference

SDFConfiguration

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:)

Properties

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

SDFResult

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 .float16

Computed helpers (from SDFChannelFormat / SDFPrecision)

result.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

SDFGenerator

// 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)

Request Modes

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.

Error Codes

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

Metal Integration

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

SDF vs MSDF — When to Use What

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);

Path Simplification with Skia PathOps

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
)

Metal Rendering (Demo)

The repository includes a demo app that demonstrates the full pipeline: CoreText glyph → SwiftSDF generation → Metal texture → on-screen rendering.

Obtaining a glyph path

// 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)
}

SwiftUI Metal view

// 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
}

Fragment shader (MSDF)

// 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.


Performance

CPU Generation (Current)

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.

GPU Generation (Not Included)

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:

  1. Pre-generating and caching at startup on a background DispatchQueue
  2. Maintaining a MTLTexture glyph atlas, updating only newly-encountered glyphs
  3. Reaching out if GPU generation becomes a priority feature for this library

Licensing

SwiftSDF is released under the MIT License — free for personal and commercial use.

Third-Party Notices

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.


Acknowledgements

  • msdfgen — Viktor Chlumský's excellent multi-channel signed distance field generator, the C++ core of this library's generation engine.
  • Skia — Google's 2D graphics library, specifically the PathOps module used for robust path simplification.

About

A Swift package for high-quality Signed Distance Field (SDF) and Multi-channel Signed Distance Field (MSDF/MTSDF) generation from CGPath on Apple platforms.

Topics

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages