A crazy brain flip
Programming in GLSL requires a fundamental shift in how you think about code. On the CPU (JavaScript), you think like a manager giving a list of instructions. "Do step A, then step B, then loop through this array." It's linear and for most programmers it is what you've learned.
In GLSL, you have to think like a pixel. You are writing a program that runs millions of times simultaneously, and each "instance" of your program is a single pixel asking:
"I am at coordinate (x, y) at time T. What color should I be?"
That's it. That's the entire job of your fragment shader. Every pixel runs the same code, but each one has different input coordinates. The magic happens when millions of these independent calculations combine to form an image.
The 4-Step mental framework for creating shaders
When approaching any shader effect, you have to work through these four steps in order. This framework will help you decompose any visual effect into manageable pieces.
1: Identify your inputs (space and time)
Every effect starts with the two fundamental questions of "Where am I?" and "What time is it?" These translate into your vUv (the 0.0 to 1.0 coordinates of your shape) and your uTime uniforms.
If you want a circle in the middle, you need to know which pixels are near the center (0.5, 0.5). If you want a wave, you need to know how far you are from the left edge (x = 0.0).
// Your two fundamental inputs
varying vec2 vUv; // Where am I? (0.0 to 1.0)
uniform float uTime; // What time is it?
void main() {
// Now every pixel knows its position and the current time
float x = vUv.x; // 0.0 on the left, 1.0 on the right
float y = vUv.y; // 0.0 at the bottom, 1.0 at the top
}2: Use shaping functions instead of if/else
In JavaScript, you might write: if (x > 0.5) { color = red; }. In shaders, if statements are slow for the GPU because they break the parallel execution model. Instead, we use math as logic.
Here are your essential shaping functions:
sin() and cos()
Your best friends for anything that repeats, pulses, or waves. Output ranges from -1.0 to 1.0.
// Pulsing value between 0 and 1
float pulse = 0.5 + 0.5 * sin(uTime);
// Horizontal waves
float wave = sin(vUv.x * 10.0 + uTime);step(edge, value)
Returns 0.0 if value is below the edge, 1.0 if above. It's a hard cut with no transition.
// Hard split at the middle
float mask = step(0.5, vUv.x);
// Left half = 0.0 (black)
// Right half = 1.0 (white)smoothstep(edge1, edge2, value)
Creates a smooth gradient between two points. This is how you make soft glows or anti-aliased edges.
// Smooth transition from 0.4 to 0.6
float soft = smoothstep(0.4, 0.6, vUv.x);
// Below 0.4 = 0.0
// Above 0.6 = 1.0
// Between = smooth curvefract(value)
Returns only the decimal part. This is the secret to tiling where you turn one square into a grid of many.
// Create a 10x10 grid
vec2 grid = fract(vUv * 10.0);
// Each cell now has its own 0.0-1.0 range3: The distance field mental model
When building shapes (like a circle or a star), don't think about drawing lines. Think about distance.
The process is simple:
- Calculate the distance from the current pixel to a point
- Use a shaping function on that distance
- The result is your shape mask
// Step 1: Calculate distance from center
float d = distance(vUv, vec2(0.5, 0.5));
// Step 2: Create a mask using step
float circle = step(d, 0.3);
// Pixels closer than 0.3 units = 1.0 (white)
// Pixels further than 0.3 units = 0.0 (black)
// Or use smoothstep for a soft edge
float softCircle = smoothstep(0.3, 0.28, d);This creates a white circle on a black background because every pixel measures its distance from the center and asks: "Am I closer than 0.3 units?" If yes, it's white. If no, it's black.
4: Layering (the mixing phase)
Once you have your shapes or patterns, you combine them. GLSL has a built-in function called mix(colorA, colorB, percentage).
- Percentage 0.0: You see Color A
- Percentage 1.0: You see Color B
- Percentage 0.5: You see a perfect blend
By using your distance mask from Step 3 as the "percentage," you can tell the GPU something like "If you are inside the circle distance, use Red; if you are outside, use Blue."
vec3 red = vec3(1.0, 0.0, 0.0);
vec3 blue = vec3(0.0, 0.0, 1.0);
// Use the circle mask to blend colors
float d = distance(vUv, vec2(0.5));
float mask = smoothstep(0.3, 0.28, d);
vec3 finalColor = mix(blue, red, mask);
// Outside circle = blue
// Inside circle = red
// Edge = smooth gradient between themYour checklist
When you see a cool effect and want to recreate it, ask yourself these questions:
fract()uTime inside a sin() or cos()distance() or vUvsmoothstep() vs step()Normalization is everything
The most powerful tool in GLSL is normalization. Always try to keep your values between 0.0 and 1.0. It makes the math predictable and allows different functions to "talk" to each other easily.
// sin() outputs -1.0 to 1.0
// Normalize it to 0.0 to 1.0:
float normalized = 0.5 + 0.5 * sin(uTime);
// Now it works perfectly with mix(), smoothstep(), etc.
vec3 color = mix(colorA, colorB, normalized);When values are normalized, you can chain operations together. A distance becomes a mask, a mask becomes a blend factor, a blend factor becomes a color. Everything flows smoothly when you're working in the 0.0 to 1.0 range.
Putting it all together
Here's a complete example that uses all four steps: identifying inputs, using shaping functions, calculating distance, and mixing colors.
uniform float uTime;
varying vec2 vUv;
void main() {
// Step 1: Inputs, we have vUv and uTime
// Step 2: Shaping, create an animated offset
float wave = sin(uTime * 2.0) * 0.1;
// Step 3: Distance, circle that pulses
vec2 center = vec2(0.5 + wave, 0.5);
float d = distance(vUv, center);
float circle = smoothstep(0.3, 0.28, d);
// Add a second ring using fract for repetition
float rings = smoothstep(0.02, 0.0, fract(d * 10.0 - uTime));
// Step 4: Mix it all together
vec3 bg = vec3(0.1, 0.1, 0.2);
vec3 circleColor = vec3(1.0, 0.3, 0.5);
vec3 ringColor = vec3(0.3, 0.5, 1.0);
vec3 color = bg;
color = mix(color, ringColor, rings * 0.5);
color = mix(color, circleColor, circle);
gl_FragColor = vec4(color, 1.0);
}This shader demonstrates the complete mental model of knowing where we are (vUv), knowing what time it is (uTime), then we use math instead of conditions, and we think in terms of distance, and finally we layer our effects with mix().
Once you internalize this framework, you'll start seeing shader effects differently and it will all sort of click. Instead of "how do I draw that," you'll think "what's the distance function for that shape, and how should it vary over time?"