SDF, MSDF and MTSDF generation from 2D contours in pure Common Lisp.
  • Common Lisp 97.8%
  • C++ 2.2%
Find a file
dxtr 078dbb2d53
All checks were successful
CI / test (push) Successful in 1m0s
Follow the current CLAUDE.md template
Its Types section now asks for full domain types in ftypes and leaves check-type to what a type cannot say, and its line on calling C lets a component make a call or two through SBCL's own FFI in its sys package.  The note on cl-sdf's ftypes says what they now declare and what SBCL checks when.
2026-10-18 16:22:36 +02:00
.forgejo/workflows Run on SBCL only 2026-10-17 04:46:38 +02:00
src Name padded-bounds for what it returns, and say what a field's bounds are 2026-10-18 16:22:13 +02:00
tests Declare the arguments' full domain types 2026-10-18 16:21:40 +02:00
.gitattributes Keep LF line endings in every checkout 2026-10-16 15:48:56 +02:00
.gitignore Pin the dependencies with stift in place of qlot 2026-10-16 10:52:39 +02:00
cl-sdf.asd Take the code-standards test out of the suite 2026-10-18 15:18:24 +02:00
CLAUDE.md Follow the current CLAUDE.md template 2026-10-18 16:22:36 +02:00
LICENSE Initial commit: SDF, MSDF and MTSDF generation 2026-10-09 16:00:16 +02:00
README.org Name padded-bounds for what it returns, and say what a field's bounds are 2026-10-18 16:22:13 +02:00
stiftfile Write the stiftfile as one property list 2026-10-16 17:44:06 +02:00

cl-sdf

cl-sdf generates signed distance fields from 2D contours:

  • SDF: one true signed-distance channel.
  • MSDF: RGB edge-colored signed-distance channels.
  • MTSDF: RGB MSDF plus alpha true SDF.

The contour format matches cl-textrender glyph outlines:

(((:line x0 y0 x1 y1)
  (:quadratic x0 y0 cx cy x1 y1)
  (:cubic x0 y0 c1x c1y c2x c2y x1 y1)))

Inside tests, signed queries and field generation require connected, closed contours. Signed distances are positive inside the filled shape and negative outside it. unsigned-distance also accepts open paths and isolated point segments.

Usage

(asdf:load-system :cl-sdf)

(defpackage #:example
  (:use #:cl)
  (:local-nicknames (#:sdf #:cl-sdf)))
(in-package #:example)

;; A unit square: one contour of four lines, counterclockwise.
(let* ((square '(((:line 0d0 0d0 1d0 0d0)
                  (:line 1d0 0d0 1d0 1d0)
                  (:line 1d0 1d0 0d0 1d0)
                  (:line 0d0 1d0 0d0 0d0))))
       ;; A 64x64 MTSDF texture: RGB median for sharp edges, alpha true distance.
       (field (sdf:generate-mtsdf square 64 64 :padding 0.25d0)))
  (list (sdf:field-ref field 32 32 3)        ; normalized; 0.5 is the edge
        (sdf:field-bounds field)             ; the square and its padding
        (sdf:field-spread field)             ; shape units mapped to 0..1
        (length (sdf:field-octets field))))  ; bytes for an RGBA8 UNORM texture
;; => (1.0 (-0.25d0 -0.25d0 1.25d0 1.25d0) 0.09375d0 16384)

generate-sdf and generate-msdf take the same arguments and return one and three channels. A field records the bounds, spread and row order it was generated with, which is what a shader needs to place and decode it.

For point queries, prepare the shape once:

(let ((shape (sdf:make-shape '(((:line 0d0 0d0 1d0 0d0) (:line 1d0 0d0 1d0 1d0)
                                (:line 1d0 1d0 0d0 1d0) (:line 0d0 1d0 0d0 0d0))))))
  (list (sdf:signed-distance shape 0.5d0 0.25d0)  ; positive inside
        (sdf:inside-p shape 2 2)))
;; => (0.25d0 NIL)

inside-p, signed-distance and unsigned-distance also accept contours directly, which is convenient for a single query but redoes the preparation on every call. A query on a prepared shape allocates nothing but its double-float result.

API overview

Contours and types

A segment is a segment; a contour is a list of segments; contours are a list of contours. rectangle is (xmin ymin xmax ymax), the form of :bounds, bounds, contour-bounds and shape-bounds, and of field-bounds unless padding took it beyond ±1e36. fill-rule is :nonzero or :even-odd, y-axis is :up or :down, and edge-color names MSDF channels.

Coordinates are of type coordinate: reals within ±1e36, so that every distance fits the single-floats a field stores. Bounds, padding, query points, spreads and tolerances are held to the same range; larger values signal an invalid-contours-error or a type-error rather than overflowing.

Geometry

bounds, contour-bounds, flatten-contours.

Prepared shapes and distance queries

make-shape, shape-fill-rule, shape-bounds; inside-p, unsigned-distance, signed-distance; encode-distance, decode-distance.

A shape fixes its fill rule and flattening tolerance when it is made. Shapes are immutable and may be queried from several threads at once.

Fields

field-width, field-height, field-channels, field-data, field-bounds, field-spread, field-y-axis, field-normalized-p, field-ref, field-distance (the sample in shape units, decoded if needed) and field-octets (normalized samples as (unsigned-byte 8), edge at 128, in field-data's layout).

Errors

Every error cl-sdf signals itself is an sdf-error, and its report says what to change. Malformed contours signal invalid-contours-error, which is also a type-error and says which contour and segment are wrong. Inside tests, signed queries and fields on a contour that does not close signal open-contour-error, with the contour's index and the gap's coordinates. Every function declares the full type of each argument, such as (integer 1) for a width, fill-rule or rectangle, so a bad argument signals a type-error that names the type expected; SBCL warns already where the call is written when the argument is a constant number or keyword. Numbers in cl-sdf's own messages are the exact double-floats used, in Lisp syntax, so a suggested fix such as the segment that closes a gap can be pasted back.

Field generation

generate-sdf, generate-msdf, generate-mtsdf.

The default fill rule is :nonzero; :even-odd is also supported. SDF and MTSDF alpha measure distance to the filled region's boundary. Intersections are split, coincident edges are resolved by the fill rule, and redundant interior contours are discarded. Reversing all contour windings preserves the result. unsigned-distance instead measures distance to every input segment, including internal outlines.

RGB uses oriented perpendicular distances with endpoint tangent extensions, nearest-edge selection, and endpoint alignment tie-breaking. Channels can have different signs at the same point. To reconstruct the shape, bilinearly sample the RGB texture first, then take the median of the three samples. The edge is at 0.5. RGB median preserves sharp corners; it is not a true Euclidean distance away from the boundary. Use SDF or MTSDF alpha for radial effects such as rounded outlines and shadows.

Every texel's median can be right while bilinear interpolation between texels with disagreeing channels is not. generate-msdf and generate-mtsdf therefore run msdfgen's default error correction (edge-priority, with exact distance checks at edges): texels that would interpolate into artifacts are flattened to their median, while texels that shape edges and corners are kept. It never changes a texel's median or the alpha channel. Pass :error-correction nil to get the uncorrected field.

color-edges colors smooth runs together and changes color at sharp corners, including the closing corner. A joint is a corner if the angle between its two edges is less than :angle-threshold radians, from 0 to pi; the default, 3 (about 172 degrees), makes any joint that turns by more than about 8 degrees a corner. Smooth loops are white; a one-corner loop may need its Beziers split into thirds. Point segments are omitted. A custom :colors palette must contain distinct CMY colors (two for an even cycle, three for an odd cycle).

To choose the colors, pass color-edges' result, or contours of edges made by make-colored-edge, to generate-msdf or generate-mtsdf in place of the contours: one argument holds the geometry and its colors, every edge colored or none. Colored edges must be cyan, magenta, yellow or white, and should change color at every sharp corner, including corners created by intersections; automatic coloring handles those after boundary resolution. generate-sdf takes colored contours too and ignores the colors, so MTSDF alpha is still its output for the same input.

Sampling and accuracy

Samples are at pixel centers. Row zero is at the bottom with :y-axis :up (the default), and at the top with :y-axis :down. Explicit bounds are (xmin ymin xmax ymax); :padding expands either explicit or automatic bounds. Final bounds must have positive width and height. Geometry bounds are conservative control-point bounds, not exact Bezier extrema.

spread is a half-range in shape units: -spread maps to 0, zero to 0.5, and +spread to 1. Values outside this interval are clamped. :normalize nil stores raw distances. When spread is omitted, px-spread (default 4) is multiplied by the larger shape-space pixel dimension; field-spread returns the result. Thus this parameter is half the full distance range used by tools whose pixel-range option describes the entire 0..1 interval.

Curves are approximated by polylines. :flatten-tolerance bounds the control-point distance from each finite chord, including curves that loop or double back. The generators default to 1/32 of the smaller output pixel dimension, which keeps the distance error below one 8-bit step at the default spread whatever units the outline uses. The point queries (inside-p, unsigned-distance, signed-distance) have no pixel size and default to 0.01 shape units. If the subdivision limit cannot satisfy a tolerance, flattening signals an sdf-error. MTSDF alpha uses exactly the same tessellation and samples as generate-sdf.

Empty or zero-area filled shapes produce normalized zero in every channel. Raw signed output uses -most-positive-single-float as a finite empty-distance sentinel.

Boundary processing uses double-precision geometry and merges vertices within 64 double-float epsilons of the coordinate scale. Extremely close features or very large translations relative to feature size can exceed that precision. Segments are indexed in a uniform grid, so preprocessing, inside tests and true distances typically examine only nearby segments; long segments spanning the shape and many intersections cost more. The RGB channels compare every segment, as msdfgen does: an endpoint extension can win far from its corner, so for glyph-sized shapes a scan is faster than searching the grid. Features thinner than about a texel cannot be represented by any distance field; they need more pixels or a smaller spread.

This is a flattened-geometry generator, not a bit-for-bit replacement for msdfgen. Fields agree with msdfgen's to within the flattening error, except that past a curve's endpoint the RGB channels extend the last chord rather than the curve's tangent (a few hundredths of a pixel at the default tolerance). Overlapping contours are resolved to their union's boundary before distances are measured, where msdfgen combines them per contour.

Development

Dependencies are pinned in the stiftfile with stift, and tests use parachute.

stift test

The suite includes analytic distance oracles, independent Bernstein curve sampling, fill-rule and overlap cases, corner coloring, bilinear corner reconstruction, numeric transformations, empty/invalid inputs, and field layout. It compares against output of upstream msdfgen: 256 raw RGBA points on polygons, whole MTSDF fields of curved shapes, and whole error-corrected MSDF fields; see fixture provenance and regeneration. It also reconstructs generated fields the way a shader does and checks the result against an oversampled true distance. asdf:test-system signals an error if any assertion or test evaluation fails, so the command exits unsuccessfully in CI.

License

MIT. See LICENSE.