Shaders — what they actually are
A shader is a tiny program the GPU runs once per thing: once per vertex, or once per fragment (a candidate pixel). You don't write the loop over pixels — you write the body of it, and the hardware runs millions of copies at once. Each copy knows only its own inputs: it can't see its neighbours, can't read the pixel it's about to write, and can't leave a note for the next one.
The mental shift
Stop thinking "for each pixel, do X". Write X.
A CPU blitter loops: for y, for x: dst[y][x] = f(x,y). A fragment shader is f — the GPU supplies the coordinate and collects the colour. That's why shader code is so short: the bookkeeping is gone, only the formula is left. It's also why "read the pixel next door" is a hard problem, not a line of code.
Three ways data gets in
Attributes change per vertex, uniforms change per draw, varyings are attributes smeared across the triangle.
- Attribute — per-vertex input from a buffer: position, normal, UV, colour. Vertex shader only.
- Uniform — constant for the whole draw call: matrices, light position, time, texture handles. Both stages read it; nobody writes it.
- Varying (modern name: stage output/input) — a value the vertex shader writes and the rasteriser interpolates across the triangle before handing it to each fragment. Three vertex UVs become a smooth UV field over the face.
That interpolation is the trick behind smooth shading, texturing and gradients alike: specify three corners, get every pixel between them free.
Why branches are expensive
A warp votes, then the whole group pays for every branch any member took.
Fragments execute in lockstep groups — 32 threads (a warp on NVIDIA) or 64 (a wavefront on AMD) sharing one instruction pointer. If half the group takes the if and half the else, the hardware runs both sides and masks off the lanes that shouldn't be affected. Cost is the sum, not the average; a data-dependent loop costs the longest trip any lane takes.
So: branching on a uniform is free (every lane agrees), branching on per-pixel data is not, and arithmetic usually wins. mix(a, b, step(0.5, t)) beats if (t > 0.5), and smoothstep in place of step gives an antialiased edge the if can't express at all. The exception is a branch that skips genuinely expensive work and is spatially coherent, so whole tiles agree.
Vectors, swizzles, precision
Vectors are first-class; .xyzw / .rgba reorder them for free.
vec2/3/4 have componentwise + - * /. Swizzling reads any components in any order — c.rgb, c.bgr, n.xxx, even v.xy = v.yx — and costs nothing; it's register naming. Constructors are forgiving: vec4(uv, 0.0, 1.0), vec3(0.5).
Precision qualifiers matter on mobile: mediump is half-float — fine for colours, visibly wrong for UVs on a big texture or for world-space positions. Desktop usually ignores them; phones don't.
Demo
Live fragment-shader playground — switch snippets and watch the source change beside the picture; the sliders are literally two uniforms. The last snippet draws the same stripes with if and with mix.
Gotchas
| Symptom | Cause |
|---|---|
| Shader compiles on desktop, fails on mobile | No precision declared for floats in the fragment shader (required in GLSL ES) |
| Banding or wobbling UVs on a phone only | mediump float carrying UVs or large coordinates |
Perf tanks when you add one if | Divergent branch inside a warp — both paths execute for the whole group |
pow(x, y) returns NaN / black pixels | x slightly negative; clamp with max(x, 0.0) first |
| Jagged edge you can't antialias | Hard if/step decision; use smoothstep over a pixel-wide band |
| Varying looks right at vertices, wrong between them | Something non-linear was interpolated (a normalised vector, a reciprocal); renormalise, or interpolate the pre-divide value |
| Uniform "doesn't update" | Location looked up against a since-recompiled program, or optimised out because it's unused |