🟩 GLSL · WebGL · GPU Programming
📅 March 2026 ⏱ ~12 min read 🟢 Beginner–Intermediate

Introduction to GLSL Shaders

Shaders run on the GPU — thousands of tiny programs executing in parallel, one per vertex or pixel. They are what turns triangles into chrome, fire, water caustics, and alien planets. GLSL (OpenGL Shading Language) is the C-like language you write them in.

1. The GPU Pipeline

To render a triangle, GPU hardware runs a fixed pipeline with programmable stages you write in GLSL:

CPU Vertex Data Upload Positions, normals, UVs written to GPU buffer (VBO)
SHADER Vertex Shader Runs once per vertex — transforms position to clip space
Fixed Rasterisation GPU fills triangle pixels, interpolates varyings between vertices
SHADER Fragment (Pixel) Shader Runs once per pixel — outputs the final RGBA colour
Fixed Output Merge / Blending Depth test, alpha blending, framebuffer write

In modern WebGPU and Vulkan, compute shaders add a third programmable stage that bypasses rasterisation entirely — used for physics, particles, and post-processing.

2. GLSL Basics

GLSL looks like C but is designed for vectors and matrices. Key types:

float x = 1.0;        // must use decimal point for floats
int n   = 3;
bool b  = true;

vec2 uv  = vec2(0.5, 0.25);   // 2D vector
vec3 col = vec3(1.0, 0.5, 0.0);  // RGB orange
vec4 pos = vec4(col, 1.0);    // w = 1 for position

mat3 rotation = mat3(1.0);   // 3×3 identity

// Swizzling: access any combination of components
vec3 rgb = col.rgb;         // same as col.xyz
float r  = col.r;           // same as col.x or col[0]
vec2 yx  = col.yx;          // reverse channels

Math functions operate component-wise on vectors: sin(v), cos(v), length(v), normalize(v), dot(a,b), cross(a,b), mix(a,b,t), clamp(x,0.,1.), smoothstep(edge0,edge1,x).

3. Vertex Shaders

The vertex shader must write to gl_Position — the clip-space coordinate (divide by w to get NDC; -1 to +1 on all axes). A minimal pass-through vertex shader:

#version 300 es
precision highp float;

in  vec3 a_position;  // attribute: vertex position from VBO
in  vec2 a_uv;        // attribute: texture coordinate
out vec2 v_uv;        // varying: interpolated to fragment shader

uniform mat4 u_mvp;   // Model-View-Projection matrix (from CPU)

void main() {
  v_uv        = a_uv;
  gl_Position = u_mvp * vec4(a_position, 1.0);
}

For fullscreen effects (ShaderToy-style), use a single quad covering the screen with a_position in range [-1, 1] — skip perspective entirely and set gl_Position = vec4(a_position, 1.0).

4. Fragment Shaders

The fragment shader runs once per pixel and writes the final colour to fragColor:

#version 300 es
precision highp float;

in  vec2 v_uv;         // from vertex shader: 0→1 UV coords
out vec4 fragColor;    // output colour (RGBA)

uniform float u_time;  // seconds since start

void main() {
  // Animate a colour gradient over time
  vec3 col = 0.5 + 0.5 * cos(u_time + v_uv.xyx + vec3(0,2,4));
  fragColor = vec4(col, 1.0);
}
ShaderToy compatibility: ShaderToy uses fragCoord (pixel position) and iResolution, iTime uniforms. Convert pixel coords to [-1, 1] UV: vec2 uv = (fragCoord - 0.5 * iResolution.xy) / iResolution.y;

5. Uniforms and Varyings

// JavaScript side (WebGL 2)
const loc = gl.getUniformLocation(prog, 'u_time');
gl.uniform1f(loc, performance.now() / 1000);   // float

const locMVP = gl.getUniformLocation(prog, 'u_mvp');
gl.uniformMatrix4fv(locMVP, false, matrix);    // 4×4

6. Drawing Shapes with SDFs

A Signed Distance Function (SDF) takes a point and returns the signed distance to the nearest surface. If < 0, the point is inside the shape.

// Circle SDF: returns distance from point p to circle of radius r
float sdCircle(vec2 p, float r) {
  return length(p) - r;
}

// Smooth anti-aliased fill
float fill(float sdf, float edge) {
  return 1.0 - smoothstep(-edge, edge, sdf);
}

void main() {
  vec2  uv  = (v_uv - 0.5) * 2.0;  // remap to -1..1
  uv.x *= u_resolution.x / u_resolution.y;  // fix aspect ratio

  float d  = sdCircle(uv, 0.4);
  float c  = fill(d, 0.005);
  fragColor = vec4(vec3(0.2, 0.8, 1.0) * c, 1.0);
}

SDFs compose beautifully: min(a, b) = union, max(a, b) = intersection, max(a, -b) = subtraction. Smooth union: smin(a, b, k) with polynomial blending.

7. Procedural Noise

GLSL has no built-in random noise. The classic hash-based value noise:

// Simple hash: returns pseudo-random float for integer seed
float hash(vec2 p) {
  return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
}

// Value noise: smooth random scalar field
float noise(vec2 p) {
  vec2 i = floor(p);
  vec2 f = fract(p);
  vec2 u = f * f * (3.0 - 2.0 * f);  // smoothstep

  float a = hash(i);
  float b = hash(i + vec2(1, 0));
  float c = hash(i + vec2(0, 1));
  float d = hash(i + vec2(1, 1));

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

// FBM (fractional Brownian motion): layered noise
float fbm(vec2 p) {
  float val = 0.0, amp = 0.5, freq = 1.0;
  for (int i = 0; i < 6; ++i) {
    val  += amp * noise(p * freq);
    amp  *= 0.5;
    freq *= 2.0;
  }
  return val;
}
Simplex noise / Perlin noise: More gradient-based, less axis-aligned visual artefacts. For production use, Inigo Quilez (iquilezles.org) has GLSL implementations of gradient noise, Voronoi (cellular), and domain-warped FBM — the foundation of most ShaderToy landscapes.

8. Running Shaders in WebGL

A minimal WebGL 2 fullscreen shader setup in ~80 lines of JavaScript:

const canvas = document.getElementById('c');
const gl = canvas.getContext('webgl2');

function compileShader(type, src) {
  const s = gl.createShader(type);
  gl.shaderSource(s, src);
  gl.compileShader(s);
  if (!gl.getShaderParameter(s, gl.COMPILE_STATUS))
    throw gl.getShaderInfoLog(s);
  return s;
}

const vert = compileShader(gl.VERTEX_SHADER, `#version 300 es
  in vec2 a_pos;
  out vec2 v_uv;
  void main() { v_uv = a_pos * 0.5 + 0.5; gl_Position = vec4(a_pos, 0, 1); }
`);

const frag = compileShader(gl.FRAGMENT_SHADER, `#version 300 es
  precision highp float;
  in vec2 v_uv;
  out vec4 fragColor;
  uniform float u_time;
  void main() {
    vec3 col = 0.5 + 0.5 * cos(u_time + v_uv.xyx + vec3(0,2,4));
    fragColor = vec4(col, 1.0);
  }
`);

const prog = gl.createProgram();
gl.attachShader(prog, vert);
gl.attachShader(prog, frag);
gl.linkProgram(prog);

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

const aPos = gl.getAttribLocation(prog, 'a_pos');
const uTime = gl.getUniformLocation(prog, 'u_time');

function frame(t) {
  gl.useProgram(prog);
  gl.enableVertexAttribArray(aPos);
  gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);
  gl.uniform1f(uTime, t / 1000);
  gl.drawArrays(gl.TRIANGLES, 0, 6);
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);

For complex projects, use Three.js (THREE.RawShaderMaterial or THREE.ShaderMaterial), which handles buffer setup, matrix uniforms, and light data automatically. Or explore live on ShaderToy — run fragment shaders with zero boilerplate in the browser.