Shaders — what they actually are

04-shader-basics.md · updated 2026-10-15

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.

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 mobileNo precision declared for floats in the fragment shader (required in GLSL ES)
Banding or wobbling UVs on a phone onlymediump float carrying UVs or large coordinates
Perf tanks when you add one ifDivergent branch inside a warp — both paths execute for the whole group
pow(x, y) returns NaN / black pixelsx slightly negative; clamp with max(x, 0.0) first
Jagged edge you can't antialiasHard if/step decision; use smoothstep over a pixel-wide band
Varying looks right at vertices, wrong between themSomething 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

Open the demo →

← all notes & demos