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:
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);
}
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
- Uniform — set once from CPU, same value for every vertex/fragment in a draw call. Used for: time, matrices, texture samplers, mouse position.
- Attribute / in — per-vertex data uploaded in a buffer. Only accessible in the vertex shader.
- Varying / out → in — output from vertex shader, automatically interpolated across the triangle surface, received as input to the fragment shader. UV coordinates are the classic example.
// 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;
}
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.