Foundations, part 1

Drawing shapes with distance

Give every pixel one number, its distance to the nearest edge, and you can fill, outline, combine and morph crisp shapes at any size.

  • Part 16 min dose
  • Beginner

A lot of shapes in shaders aren’t pictures at all: the highlight in an anime eye, a glowing emblem, a magic circle. Each one is a tiny formula that tells every pixel how far it is from the shape’s edge. That number is its signed distance. Let’s draw shapes with it, then outline, combine and morph them. It’s the first Foundations article, so I’ll go slow.

A glowing shape that keeps changing

Turn on JavaScript to run this demo.

hero.hlsl draws the demo above
Fig. 1

One shape melting into the next. There’s no texture hiding in here. Every frame is computed from distances.

Every pixel gets one number

A shader runs once for every pixel, and each time it knows where that pixel is. Its position p=(x,y)p = (x, y) says how far across and how far up it is. In these demos the picture is 2 units tall, and its middle is the origin, p=(0,0)p = (0, 0). Let’s start with the friendliest shape there is. For a circle of radius rr centred on the origin, the distance from pp to the edge is:

d(p)=∣p∣−rd(p) = |p| - r

The bars in ∣p∣|p| mean the length of pp, which is how far the pixel is from the centre. Take away the radius, and the sign tells you which side you’re on.

  • d<0d < 0 means the pixel is inside.
  • d=0d = 0 means it sits exactly on the edge.
  • d>0d > 0 means it is outside, dd units away.

There’s one small trap here. Around a single number, the same bars drop the minus sign, so ∣d∣|d| is how far the pixel is from the edge, on either side. And that’s where the name signed distance field, or SDF, comes from. Signed because of that minus, field because every point of the picture has one.

Distance to a circle's edge
Drag the marked part, or press Tab to reach it and use the arrow keys

Turn on JavaScript to run this demo.

Size of the circle.0.45
distance.hlsl draws the demo above
Fig. 2

Drag the dot and play with Radius r. The circle around the dot has radius ∣d∣|d|, so it reaches the edge but never crosses it. Mint is inside, pink is outside, and each stripe is 0.1 units further from the edge. The line shows the shortest way from the dot to the edge.

Three looks, one number

Once a pixel knows dd, how it looks is up to you.

LookRuleReads as
Filld<0d < 0inside the shape
Outline∣d∣−w<0\lvert d \rvert - w < 0within ww of the edge
Glowe−smax⁡(d, 0)e^{-s \max(d,\,0)}bright at the edge, fading outside

The outline works because ∣d∣|d| is small on both sides of the edge. The glow needs two little helpers. The first, e−xe^{-x} (exp in shader code), is a fade that is 1 when xx is 0 and sinks toward 0 as xx grows. The second, max⁡(d,0)\max(d, 0), picks the larger of dd and 0. It stays 0 inside the shape and equals dd outside. Put them together and the glow is 1 inside and falls off outside. A larger ss makes it tighter.

Fill, outline and glow

Turn on JavaScript to run this demo.

Half the outline's width, in the same units as d.0.020
How fast the glow fades with distance.12
masks.hlsl draws the demo above
Fig. 3

Set Show to one look at a time, then move Outline w and Glow s. All three read the very same dd.

A hard test like d<0d < 0 gives jagged edges. The demos here quietly soften every edge over about one pixel, and the next article shows how.

Mixing and matching shapes

Now let’s put two shapes together. Call the distance to the box aa and the distance to the circle you drag bb. Min picks the smaller of two numbers, max the larger.

union=min⁡(a,b)intersection=max⁡(a,b)subtraction=max⁡(a,−b)\begin{aligned} \text{union} &= \min(a, b) \\ \text{intersection} &= \max(a, b) \\ \text{subtraction} &= \max(a, -b) \end{aligned}

Remember that inside means a negative distance. A pixel is inside the union when it is inside either shape, so the smaller distance wins. It is inside the intersection only when it is inside both, so the larger one wins. Flipping the sign of bb swaps its inside and outside, which turns an intersection into cutting bb away. That last one bends brains a little, so give the demo a go.

Union, intersection and subtraction
Drag the circle, or press Tab to reach it and use the arrow keys

Turn on JavaScript to run this demo.

lib.hlsl and combine.hlsl draw the demo above
Fig. 4

Drag the circle and switch Operation. The thin lilac lines are the box aa and the circle bb, and the mint fill is the result.

Melting two shapes together

The plain union leaves a sharp crease where two shapes meet. The smooth union rounds it off. It looks scarier than it is, so let’s take it one piece at a time:

h=saturate⁡ ⁣(12+12⋅b−ak)d=lerp⁡(b,a,h)−k h (1−h)\begin{aligned} h &= \operatorname{saturate}\!\left(\tfrac12 + \tfrac12 \cdot \tfrac{b - a}{k}\right) \\ d &= \operatorname{lerp}(b, a, h) - k\,h\,(1 - h) \end{aligned}

kk is Smoothness k, and it decides how close the shapes get before they melt. hh is a weight from 0 to 1. It is 1 where aa is nearer by at least kk, and 0 where bb is. It is in between only where the two distances differ by less than kk. Saturate keeps it between 0 and 1. lerp⁡(b,a,h)\operatorname{lerp}(b, a, h) slides from bb to aa as hh goes from 0 to 1 (GLSL, the language your browser runs, calls it mix).

Away from where the shapes meet, this is the plain union. Where they meet, k h (1−h)k\,h\,(1 - h) is at its largest, and subtracting it moves the edge outwards to fill the corner. That little bulge is the whole melt.

Melt the circle into the box
Drag the circle, or press Tab to reach it and use the arrow keys

Turn on JavaScript to run this demo.

How close the shapes get before they melt together (Smooth union only).0.15
Fig. 5

Drag the circle near the box and raise Smoothness k to widen the melted neck. Far from the box, the circle keeps its own edge, as if nothing happened.

Rings and morphs for free

Because dd is a number like any other, two more tricks need only one small step each.

  • Rings. Remember the outline rule, ∣d∣−w|d| - w, with ww as half the outline’s width? That rule is itself a new distance, the distance to a band 2w2w wide around the old edge. You can fill it, glow it or combine it again, and it works for any shape.
  • Morphs. lerp⁡(dA,dB,t)\operatorname{lerp}(d_A, d_B, t) blends two distances, and the edge travels from shape A to shape B as tt goes from 0 to 1.

I have a small confession to make. A blend is no longer the true distance to anything. But where it crosses 0 is still a clean edge, and for drawing a shape, that edge is all that matters.

Morph two shapes, add a ring

Turn on JavaScript to run this demo.

0 shows shape A, 1 shows shape B.0.50
Half the ring's thickness. Above 0, the edge becomes a band 2w wide.0.000
morph.hlsl draws the demo above
Fig. 6

Pick Shape A and Shape B and slide Morph t. Raise Ring w to turn the result into a ring. The stripes bend and bunch up away from the shape, but the edge itself stays clean.

In practice

Shaders draw shapes this way wherever they must stay sharp up close, like eye highlights, pupils, glowing marks on clothing and spell circles. In a Unity shader, pp usually comes from the mesh UVs (the flat map that places a texture on the mesh). A material slider can drive the morph, so an animation in a VRChat menu can switch the shape. Here’s a small example of my own:

float2 p = (i.uv - 0.5) * 2.0; // -1 to 1
float dA = length(p) - 0.5;
float dB = sdStar5(p, 0.6, 0.45);
float d = lerp(dA, dB, _Morph);
float fill = saturate(0.5 - d / max(fwidth(d), 1e-5));

The first line centres the UVs, so the middle is (0, 0), like in the demos. sdStar5 is a star from Inigo Quilez’s list of 2D shapes (see Credits), and _Morph is a material slider. The last line is the one-pixel soft edge from the next article, and fwidth(d) in it is how much dd changes from one pixel to the next.

Four little traps to watch for:

  • Stretched UVs. If the UVs stretch the surface, for example a wide panel mapped onto a square of UV space, your circle turns into an oval. Scale one axis of pp to match and it’s round again.
  • Resizing. To draw a shape nn times bigger, divide pp by nn and multiply the result by nn. Then dd is still measured in the same units, so outline widths and glows keep their size.
  • Thin lines far away. A thin outline covers many pixels up close and less than one from across a room, where it flickers. Keep it at least one pixel wide: w = max(w, 0.5 * fwidth(d)).
  • Edges inside a choice. Shader code can take one of two paths with if. When neighbouring pixels take different paths, fwidth gets a wrong answer and the edge breaks up. Work out fwidth(d) before any such choice.

Credits

The 2D shape functions in my shaders and on this site follow Inigo Quilez’s 2D distance functions. The smooth union is his polynomial smooth minimum. His example code is MIT licensed. Thank you, Inigo!

Takeaways

  • A signed distance is negative inside, zero on the edge and positive outside.
  • Fill, outline and glow are three ways of reading that same number.
  • Min, max and a sign flip combine shapes, ∣d∣−w|d| - w makes rings, and lerp morphs.
Search the lessons

Type any word from the lessons, like normalize or smoothstep. The start of a word is enough.