Lesson 07

Shaders &
GLSL.

Shaders are small programs that run directly on the GPU — massively parallel, blindingly fast, and deeply visual. GLSL (OpenGL Shading Language) is their language: C-like syntax, built-in vector types, and a single job: decide the colour of every pixel on screen.

The key mental shift: a CPU program runs once, top to bottom. A shader runs simultaneously for every pixel — potentially millions of instances at once, each knowing only its own coordinates. There are no loops over pixels; the GPU is the loop.

The GPU rendering pipeline

Your JavaScript hands data to the GPU; two shader stages do the visual work.

📦 CPU Geometry
📍 Shader 1 Vertex
🔺 GPU Rasterise
🎨 Shader 2 Fragment
🖥️ Output Screen
📍
Vertex shader

Runs once per vertex. Transforms 3D positions into 2D screen coordinates using matrix math. Outputs gl_Position.

🎨
Fragment shader

Runs once per pixel. Receives interpolated data, computes and outputs a colour as gl_FragColor — a vec4 (r,g,b,a).

🔗
Uniforms & varyings

Uniforms are constants sent from JS (time, resolution, mouse). Varyings are values interpolated between vertex and fragment stages.

GLSL built-in types

GLSL speaks vectors natively — no library needed.

TypeSizeUsed for
float1Single decimal number. Everything in GLSL is floating point.
vec222D coordinates, UV texture coords, screen position (x, y)
vec333D position, RGB colour, surface normals, light direction
vec44RGBA colour, homogeneous 3D coords (x, y, z, w)
mat2/3/44/9/16Transformation matrices for rotation, scale, projection
sampler2DA texture unit — the shader can sample any pixel of an image
bool / int1Conditionals and loop counters (loops must have constant bounds)

Your first shader — raw WebGL

A minimal WebGL setup in JavaScript that draws a colour gradient across a full-screen quad.

GLSL · Vertex Shader positions a full-screen triangle
// attribute: per-vertex data sent from JS
attribute vec2 a_position;
varying   vec2 v_uv;        // passes UV to fragment shader

void main() {
  v_uv         = a_position * 0.5 + 0.5;  // remap [-1,1] → [0,1]
  gl_Position  = vec4(a_position, 0.0, 1.0);
}
GLSL · Fragment Shader colours every pixel based on its UV coordinate
precision mediump float;
varying vec2 v_uv;          // (0,0) bottom-left → (1,1) top-right
uniform float u_time;       // seconds since start, sent from JS

void main() {
  // Animate hue using time
  vec3 colA = vec3(0.78, 0.32, 0.16);   // terracotta
  vec3 colB = vec3(0.08, 0.08, 0.06);   // near-black
  float t   = sin(u_time + v_uv.x * 3.14) * 0.5 + 0.5;
  vec3 col  = mix(colB, colA, t * v_uv.y);

  gl_FragColor = vec4(col, 1.0);
}
JavaScript boilerplate — compile shaders, draw loop, pass time uniform
const canvas = document.querySelector('canvas');
const gl     = canvas.getContext('webgl');

// helper: compile one shader stage
function compile(type, src) {
  const s = gl.createShader(type);
  gl.shaderSource(s, src);
  gl.compileShader(s);
  return s;
}

const prog = gl.createProgram();
gl.attachShader(prog, compile(gl.VERTEX_SHADER,   vertSrc));
gl.attachShader(prog, compile(gl.FRAGMENT_SHADER, fragSrc));
gl.linkProgram(prog);
gl.useProgram(prog);

// full-screen quad: two triangles
const verts = new Float32Array([-1,-1, 1,-1, -1,1, 1,1]);
const buf   = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.bufferData(gl.ARRAY_BUFFER, verts, gl.STATIC_DRAW);

const loc  = gl.getAttribLocation(prog, 'a_position');
const uTime = gl.getUniformLocation(prog, 'u_time');
gl.enableVertexAttribArray(loc);
gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);

// animation loop
function loop(t) {
  gl.uniform1f(uTime, t / 1000);
  gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
  requestAnimationFrame(loop);
}
requestAnimationFrame(loop);

Activity — Live WebGL gradient

The fragment shader below runs in real time in your browser. Adjust the sliders to change the colours.

Fragment Shader — Animated Gradient
u_time increments every frame · u_colorA and u_colorB are your uniforms
Colour A — hue 15°
Colour B — hue 220°
Wave speed 1.0×
Wave frequency 2.0

Shaders in p5.js

p5.js wraps WebGL with a friendlier API. Use createShader() and shader() — the GLSL is identical.

p5.js + GLSL radial distance field — concentric rings that pulse
// ── fragment shader (string passed to createShader) ──
const fragSrc = `
  precision mediump float;
  uniform vec2  u_resolution;
  uniform float u_time;

  void main() {
    vec2 uv = gl_FragCoord.xy / u_resolution;
    uv = uv * 2.0 - 1.0;            // centre origin
    uv.x *= u_resolution.x / u_resolution.y;  // fix aspect

    float dist = length(uv);         // distance from centre
    float rings = sin(dist * 12.0 - u_time * 2.5);
    float col   = smoothstep(0.0, 0.05, rings);  // sharp edges

    gl_FragColor = vec4(col * 0.78, col * 0.32, col * 0.16, 1.0);
  }
`;

// ── p5.js sketch ──
let sh;
function setup() {
  createCanvas(400, 400, WEBGL);
  sh = createShader(defaultVertSrc, fragSrc);
}
function draw() {
  shader(sh);
  sh.setUniform('u_resolution', [width, height]);
  sh.setUniform('u_time', millis() / 1000.0);
  rect(-width/2, -height/2, width, height);  // full canvas quad
}

Activity — Distance field rings

A classic shader pattern: compute the distance from each pixel to the centre, then use sin() to draw concentric rings.

Radial Distance Field
gl_FragCoord.xy → normalise → length() → sin() → colour
Ring frequency 10
Pulse speed 2.0×
Sharpness 0.06
Centre X offset 0.00

JavaScript vs GLSL — key differences

Same mathematical ideas, very different execution model.

ConceptJavaScript (CPU)GLSL (GPU)
ExecutionSequential, one threadParallel, millions at once
Loop over pixelsfor (let y…) for (let x…)No loop — you ARE one pixel
Vector mathManual: x*cos-y*sin …Built-in: mat2 * vec2
TexturesImageData, getImageData()texture2D(sampler, uv)
TimeDate.now(), performance.now()uniform float u_time (sent from JS)
Conditionalsif/else freelyPrefer step(), mix(), smoothstep() — branches are costly
Outputctx.fillStyle = …gl_FragColor = vec4(r,g,b,a)