Shader Playground Classroom
Chapter 03 — The Classroom

Randomness
and Noise

A shader can't call random(). Instead, it takes a coordinate and returns something that looks random — deterministically, the same way, every frame. That constraint is where nearly every natural-looking pattern in shader graphics comes from.

Chapters 1 and 2 drew regular things — centered shapes, grids, pulses. Every pixel knew exactly what to compute because every input was either the pixel's position or the current time. Everything was orderly.

Real-world surfaces aren't orderly. Clouds, bark, water, fire, stone, fur, dust, hair, rust — they all have a quality we might call specific variation: the overall shape is meaningful, but the detail is different everywhere you look. To get that in a shader, we need a way to turn a coordinate into a "random" number. Not actually random — the same pixel has to get the same answer every frame, or the image would flicker into chaos. What we want is a number that looks random while being completely deterministic.

The hash

The trick is a small nonlinear function with aggressively-tuned constants. The canonical one in shader code is this:

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

The dot product mixes the two coordinates into one number. The sine introduces steep nonlinearity — small changes to the input cause large, uncorrelated changes to the output. Multiplying by a large constant amplifies that further, and fract() takes only the fractional part, discarding the whole and returning a value in [0, 1).

The numbers 12.9898, 78.233, and 43758.5453 are traditional. Nobody derives them; they get passed down from shader to shader because they happen to produce a satisfying pattern. Change them a little and the noise looks the same. Change them a lot and it might still look the same. This is folk math.

vec2 cell = floor(p * u_cells);
float v = hash(cell + u_seed);

Turn u_cells up. At a few dozen cells the pattern reads as a pixelated Mondrian. Past a hundred, it stops looking like cells and starts looking like noise — TV static. There's no change to the hash function; the cells just got smaller than your eye can resolve. The chunky-block pattern and the static pattern are the same thing. The u_seed slider shifts the input by a constant, giving you a different arrangement of the same pattern.

Smoothing

TV static is the primitive. On its own it's not useful for much — you can't draw a cloud by painting in random pixels. What we want is a smooth field of variation: values that drift between neighbors instead of jumping.

The trick is to hash only the corners of a grid, and for each pixel in between, interpolate between the four corners. At the grid points we get our random values; in between, we get a smooth blend.

vec2 i = floor(p);
vec2 f = fract(p);
vec2 u = smoothstep(0.0, 1.0, f) * u_smooth;

float a = hash(i);
float b = hash(i + vec2(1.0, 0.0));
float c = hash(i + vec2(0.0, 1.0));
float d = hash(i + vec2(1.0, 1.0));

return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);

With u_smooth at zero, each pixel takes only the top-left corner's hash — we get the same blocky grid from section one. Turn the slider up and the grid dissolves: pixels near cell boundaries take a blend of the four surrounding hash values, and the cell edges soften into a smooth field. The smoothstep around f is the standard move — it makes the derivative continuous at the grid lines so there are no visible seams. What comes out is called value noise.

Gradient noise

Value noise has a subtle tell: the grid is still there. Because every pixel interpolates toward whichever corner's random value, patches of uniform color tend to appear at each lattice point — little hills and valleys aligned with the grid. Zoom in with the slider below and you can see them.

Ken Perlin's fix was to store a random direction at each grid point instead of a random value. At any pixel, take the dot product of that direction with the pixel's offset from the corner. The result is naturally zero at the corner and grows toward the interior, which means the interpolated surface has no lumps parked on the lattice points. It's a more organic field.

// u_mode 0 = value noise, 1 = gradient noise
float n = (u_mode < 0.5) ? valueNoise(p) : gradientNoise(p);

Flip the u_mode slider between 0 and 1. Same input coordinate, same scale, same seed lattice. Value noise reads a little busier, the features a little more packed into cells. Gradient noise is softer, feels like smoke or breath. Much professional shader work uses gradient noise (or a variant) for this reason. Value noise is still valuable — it's faster and perfectly fine when you're going to layer it.

Stacking

One more move and we have the most useful texture function in computer graphics.

Sample noise at a low frequency. Then sample it again at double the frequency and half the amplitude. Do it again. And again. Add them all up. Each layer is an octave, and the result is called Fractal Brownian Motion, or FBM. At one octave it's just smooth noise. At three or four octaves, something remarkable happens: it starts to look natural.

float amplitude = 1.0;
float frequency = 1.0;
float sum = 0.0;

for (int i = 0; i < 6; i++) {
    if (float(i) >= u_octaves) break;
    sum += valueNoise(p * frequency) * amplitude;
    frequency *= 2.0;
    amplitude *= u_persistence;
}

Walk u_octaves up from 1 to 6. Each step adds a finer layer of variation: broad shapes persist, crisp detail accumulates on top. u_persistence controls how much each octave contributes; low values produce soft puffy clouds, high values produce sharp turbulent fields. This single function, with small parameter changes, can draw clouds, terrain, bark, smoke, ground mist, distant mountains, cumulus, rust, marble. Most procedural texture work is some variation on this.

Noise in time

Chapter 2 animated things with sin(u_time). Noise can be animated too, but in a richer way. Extend the input coordinate to three dimensions and include u_time as the third axis. As time advances, you take a slice through a slowly-moving 3D noise field. The pattern doesn't scroll across the screen — it evolves in place.

vec3 q = vec3(p * u_scale, u_time * u_speed);
float n = valueNoise3D(q);

Smoke, breath, flame, watered ink — anything that drifts rather than moves in a single direction — uses this trick. The slight lumpiness in the pattern is because this is value noise, not gradient noise. At small scale you can see it pulsing; at larger scale, it just flows.

Noise as a verb

All of the above produces a value from 0 to 1. We've drawn it as brightness. But the value can be used for anything else: a color, a transparency, a height, a rotation angle. And the most useful place to put it is inside a coordinate — before we compute a shape, we offset the pixel's input position using noise.

vec2 offset = vec2(
    valueNoise(p * u_scale + vec2(0.0, 0.0)),
    valueNoise(p * u_scale + vec2(5.2, 1.3))
) - 0.5;

vec2 q = p + offset * u_strength;
float d = length(q);

Instead of measuring distance from the pixel's actual position, we measure distance from a noise-perturbed position. The circle doesn't know it's being deformed — it draws its boundary honestly; the coordinate space just happens to be warped. At low u_strength the circle wobbles; at high strength it collapses into something nearly unrecognizable.

Many hand-drawn-looking shader aesthetics, from wet brushstrokes to melting metal to dust in light, are a noise-perturbed coordinate feeding a simple shape. Noise stopped being a pattern. It became an operator.

Domain warping

One more composition, and it closes the chapter on the move that makes most of Shadertoy look the way it does.

Take the noise you just used to perturb a coordinate. Use it as the input to another noise. Use that as the input to a third. The output of each pass is a coordinate offset for the next. What you get is a flowing landscape of slow, coherent change — an alive-looking pattern that seems to think.

vec2 q = vec2(fbm(p), fbm(p + vec2(5.2, 1.3)));
vec2 r = vec2(fbm(p + q * u_strength), fbm(p + q * u_strength + vec2(8.3, 2.8)));
float n = fbm(p + r * u_strength);

This one animates on its own, slowly. Drag u_strength up to watch the layers deepen: at zero it's flat FBM; at two or three you get the organic weather-looking drift that shows up in a thousand shaders. Inigo Quilez has a whole article on this move — what he calls "domain warping" — and it's worth reading once you've seen it happen.

What we've built

We started with a single line of math that returns a pseudo-random number from a coordinate. We smoothed it into a field. We layered the field into octaves. We used it to perturb other coordinates. And we fed it back into itself. Each step was small. What emerged was most of the vocabulary of procedural natural texture — cloud, flame, stone, sea, wind.

Chapter 4 is the third dimension. Every trick from this chapter — hash, FBM, domain warping — works in 3D too, and when we start sampling a 3D noise field along the ray of a raymarched camera, the same functions become terrain, fog, and volumetric light.


Next — Back to the Classroom