2D toolkit · lesson 2 of 10 · Intermediate · about 5 min

Supersampling: when fwidth is not enough

You will build: Replace the single sample with 2 x 2 supersampling to remove the moir from the rings: four samples at plus or minus a quarter of a pixel (px = fwidth(p.x)), averaged.

The picture produced by the shader in the lesson "Supersampling: when fwidth is not enough"
The target picture. In the lesson you write the shader that draws it, and a match bar shows how close you are.

fwidth works when your picture is a shape with an edge: you know where the edge is, so you can soften it. But some patterns are not like that: stripes that repeat faster than the pixels, noise, a checkerboard in the distance. Here a pixel covers many stripes at once, and a single yes-or-no sample gives wild moiré patterns, bands that are not in the picture at all.

The general fix is supersampling: run the pattern several times per pixel at slightly different positions and average the results. A pixel half covered by white now averages to gray, with no need to know where the edge is.

float px = fwidth(p.x);   // size of one pixel
float s = 0.0;
s += pat(p + float2(-0.25, -0.25) * px);
s += pat(p + float2( 0.25, -0.25) * px);
s += pat(p + float2(-0.25,  0.25) * px);
s += pat(p + float2( 0.25,  0.25) * px);
float m = s * 0.25;       // 2 x 2 samples, averaged

The offsets are a quarter of a pixel either way: the four samples sit in the four quadrants of the pixel. That is called 2×2 supersampling (SSAA).

The price

Four samples means the shader runs four times: a pattern that cost one millisecond now costs four. That is why games do not use it for the whole screen. They use cheaper tricks: MSAA (multisample only along triangle edges, done by the hardware), FXAA / SMAA (a filter that finds edges in the finished picture and blurs them) and TAA (use the previous frames as extra samples). Inside a pixel shader you can only do what you control yourself: soft edges with fwidth, or a few samples averaged as here.

The numbers you need

Everything the picture depends on is given, so the challenge is the code, not guessing.

Pattern
pat(p) = step(0.5, frac(length(p) * 120.0)): provided, rings far too thin for one sample
Pixel size
px = fwidth(p.x)
Offsets
(-0.25, -0.25), (0.25, -0.25), (-0.25, 0.25), (0.25, 0.25), each times px
Average
m = (sum of the four) * 0.25
Colors
dark float3(0.06, 0.07, 0.12), light float3(0.95, 0.9, 0.75)

Try it in your own language

The lesson is written once and shown in every language. Pick yours: Unity (Built-in), Unity (URP), Unreal, Shadertoy / WebGL, OpenGL (LWJGL, raylib), three.js, GameMaker, LÖVE (Lua), Godot, Apple (iOS, macOS), WebGPU / Bevy.

Start coding