8 Distance Field Atlas
dxtr edited this page 2026-10-19 07:15:59 +02:00
This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

Distance Field Atlas

The optional system cl-textrender/sdf is for renderers that draw text from distance fields. A distance field stays sharp over a range of sizes and makes outlines, glows and soft shadows cheap. This system makes each glyph into a multi-channel true signed distance field (MTSDF) once, with cl-sdf, and packs it into a cl-atlas atlas of four bytes a texel. Each face has an atlas of its own, which the face keeps and which goes with it. The renderer uploads the atlases' pages as RGBA8 textures.

(asdf:load-system :cl-textrender/sdf)

(tr:with-font (font "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf" :size 16)
  (let* ((fields (cl-textrender/sdf:make-field-atlas))    ; :page-size, :texels-per-em, :spread
         (region (cl-textrender/sdf:glyph-region fields font
                                                 (tr:font-glyph-id font #\A))))
    (list (cl-atlas:region-width region) (cl-atlas:region-height region)
          (multiple-value-list (cl-textrender/sdf:glyph-field-bounds region)))))
;; => (38 40 (-0.2421875 -0.25 0.9453125 1.0))

The A's field is 38 by 40 texels and covers the rectangle from (-0.24, -0.25) to (0.95, 1.0) in ems, around the glyph's origin.

Making a field atlas

(cl-textrender/sdf:make-field-atlas &key (page-size 512) (texels-per-em 32) (spread 1/4))
  • page-size is the width and height of every page of every face's atlas, in texels.
  • texels-per-em is the field's resolution: how many texels span an em. More keep corners sharp at larger sizes, and cost memory and time.
  • spread is how far, in ems, each field reaches beyond the glyph's outline on every side. It limits how far outlines, glows and shadows can reach. A larger spread lets them reach further, and costs precision.

field-atlas-page-size, field-atlas-texels-per-em and field-atlas-spread return the settings. A field atlas is a plain Lisp object that needs no releasing: what it makes is kept with the faces it draws, and goes with them, or with the field atlas once the collector takes it. Make one and keep it for the life of the program.

(face-atlas field-atlas face) returns the cl-atlas atlas a face's fields are packed into, made the first time. The face keeps it and frees it with itself. Its cl-atlas:atlas-data is the face, so a renderer that holds one of its pages reaches the face, and keeps it alive.

One field per face

Fields are made in ems, so one field serves every size of a face: the fonts face-font gives of one face share them.

(let* ((face (tr:make-face "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"))
       (fields (cl-textrender/sdf:make-field-atlas))
       (small (tr:face-font face 16))
       (big (tr:face-font face 64)))
  (eq (cl-textrender/sdf:glyph-region fields small (tr:font-glyph-id small #\A))
      (cl-textrender/sdf:glyph-region fields big (tr:font-glyph-id big #\A))))
;; => T

Fonts of different faces get fields of their own, even of the same file: make one face and ask it for each size. Weights of a variable font are different faces, and get fields of their own.

What a field holds

(cl-textrender/sdf:glyph-field-bounds region)   ; => left, bottom, right, top

glyph-field-bounds returns the rectangle of ems the region covers, y up from the glyph's origin on the baseline. The region's left texel column starts at the left edge and its top row at the top. The rectangle reaches spread ems beyond the outline on every side, and a texel is exactly 1/texels-per-em of an em.

Each texel has four channels:

  • Red, green and blue hold the multi-channel distance. Their median draws the glyph with sharp corners.
  • Alpha holds the true distance to the outline, for effects that reach away from the edge, where the median is unreliable.

Every channel maps a distance d, in ems, positive inside the glyph, to 0.5 + d / (2 × spread), clamped to the range 0 to 1: −spread ems is 0, the edge 0.5, and +spread ems 1.

A glyph without ink, such as a space, has an empty region, whose cl-atlas:region-page is nil, and bounds of zero.

Drawing a layout

(cl-textrender/sdf:map-layout-fields function field-atlas layout
                                     &key (scale 1) (x 0) (y 0))

map-layout-fields calls function for each glyph of layout that has ink, drawn at scale pixels per layout unit with the layout's top left at pixel (x, y). It passes eight arguments:

  1. the glyph's region in its face's atlas;
  2. the pixel position x, y of the corner of the field's first texel, its top left in the atlas;
  3. the vector ux, uy that the field's rows run along;
  4. the vector vx, vy that its columns run down;
  5. the cl-textrender:glyph, for its font, style data and so on.

The field covers the quad from (x, y) to (x + ux + vx, y + uy + vy). Draw it with the region's texture coordinates, cl-atlas:region-uv, running from the first corner along u and down v:

Corner Texture coordinates
(x, y) (u0, v0)
(x + ux, y + uy) (u1, v0)
(x + ux + vx, y + uy + vy) (u1, v1)
(x + vx, y + vy) (u0, v1)
(tr:with-font (font "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf" :size 16)
  (let ((fields (cl-textrender/sdf:make-field-atlas))
        (quads '()))
    (cl-textrender/sdf:map-layout-fields
     (lambda (region x y ux uy vx vy glyph)
       (declare (ignore region glyph))
       (push (list x y ux uy vx vy) quads))
     fields (tr:layout-text "Hi" font) :scale 2 :x 10 :y 20)
    (nreverse quads)))
;; => ((5.140625 17.703125 34.0 0.0 0.0 40.0)
;;     (29.078125 16.703125 19.0 0.0 0.0 41.0))

Upright glyphs have their rows along x and their columns down y, as here. A glyph turned in vertical text (glyph-rotated-p) gets its quad turned with it: its rows run down and its columns to the left, and the same drawing code handles it. Positions are exact, not snapped to pixels, and rows grow downwards from the layout's top whatever its y axis. Fields are made as they are first met, which takes milliseconds each; once they all have been, map-layout-fields allocates nothing.

Placing one glyph yourself

For a single upright glyph at pixel (gx, gy), y down, in a font drawn at s pixels per em (the font's size times the scale), the field's quad is

left   = gx + field-left   × s        top    = gy - field-top    × s
right  = gx + field-right  × s        bottom = gy - field-bottom × s

where glyph-field-bounds gives the field's left, bottom, right and top in ems.

The shader

In the fragment shader, sample with linear filtering and turn the sample into a distance in pixels:

d      = (median(r, g, b) - 0.5) × 2 × spread × s     ; pixels, positive inside
alpha  = clamp(d + 0.5, 0, 1)                          ; coverage of the glyph

For effects, use the true distance in the alpha channel the same way: (a - 0.5) × 2 × spread × s pixels. An outline of width w pixels covers where that distance is above −w; a glow fades out as it falls; a shadow samples it at an offset. None can reach further than spread ems.

Recipes draws a glyph this way on the CPU, which shows the arithmetic in full.

The pixels per em, s, are the scale times the glyph font's size: 32 in the example above, where a field's texel lands on exactly one pixel.

Uploading pages

Pages are four bytes a texel, red, green, blue and alpha: an RGBA8 texture, without sRGB conversion. Upload the pages of face-atlas for each face you draw with as for the glyph atlas, with cl-atlas:take-page-changes and cl-atlas:copy-page-region; see Glyph Atlas.

Threads and time

Making a field takes a few milliseconds per glyph. A page of new text made on the render thread would stall a frame, so a renderer that must not stall calls glyph-region on worker threads, and uploads at a frame boundary. Finding a field made before allocates nothing, so a renderer may look fields up every frame.

A field atlas may be used from any number of threads at once. Fields are made outside the atlas's lock, so fields for different glyphs are made in parallel; if two threads make the same field, the first packed is kept.

Errors

glyph-region signals textrender-error if a glyph's outline cannot be made into a field, which happens only for a malformed outline. Draw that glyph another way, such as rasterized. A glyph ID the font does not have is no error: its region is .notdef's, made once for them all.