In chapter 02, subtracting two positions gave us a displacement. We knew how far to move along each axis. Now we want the straight-line distance between those positions.
This answers a useful drawing question: is this pixel close enough to our center to belong to a circle? We already used length to draw small circles. Let’s open that part of the code.
Measure the displacement
Place the center at (0, 0) and a point at (0.3, 0.4). The displacement from the center is:
vec2 center = vec2(0.0);
vec2 point = vec2(0.3, 0.4);
vec2 v = point - center; // (0.3, 0.4)
The horizontal and vertical steps form the two short sides of a right triangle. The straight line connecting the points is its long side. The Pythagorean theorem gives its length:
// length² = horizontal² + vertical²
float len = sqrt(v.x * v.x + v.y * v.y);
// sqrt(0.09 + 0.16) = sqrt(0.25) = 0.5
Adding the components would give 0.7. That measures the route along the two sides, not the straight line. Squaring, adding, and taking the square root gives the distance we need.
GLSL provides this operation directly:
float len = length(v);
The result is one nonnegative number, in the same units as the vector. Negative components work too: (-0.3, 0.4) has length 0.5, because squaring removes the signs. A zero vector has length zero.
Length discards direction. Many different vectors have the same length. That is exactly what makes this operation useful for drawing a circle: every direction must receive the same distance test.
A number at every pixel
Replace the single point with the current fragment’s position. Keep the equal-unit coordinates from chapter 01:
vec2 p = (2.0 * gl_FragCoord.xy - uResolution) / uResolution.y;
vec2 displacement = p - center;
float distanceToCenter = length(displacement);
p and center are positions in the same space. Their difference points from the center to this fragment. Its length is zero at the center and grows as we move away.
A value defined throughout space is a field. Here we have a distance field. To inspect it, map small distances to a dark color and larger distances to a brighter one. The playground’s distance view uses this mapping:
float brightness = clamp(distanceToCenter / 1.0, 0.0, 1.0);
The chosen display range is zero to one coordinate unit. clamp keeps the color weight between zero and one; distances above one share the brightest color. The measurement itself is still available in the readout.
Points at equal distance receive equal brightness, forming circular bands. Move the center and the whole field follows it. The rule has no preferred horizontal or vertical direction.
Find the boundary
Choose a radius of 0.5. The simplest filled circle keeps every fragment whose distance is smaller than the radius:
float radius = 0.5;
float circle = distanceToCenter < radius ? 1.0 : 0.0;
This value is a mask: one means filled, zero means background. Strictly, the filled region is a disk; the circle is its boundary. We will use “circle” for the familiar filled shape and say “boundary” when the distinction matters.
Subtract the radius to move the threshold to zero:
float distanceToCircle = distanceToCenter - radius;
This is a signed distance to the circular boundary. Its magnitude measures the shortest distance to that boundary; its sign identifies which side we are on.
| Distance to center | Minus radius | Meaning |
|---|---|---|
| 0.2 | -0.3 | Inside, 0.3 from boundary |
| 0.5 | 0.0 | On boundary |
| 0.7 | +0.2 | Outside, 0.2 from boundary |
At the center, the signed distance is -radius, not zero. Zero distance to the center and zero distance to the boundary describe different places.
Give the edge a width
The hard comparison switches immediately from one to zero. On a pixel grid, its outline can look jagged. Let’s replace that switch with a short transition:
float edge = 0.015; // Half-width of the transition, in coordinate units.
float circle = 1.0 - smoothstep(-edge, edge, distanceToCircle);
Treat smoothstep as a small shaping tool: it returns zero below the first threshold, one above the second, and a smooth change between them. Subtracting from one makes the inside filled and the outside empty. At the boundary, our mask is 0.5.
The transition extends from -edge to +edge, so its total width is 2 * edge. Keep edge > 0; equal or reversed thresholds have undefined behavior. For a hard edge, use the comparison above.
We blend the background and fill with mix(background, fill, circle). A mask of zero selects the background, one selects the fill, and intermediate values blend them. We will study that blending operation in chapter 05.
This softness is measured in coordinate units, not pixels. A fixed value looks wider in pixels on a larger canvas. It is a deliberate visual transition, not automatic pixel-perfect antialiasing.
Keep a narrow band around the boundary
For a ring, we want pixels near the boundary on either side. The sign no longer matters. abs gives the unsigned distance to the circular boundary:
float distanceToOutline = abs(distanceToCircle);
Choose a full ring thickness of 0.12. Half of that thickness lies on each side of the original circle:
float thickness = 0.12;
float ringDistance = abs(distanceToCircle) - thickness * 0.5;
float ring = 1.0 - smoothstep(-edge, edge, ringDistance);
With radius 0.5, the ring’s inner and outer boundaries are at distances 0.44 and 0.56 from the center. Those are the mask’s half-strength boundaries; softness spreads around them.
Thickness and softness do different jobs. Thickness sets the band’s size. Softness sets the transition at its edges. If softness exceeds half the thickness, the transitions overlap and even the middle of the ring no longer reaches full strength.