GLSL - Shaders and the GPU

Why code that runs millions of times in parallel is written differently, and an introduction to GLSL.

glsl shaders webgl

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.

TypeHolds
floatA single decimal number, e.g. 0.75
intA whole number, e.g. 4
booltrue or false
vec2, vec3, vec42, 3 or 4 floats grouped together
mat2, mat3, mat4A square matrix, used for transforms
sampler2DA 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

FunctionDoes
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-vs-smoothstep.png

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

Resources


Source: WebGL & GLSL — A Primer by Matt DesLauriers, and its Intro to GLSL Syntax guide

-
-