Shader Playground Classroom
Chapter 02 — The Classroom

Time and
Repetition

A shader that answers a different question every frame, and the same question a thousand times in a grid.

Every shader in Chapter 1 gave the same answer from frame to frame. The canvas painted once and held still. Shaders are capable of a great deal more than that — they can change their answer thousands of times a second, and if they change it smoothly we call the result animation. They can also answer the same question many times across a single frame, tiled across a grid, producing patterns from one rule. This chapter is about both of those moves.

The clock

WebGL passes a new value for u_time into the shader every frame. It starts at zero when the page loads and counts up, in seconds. On its own it isn't very useful — a number that grows forever would quickly exceed the range of a color — but if we pass it through sin(), we get an oscillator: a value that rides smoothly between −1 and 1, over and over.

Shift and scale that wave so it rides between 0 and 1 instead and it becomes something we can use as a color:

float v = 0.5 + 0.5 * sin(u_time);
gl_FragColor = vec4(vec3(v), 1.0);

There are no controls on this one. Just watch. The whole canvas fades from black to white and back, about once every six seconds. That little pulse is the heartbeat that drives almost every animated shader you will ever write.

A pulsing shape

A number that moves is only interesting once we point it at something. Take the oscillator from the last section and feed it into the radius of our circle:

float r = u_base_radius + u_amplitude * sin(u_time * u_speed);
float d = length(p);
float circle = 1.0 - smoothstep(r - 0.01, r + 0.01, d);

Three parameters now — a base radius, an amplitude, and a speed. The base radius sets the size the circle oscillates around; the amplitude is how much it swells and shrinks; the speed multiplies time before we take its sine. Drag u_speed to zero and the circle stops breathing entirely. Drag it up and the circle starts to flicker. This pattern — a uniform multiplied by time, passed through sin, added to a base — will come up again in every animated shader you read.

Orbiting

Sine moves a value up and down; cosine moves it the same way, but a quarter turn offset. Feed sin into a point's x-coordinate and cos into its y-coordinate and the point traces a circle around the origin:

vec2 center = u_orbit * vec2(sin(u_time * u_speed), cos(u_time * u_speed));
float d = length(p - center);

The u_orbit slider scales how far the circle travels from the middle; u_speed controls how fast. We haven't changed the shape at all — it's still the same movable circle from Chapter 1. All we did was drive its center with time instead of with a slider.

Color in motion

Time doesn't only move positions. It can drive anything — scale, rotation, brightness, hue. Passing u_time into the hue of a color produces a cycle through every color of the spectrum, back to the beginning:

vec3 color = hue(u_time * u_speed);
float gray = dot(color, vec3(0.3333));
gl_FragColor = vec4(mix(vec3(gray), color, u_saturation), 1.0);

The hue() function is the same one from the end of Chapter 1: it takes a number in 0..1 and returns a fully-saturated color wheel. Time goes in; color comes out. The u_saturation slider fades the cycling color toward neutral gray so you can see how the brightness changes underneath.

Mirrors

Time moves things. Space can also be moved. If we take the absolute value of the x-coordinate before drawing, every pixel on the left of the canvas sees the same shader as the pixel opposite it on the right. The image becomes symmetrical across the Y axis:

vec2 q = vec2(abs(p.x), p.y);
float d = length(q - vec2(u_cx, 0.0));

Drag u_cx. You are moving a single circle, but two appear, locked together in mirror. The trick is that abs() folds the x-axis in half — negative coordinates become positive — so the shader can only see one half of the canvas. Whatever we draw on the right is necessarily mirrored on the left.

Take the absolute value of both coordinates and the canvas folds into quadrants. Every pattern that comes out of abs() is symmetrical. Kaleidoscopes, mandalas, snowflakes — at the heart of each of them is a move like this one.

Repetition

One more spatial trick, and this one is the engine of pattern. fract(x) returns the fractional part of a number — it throws away the whole. If our coordinate is 1.4, fract gives back 0.4; at 2.4, it gives back 0.4 again; at 3.4, again 0.4. The number cycles from 0 to 1 over and over.

If we scale our coordinates up by some count and then take their fractional part, each unit of the canvas becomes a tiny cell running from 0 to 1. Draw a shape inside those cell coordinates, and we've drawn that shape everywhere:

vec2 cell = fract(p * u_count) - 0.5;
float d = length(cell);
float circle = 1.0 - smoothstep(u_radius - 0.02, u_radius + 0.02, d);

The − 0.5 at the end shifts each cell's origin to the middle, so our circles land at the center of each tile. Turn u_count up and the shape tiles more finely. We wrote one length and one smoothstep, and the GPU is running them a few hundred thousand times, once per pixel, to build the whole field. This is what shaders are for.

Time and space together

The moves compose. Each cell in the grid already knows its own position; we can use that position to give every cell its own timing offset, and then drive the radius with time the way we did in section 02:

vec2 id = floor(p * u_count);
float phase = id.x * 0.9 + id.y * 1.3;
float r = u_radius + 0.18 * sin(u_time * u_speed + phase);

Each cell breathes, but the cells breathe at slightly offset times, so a wave runs diagonally across the grid. Two tricks from this chapter — time as a driver, space as a grid — combined in six lines. This is where shader authorship starts to feel less like math and more like making.

What we've built

Chapter 1 gave us a single shape. Chapter 2 gave us a shape that moves, a shape that mirrors, a shape that tiles, and a shape that does all three at once. Those are the four directions a shader can grow: change over time, change across position, fold space with abs, repeat space with fract. Almost every shader you will read from here on is a composition of these moves.

The next chapter introduces randomness — a hash function lets every cell in a grid pick a different number, from nothing but its own coordinate. Once you have that, the grids stop looking like grids.


Next — Back to the Classroom