GLSL - Shaders and the GPU
Why code that runs millions of times in parallel is written differently, and an introduction to GLSL.
A shader is a small program that runs on the GPU. It is written in GLSL, which looks like C, and it runs once for every vertex or every pixel — millions of times per frame, all at once.
That last part is why shaders are strange to write. Almost every unfamiliar thing about GLSL follows from the execution model rather than from the language design.
Where This Fits
The GLSL layer, engine-agnostic. A shader written against these rules runs the same wherever it is compiled, and this is the layer you keep writing by hand no matter how much else is abstracted away.
Assumes nothing. Read before Vertex and Fragment Shaders and Attributes, Uniforms and Varyings.
One Program, Millions of Invocations
Ordinary code runs once and works through its data in a loop. A shader is the inside of that loop, and the GPU runs every iteration simultaneously across thousands of cores.
You never write the loop. A fragment shader does not iterate over pixels — it is one pixel, with a million other copies running right now on the others.
This buys throughput and costs three things.
No access to neighbours. Your invocation cannot see what any other computed, because they run at the same time. There is no “what colour is the pixel to my left”. Anything about elsewhere has to be derived from the coordinates you were given.
No memory between frames. Each invocation starts fresh. If an effect depends on history, that history has to be passed in from outside or encoded into a texture and read back.
Branching is not free. When invocations running together take different paths through an if, the hardware often runs both and discards one. This is why shader code leans on functions that blend smoothly rather than conditionals that switch.
Once “I am a single pixel and I know nothing but my own coordinates” is instinctive, the rest is arithmetic.
The Shape of a GLSL Program
Every shader has a main that returns nothing:
void main () {
// work out this invocation's result
}
main takes no arguments. Input arrives through variables declared outside it, and output is written to a built-in variable rather than returned.
Types
GLSL is statically typed and every declaration states its type.
| Type | Holds |
|---|---|
float | A single decimal number, e.g. 0.75 |
int | A whole number, e.g. 4 |
bool | true or false |
vec2, vec3, vec4 | 2, 3 or 4 floats grouped together |
mat2, mat3, mat4 | A square matrix, used for transforms |
sampler2D | A handle to a texture |
There is no automatic conversion. float a = 1; is an error, because 1 is an int and nothing will quietly make it a float. Write 1.0. This catches people constantly and the compiler error rarely says so.
Vectors Are First-Class
A vec3 is a primitive with arithmetic defined on it, not an array or a struct. Constructors take other vectors as long as the component counts add up:
float alpha = 0.5;
vec3 rgb = vec3(1.0); // (1, 1, 1) — one value fills all three
vec4 myRGBA = vec4(rgb, alpha); // (1, 1, 1, 0.5)
Components have several interchangeable names: .xyzw for positions, .rgba for colours, .stpq for texture coordinates. .x and .r are the same component; picking the set that matches your intent makes shaders readable later.
Pulling components out in any order or repetition is swizzling:
vec3 bgr = colour.bgr; // reversed
vec3 grey = colour.rrr; // red channel copied to all three
vec2 flipped = uv.yx; // axes swapped
Arithmetic is componentwise, and multiplying a vector by a single float scales all of it.
The Built-in Functions
| Function | Does |
|---|---|
mix(a, b, t) | Blends between a and b by t — linear interpolation |
step(edge, x) | 0.0 below the edge, 1.0 above — a hard cutoff |
smoothstep(a, b, x) | Like step but with a smooth ramp between a and b |
clamp(x, lo, hi) | Constrains x to a range |
fract(x) | The fractional part — the basis of tiling and repetition |
distance(a, b) | Distance between two points |
normalize(v) | Scales a vector to length 1, keeping its direction |
dot(a, b) | Dot product — how aligned two directions are |
step and smoothstep applied to the same threshold, on the same distance field:
float dist = distance(vUv, vec2(0.5));
float threshold = 0.3;
float hard = step(dist, threshold);
float soft = smoothstep(threshold, threshold - 0.02, dist);
float shape = vUv.x < 0.5 ? hard : soft;
gl_FragColor = vec4(mix(vec3(0.08), vec3(0.31, 0.79, 0.69), shape), 1.0);

step on the left gives a jagged edge, because every pixel is fully inside or fully outside. smoothstep on the right ramps across a narrow band and the edge resolves. Neither uses a branch to draw the circle.
This is the pattern most shader logic takes: compute both outcomes, then blend by a factor between 0 and 1, rather than choosing with an if.
See Also
- Vertex and Fragment Shaders — the two stages a shader can be written for
- Attributes, Uniforms and Varyings — how data gets into a shader
- Waves and Noise — the functions most generative shaders are built from
Resources
- The Book of Shaders — the standard introduction, best read alongside its live editor
- Shaping Functions —
step,smoothstepand friends, visualised - Shaderific GLSL reference — types and built-in function signatures
Source: WebGL & GLSL — A Primer by Matt DesLauriers, and its Intro to GLSL Syntax guide