šŸŽØ GLSL Shader Programming: Writing Your First Fragment Shader

Shaders are programs that run on the GPU, executing in parallel for every pixel on screen simultaneously. They're the reason a Mandelbrot set can render in milliseconds, and why fluid simulations can achieve thousands of particles at 60 fps. Understanding shaders unlocks the full power of real-time graphics.

The GPU Pipeline: Vertex and Fragment Shaders

Modern GPUs process graphics through a programmable pipeline. Two stages are directly programmable via GLSL (OpenGL Shading Language): the vertex shader and the fragment shader.

The vertex shader runs once per vertex of your geometry. It transforms 3D positions into 2D screen coordinates using projection matrices, and can pass arbitrary data (texture coordinates, normals, colors) to subsequent stages. Its primary output is gl_Position — the clip-space position of the vertex.

The fragment shader runs once per pixel covered by your geometry. It receives interpolated values from the vertex shader and outputs a final color for that pixel. The rasterizer automatically interpolates any varying variables between vertices — so if vertex A has UV coordinate (0,0) and vertex B has UV (1,0), the pixel halfway between them gets UV (0.5, 0).

// Minimal fragment shader
precision mediump float;
varying vec2 vUv;      // interpolated UV from vertex shader
uniform float uTime;   // CPU-supplied time value

void main() {
  // Color based on UV coordinates
  gl_FragColor = vec4(vUv.x, vUv.y, sin(uTime) * 0.5 + 0.5, 1.0);
}

UV Coordinates and the Unit Square

UV coordinates (also called texture coordinates) map your geometry to a 2D space, typically [0,1] Ɨ [0,1]. The letter U corresponds to the horizontal axis and V to the vertical. When you sample a texture at UV (0.5, 0.5), you get the pixel at the center of the texture.

For a fullscreen quad — the standard setup for compute-style shaders — UVs range from (0,0) at the bottom-left to (1,1) at the top-right. Normalizing to [-1,1] and adjusting for aspect ratio gives vec2 p = (vUv - 0.5) * vec2(aspectRatio, 1.0) * 2.0 — a coordinate system centered on screen, useful for symmetric patterns.

Noise Functions: The Artist's Workhorse

Procedural shaders rely heavily on noise functions — pseudo-random values that vary smoothly in space and time. The most important are:

// Classic value noise in GLSL
float hash(vec2 p) {
  p = fract(p * vec2(123.34, 456.21));
  p += dot(p, p + 19.19);
  return fract(p.x * p.y);
}

float noise(vec2 p) {
  vec2 i = floor(p);
  vec2 f = fract(p);
  vec2 u = f * f * (3.0 - 2.0 * f); // smoothstep

  return mix(
    mix(hash(i + vec2(0,0)), hash(i + vec2(1,0)), u.x),
    mix(hash(i + vec2(0,1)), hash(i + vec2(1,1)), u.x),
    u.y
  );
}

Fractal noise (fBm — fractional Brownian motion) stacks multiple octaves of noise at different frequencies and amplitudes. Each octave doubles the frequency and halves the amplitude: fbm += amplitude * noise(p); p *= 2.0; amplitude *= 0.5;. This mimics natural self-similar patterns — clouds, terrain, turbulence.

Signed Distance Functions and Raymarching

Raymarching is a rendering technique that traces rays through a scene described by Signed Distance Functions (SDFs). An SDF returns the signed distance from any point to the nearest surface — positive outside, negative inside. For a sphere of radius r centered at origin: float sdf = length(p) - r;.

The raymarching loop advances a point along the ray by the SDF value at each step. Because the SDF tells you the minimum safe step size (you can move at least that far without crossing a surface), the algorithm is both efficient and exact:

float raymarch(vec3 ro, vec3 rd) {
  float t = 0.0;
  for (int i = 0; i < 100; i++) {
    vec3 p = ro + t * rd;
    float d = sceneSDF(p);
    if (d < 0.001) return t;  // hit!
    t += d;                    // safe to step by d
    if (t > 100.0) break;     // max distance
  }
  return -1.0; // miss
}

SDFs compose elegantly: min(sdfA, sdfB) is a union, max(sdfA, -sdfB) is subtraction, and max(sdfA, sdfB) is intersection. Smooth blending between shapes — a technique called smooth minimum — creates organic merging: float smin(float a, float b, float k) { float h = clamp(0.5 + 0.5*(b-a)/k, 0.0, 1.0); return mix(b, a, h) - k*h*(1.0-h); }.

How mysimulator Uses GLSL

The Mandelbrot simulator runs entirely in a fragment shader — the CPU only draws a single rectangle covering the screen and passes the zoom level, center position, and iteration count as uniforms. The shader maps each pixel to a complex number c = (uv * scale) + center, iterates z = z² + c, and colors the pixel based on the escape time. This parallelism is what allows interactive zooming into regions of astronomical mathematical depth.

Fluid simulations use multiple render passes: one shader advects velocity, another computes divergence, a Jacobi iteration shader solves for pressure, another projects velocity to be divergence-free, and a final shader renders particles or density fields. GPU parallelism makes what would take seconds in JavaScript run in under a millisecond.

The Mandelbrot simulation is a pure GLSL fragment shader — every pixel computes its own iteration independently. Zoom in to see how the same shader code generates infinite structural complexity from a handful of arithmetic operations.