A fragment shader looks as if it ran alone, one pixel at a time, with no idea of its neighbours. It does not. The GPU runs pixels in groups of four, and a shader can ask how much any value changes from one pixel to the next. Those answers, the derivatives dFdx, dFdy and fwidth, are what makes edges smooth at every zoom, what lets texture() choose a mip level, and what turns a position into a normal.
By the end you can
- Say what dFdx and dFdy return, and where the numbers come from.
- Anti-alias any edge, line or pattern with fwidth, at any zoom.
- Fix the line that atan or fract leaves in a texture.
- Build a flat normal from the derivatives of a position.
- Recognise where derivatives are undefined, and what to use instead.
The rasteriser does not hand pixels to the shader one by one. It hands them over in squares of 2 × 2, called quads, and the four pixels of a quad run the same instructions at the same time. At any line of the shader, a pixel can therefore see the value its neighbour in the quad has just computed for the same expression.
dFdx(v) is that value in the right-hand pixel minus the value in the left-hand pixel. dFdy(v) is the upper pixel minus the lower one, since y grows upwards in gl_FragCoord. Both pixels of a pair get the same answer. fwidth adds the two sizes together.
float w = fwidth(v); // = abs(dFdx(v)) + abs(dFdy(v))The unit is always "per pixel". If v is a distance in metres, dFdx(v) is metres per pixel; if v is a uv coordinate, it is how much of the texture one pixel spans. For f = 0.1 · gl_FragCoord.x, dFdx(f) is 0.1 in every pixel and dFdy(f) is 0. Most of this chapter comes down to dividing by that number to measure something in pixels.
thick squares = quads · y grows upwards
Every pixel runs, so every quad has its four values. The two pixels of a pair share one difference: dFdx of the left pixel and of the right pixel is the same number.
Tap a pixel. The amber arrow is the subtraction behind dFdx and the blue one is dFdy, with y growing upwards as in gl_FragCoord. Try "bowl" with coarse: the whole quad gets the bottom row's slope, even for the top pixels. Then try "angle" on the row just left of the centre, where the value jumps from 1 back to 0: the derivative there is huge, and that is what makes a seam appear in a texture.
Fine and coarse
A quad has two rows, so there are two possible dFdx values in it, one per row. GLSL 4.50 lets you choose: dFdxFine uses the pixel's own row, dFdxCoarse may use one row for the whole quad, which is cheaper. Plain dFdx lets the driver pick, and GLSL ES and WebGL have only the plain versions. Either way, a derivative is a difference over one pixel, not an exact slope: where a value bends sharply, it is off by up to a pixel's worth.
Helper pixels
A triangle's edge cuts through quads. The pixels of a cut quad that lie outside the triangle still run the shader, as helper invocations, only so that their neighbours have values to subtract. Their colour is thrown away (gl_HelperInvocation is true for them, from GLSL 4.50 and ES 3.10). This is why a mesh made of tiny triangles costs far more than its pixel count suggests: a triangle covering one pixel still runs four.
Shapes in shaders are usually decided by a signed distance d, negative inside, as in the Signed Distance Functions chapter. step(d, 0.0) asks the question once, at the pixel's centre, and answers all or nothing. A pixel half covered by the shape gets either full colour or none, and edges turn into stairs.
What the pixel should get is the fraction of it that the shape covers. Across one pixel, d changes by about fwidth(d), so d / fwidth(d) is the distance to the edge measured in pixels. That gives the coverage in four steps:
1// Coverage of a filled shape: d < 0 inside
2float fillAA(float d) {
3 float w = fwidth(d); // scene units per pixel, here
4 return clamp(0.5 - d / w, 0.0, 1.0); // a one-pixel ramp centred on the edge
5}
6
7// Coverage of a line of half-width h, r = distance from its centre line.
8// A line thinner than a pixel is drawn one pixel wide and dimmed instead,
9// so it stays continuous and keeps the brightness it really has.
10float lineAA(float r, float h) {
11 float w = fwidth(r);
12 float wide = max(2.0 * h, w);
13 return clamp((0.5 * wide - abs(r)) / w + 0.5, 0.0, 1.0) * (2.0 * h / wide);
14}
15
16// Grid lines on a plane: past ~2 pixels per cell, fade to the average coverage
17float gridAA(vec2 uv, float h) {
18 vec2 g = abs(fract(uv + 0.5) - 0.5); // distance to the nearest line, per axis
19 vec2 w = fwidth(uv);
20 float cx = mix(lineAA(g.x, h), 2.0 * h, smoothstep(0.3, 0.7, w.x));
21 float cy = mix(lineAA(g.y, h), 2.0 * h, smoothstep(0.3, 0.7, w.y));
22 return 1.0 - (1.0 - cx) * (1.0 - cy); // either line covers the pixel
23}Each pixel asks one question at its centre, inside or outside, and gets all or nothing. Edges become stairs, lines thinner than a pixel break into dashes, and fine stripes turn into patterns that are not there (moiré).
The scene is drawn at a low resolution and then blown up without smoothing, so you can count the pixels. Pixel size 1 is your screen's real resolution. Compare the four modes on each scene, then turn on "move": a still image hides most of the aliasing, motion shows it.
Detail smaller than a pixel
The one-pixel ramp fixes edges, but two kinds of detail need more. A line thinner than a pixel never covers a pixel's centre fully, so the plain ramp draws it as a broken row of dashes that flicker when it moves. lineAA above draws it one pixel wide and multiplies by its real width in pixels, 2h / w: a line a quarter of a pixel wide becomes a full-width line at a quarter of the brightness, which is what the eye sees from a real thin line.
A pattern that repeats faster than every two pixels cannot be shown at all. One sample per pixel cannot tell a stripe pattern of period 1.5 pixels from a much wider one, and the screen shows the wider one, a moiré that is not in the scene. The only honest picture is the pattern's average colour. fwidth of the pattern's coordinate says how many periods fit in a pixel, and the shader fades to the average as that number nears one half. Mipmaps do the same thing for textures, which is why textures need no such code.
texture(s, uv) takes the derivatives of uv itself. Multiplied by the texture's size, they say how many texels one pixel spans, and the base-2 log of that is the mip level. One texel per pixel reads level 0; four texels per pixel reads level 2. The Mipmapping chapter of the OpenGL track works through the formula with live numbers.
That is fine as long as uv is smooth. Polar coordinates are not: atan returns −π on one side of the negative x axis and +π on the other, so u jumps by a whole turn between two neighbouring pixels. Inside that quad the derivative is huge, texture() concludes that the pixel spans the whole texture, and reads the smallest mip level, a single average colour. The result is a thin seam one pixel wide. Tiling with fract(uv * 4.0) inside a texture atlas makes the same seam at every tile border.
Polar texture coordinates: u = angle, v = distance. On the left, u jumps back by a whole turn (4 repeats of the texture), the derivatives there are huge, texture() picks the smallest mip level and draws a thin dotted line of grey pixels. The fix keeps the coordinate but gives textureGrad the derivatives of a copy whose jump is on the other side.
The fix is to keep the coordinate but give the texture the derivatives it should have had. textureGrad(s, uv, dx, dy) takes them as arguments. For a tiled atlas, they are simply the derivatives of the coordinate before fract. For polar coordinates, compute them for two copies of the angle whose jumps are on opposite sides, and use whichever is smaller: at any pixel, at least one of the two is smooth.
1vec2 tiled = uv * 4.0; // smooth: no jumps
2vec2 inTile = fract(tiled); // jumps from 1 back to 0 at every tile border
3vec2 atlasUV = uTileOrigin + inTile * uTileSize;
4
5// texture(uAtlas, atlasUV) would see the jump and draw a seam.
6// The derivatives of the smooth coordinate, scaled like atlasUV, are the right ones:
7vec4 c = textureGrad(uAtlas, atlasUV, dFdx(tiled) * uTileSize, dFdy(tiled) * uTileSize);Apply dFdx and dFdy to the world position of the surface. dFdx(vWorld) is the step along the surface from one pixel to the next one on the right, and dFdy(vWorld) the step to the pixel above. Both lie in the surface, so their cross product is perpendicular to it: a normal. Inside one triangle the position changes linearly, so this normal is constant per triangle, the faceted look of flat shading, with no extra vertices. It is also always true to the geometry actually drawn, even after a vertex shader has moved the vertices and left the stored normals behind.
vec3 N = normalize(cross(dFdx(vWorld), dFdy(vWorld)));The vertex shader pushes the sphere into bumps, but aNormal still holds the sphere's normals, so the stored normals light it as if it were round. cross(dFdx(vWorld), dFdy(vWorld)) is built from the real, displaced surface: always right, one normal per triangle.
A derivative needs the neighbour to have computed the same expression. Inside an if, a loop or a function call whose condition differs between the pixels of a quad (non-uniform control flow), some neighbours skipped that code, and the specification calls the result undefined. That includes the hidden derivatives in texture() and fwidth. Many GPUs happen to give a sensible answer anyway, so the bug shows up only on some hardware, as blocky blurring or seams along the branch's border.
1// Undefined: texture() needs derivatives, and only some pixels of a quad get here
2if (noise(vWorld.xz) > 0.5) {
3 col = texture(uMoss, vUV).rgb;
4}
5
6// Defined: take the derivatives (or the sample) before the branch
7vec2 dx = dFdx(vUV), dy = dFdy(vUV);
8if (noise(vWorld.xz) > 0.5) {
9 col = textureGrad(uMoss, vUV, dx, dy).rgb; // or textureLod with a level you chose
10}| Situation | What happens | What to do |
|---|---|---|
| vertex, geometry or compute shader | there are no quads: dFdx does not exist, and texture() in a vertex shader reads level 0 | textureLod with an explicit level |
| inside a branch that differs within a quad | undefined | compute derivatives before the branch; textureGrad or textureLod inside |
| a value that jumps (atan, fract, a hash per cell) | a huge derivative at the jump: seams, or a one-pixel edge in the AA | take derivatives of a smooth version of the value |
| large values, such as world positions far from the origin | the difference of two nearly equal big floats has few correct digits, and the normals become noisy | work relative to the camera or to the object |
| Symptom | Cause | Fix |
|---|---|---|
| edges sharp when zoomed out, blurry when zoomed in | a smoothstep of fixed width | make the width fwidth(d) |
| a one-pixel line along a seam in a texture | the coordinate jumps inside a quad and picks the smallest mip | textureGrad with derivatives of the smooth coordinate |
| thin lines break into flickering dashes | lines thinner than a pixel cut with a one-pixel ramp | draw at least one pixel wide and dim by the real width |
| the far floor shows curved bands that are not in the scene | a pattern finer than two pixels per period (moiré) | fade to the average where fwidth of the coordinate nears ½ |
| a flat normal points into the surface | y points down (Vulkan, or a flipped render target) | swap the cross product's order |
| dFdx is 0 or garbage in some pixels only | it was called inside a branch that differs within the quad | move it before the branch |
Key ideas
- Pixels run in 2×2 quads; dFdx and dFdy are the differences between neighbours in a quad, in units per pixel.
- clamp(0.5 − d / fwidth(d), 0, 1) turns any signed distance into a one-pixel anti-aliased edge, at every zoom.
- Detail smaller than a pixel must be dimmed (thin lines) or replaced by its average (fine patterns), never sampled raw.
- texture() picks its mip level from the derivatives of uv; a coordinate that jumps leaves a seam, fixed with textureGrad.
- cross(dFdx(p), dFdy(p)) is the true flat normal of whatever was drawn.
- Derivatives are undefined inside branches that differ within a quad, and do not exist outside fragment shaders.