Field Guide · Computer Graphics

A Shader
Primer,
From Scratch

An interactive essay on the strangest, most parallel little programs in your computer — for engineers who've never written one.

Read time · ~30 min Eight live demos No prior GL needed
Contents
  1. The Strangest Function You'll Ever Write§1
  2. UV: How a Pixel Knows Where It Is§2
  3. Color Is a Vector, Distance Is Everything§3
  4. Drawing With Distance Fields§4
  5. Smoothstep, the Shader's Best Friend§5
  6. Time, the Free Animation§6
  7. Noise: Where Beauty Lives§7
  8. Capstone: A Plasma You Built§8
§ 01

The Strangest Function You'll Ever Write

You already know how to write code that runs. You write a function, you call it, it runs once, you get an answer. A shader is a function too — but the model of when and how it runs is different enough that it's worth a full mental reset before you write a line of GLSL.

Here is the entire model in one sentence:

A fragment shader is a function that runs in parallel, once per pixel, and its only job is to return a color.

That's it. Your GPU has thousands of tiny cores. When the GPU draws a triangle that covers, say, 800,000 pixels on your screen, it runs your shader function 800,000 times — many of them genuinely simultaneously — and each invocation is responsible for exactly one of those pixels.

Each invocation gets a single, crucial piece of input: where this pixel is. From that, plus whatever extra parameters you've passed in (called uniforms), it must compute a color. Then it returns. The next invocation is on its own.

What this means in practice

Three consequences fall out of this model, and you'll feel each of them constantly:

First, your function cannot see what its neighbors did. There's no shared state, no "look at the pixel to my left." Every pixel's color is computed from scratch, from math. This sounds limiting and is actually the source of all the strange beauty.

Second, everything is a math problem. There's no "draw a circle" function. You have to describe a circle as: "for each pixel, return red if the distance from this pixel to the center is less than the radius, otherwise return black." The drawing is a side effect of millions of independent decisions.

Third, it has to be fast. At 60fps, a 1080p screen needs ~125 million shader invocations per second. Branches and loops are expensive; closed-form math is cheap. The aesthetics of shader code follow.

A note on naming Technically there are several kinds of shaders — vertex shaders, geometry shaders, compute shaders, and others. This essay is entirely about fragment shaders, the per-pixel kind, because they're where most of the visual magic happens and they're the easiest to play with. When I say "shader" below, I mean fragment shader.

Below is the smallest shader that does anything. It runs for every pixel and returns the same color: a flat orange.

precision mediump float;

void main() {
  // gl_FragColor is the output: an RGBA color, each channel 0.0 to 1.0
  gl_FragColor = vec4(1.0, 0.36, 0.23, 1.0);
}
Demo 1 · Solid Color Live
rgb(1.00, 0.36, 0.23)
1.00
0.36
0.23

Drag the sliders. The shader is recompiling — well, no, it isn't. The shader is the same; you're just changing values it reads. We'll get to that distinction in a moment.

Notice the colors are floats from 0.0 to 1.0, not integers from 0 to 255. Get used to it. Almost everything in shader-land is a normalized float. It makes the math compose better.

· · ·
§ 02

UV: How a Pixel Knows Where It Is

A solid color is boring because every pixel returns the same answer. The interesting question is: how does each pixel return a different answer?

The answer is that each invocation has access to its own coordinates. By convention these are called uv — historically because they map to a 2D texture with axes named u and v, distinct from the 3D x, y, z. They typically run from 0 to 1 across the canvas: (0,0) at the bottom-left, (1,1) at the top-right.

This sounds trivial and is the most important idea in the whole essay. Once your pixel knows where it is, it can do anything with that knowledge — use it as a color, use it as a position, feed it through math to generate any pattern you like.

Move your mouse over the canvas below. The readout shows you the UV at your cursor.

Demo 2 · UV as Color Hover
uv = (—, —)

The default view maps u to the red channel and v to the green channel. So the bottom-left corner (where u=0, v=0) is black. The bottom-right (u=1, v=0) is pure red. The top-left is green. The top-right is yellow (red + green). You are looking at coordinates rendered as colors.

The shader for that view is almost shockingly simple:

precision mediump float;
varying vec2 v_uv;  // each pixel gets its own uv

void main() {
  gl_FragColor = vec4(v_uv.x, v_uv.y, 0.0, 1.0);
}
Where does v_uv come from? It's set up by a sibling program called the vertex shader, which we're glossing over. For our purposes, just trust that v_uv arrives in your fragment shader interpolated smoothly across the canvas. Every pixel sees a slightly different value.

This is the foundation. Almost every shader you'll ever read starts by taking v_uv and doing math to it. Stretch it, rotate it, repeat it, distort it, measure distances within it. Everything else is decoration.

Exercise

Predict before you click

Click the "grid" button. Before you do — what do you think a shader that returns black if fract(uv.x * 10.0) < 0.05 || fract(uv.y * 10.0) < 0.05 would draw? fract returns the fractional part of a number, so fract(3.7) = 0.7.

It's a grid of thin black lines. The expression uv.x * 10.0 stretches the 0–1 range to 0–10, and fract wraps it back to 0–1 every unit, so we get ten "bands" along each axis. Wherever the band is just starting (its fractional part is below 0.05), we draw black. This trick — multiply, then take the fract — is how you tile patterns in shaders.

· · ·
§ 03

Color Is a Vector, Distance Is Everything

Two ideas in this section, both load-bearing.

First, colors are vectors. A vec3 is just three floats. Whether those floats represent a 3D position, a direction, or an RGB color is up to you and the context. This means you can do math on colors: add them, scale them, mix them — the same way you'd manipulate a position. red * 0.5 + blue * 0.5 gives you purple, naturally.

Second, distance is everything. A huge fraction of all shader work — drawing shapes, blending things, creating glows, masks, edges — comes down to: "compute the distance from this pixel to some reference, then make a decision based on that distance."

The simplest distance measure is length(uv - center). That gives you, at every pixel, the straight-line distance from that pixel to the center point. Drag the white dot around the canvas to change the center. Watch the gradient follow it.

Demo 3 · Distance Field Drag
center = (0.50, 0.50)
2.00
1.00

The shader computing this is, again, very small:

vec2 center = vec2(0.5, 0.5);
float d = length(v_uv - center);
gl_FragColor = vec4(vec3(d), 1.0);

The line vec3(d) is shader shorthand: passing one number to a vec3 constructor splats it into all three channels, so vec3(0.4) == vec3(0.4, 0.4, 0.4), which is gray. The brightest pixels (white) are the ones farthest from the center; the darkest (black) is the center itself. You're seeing distance rendered as color.

If you can compute a distance, you can draw a shape. If you can draw a shape, you can compose a scene.

The "rings" mode uses one extra trick: fract(d * 10.0). Wrapping the distance every tenth of a unit makes concentric rings appear, because pixels at distance 0.10, 0.20, 0.30 all suddenly produce the same value. The same fract pattern from §2, applied to a different input, makes a wholly different image. This is the recurring shader rhythm: same operations, fed different inputs, produce wildly different outputs.

· · ·
§ 04

Drawing With Distance Fields

Now we promote the distance field from "pretty gradient" to "actual shape." A circle is just: at every pixel, return one color if the distance to the center is less than the radius, otherwise return another. Written naively in shader:

float d = length(v_uv - center);
float circle = d < 0.2 ? 1.0 : 0.0;
gl_FragColor = vec4(vec3(circle), 1.0);

That works, but you'd never write it that way in practice. Branches are slow and you get jagged "aliased" edges. The idiomatic version uses step, a function that returns 0 if its argument is below a threshold and 1 above:

float circle = step(d, 0.2);  // 1 inside, 0 outside

Two circles? Compute two distances, take whichever shape you want with max (intersection), min (union), or subtraction. This whole approach has a name: signed distance fields (SDFs). It's the basis for an entire genre of graphics.

Drag the two handles below. Try the different boolean modes.

Demo 4 · Two Circles, Boolean Operations Drag
union
0.20
0.20

The math really is just min/max. union(a, b) = min(d_a, d_b) because a pixel is "inside the union" if it's inside either shape — i.e., its smaller distance is below the threshold. intersect(a, b) = max(d_a, d_b) because a pixel must be inside both. Subtraction is intersection with the inverse of B. XOR is a little more arithmetic but the same flavor.

Why "signed"? A true SDF returns negative numbers inside the shape and positive outside, so the magnitude tells you "how far am I from the boundary." This makes a ton of effects easy: outlines, glows, soft edges, even 3D ray-marching of surfaces. Inigo Quilez's website is the canonical reference if you want to dive deeper. Once you have an SDF for a shape, you have everything about that shape.
· · ·
§ 05

Smoothstep, the Shader's Best Friend

You may have noticed that the circles in the last demo had genuinely soft edges, not the staircase-jagged ones you'd expect from step. That smoothness is the work of one tiny function that, more than any other, is why shaders look the way they do.

smoothstep(edge0, edge1, x) returns 0 below edge0, 1 above edge1, and a smooth Hermite-interpolated curve in between. It is a step function with a soft shoulder, and it is everywhere.

// hard edge — looks aliased and ugly
float hard = step(0.5, x);

// soft edge — anti-aliased, gorgeous
float soft = smoothstep(0.48, 0.52, x);

Once you've internalized smoothstep, you start using it for everything: anti-aliased shape edges, soft glows, gradient remapping, masking, blending between effects, easing animations. Anywhere you'd want a "fade from this to that," you reach for smoothstep.

In the demo below, you control the two edges of a smoothstep applied to the distance from the center. When the edges are far apart, you get a gentle gradient. When they're close together, you get a sharp anti-aliased circle. When they're inverted (edge1 < edge0), the function flips.

Demo 5 · The Anatomy of Smoothstep Drag edges
smoothstep(0.30, 0.50, d)
0.300
0.500

Try the "sharp circle" preset, then look at the edge of the canvas. The transition from black to orange happens over a band of just two pixels — exactly enough to anti-alias, not so much that the shape looks fuzzy. This is the standard idiom for crisp shapes:

float d = length(uv - center) - radius;
// fwidth(d) is roughly "one pixel of d", giving us a perfect 1-pixel edge
float mask = 1.0 - smoothstep(0.0, fwidth(d), d);

You don't have to understand fwidth right now — just notice that smoothstep + signed distance is the recipe for crisp shapes at any zoom level.

· · ·
§ 06

Time, the Free Animation

Up until now, every shader we've written produces the same image every frame. That changes the moment we add time as an input.

Time is just a uniform — a single float you pass in from JavaScript every frame, containing the seconds since the page loaded. Once it's in the shader, you can stuff it anywhere a number could go: into a coordinate, into a color, into a phase. Suddenly things move.

uniform float u_time;  // seconds, updated each frame from JS

void main() {
  // shift the v coordinate by time → image scrolls upward
  vec2 uv = v_uv + vec2(0.0, u_time * 0.2);
  // or modulate brightness by sin(time) → image pulses
  float pulse = 0.5 + 0.5 * sin(u_time * 3.0);
}

The choice of where you inject time determines the character of the animation. Add it to a coordinate, things scroll or rotate. Add it inside a sine, things oscillate. Multiply two sines with different speeds, you get hypnotic interference patterns.

Below, you control four classic uses of time on a single shader: scrolling stripes, a rotating pattern, a pulsing brightness, and an oscillating wave. Each slider is the speed coefficient — set it to zero to freeze that channel, crank it to make it frantic.

Demo 6 · Four Flavors of Time Live
t = 0.00s
0.50
0.30
1.00
1.50
Why sin and cos rule shader-land Trig functions are everywhere in shaders for three reasons: they're cheap on GPU hardware, they wrap smoothly (no jumps), and they bound their output to [-1, 1], which is easy to remap into colors or positions. If you see a shader doing something pretty and you can't figure out how, your first guess should be "there's a sin in there somewhere."
Exercise

Decompose what you see

Pause the demo. Set every slider to zero except "Wave." What is the shape of the pattern? Now set "Wave" to zero and "Pulse" to 1.0. What's different? The patterns share a mathematical structure but use time differently — pulse multiplies brightness, wave shifts a phase inside a sine. Same input, different injection point, different feel.

· · ·
§ 07

Noise: Where Beauty Lives

Pure math gives you grids, circles, stripes — clean, geometric, a little sterile. To get organic textures — clouds, fire, marble, terrain, fur — you need noise.

The simplest random function is random(uv): a function that returns a different value at every pixel, but always the same value at the same pixel. (You can't use a true RNG in a shader; you need a deterministic hash, because every invocation must be reproducible.) The classic one-liner is:

float random(vec2 p) {
  return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453);
}

Don't worry about the magic numbers — they're arbitrary, chosen empirically because they produce something that looks uniformly random. By itself, this gives you TV-static. Useful, but not beautiful.

The breakthrough is value noise (and its more sophisticated cousins: Perlin, simplex, gradient noise). The recipe: take a low-resolution grid of random values, and smoothly interpolate between them. The result is hills and valleys instead of static — the foundation of nearly every organic texture in graphics.

Push it further by stacking octaves: add the noise to itself at half scale and double frequency, and again, and again. Each octave adds finer detail. This is called fractal Brownian motion, or fBm, and it's how clouds, terrain, and a thousand other things are made.

Demo 7 · From Static to Clouds Live
octaves = 1, scale = 4.0
value
4.00
1
0.00

Drag the "Type" slider from 0 (raw random) to 1 (value noise) to 2 (fBm). Watch the texture transform from TV-static to soft hills to recognizable cloud-like detail. Bump the octaves up — each one adds finer resolution at the cost of computing more noise samples.

The "marble" preset does one more trick: feed the fBm value into sin(uv.x * 6.0 + fbm * 4.0) and you get veined patterns, because the noise is now distorting a sine wave. This pattern of "use noise to perturb something else" is called domain warping, and it's where shader art gets hypnotic.

Math gives you geometry. Noise gives you nature. Combine the two and you can render anything.

· · ·
§ 08

Capstone: A Plasma You Built

Everything we've covered is now in your hands at once. The shader below combines a moving distance field, smoothstep mapping, time as an animator, multi-octave fBm noise, and color mixing. It's a small palette, but the parameter space is huge — it can be made to look like fire, like ocean, like aurora, like nothing you've seen.

Read the shader source first. Then play. Try to predict each slider before you move it.

void main() {
  vec2 uv = v_uv * u_zoom;

  // domain warp: distort uv with noise before sampling
  vec2 warp = vec2(
    fbm(uv + u_time * u_speed),
    fbm(uv + 5.2 + u_time * u_speed)
  ) * u_warp;

  float n = fbm(uv + warp);

  // remap noise to a color gradient between two anchors
  vec3 col = mix(u_colorA, u_colorB, smoothstep(0.2, 0.8, n));
  col += u_glow * smoothstep(0.5, 0.9, n) * u_colorB;

  gl_FragColor = vec4(col, 1.0);
}
Capstone · Domain-Warped Plasma Yours
— hover for uv —
2.50
0.15
1.20
0.40
4
1.00
click to change

Where to go from here

You now have a working mental model of shaders, plus hands-on time with every fundamental building block: UVs, distance, smoothstep, time, noise, and composition. From here, the path forward depends on what calls to you.

If you want more interactive playground: thebookofshaders.com by Patricio Gonzalez Vivo is the canonical follow-up — the same flavor as this essay but ten times longer and deeper. Shadertoy is where the demoscene lives; read other people's shaders and try to break them.

If you want 3D and ray-marching: Inigo Quilez's website is the bible of signed distance fields. He invented half the techniques and writes them up beautifully.

If you want to build real apps: pick up Three.js for 3D or Curtains/OGL for plane-based effects. Both let you write GLSL while handling the WebGL boilerplate.

If you want the deep theory: Real-Time Rendering by Akenine-Möller is the textbook. It's enormous and excellent.

But mostly: open up a fragment shader, change a number, see what happens. The feedback loop is so tight, and the parameter space so large, that the only way to develop intuition is to spend time in it. You have all the tools you need. Go make something nobody's ever seen.