Learn Creative Coding (#21) - Shaders: A First Taste of GLSL
Everything we've built so far in this series runs on the CPU. Every pixel we've drawn, every particle we've simulated, every noise value we've computed -- the CPU did that work, one thing at a time. Your graphics card has been sitting there doing nothing this whole time. Shaders change that. They're tiny programs that run directly on the GPU, processing millions of pixels simultaneously. Where our CPU loops take milliseconds to iterate over a 600x600 canvas pixel by pixel, a shader does the same work in microseconds. In parallel. All at once.
But shaders aren't just about speed. They force you to think about visual computation in a completely different way. Instead of "draw a circle at position (200, 300)," you think "for every pixel on the screen, what color should it be?" It's a paradigm shift, and honestly it took me a few days before it clicked. Once it does, you can create effects that are flat-out impossible with CPU-based canvas code -- real-time fractals, fluid simulations, volumetric lighting, infinite procedural detail.
This episode is our first taste. We'll set up the boilerplate, learn the language basics, draw shapes using distance math, and get something animated and interactive running on the GPU. It's a lot of new concepts all at once, but the payoff is massive. Let's go :-)
What is a shader?
A shader is a tiny program that runs on your graphics card. The GPU takes your shader code and runs it once for every pixel on screen -- all in parallel. A 600x400 canvas? Your shader runs 240,000 times per frame. A 1920x1080 display? Over two million times per frame. In parallel.
This is why shaders are absurdly fast. That particle system from episode 11 that was starting to chug with 3000 particles? A shader could do the equivalent visual effect with zero effort. The GPU is built for this -- it has thousands of tiny cores designed specifically for running the same program on different data points simultaneously.
The trade-off: each pixel runs independently. A pixel can't ask its neighbor "hey, what color are you?" (well, it can with textures, but not directly). You have to think in terms of "given my position on screen, what color should I be?" -- no for loops over other pixels, no state shared between pixels, no "draw this then draw that." Each pixel figures itself out alone. These constraints are what make shaders fast -- the GPU can run everything in parallel precisely because nothing depends on anything else.
Two types of shaders
There are two main types you'll encounter:
- Vertex shader: positions geometry in 3D space. Moves points around. For 2D creative coding we barely touch this -- it's just a passthrough that says "put the geometry exactly where it is."
- Fragment shader (also called pixel shader): determines the color of each pixel. This is where all the creative magic happens.
For 2D creative coding, you write fragment shaders and leave the vertex shader as a boring passthrough. All the examples in this episode are fragment shaders.
The boilerplate: setting up WebGL
We need WebGL to run shaders in the browser. Here's the setup code. It's a lot, I know -- but you write it once, save it, and then the fragment shader is where all the fun happens:
// shader-setup.js
const canvas = document.getElementById('glcanvas');
const gl = canvas.getContext('webgl');
canvas.width = 600;
canvas.height = 400;
gl.viewport(0, 0, canvas.width, canvas.height);
// vertex shader -- just a passthrough
const vertexSource = `
attribute vec2 position;
void main() {
gl_Position = vec4(position, 0.0, 1.0);
}
`;
// our first fragment shader
const fragmentSource = `
precision mediump float;
uniform vec2 u_resolution;
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution;
gl_FragColor = vec4(uv.x, uv.y, 0.5, 1.0);
}
`;
function createShader(gl, type, source) {
let shader = gl.createShader(type);
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.error(gl.getShaderInfoLog(shader));
gl.deleteShader(shader);
return null;
}
return shader;
}
function createProgram(gl, vertSrc, fragSrc) {
let vert = createShader(gl, gl.VERTEX_SHADER, vertSrc);
let frag = createShader(gl, gl.FRAGMENT_SHADER, fragSrc);
let program = gl.createProgram();
gl.attachShader(program, vert);
gl.attachShader(program, frag);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.error(gl.getProgramInfoLog(program));
return null;
}
return program;
}
// full-screen quad (two triangles covering the canvas)
let vertices = new Float32Array([
-1, -1, 1, -1, -1, 1,
-1, 1, 1, -1, 1, 1
]);
let buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);
let program = createProgram(gl, vertexSource, fragmentSource);
gl.useProgram(program);
let posLoc = gl.getAttribLocation(program, 'position');
gl.enableVertexAttribArray(posLoc);
gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 0, 0);
// pass canvas resolution to the shader
let resLoc = gl.getUniformLocation(program, 'u_resolution');
gl.uniform2f(resLoc, canvas.width, canvas.height);
// draw
gl.drawArrays(gl.TRIANGLES, 0, 6);
That's a lot of code for a gradient. I know. WebGL is verbose -- you have to create shaders, compile them, link them into a program, set up geometry buffers, and configure vertex attributes. It's the worst part of working with shaders. But here's the thing: this boilerplate is essentially the same every time. Copy it, save it as your template, and from here on out you only change the fragment shader string. That's where the creativity lives.
What those two triangles are doing: we need a surface for the shader to paint on. WebGL doesn't have a "fill the whole screen" command. Instead, we define two triangles that together cover the entire canvas (a quad), and the fragment shader runs for every pixel inside those triangles. Weird? Yes. But it works, and you never need to think about it again after the initial setup.
GLSL: the language
GLSL (OpenGL Shading Language) looks like C. If you've been writing JavaScript with us this whole series, the syntax won't be too surprising. The fragment shader receives gl_FragCoord (the pixel's position in screen coordinates) and must output gl_FragColor (the pixel's color as RGBA, each component 0.0 to 1.0).
precision mediump float;
uniform vec2 u_resolution; // canvas size in pixels
uniform float u_time; // elapsed time in seconds
void main() {
// normalize coordinates to 0-1 range
vec2 uv = gl_FragCoord.xy / u_resolution;
// output color
gl_FragColor = vec4(uv.x, uv.y, 0.5, 1.0);
}
Key GLSL types you need to know:
float-- a number. Always use decimal notation:1.0not1. This catches everyone. GLSL is strictly typed and1is an integer,1.0is a float. Mix them up and the compiler yells at you.vec2-- two floats (x, y). Great for positions and UV coordinates.vec3-- three floats (r, g, b or x, y, z). Used for colors and 3D positions.vec4-- four floats (r, g, b, a). Whatgl_FragColorexpects.uniform-- a value passed in from JavaScript. Constants that the shader reads but can't change.
One thing that's genuinely cool: swizzling. You can grab components from vectors in any order. myVec3.xy gives a vec2. myVec4.rgb gives a vec3. You can even reorder: color.bgr reverses the channels. Same data, different names -- color.r and color.x access the same component. It's syntactic sugar but it makes shader code surprisingly readable once you're used to it.
Your first shader: solid color to gradient
Let's start with the absolute simplest fragment shader -- every pixel gets the same color:
precision mediump float;
void main() {
gl_FragColor = vec4(1.0, 0.0, 0.5, 1.0); // hot pink, fully opaque
}
Every pixel, the same RGBA value. Not 0-255 like in JavaScript -- shaders use normalized floats, 0.0 to 1.0. That vec4(1.0, 0.0, 0.5, 1.0) is full red, no green, half blue, full alpha. Hot pink.
Now let's use the pixel's position to make something more interesting. gl_FragCoord tells each pixel where it is on screen. Divide by the canvas resolution (which we pass as a uniform) and you get normalized 0-to-1 coordinates:
precision mediump float;
uniform vec2 u_resolution;
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution;
gl_FragColor = vec4(uv.x, uv.y, 0.5, 1.0);
}
Red increases left to right, green increases bottom to top, blue stays constant. A gradient, computed per-pixel, running on the GPU at ridiculous speed. This is the "hello world" of shaders, and it already demonstrates the core idea: every pixel independently computes its own color based on its own position. No loops. No drawing commands. Just a function from coordinate to color.
The mental shift: thinking per-pixel
This is the hardest part of learning shaders, and it's not a syntax thing -- it's a thinking thing. In JavaScript canvas code, you think procedurally: "draw a circle at (200, 300), then draw a line from here to there, then fill this rectangle." Sequential instructions, one after another. In a shader, there is no "draw." You write a single function that takes a pixel coordinate as input and returns a color. That function runs for every pixel simultaneously.
You can't say "draw a circle." Instead you say "for this pixel, how far am I from point (200, 300)? If the distance is less than 50, I'm red. Otherwise, I'm black." The circle emerges from the distance check, not from a drawing command.
This feels awkward at first. You'll reach for for loops and realize that the entire shader IS the loop -- one that iterates across all pixels in parallel. You'll want to store state between pixels and realize you can't -- each pixel is on its own. These constraints are exactly what makes shaders fast. The GPU can parallelize everything precisely because nothing depends on anything else.
Remember when we built pixel manipulation code in episode 10, iterating through every pixel with nested for loops? Shaders do the same thing but the GPU handles the iteration. You just write the per-pixel logic, the hardware handles running it millions of times.
Animating with time
Static gradients are fine, but we want things that move. Pass time as a uniform from JavaScript:
let timeLoc = gl.getUniformLocation(program, 'u_time');
function animate(timestamp) {
gl.uniform1f(timeLoc, timestamp / 1000.0);
gl.drawArrays(gl.TRIANGLES, 0, 6);
requestAnimationFrame(animate);
}
requestAnimationFrame(animate);
Now use u_time in the shader:
precision mediump float;
uniform vec2 u_resolution;
uniform float u_time;
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution;
// animated color channels
float r = sin(u_time + uv.x * 6.0) * 0.5 + 0.5;
float g = sin(u_time * 0.7 + uv.y * 4.0) * 0.5 + 0.5;
float b = sin(u_time * 1.3 + (uv.x + uv.y) * 3.0) * 0.5 + 0.5;
gl_FragColor = vec4(r, g, b, 1.0);
}
A flowing, shifting gradient of color. Every pixel computed independently, all at once. And it runs at 60fps without effort, even on a phone. The trig here is the same sin we used in episode 13 for oscillation -- sin(something) * 0.5 + 0.5 maps the -1 to +1 range into 0 to 1 (valid color range). The different multipliers on u_time (1.0, 0.7, 1.3) make each channel oscillate at a different speed, so the colors shift in complex, non-repeating patterns. Layered sine waves -- same technique we used for organic motion, but now applied to every pixel on the canvas simultaneously.
Distance functions: drawing shapes with math
This is where shaders get really powerful. Instead of "draw a circle" you calculate the distance from each pixel to the shape's center. If the distance is less than the radius, you're inside the circle.
precision mediump float;
uniform vec2 u_resolution;
void main() {
// center coordinates at (0,0), normalize by height for aspect ratio
vec2 uv = (gl_FragCoord.xy - u_resolution * 0.5) / u_resolution.y;
// distance from center
float d = length(uv);
// sharp circle: 0 inside, 1 outside
float circle = step(0.3, d);
gl_FragColor = vec4(vec3(1.0 - circle), 1.0); // white circle, black bg
}
step(edge, x) returns 0 when x is less than edge, 1 when x is greater or equal. Sharp boundary. It's a built-in threshold function -- think of it as a hard cutoff.
But hard edges look jaggy. For a smooth edge, use smoothstep:
float circle = smoothstep(0.28, 0.32, d);
smoothstep blends smoothly between two threshold values. At d=0.28 you get 0 (fully inside). At d=0.32 you get 1 (fully outside). Between those values it fades. This is anti-aliasing, built into the math, for free. No special rendering settings needed, no multisampling -- just two numbers defining a fade zone. The narrower the gap between the two values, the sharper the edge. The wider, the softer.
Notice what we did with the UV coordinates: (gl_FragCoord.xy - u_resolution * 0.5) / u_resolution.y. Subtracting half the resolution centers the origin at the middle of the canvas. Dividing by u_resolution.y (not the full resolution) normalizes by height, which preserves the aspect ratio -- circles stay circular instead of getting squished on wide canvases. This is a standard shader convention you'll see everywhere.
Combining shapes with boolean operations
Since everything is distance math, combining shapes means combining distances. This is called Constructive Solid Geometry, and in shaders it's just basic min and max:
precision mediump float;
uniform vec2 u_resolution;
uniform float u_time;
void main() {
vec2 uv = (gl_FragCoord.xy - u_resolution * 0.5) / u_resolution.y;
// two circles, slightly offset
float d1 = length(uv - vec2(-0.15, 0.0)) - 0.2;
float d2 = length(uv - vec2(0.15, 0.0)) - 0.2;
// union: both shapes visible
float shape = min(d1, d2);
// try these instead:
// intersection: only where both overlap
// float shape = max(d1, d2);
// subtraction: first shape minus second
// float shape = max(d1, -d2);
float color = 1.0 - smoothstep(0.0, 0.01, shape);
gl_FragColor = vec4(vec3(color), 1.0);
}
min(d1, d2) gives you the union -- both shapes. max(d1, d2) gives you the intersection -- only the overlap. max(d1, -d2) gives you subtraction -- the first shape with the second shape cut out of it. Three operations, three completley different results, and the math is trivially simple.
There's also smooth blending, which is where things get really beautiful:
// smooth union -- the shapes melt into each other
float k = 0.1;
float h = clamp(0.5 + 0.5 * (d2 - d1) / k, 0.0, 1.0);
float shape = mix(d2, d1, h) - k * h * (1.0 - h);
The smooth union blends the two shapes organically, like two blobs of mercury merging. The k parameter controls how much blending happens -- smaller values give a tighter blend, larger values make the shapes melt together more. It's the kind of effect that would be incredibly difficult to achieve with CPU canvas drawing but in a shader it's four lines of math. This is why shader artists get obsessed with distance functions -- you can build entire visual worlds from these simple building blocks.
Mouse interaction
Making shaders interactive is just passing another uniform. Send the mouse position from JavaScript:
let mouseLoc = gl.getUniformLocation(program, 'u_mouse');
canvas.addEventListener('mousemove', (e) => {
let rect = canvas.getBoundingClientRect();
gl.uniform2f(mouseLoc,
e.clientX - rect.left,
canvas.height - (e.clientY - rect.top) // flip Y for WebGL
);
});
Note the Y flip -- WebGL's coordinate system has Y going upward, but the browser's mouse coordinates have Y going downward. Forgetting this flip is a classic gotcha that results in the mouse effect being mirrored vertically. Ask me how I know :-)
Now use it in the shader for a glowing light effect:
precision mediump float;
uniform vec2 u_resolution;
uniform vec2 u_mouse;
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution;
vec2 mouse = u_mouse / u_resolution;
float d = length(uv - mouse);
float glow = 0.02 / d; // inverse distance = natural light falloff
gl_FragColor = vec4(vec3(glow) * vec3(0.4, 0.7, 1.0), 1.0);
}
A soft blue glow that follows your cursor. The 0.02 / d creates inverse-distance falloff -- bright at the center, fading rapidly with distance. This is how real light behaves physically (intensity falls off with the square of distance, technically, but inverse distance looks good on screen). The multiplication by vec3(0.4, 0.7, 1.0) tints the white glow blue. Move your mouse around and you've got an interactive light painting tool running entirely on the GPU.
Using shaders in p5.js
If the WebGL boilerplate feels heavy, p5.js wraps it nicely. You write the exact same GLSL shader code, but p5 handles all the setup:
let myShader;
function preload() {
myShader = loadShader('shader.vert', 'shader.frag');
}
function setup() {
createCanvas(600, 400, WEBGL);
}
function draw() {
shader(myShader);
myShader.setUniform('u_resolution', [width, height]);
myShader.setUniform('u_time', millis() / 1000.0);
myShader.setUniform('u_mouse', [mouseX, height - mouseY]);
rect(0, 0, width, height);
}
Same shader code, much less JavaScript setup. The createCanvas(600, 400, WEBGL) switches p5 to WebGL mode, and loadShader handles compilation and linking. Good for experimenting. When you're sketching ideas and want to iterate fast, p5's shader support gets you to the fun part quicker.
You'll need a local server to load shader files from disk -- p5 loads them via HTTP requests, and browsers block local file access for security reasons. A quick python3 -m http.server in your project folder handles that. Same local server you'd use for any web development.
Debugging shaders
Here's something nobody warns you about: debugging shaders is hard. There's no console.log in GLSL. You can't print a value, you can't set a breakpoint, you can't step through line by line. The shader runs on the GPU and JavaScript has no visibility into what's happening inside it.
The standard technique: output a value as a color. If you want to see what distance looks like, set gl_FragColor = vec4(distance, distance, distance, 1.0) and look at the brightness pattern. Bright pixels = high value, dark pixels = low value. It's crude but effective. You're essentially using the screen as a debugger, where color IS the debug output. You'll use this technique constantly.
Shader error messages are also notoriously unhelpful. Something like "ERROR: 0:15: 'x' : undeclared identifier" means line 15 has a variable that doesn't exist -- but the line number might be off by one or two depending on how you embedded the shader string in JavaScript. Read the error carefully, check for typos, and make sure every variable has a type declaration. float x = 0.0; works, x = 0.0; doesn't -- GLSL requires explicit types for everything.
The biggest gotcha for JavaScript developers: GLSL has no implicit type conversion. In JavaScript, 1 / 3 gives you 0.333... because JS converts everything to floats automatically. In GLSL, 1 / 3 is integer division and the result is 0. You wanted 1.0 / 3.0 for the float result. This catches everyone at least once. Use decimal points on everything: 1.0 not 1, 0.0 not 0, 3.0 not 3. It becomes second nature, but the first few hours are full of "why is my value zero" moments.
Putting it all together: an animated, interactive shader
Allez, let's build something that uses everything we've covered. An animated background with pulsing concentric rings centered on the mouse, colored with time-varying hues:
precision mediump float;
uniform vec2 u_resolution;
uniform float u_time;
uniform vec2 u_mouse;
void main() {
// center on mouse position, aspect-correct
vec2 uv = (gl_FragCoord.xy - u_mouse) / u_resolution.y;
// distance from mouse
float d = length(uv);
// concentric rings that pulse outward
float rings = sin(d * 30.0 - u_time * 3.0) * 0.5 + 0.5;
// smooth falloff so it fades with distance
float falloff = 1.0 / (1.0 + d * 5.0);
// time-varying color
float r = sin(u_time * 0.5) * 0.5 + 0.5;
float g = sin(u_time * 0.7 + 2.0) * 0.5 + 0.5;
float b = sin(u_time * 0.3 + 4.0) * 0.5 + 0.5;
vec3 color = vec3(r, g, b) * rings * falloff;
gl_FragColor = vec4(color, 1.0);
}
Concentric rings radiate outward from the mouse position, shifting color over time and fading with distance. The sin(d * 30.0 - u_time * 3.0) creates the ring pattern -- the subtraction of time makes them pulse outward. Multiplying by falloff (inverse distance) keeps the effect concentrated near the mouse. The three color channels oscillate at different speeds so the palette constantly shifts. All of that, running for every pixel independently, 60 frames per second. And the GPU barely notices.
This is the kind of thing that would be extremely painful to build on the CPU. You'd need nested for loops over every pixel, per-frame distance calculations, and the whole thing would probably run at 5fps on a good day. The GPU does it without breaking a sweat because this is literally what it was designed for.
Where to go deeper
This episode is genuinely scratching the surface. Shaders can do things we haven't even hinted at:
- Signed distance functions for complex 2D and 3D shapes
- Raymarching -- rendering entire 3D scenes from pure math, no geometry
- Fractal rendering -- Mandelbrot sets, Julia sets, in real-time
- Noise on the GPU -- Perlin noise and friends, but massively parallel
- Post-processing effects -- bloom, blur, chromatic aberration, vignettes
- Feedback loops -- using the previous frame as input to the current one
- Fluid simulation -- Navier-Stokes on the GPU
The best learning resource is The Book of Shaders by Patricio Gonzalez Vivo. It's free, interactive, and beautifully made -- you edit shader code right in the browser and see results live. Also check out Shadertoy -- thousands of shaders you can read, modify, and learn from. Some of them are mind-blowing: entire photorealistic 3D scenes rendered from a single fragment shader, no 3D models involved.
And Inigo Quilez's website (iquilezles.org) is an encyclopedia of shader techniques. His articles on distance functions are the definitive reference -- if you want to draw any shape in a shader, he's probably documented exactly how. Between these three resources you have everything you need to go from beginner to advanced.
A practical note on compatibility
WebGL shaders run in every modern browser -- Chrome, Firefox, Safari, Edge. They even work on mobile. But there are some practical things to keep in mind:
- precision qualifiers matter:
precision mediump float;at the top of every fragment shader. Desktop GPUs don't care, but mobile GPUs need this or they'll throw errors. - no for loops with variable bounds in WebGL 1.0:
for (int i = 0; i < 10; i++)works, butfor (int i = 0; i < someUniform; i++)might not on some devices. WebGL 2.0 relaxes this, but for maximum compatibility keep loop bounds constant. - keep shaders short while learning. Long, complex shaders with many branches can run into driver-specific bugs on certain GPUs. Start simple and build up gradually.
None of this should scare you off -- shaders are incredibly well supported on the web. Just be aware that the GPU is a different environment than the CPU, and some of the assumptions you've built up writing JavaScript don't apply.
't Komt erop neer...
- Shaders run on the GPU -- every pixel processed in parallel, simultaneously
- Fragment shaders take a pixel position and output a color. That's the whole contract.
- GLSL uses
float,vec2,vec3,vec4-- always write1.0not1(strict types!) - Uniforms pass data from JavaScript to the shader: time, mouse position, resolution
- Distance functions let you draw shapes with pure math --
length(uv) - radiusIS a circle - Combine shapes with
min(union),max(intersection), andmax(a, -b)(subtraction) smoothstepgives you built-in anti-aliasing between any two threshold values- Debug by outputting values as colors -- bright = high, dark = low
- The WebGL boilerplate is verbose but you write it once and reuse it forever
- The Book of Shaders, Shadertoy, and Inigo Quilez are your three best friends
Phase 3 wraps up here. Over the last six episodes we built smooth motion with lerp and easing (episode 16), structured behavior with state machines (17), physical weight with springs, friction, and flocking (18), wired visuals to sound (19), learned to capture and share everything (20), and now we've had our first look at GPU programming. Our creative coding toolkit has grown massively -- our sketches move naturally, respond to input, feel alive, and we can render them on the GPU and export them in any format. Next up is a mini-project where we pull several of these pieces together into something complete :-)
Sallukes! Thanks for reading.
X