Always cooking something
โ† All posts
April 13, 2026ยท9 min read

Fragment Shaders: Programming Every Pixel

WebGLGLSLCreative Coding

The vertex shader decides where geometry lives in space. The fragment shader decides what color each pixel of that geometry should be. That description undersells it.

Once you have a full-screen quad (two triangles covering the entire canvas), the fragment shader is effectively a function that runs once per pixel and can produce anything: gradients, shapes, textures, animations, noise. No additional geometry required. This is how most of what you see on Shadertoy is built, and it is the foundation of image processing pipelines.

This post covers the coordinate system the fragment shader works in, how to draw shapes without any vertex geometry using distance functions, and how to combine math and time into animated patterns.


The full-screen quad

Before anything else, you need a canvas to paint. Two triangles that cover the entire clip space give the fragment shader a pixel for every position on screen.

js
// Two triangles forming a rectangle from (-1,-1) to (1,1)
const verts = new Float32Array([
  -1, -1,   1, -1,   1,  1,
  -1, -1,   1,  1,  -1,  1,
])

// Vertex shader just passes position through as a UV coordinate
const vs = `
  attribute vec2 aPos;
  varying vec2 vUv;
  void main() {
    vUv = aPos * 0.5 + 0.5;  // remap from NDC (-1..1) to UV (0..1)
    gl_Position = vec4(aPos, 0.0, 1.0);
  }
`

The vertex shader converts NDC to UV space by scaling and shifting: aPos * 0.5 + 0.5. This gives the fragment shader a coordinate where (0, 0) is the bottom-left corner and (1, 1) is the top-right. That coordinate is called UV, and it is the fragment shader's primary way of knowing where it is on screen.


UV coordinates: the fragment's GPS

Every fragment shader invocation knows one thing about where it is: its UV coordinate. That is a vec2 between (0, 0) and (1, 1) that identifies the pixel's position on the quad. How you use that coordinate to produce a color is the entire art of fragment shading.

The most direct use: map U to red and V to green. The result is a color gradient that makes the coordinate space visible.

glsl
precision mediump float;
varying vec2 vUv;

void main() {
  // vUv.x runs 0โ†’1 left to right   โ†’ red channel
  // vUv.y runs 0โ†’1 bottom to top   โ†’ green channel
  gl_FragColor = vec4(vUv.x, vUv.y, 0.45, 1.0);
}

Switch to the grid mode below to see fract in action: it takes the fractional part of a number, which repeats the 0..1 range. Multiplying UV by a constant sets the grid density.

Interactive ยท UV coordinate space, three views

The vertex shader converts NDC (-1..1) to UV (0..1) with aPos * 0.5 + 0.5 and passes it as a varying. The fragment shader uses it as a coordinate system.

The polar view re-centers the coordinate with vUv * 2.0 - 1.0 so the origin is at the center, then uses atan and length to convert to polar. All three modes use the same two triangles and the same vertex shader. Only the fragment math changes.

Aspect ratio. UV coordinates are always square (0..1 in both axes), but your canvas usually is not. To avoid circles becoming ovals, scale the horizontal axis by resolution.x / resolution.y after centering: p.x *= uResolution.x / uResolution.y. The demos below all do this.

Distance functions: shapes without vertices

In rasterized 3D graphics, a circle is a polygon with many sides. In a fragment shader, a circle is a math expression: every pixel measures its distance from a center point and colors itself based on whether that distance is inside or outside a threshold.

The key function is length, which returns the Euclidean distance from the origin to a point. Combine it with smoothstep to get a soft edge.

glsl
precision mediump float;
varying vec2 vUv;
uniform float uRadius;
uniform float uSoft;    // controls edge sharpness

void main() {
  vec2 p    = vUv * 2.0 - 1.0;  // center origin
  float dist = length(p);         // distance from center

  // smoothstep(edge0, edge1, x):
  //   returns 0 when x < edge0, 1 when x > edge1, smooth in between
  float circle = smoothstep(uRadius + uSoft, uRadius - uSoft, dist);

  gl_FragColor = vec4(vec3(0.49, 0.36, 1.0) * circle, 1.0);
}

Drag the softness slider all the way down and the edge becomes a hard one-pixel line. Increase it and you get a glow. The math is the same; only the transition width changes.

Interactive ยท SDF ring, radius + edge softness
Radius0.350
Edge softness0.012

No circle geometry. The shape exists only in math: length(p) gives distance from center,smoothstep converts that to a sharp or soft edge. Drag softness to 0.001 for a hard pixel edge.

The demo adds two more ideas: a ring (outer circle minus inner circle) and a glow halo (a wider smoothstep with low opacity blended on top of the background). Neither requires any additional geometry. They are all computed from the samedist variable.


Composing shapes with set operations

Distance functions compose the same way boolean operations do. Two distances can be combined using min and max to union, intersect, or subtract shapes.

glsl
float circleA = length(p - vec2(-0.3, 0.0)) - 0.3;  // signed distance
float circleB = length(p - vec2( 0.3, 0.0)) - 0.3;

// Union: minimum of both distances โ€” inside either circle
float united      = min(circleA, circleB);

// Intersection: maximum โ€” only inside both circles
float intersected = max(circleA, circleB);

// Subtraction: A minus B โ€” inside A, outside B
float subtracted  = max(circleA, -circleB);

// Convert signed distance to color (negative = inside)
float shape = smoothstep(0.01, -0.01, united);

The values returned here are signed distance fields (SDFs): negative inside the shape, positive outside, zero exactly on the edge. Signed distances compose perfectly: you can add them, blend between them, and animate them smoothly. This is the foundation of 2D shape rendering in fragment shaders.

Signed vs unsigned distance. The circle demos above uselength(p) (unsigned, always positive) combined with smoothstepdirectly. Signed distances subtract the radius: length(p) - r. Negative means inside, positive means outside, zero is the exact edge. The signed form is more composable for set operations.

Animation: time as an input

The fragment shader is a pure function: given a UV coordinate (and any uniforms), it returns a color. Make uTime a uniform and the function gains a temporal dimension. Every pixel can be a function of both position and time simultaneously.

A classic technique: use sin(distance - time) to create expanding rings. As time increases, the argument to sine shifts, which moves the peaks and troughs outward, like dropping a stone in water.

glsl
precision mediump float;
varying vec2 vUv;
uniform float uTime;
uniform float uWaves;

void main() {
  vec2  p    = vUv * 2.0 - 1.0;
  float dist = length(p);

  // sin(dist ร— frequency - time) produces outward-moving rings
  float ripple = sin(dist * uWaves * 3.14159 - uTime * 3.0) * 0.5 + 0.5;

  // Fade toward the edge so it does not clip hard
  float fade = 1.0 - smoothstep(0.0, 1.2, dist);
  ripple *= fade;

  vec3 col = mix(vec3(0.05), mix(violet, mint, ripple), ripple);
  gl_FragColor = vec4(col, 1.0);
}

The demo below adds a second source at a slight offset, creating interference where the two wave systems overlap. The interference emerges from two independent sinexpressions being averaged, no special handling required.

Interactive ยท ripple interference, two wave sources, composited
Speed1.0
Waves5.0

Two interference patterns: sin(dist ร— waves - time) from different origins. No geometry, no textures: every pixel is computed from its UV coordinate and the time uniform.

Set speed to zero and drag the waves slider. The pattern freezes but you can see the spatial frequency. Crank speed up and the two sources pulse at different rates, producing a beat-like interference pattern.


What comes next

Fragment shaders are where WebGL gets generative. The vertex shader handles where things are. The fragment shader handles what they look like, and with only UV coordinates and time as inputs, the range of possible outputs is enormous.

  • โ†’UV coordinates. The fragment's position on the quad, remapped from NDC (-1..1) to (0..1). Every technique here builds on this.
  • โ†’Distance functions. length(p) gives distance from any point. Combined with smoothstep, you get shapes with controllable edge softness.
  • โ†’Set operations. min and max on signed distances union, intersect, and subtract shapes. Compose them arbitrarily.
  • โ†’Time as input. A uTime uniform makes the shader's output a function of both position and time. Shifting the phase of a periodic function produces motion.
Natural next steps from here: noise functions (simplex, value) for organic randomness, texture sampling with sampler2D uniforms for image effects, and blending multiple shader techniques using the alpha channel. The full-screen quad pattern and the UV coordinate system carry through all of it.

newsletter

Stay in the loop

New experiments, articles, and tools โ€” straight to your inbox. No spam, unsubscribe anytime.