2 Recipes
dxtr edited this page 2026-10-12 02:21:42 +02:00

Recipes

Each recipe is self-contained apart from the helpers in Simple shapes, which other pages use too. All were run against cl-sdf as published.

Simple shapes

(defun polygon-contour (points)
  "A closed contour of lines through POINTS, a list of (X Y)."
  (loop for (p q) on (append points (list (first points)))
        while q
        collect (list :line (first p) (second p) (first q) (second q))))

(defun rectangle-contour (xmin ymin xmax ymax)
  "A counterclockwise rectangle."
  (polygon-contour (list (list xmin ymin) (list xmax ymin)
                         (list xmax ymax) (list xmin ymax))))

(defun circle-contour (cx cy r)
  "A counterclockwise circle of four cubics, each a quarter."
  (let ((k (* r 0.5522847498d0)))
    (list (list :cubic (+ cx r) cy (+ cx r) (+ cy k) (+ cx k) (+ cy r) cx (+ cy r))
          (list :cubic cx (+ cy r) (- cx k) (+ cy r) (- cx r) (+ cy k) (- cx r) cy)
          (list :cubic (- cx r) cy (- cx r) (- cy k) (- cx k) (- cy r) cx (- cy r))
          (list :cubic cx (- cy r) (+ cx k) (- cy r) (+ cx r) (- cy k) (+ cx r) cy))))

(defun reverse-contour (contour)
  "CONTOUR traced the other way round."
  (reverse (mapcar (lambda (segment)
                     (destructuring-bind (kind &rest coordinates) segment
                       (cons kind (loop for (x y) on (reverse coordinates) by #'cddr
                                        append (list y x)))))
                   contour)))

;; A five-pointed star drawn in one stroke, crossing itself.
(defparameter *star*
  (list (polygon-contour (loop for i below 5
                               for angle = (+ (/ pi 2) (* i 4/5 pi))
                               collect (list (cos angle) (sin angle))))))

A ring is two circles, the hole reversed for :nonzero:

(defparameter *ring* (list (circle-contour 0 0 1) (reverse-contour (circle-contour 0 0 0.5))))

polygon-contour reuses each point for the end of one line and the start of the next, so the contour is exactly closed. Computing the two separately invites rounding differences, which cl-sdf tolerates only at the level of a few units in the last place.

A glyph from a font

With cl-textrender loaded, a font of size 1 gives outlines in ems:

(defun glyph-contours (path char)
  (textrender:with-font (font path :size 1)
    (textrender:outline-contours
     (textrender:glyph-outline font (textrender:font-glyph-id font char)))))

(defparameter *a* (glyph-contours "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf" #\A))

;; A quarter of an em of room for outlines and glows.
(sdf:generate-mtsdf *a* 48 48 :padding 1/4 :spread 1/4)

For a whole font, cl-textrender/sdf makes and caches one field per glyph and packs them into an atlas; prefer it to doing this by hand. The next recipe is what it does to size each field.

A fixed number of texels per unit

Glyphs differ in size, and fields drawn together look alike only if their texels are the same size. Fix the texel density, and derive each field's size and bounds from it:

(defun fixed-density-field (contours texels-per-unit reach)
  "An MTSDF of CONTOURS with square texels of 1/TEXELS-PER-UNIT shape units,
reaching REACH units beyond the outline on every side, and with a spread of
REACH."
  (destructuring-bind (xmin ymin xmax ymax) (sdf:bounds contours)
    (let* ((left (- xmin reach))
           (bottom (- ymin reach))
           (width (ceiling (* texels-per-unit (+ (- xmax xmin) (* 2 reach)))))
           (height (ceiling (* texels-per-unit (+ (- ymax ymin) (* 2 reach))))))
      (sdf:generate-mtsdf contours width height
                          :bounds (list left bottom
                                        (+ left (/ width texels-per-unit))
                                        (+ bottom (/ height texels-per-unit)))
                          :spread reach))))

(let ((field (fixed-density-field (list (circle-contour 0 0 1)) 16 1/4)))
  (list (sdf:field-width field) (sdf:field-height field)
        (sdf:field-bounds field) (sdf:field-spread field)))
;; => (40 40 (-1.25d0 -1.25d0 1.25d0 1.25d0) 0.25d0)

(let ((field (fixed-density-field *a* 32 1/4)))
  (list (sdf:field-width field) (sdf:field-height field)))
;; => (38 40)

The width and height are rounded up to whole texels, and the bounds grown to match, so a texel is exactly 1/32 of an em in every glyph's field.

An MSDF as RGBA bytes

GPUs rarely sample three-channel 8-bit textures. Pad an MSDF's bytes to four channels, or generate an MTSDF instead:

(defun rgba-octets (field)
  "FIELD's three channels as RGBA bytes, alpha 255."
  (let* ((rgb (sdf:field-octets field))
         (rgba (make-array (* 4 (/ (length rgb) 3))
                           :element-type '(unsigned-byte 8) :initial-element 255)))
    (loop for from below (length rgb) by 3
          for to from 0 by 4
          do (replace rgba rgb :start1 to :start2 from :end2 (+ from 3)))
    rgba))

(length (rgba-octets (sdf:generate-msdf *star* 32 32 :padding 0.1)))  ; => 4096

Drawing a field on the CPU

This draws a field the way a shader does, which is useful for checking a field, or for tests, before any GPU code exists. sample is the GPU's bilinear filter with clamping; coverage is the fragment shader of Drawing with Fields.

(defun sample (field u v channel)
  "FIELD's CHANNEL at U, V in texels, bilinearly filtered like a GPU's
linear sampler: texel centers are at half-texel offsets, edges clamp."
  (let* ((width (sdf:field-width field))
         (height (sdf:field-height field))
         (u (- (max 0.5 (min u (- width 0.5))) 0.5))
         (v (- (max 0.5 (min v (- height 0.5))) 0.5))
         (x0 (floor u)) (y0 (floor v))
         (x1 (min (1+ x0) (1- width))) (y1 (min (1+ y0) (1- height)))
         (fx (- u x0)) (fy (- v y0)))
    (flet ((at (x y) (sdf:field-ref field x y channel)))
      (+ (* (- 1 fy) (+ (* (- 1 fx) (at x0 y0)) (* fx (at x1 y0))))
         (* fy (+ (* (- 1 fx) (at x0 y1)) (* fx (at x1 y1))))))))

(defun median (a b c)
  (max (min a b) (min (max a b) c)))

(defun coverage (field u v pixels-per-unit)
  "How much of a pixel at U, V the shape covers, for a field drawn at
PIXELS-PER-UNIT screen pixels per shape unit."
  (let* ((encoded (if (= (sdf:field-channels field) 1)
                      (sample field u v 0)
                      (median (sample field u v 0) (sample field u v 1) (sample field u v 2))))
         (pixels (* (- encoded 0.5) 2 (sdf:field-spread field) pixels-per-unit)))
    (max 0 (min 1 (+ pixels 0.5)))))

(defun draw (field columns rows)
  "Print FIELD scaled to COLUMNS by ROWS characters, as a shader would draw
it, top row first.  Characters are about twice as tall as wide."
  (destructuring-bind (xmin ymin xmax ymax) (sdf:field-bounds field)
    (declare (ignore ymin ymax))
    (let ((pixels-per-unit (/ columns (- xmax xmin))))
      (dotimes (row rows)
        (dotimes (column columns)
          (let* ((u (* (/ (+ column 0.5) columns) (sdf:field-width field)))
                 (v (* (/ (+ row 0.5) rows) (sdf:field-height field))))
            (when (eq (sdf:field-y-axis field) :up)
              (setf v (- (sdf:field-height field) v)))
            (write-char (char " .:-=+*#" (min 7 (floor (* 8 (coverage field u v pixels-per-unit))))))))
        (terpri)))))

The star at 24 by 24 texels, drawn 48 characters wide:

(draw (sdf:generate-msdf *star* 24 24 :padding 0.1) 48 24)
                       ..
                       **
                      -##-
                      ####
                     +####+
                    :######:
                    ########
                   =########=
  =================##########=================
    .*####################################*.
       -################################-
          =##########################=
            .*####################*.
               -################-
               *################*
              =##################=
             .####################.
             +#######+.  .+#######+
            -######:        :######-
            ####=              =####
           =#*.                  .*#=
          .-                        -.

Concepts uses draw to compare an SDF with an MSDF of a triangle.

Saving a field as an image

A binary PPM needs no library and opens in most image viewers. This writes the first three channels, or the one channel of an SDF as grey, top row first:

(defun write-ppm (field pathname)
  "Write FIELD's first three channels as a binary PPM image, top row first."
  (let ((octets (sdf:field-octets field))
        (width (sdf:field-width field))
        (height (sdf:field-height field))
        (channels (sdf:field-channels field)))
    (with-open-file (out pathname :direction :output :if-exists :supersede
                                  :element-type '(unsigned-byte 8))
      (loop for char across (format nil "P6~%~D ~D~%255~%" width height)
            do (write-byte (char-code char) out))
      (dotimes (i height)
        (let ((row (if (eq (sdf:field-y-axis field) :up) (- height i 1) i)))
          (dotimes (column width)
            (dotimes (channel 3)
              (write-byte (aref octets (+ (* (+ (* row width) column) channels)
                                          (min channel (1- channels))))
                          out))))))
    pathname))

(write-ppm (sdf:generate-msdf *star* 64 64 :padding 0.1) "/tmp/star.ppm")

An MSDF looks like a patchwork of colors with the shape's edge running through it; its median, not its colors, is the shape.

Raw distances for collision

A raw field is a lookup table of distances in shape units, for collision against a fixed outline, or for placing things a set distance from it. A query on it is an array access.

(defparameter *walls*
  (sdf:generate-sdf *ring* 64 64 :padding 0.5 :normalize nil))

(defun wall-distance (x y)
  "The signed distance from X, Y to the ring, from the nearest texel."
  (destructuring-bind (xmin ymin xmax ymax) (sdf:field-bounds *walls*)
    (let ((column (floor (* (- x xmin) (/ (sdf:field-width *walls*) (- xmax xmin)))))
          (row (floor (* (- y ymin) (/ (sdf:field-height *walls*) (- ymax ymin))))))
      (sdf:field-distance *walls* column row))))

(wall-distance 0.75 0)  ; => 0.2258976697921753d0, inside the ring

The value comes from the texel's center, so it is off by up to half a texel's diagonal; interpolate with sample above for smoother values, or use signed-distance on a shape where exact answers matter.

Checking what the fill rule does

The self-intersecting star shows the difference between the fill rules. Under :nonzero the center is covered twice, so it is filled; under :even-odd it is not:

(sdf:inside-p *star* 0 0)                       ; => T
(sdf:inside-p *star* 0 0 :fill-rule :even-odd)  ; => NIL
(draw (sdf:generate-msdf *star* 32 32 :padding 0.1 :fill-rule :even-odd) 48 24)
                       ..
                       **
                      -##-
                      ####
                     +####+
                    :######:
                    ########
                   =########=
  ==================--------==================
    .*############.          .############*.
       -#########=            =#########-
          =######              ######=
            .*##:              :##*.
               -.              .-
               *##:          :##*
              =#####+      +#####=
             .#########++#########.
             +#######+.  .+#######+
            -######:        :######-
            ####=              =####
           =#*.                  .*#=
          .-                        -.