Shader concept

Anti-aliasing in shaders: smooth edges without MSAA

To anti-alias an edge you draw yourself in a shader, fade it from 1 to 0 over about one pixel with smoothstep instead of cutting it with step. The staircase turns into a clean gradient.

Running live in your browser. The code below is exactly what draws it.

Why edges look jagged

A pixel is either on or off. When you draw an edge with step, every pixel along it picks a side, and a slanted line turns into a staircase. Normal graphics hardware fixes the edges of triangles with multisampling (MSAA), but that only knows about triangle edges. An edge you calculate inside the shader, such as a circle drawn from a distance, is invisible to it, so you have to smooth that yourself.

The fix

Replace step(edge, x) with smoothstep(edge - w, edge + w, x), where w is about half a pixel in the same units as x. Pixels fully inside or outside are unchanged, and the ones straddling the edge get a value in between, which the eye reads as a smooth line. You can get w exactly from the screen-space derivatives: fwidth(x), which is abs(ddx(x)) + abs(ddy(x)), says how much x changes across one pixel. (The picture writes it out; in GLSL, WGSL and Metal the derivative functions are called dFdx, dpdx and dfdx.)

Where to use it

  • Distance fields: 1 - smoothstep(-w, w, d) gives a perfectly smooth shape.
  • Stripes, grids and checkerboards, which alias badly in the distance.
  • Text and icons drawn from distance functions.

Common mistakes

  • Making w too large: the edge looks blurry instead of crisp. One pixel is enough.
  • Using a fixed w when you zoom. If the picture scales, w must scale too, or use fwidth.
  • Expecting this to fix shimmering high-frequency textures far away. That needs filtering or lower-detail versions (mipmaps), not just a softer edge.

The code, in every shader language

The same example for each language family. Open one to see it.

HLSL Unity, Unreal
float4 frag(float2 uv)
{
    float2 p = (uv - 0.5) * float2(aspect, 1.0);
    float s = (p.x * 0.8 + p.y * 0.6) * 22.0;           // diagonal stripes: the worst case for jaggies
    float tri = abs(frac(s) - 0.5) * 2.0;               // a zig-zag wave from 0 to 1
    float hard = step(0.5, tri);                         // a hard cut: jagged
    float w = abs(ddx(s)) + abs(ddy(s));                 // how much s changes across one pixel (this is fwidth)
    float soft = smoothstep(0.5 - w, 0.5 + w, tri);     // the same cut, faded over about one pixel
    float t = lerp(hard, soft, step(0.0, p.x));          // left half: hard, right half: smooth
    float3 col = lerp(float3(0.08, 0.1, 0.22), float3(0.95, 0.8, 0.3), t);
    return float4(col, 1.0);
}
GLSL Shadertoy, three.js, OpenGL
vec4 frag(vec2 uv)
{
    vec2 p = (uv - 0.5) * vec2(aspect, 1.0);
    float s = (p.x * 0.8 + p.y * 0.6) * 22.0;           // diagonal stripes: the worst case for jaggies
    float tri = abs(fract(s) - 0.5) * 2.0;               // a zig-zag wave from 0 to 1
    float hard = step(0.5, tri);                         // a hard cut: jagged
    float w = abs(dFdx(s)) + abs(dFdy(s));                 // how much s changes across one pixel (this is fwidth)
    float soft = smoothstep(0.5 - w, 0.5 + w, tri);     // the same cut, faded over about one pixel
    float t = mix(hard, soft, step(0.0, p.x));          // left float: hard, right float: smooth
    vec3 col = mix(vec3(0.08, 0.1, 0.22), vec3(0.95, 0.8, 0.3), t);
    return vec4(col, 1.0);
}
WGSL WebGPU, Bevy
fn frag(uv: vec2f) -> vec4f
{
    let p = (uv - 0.5) * vec2f(aspect, 1.0);
    let s = (p.x * 0.8 + p.y * 0.6) * 22.0;           // diagonal stripes: the worst case for jaggies
    let tri = abs(fract(s) - 0.5) * 2.0;               // a zig-zag wave from 0 to 1
    let hard = step(0.5, tri);                         // a hard cut: jagged
    let w = abs(dpdx(s)) + abs(dpdy(s));                 // how much s changes across one pixel (this is fwidth)
    let soft = smoothstep(0.5 - w, 0.5 + w, tri);     // the same cut, faded over about one pixel
    let t = mix(hard, soft, step(0.0, p.x));          // left half: hard, right half: smooth
    let col = mix(vec3f(0.08, 0.1, 0.22), vec3f(0.95, 0.8, 0.3), t);
    return vec4f(col, 1.0);
}
Godot Godot 4 shading language
shader_type canvas_item;

vec4 frag(vec2 uv)
{
    vec2 p = (uv - 0.5) * vec2(aspect, 1.0);
    float s = (p.x * 0.8 + p.y * 0.6) * 22.0;           // diagonal stripes: the worst case for jaggies
    float tri = abs(fract(s) - 0.5) * 2.0;               // a zig-zag wave from 0 to 1
    float hard = step(0.5, tri);                         // a hard cut: jagged
    float w = abs(dFdx(s)) + abs(dFdy(s));                 // how much s changes across one pixel (this is fwidth)
    float soft = smoothstep(0.5 - w, 0.5 + w, tri);     // the same cut, faded over about one pixel
    float t = mix(hard, soft, step(0.0, p.x));          // left float: hard, right float: smooth
    vec3 col = mix(vec3(0.08, 0.1, 0.22), vec3(0.95, 0.8, 0.3), t);
    return vec4(col, 1.0);
}

void fragment()
{
    // Godot's UV starts at the top-left, the lessons use bottom-left
    COLOR = frag(vec2(UV.x, 1.0 - UV.y));
}
Metal iOS, macOS
float4 frag(float2 uv)
{
    float2 p = (uv - 0.5) * float2(aspect, 1.0);
    float s = (p.x * 0.8 + p.y * 0.6) * 22.0;           // diagonal stripes: the worst case for jaggies
    float tri = abs(fract(s) - 0.5) * 2.0;               // a zig-zag wave from 0 to 1
    float hard = step(0.5, tri);                         // a hard cut: jagged
    float w = abs(dfdx(s)) + abs(dfdy(s));                 // how much s changes across one pixel (this is fwidth)
    float soft = smoothstep(0.5 - w, 0.5 + w, tri);     // the same cut, faded over about one pixel
    float t = mix(hard, soft, step(0.0, p.x));          // left half: hard, right half: smooth
    float3 col = mix(float3(0.08, 0.1, 0.22), float3(0.95, 0.8, 0.3), t);
    return float4(col, 1.0);
}

Practise it in the lessons

Start with "smoothstep(): soft edges"

Questions

What does fwidth do?

It returns the sum of the absolute screen-space derivatives of a value in x and y: roughly how much the value changes from one pixel to the next. It is the right width for a one-pixel soft edge.

Is this the same as FXAA or MSAA?

No. MSAA samples triangle edges more than once, and FXAA blurs the finished image. This technique softens the edges that your own shader code creates, at no extra cost.

Related concepts