Foundations, part 2

Why a clean edge needs one pixel of blur

A hard inside or outside test draws jagged stairs. Here's how I blur every edge over about one pixel with smoothstep and fwidth, so it stays clean at any size.

  • Part 27 min dose
  • Beginner

Smoothstep is a small tool, and it’s behind the clean edges in my shaders. Here’s why an edge needs it. A screen is a grid of tiny square pixels, and each pixel shows one color. When a shape’s edge cuts right through a pixel, a hard yes or no to “is this pixel inside?” turns every curve into stairs. Let’s meet smoothstep, measure one pixel with fwidth, and blur each edge over about that one pixel.

Drag the divider
Drag the split line, or press Tab to reach it and use the arrow keys

Turn on JavaScript to run this demo.

How many big pixels fit across the short side of the picture.32
lib.hlsl and hero.hlsl draw the demo above
Fig. 1

I made the pixels huge on purpose. Drag the divider and compare the two sides. On the left each pixel is either in or out, while on the right a pixel on the edge gets an in-between shade. Lower Pixels across for even bigger stairs.

A curve with soft shoulders

First, the star of the show. smoothstep(a, b, x) is 0 before aa, 1 after bb, and a soft S curve in between:

t=saturate⁡ ⁣(x−ab−a)y=t2 (3−2t)\begin{aligned} t &= \operatorname{saturate}\!\left(\tfrac{x - a}{b - a}\right) \\ y &= t^2\,(3 - 2t) \end{aligned}

Let’s take it one piece at a time. xx is the value you test (in the plot, the horizontal axis) and yy is what comes out. aa and bb are the two edges, called Edge a and Edge b in the plot. tt is a straight ramp from 0 to 1, and saturate keeps it between 0 and 1. Then the t2(3−2t)t^2(3 - 2t) part bends the ramp so it starts and ends flat. Those flat ends are the soft shoulders, and they leave no visible corner where the edge begins or ends.

Smoothstep, a ramp and a step

Turn on JavaScript to run this demo.

Where the curve starts to rise.0.30
Where the curve reaches 1.0.70
Code for the plot above
  • f1(x):smoothstep(_A, _B, x)
  • f2(x):saturate((x - _A) / (_B - _A))
  • f3(x):step(0.5, smoothstep(_A, _B, x))
Fig. 2

Drag Edge a past Edge b and watch the curve flip over. Now it falls from 1 to 0.

Swap the edges like that, with aa past bb, and smoothstep falls instead of rising. Unity’s HLSL is happy with this, but some shader languages are not. To be safe everywhere, 1 - smoothstep(b, a, x) gives the same falling curve.

Where the stairs come from

A quick reminder first. Each pixel knows its signed distance dd to the edge, negative inside and positive outside. Now the catch. A pixel runs the shader only once, at its centre. With a hard test like d<0d < 0, all it learns is which side its centre is on. So a slanted edge jumps a whole pixel at a time, and the jumps line up into stairs that crawl when the shape moves. This is called aliasing, and softening it is anti-aliasing, or AA for short.

The fix is to ask “how much?” instead of “yes or no?”. Let’s turn dd into a coverage mm, meaning how much of the pixel the shape fills, with a ramp one pixel wide:

m=saturate⁡ ⁣(12−dw)m = \operatorname{saturate}\!\left(\tfrac12 - \tfrac{d}{w}\right)

ww is the size of one pixel, in the same units as dd (finding it is the next section). A pixel whose centre sits right on the edge gets 0.5, and the ramp fades to 0 and 1 half a pixel either side. Look familiar? It’s the straight ramp from the plot above, turned to fall and squeezed into one pixel. Smoothstep over the same pixel works too and looks a touch softer. I use it further down for soft edges.

Three ways to shade an edge

Turn on JavaScript to run this demo.

How many big pixels fit across the short side. Fewer pixels is like seeing the shape from further away.24
How each big pixel turns the distance at its centre into a brightness.
Draws the exact edge of the shape on top.
stairs.hlsl draws the demo above
Fig. 3

Try all three Edge modes at a few Pixels across values, and turn on True outline to see where the real edge runs. The hard step is jagged at every size. The fixed blur goes mushy with many pixels and jagged with few. One pixel never breaks the bar and never gets mushy. It’s as clean as those pixels allow. That’s the one you want.

Peeking at the neighbours with fwidth

But how does a pixel know how big it is? This is my favourite part. The graphics card shades pixels in little blocks of 2 by 2, side by side, so each pixel can peek at its neighbours’ values. dFdx is how much a value changes from the left pixel of the block to the right one, and dFdy from the bottom one to the top one. HLSL, the language of Unity shaders, calls them ddx and ddy, and there ddy often points down.

fwidth⁡(d)=∣dFdx⁡(d)∣+∣dFdy⁡(d)∣\operatorname{fwidth}(d) = |\operatorname{dFdx}(d)| + |\operatorname{dFdy}(d)|

The bars drop the sign, so a step left counts the same as a step right. For a distance, this is how much dd changes over one pixel. There’s the ww the ramp was waiting for! It is exactly one pixel’s worth of distance where the edge runs along the pixel grid. Where the edge runs diagonally, it is up to about 1.4 pixels’ worth, so diagonal edges come out a little softer.

Pixels peeking at their neighbours
Point or use the arrow keys

Turn on JavaScript to run this demo.

Color each pixel by its distance, or by how much the distance changes across its block.
quad.hlsl draws the demo above
Fig. 4

Move over the picture to pick a block. The arrows are the two steps that block measures. Mint pixels are inside the circle, pink ones outside. Then switch Show to fwidth(d). Now the faint pixels are exactly one pixel’s worth of distance, and the bright ones go up to 1.4 where the circle runs diagonally.

One pixel, in any units you like

Here’s a nice bonus. fwidth works on any value, not only distances. Below, the lines are drawn on their own grid, which you can zoom and turn. A point on that grid is uu, a bit like the UV coordinates of a mesh. Its two numbers are u.x across the grid and u.y up the grid. fwidth(u.x) is how much u.x changes from one screen pixel to the next. Let’s average the two:

w=12 (fwidth⁡(u.x)+fwidth⁡(u.y))w = \tfrac12\,\big(\operatorname{fwidth}(u.x) + \operatorname{fwidth}(u.y)\big)

So ww is the size of one screen pixel, measured on the grid. With it, a line can be a set number of pixels wide, whatever its size on screen. On an avatar, uu is often the UV of the mesh, and it shrinks on screen as you walk away.

Lines on a zoomable grid

Turn on JavaScript to run this demo.

How large one unit of u looks on screen.1.00
Rotates the u coordinates, in degrees.20
Fixed in u keeps the lines 0.04 units of u wide, and In pixels sets their width in screen pixels with fwidth.
zoom.hlsl draws the demo above
Fig. 5

With Width on Fixed in u, zoom in and the lines get chunky. Zoom out and they get thinner than a pixel, then start to break up. Now switch to In pixels and zoom again. This time the lines keep the same width on screen.

Soft edges that never get sharper than a pixel

Not every edge wants to be crisp. A soft dot or a thin glowing ring gets a softness ss you pick by hand, for example 0.004 units, where the picture is 2 units tall. This one is sneaky. Far away, one pixel can be much wider than that, and your soft edge turns back into a hard step. Stairs again, plus dots that vanish and thin rings that fall apart. The cure is to never blur less than one pixel:

b=max⁡(s, w)m=1−smoothstep⁡(−b2, b2, d)\begin{aligned} b &= \max(s,\ w) \\ m &= 1 - \operatorname{smoothstep}(-\tfrac{b}{2},\ \tfrac{b}{2},\ d) \end{aligned}

bb is the blur the edge really gets. It’s your chosen softness ss, or one pixel ww when that is wider. As before, dd is the signed distance and mm the coverage, how much of the pixel the shape fills. The 1−1 - flips the curve, so the inside, where dd is below 0, comes out as 1.

Never sharper than one pixel

Turn on JavaScript to run this demo.

The size of one pixel in the units of d. It grows as the shape gets further away.0.050
Code for the plot above
  • f1(x):smoothstep(0.01, -0.01, x)
  • f2(x):softEdge(x, 0.02, _W)
Fig. 6

Drag Pixel w. The plot uses a softness of 0.02 so the curve is wide enough to see. Below a Pixel w of 0.02, the two curves are the same. Above it, the edge spreads over one pixel.

Bring the stars back

Turn on JavaScript to run this demo.

How many big pixels fit across the short side. Fewer pixels is like seeing the picture from further away.32
With Fixed softness every edge keeps its hand chosen softness. With At least a pixel no edge gets narrower than one pixel.
soft.hlsl draws the demo above
Fig. 7

With Edges on Fixed softness and the starting 32 Pixels across, the ring breaks into pieces and the stars vanish. Poor stars. Switch to At least a pixel and watch the ring hold together and every star come back. Then raise Pixels across to 200. The planet looks the same in both modes, because its softness is already wider than a pixel, but the thin ring and the stars still need the wider blur.

In practice

You’ll find this blur wherever a shader draws shapes from math instead of a texture, like eyes, glowing marks on clothing, spell circles and the stars in a sky. Here’s my own example in HLSL:

// 1 inside, 0 outside, a one pixel edge.
float fillAA(float d)
{
    // never divide by zero
    float w = max(fwidth(d), 1e-5);
    return saturate(0.5 - d / w);
}

Sky Rift, my spell effect, gets the pixel size ww (called px there) handed in ready made, and uses a falling smoothstep from +w+w to −w-w. That’s the S curve version of the one pixel ramp, spread over about two pixels so it looks a touch softer.

SkyRift_World.cgincRuns in Unity only
inline float srCoverPx(float d, float px)
{
    px = max(px, 1e-6);
    return smoothstep(px, -px, d);
}

Three little traps to watch for in Unity and VRChat:

  • Measure once, at the top. With an if, a shader can take different paths for different pixels. But fwidth needs all four pixels of a 2 by 2 block on the same path, so when it sits inside an if, Unity’s shader compiler runs both paths on every pixel, which is slower. So I work out ww once, before any if, and reuse it.
  • MSAA does not help. Sadly, the anti-aliasing setting in VRChat only smooths the outline of the mesh, not edges drawn inside a shader. Those need their own one pixel blur.
  • Wider means brighter. A thin bright line widened to one pixel covers more pixels, so far away it adds light. To keep the same total light, dim it by the same factor you widened it by.

Credits

The star, disc, bar and ellipse in the demos use Inigo Quilez’s 2D distance functions (MIT). Widening a thin line to one pixel and dimming it to match comes from Emil Persson’s (Humus) Phone-wire AA.

Takeaways

  • smoothstep is a ramp with soft, flat ends. Swap its edges, or use 1 - smoothstep, to make it fall.
  • A hard inside test makes stairs. A ramp one pixel wide, with the pixel size from fwidth, gives you clean edges at any zoom.
  • Never let a soft edge get narrower than one pixel. Blur by max⁡(s,w)\max(s, w), the larger of your softness and one pixel.
Search the lessons

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