Shader Playground Classroom
Chapter 01 — The Classroom

The Pixel

A shader, from the first line of code to a shape you can move.

A shader is a small program that the graphics card runs in parallel, once for every pixel on the screen. When you paint a wall, you move a brush across it. A shader paints every pixel at once, in a fraction of a millisecond.

The kind we will work with is called a fragment shader. Its job is to answer one question: given this pixel, what color should it be? We can answer with a constant, a gradient, the result of a formula — any rule we like. The fragment shader runs that rule once per pixel, and the image appears.

Let's start with the simplest possible rule.

One color

Here is a shader that gives every pixel the same color. There is no geometry, no math, no trick. Just a fixed answer. Move the sliders to change the answer.

The color comes out as three numbers — a red amount, a green amount, a blue amount — each between 0 and 1. Inside the shader this is a single line:

gl_FragColor = vec4(u_red, u_green, u_blue, 1.0);

gl_FragColor is the shader's output. The name is built into the language. Whatever we assign to it is the color that pixel will be. The fourth number, the 1.0, is the alpha channel; we can leave it alone for now.

One thing worth noticing: this rule doesn't use the pixel's position. Every pixel gets the same color because every pixel runs the same rule with the same inputs. If we want to draw anything more interesting than a flat wash, we need each pixel to know where it is.

Position as color

Every fragment shader has a built-in variable called gl_FragCoord. It holds the pixel's coordinates on the screen, in pixels: zero at the bottom-left corner, larger values up and to the right.

If we divide those coordinates by the canvas size, we get a value between 0 and 1 across the image. Conventionally that's called uv. We can feed uv.x into red and uv.y into green, just to see what happens:

vec2 uv = gl_FragCoord.xy / u_resolution;
gl_FragColor = vec4(uv.x, uv.y, 0.0, 1.0);

Bottom-left is black: uv is (0, 0), so there's no red and no green. The bottom-right has full red. The top-left has full green. And the top-right, where both are full, is yellow — because red plus green, in light, makes yellow.

We didn't draw anything. We just told each pixel to look at its own coordinates and convert them, directly, into a color. That's the whole technique, and nearly everything else in this chapter is a variation on it.

Distance from the center

Let's ask a more useful question. For each pixel, how far away is it from the middle of the canvas?

We center the coordinates first — subtracting 0.5 from uv puts the origin in the middle — and then we take the length of that vector. length(p) is the Pythagorean distance from (0, 0) to the point p:

vec2 p = (gl_FragCoord.xy - 0.5 * u_resolution) / min(u_resolution.x, u_resolution.y);
float d = length(p);
gl_FragColor = vec4(vec3(d * u_scale), 1.0);

What we have now is not a solid disk but a distance field: a grayscale picture where every pixel's brightness tells us how far it is from a point. This is the trick that almost everything else in shader graphics is built on. Once we have a distance at every pixel, we can ask questions about it.

A threshold

The question we want to ask next is: is this pixel close enough to the center to be part of a shape?

GLSL has a function called step(edge, x). It returns 0 if x is below edge, and 1 if it's above. We pass our distance into it, compare it to u_radius, and flip the result so that inside is white:

float circle = 1.0 - step(u_radius, d);

We've made a circle. Move the slider and it grows. What we actually did, though, is more general: we took a continuous field of numbers (the distance) and turned it into a binary mask (inside or outside) by comparing it against a threshold. The same trick makes rectangles, stars, letterforms, lens flares, anything with an edge.

But the edge of this circle is sharp to the point of being jagged. On a screen made of square pixels, a hard threshold can only be as smooth as the grid allows.

A softer edge

GLSL has a cousin of step called smoothstep(edgeA, edgeB, x). Instead of a single threshold, it takes two. Values of x below edgeA return 0; above edgeB they return 1; between the two, smoothstep smoothly interpolates. The result is a soft transition instead of a hard one:

float circle = 1.0 - smoothstep(u_radius - u_softness, u_radius + u_softness, d);

The u_softness slider sets the width of the transition band. At zero it behaves like step. Turn it up and the circle becomes a soft blob. In practice, shader authors almost never use plain step for edges — smoothstep with a small softness (often as small as one pixel) gives an anti-aliased edge for free.

A shape you can move

So far our circle has lived in the middle of the canvas. That's a choice we baked into the math — the -0.5 that centered the coordinates. Nothing is stopping us from pulling that offset out into a parameter and giving it to the shader as a number we can change:

vec2 center = vec2(u_cx, u_cy);
float d = length(p - center);
float circle = 1.0 - smoothstep(u_radius - 0.02, u_radius + 0.02, d);

The shape is identical. The math is identical. We have only changed which point we measure the distance from. This is one of the deep habits of shader code: if a number feels special, pull it out into a uniform and let it be changed from the outside.

Color, inside and out

Our circle has been white on black this whole chapter. Let's fix that.

When we apply smoothstep to a distance field, we get a number that is 1 inside the shape, 0 outside, and somewhere in between along the edge — already a mask. GLSL has a function called mix(a, b, t) that returns a when t is 0, b when t is 1, and a blend of the two in between. If we pass in two colors and our mask, we paint one color inside the shape and another outside:

float mask = 1.0 - smoothstep(u_radius - u_softness, u_radius + u_softness, d);
vec3 inside  = hue(u_hue_in);
vec3 outside = hue(u_hue_out);
gl_FragColor = vec4(mix(outside, inside, mask), 1.0);

The two hue sliders are just picking points on a color wheel. The important thing is the pattern: a distance field becomes a mask, a mask drives a mix, and a mix chooses between two things — here colors, but later it will be textures, images, or the output of entirely different shaders.

What we've built

At the start of this chapter a shader was a single line that returned a constant color. By the end, it measures distance, applies a threshold, softens the result, moves a shape around, and paints two colors on either side of an edge. You now have the basic grammar of 2D shader drawing: coordinates, distance, threshold, softness, position, and mix. Patterns like stripes, grids, rings, and dashed lines are variations on the same moves; the rest of this course is largely about composing them.

The next chapter introduces time. Until now every image has been static — the same answer every frame. Once the shader can ask what time it is, the circle you just moved can move on its own.


Next — Back to the Classroom