Shaders, Explained for Web Developers

A shader is a small program that runs on the GPU, once per vertex or once per pixel, in parallel across thousands of cores. That sentence is in every tutorial, and on its own it explains nothing — which is why shader code stays cryptic for people who have read it a dozen times.

The thing that makes shaders click is not syntax. It is understanding the execution model: your code runs an enormous number of times simultaneously, each copy knows only about its own pixel, none of them can talk to each other, and they all take the same path through the code whether they need to or not. Every strange rule about shaders follows from those four facts. This page is about them.

The two shaders you write

Every draw runs two programs, and they have completely different jobs.

The vertex shader runs once per vertex — per corner of every triangle. Its one required job is to output where that vertex ends up on screen. A cube has 8 corners (24 with split normals), so this runs a handful of times. On a detailed character mesh, tens of thousands.

The fragment shader runs once per fragment — roughly, per pixel the triangle covers. Its one required job is to output a colour. This is where lighting, texturing, and every interesting visual effect lives.

The count difference between them is the single most important performance fact in graphics. A full-screen quad on a 1440p display at 2× device pixel ratio is around fifteen million fragment shader invocations per frame. The vertex shader for that same quad ran four times.

So the first optimisation instinct to build is: can this move to the vertex shader? Anything that varies smoothly across a surface — a gradient, a fade based on distance, a value derived only from position — can usually be computed per-vertex and interpolated across the triangle for free. The rasteriser interpolates between vertices at no cost. Computing the same thing per-pixel means doing the work millions of times instead of thousands.

The mental model that makes it click

Four properties, and every confusing rule about shaders is downstream of one of them.

1. Your code runs millions of times, in parallel. You are not writing a program that draws a shape. You are writing the answer to a question the GPU asks about one pixel, and it asks a few million of those simultaneously. Stop thinking "draw a circle" and start thinking "given this pixel's coordinate, is it inside the circle?" That reframing is most of the battle.

2. Each invocation is blind. A fragment shader cannot see the pixel next to it, cannot see the previous frame, and cannot accumulate anything into a shared variable. There is no for each pixel loop you are inside of. Effects that genuinely need neighbouring pixels — blur, edge detection — are done by rendering to a texture first and sampling that texture in a second pass.

3. Branching is not free. A GPU executes threads in lockstep groups. If some pixels in a group take the if and others take the else, the hardware runs both branches for the whole group and discards the results that do not apply. A branch on something that varies per-pixel costs you both sides. This is why shader code leans on mix(), step() and smoothstep() instead of if: they are branchless arithmetic that produces the same result.

// Costs both paths when the condition varies across the group
if (dist < 0.5) { color = red; } else { color = blue; }

// Branchless: one arithmetic path, always
color = mix(red, blue, step(0.5, dist));

4. There is no state between frames. Every frame starts from nothing. Anything that needs to persist — a simulation, a trail, an accumulation — has to be stored in a texture or buffer you read next frame. That constraint is exactly what compute shaders in WebGPU exist to make bearable.

Coordinate spaces, where everyone gets lost

More shader bugs come from being in the wrong coordinate space than from any other cause. There are five that matter, and the vertex shader's job is walking a position through them.

Space What it means
Model / local Coordinates as authored, relative to the object's own origin
World After the object's position, rotation and scale are applied
View Relative to the camera — camera at the origin, looking down −Z
Clip After the projection matrix; what the vertex shader must output
Screen Pixels, after the GPU divides by w and maps to the viewport

The classic vertex shader line is just that walk, right to left:

gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);

Two rules worth memorising, because they cause the two most common visual bugs:

Lighting must happen in a consistent space. Mixing a world-space light direction with a view-space normal produces lighting that swims as the camera moves — a bug that looks like a lighting problem and is actually a space problem.

Normals do not transform like positions. Under non-uniform scale, transforming a normal by the model matrix skews it and your lighting goes wrong. Normals need the inverse transpose — which is what Three.js's normalMatrix is. If your lighting breaks only on scaled objects, this is why.

Then there is UV space: the 2D coordinates, conventionally 0–1, that map a texture onto a surface. Most shader effects are ultimately arithmetic on UVs. Distort the UV before sampling and you get warping, ripples, and most screen-space effects.

Reading your first shader

Here is a complete, minimal fragment shader. It is worth reading line by line, because the shape is universal.

varying vec2 vUv;         // interpolated from the vertex shader
uniform float uTime;      // same for every pixel, set from JavaScript
uniform vec3 uColor;

void main() {
  // Distance from this pixel to the centre of the surface
  float dist = distance(vUv, vec2(0.5));

  // A soft circle: 1 inside, 0 outside, smooth at the boundary
  float circle = 1.0 - smoothstep(0.3, 0.32, dist);

  // Animate by pulsing the radius over time
  circle *= 0.75 + 0.25 * sin(uTime * 2.0);

  gl_FragColor = vec4(uColor * circle, 1.0);
}

Three kinds of input, and knowing which is which is most of reading shader code:

  • uniform — a value from JavaScript, identical for every invocation this draw. Time, a colour, a texture, the camera position.
  • varying (called in/out in newer GLSL) — a value the vertex shader wrote, interpolated across the triangle by the rasteriser. Different for every pixel, free to produce.
  • attribute — per-vertex data from the geometry buffers. Vertex shader only.

And note what the code does not do: there is no loop over pixels, and no branch. smoothstep draws the circle's edge, and a multiply animates it. That is idiomatic shader code — arithmetic on coordinates, not control flow.

Where custom shaders actually pay off

Custom shaders are a cost: they are harder to write, harder to debug, harder to hand over, and they bypass the material system's features unless you re-implement them. Worth it in these cases, and often not otherwise.

Effects with no material equivalent. Dissolves, holograms, energy fields, stylised outlines, water, terrain blending, anything non-photorealistic. A standard PBR material has no path to these.

Animation the GPU should own. Moving thousands of vertices — grass, cloth ripple, waves, morphing — in a vertex shader rather than updating positions in JavaScript. This is often the difference between fifteen and sixty frames per second, because the CPU stops touching per-vertex data at all.

Weight, when weight matters. A generated shader costs kilobytes where an equivalent set of textures costs megabytes. Procedural noise, gradients and patterns download as almost nothing. That is the argument the hero on this site's interactive 3D page makes.

Data-driven visuals. Colouring a hundred thousand instances by a value, in one draw call, with no CPU involvement.

Where they do not pay off: matching a photographed material, standard lighting on a standard object, or anything the built-in materials already do. Reaching for a custom shader to reproduce MeshStandardMaterial badly is a common and expensive mistake.

GLSL, WGSL, or TSL?

There are now three answers to "which language do I write this in", and the choice matters more than it used to.

GLSL is what WebGL runs. Every tutorial, every Shadertoy example, every Book of Shaders chapter is GLSL. It is the lingua franca, and it is what you will be reading regardless of what you write.

WGSL is what WebGPU runs. Newer, stricter, Rust-flavoured, no preprocessor. Better designed, with far less existing material to learn from.

TSL — the Three.js Shading Language — is neither. You describe the shader as a graph of nodes in JavaScript, and Three.js compiles it to WGSL or GLSL depending on which backend is running. One source, both APIs.

For work that ships to real users, TSL is what I now reach for, because it makes the WebGPU-or-WebGL question a runtime detail rather than a commitment. For learning, GLSL is still the better first language — the material available is vastly deeper, and the concepts are identical either way.

The practical route is to learn the ideas in GLSL and then move production work to TSL. If you already have GLSL you want to bring across, the GLSL to TSL converter on this site does the mechanical part.

How to actually learn this

The failure mode is reading shader tutorials and never writing one. Shader code is not learnable by reading, because the feedback loop — change a number, see the picture change — is the entire teaching mechanism.

What works, roughly in order:

  1. Draw shapes with maths, in 2D, before touching 3D. A circle, a rectangle, a grid, a ring. This teaches the "answer a question about one pixel" reframing better than anything else, and it is where step and smoothstep stop being mysterious.
  2. Then noise. Value noise, then gradient noise, then fractal noise (several octaves summed). Almost every organic-looking shader effect is noise plus a colour ramp. This is the highest-leverage single topic.
  3. Then move to 3D, where the same tools apply to surfaces instead of a flat plane.
  4. Change one thing at a time and look. Multiply a UV by 5. Add time to it. Feed the result into a colour. The intuition comes from watching what each change does, not from understanding it in advance.

Read other people's shaders constantly — Shadertoy is unmatched for this, and much of it is GLSL you can paste and modify.

When you want structure rather than self-direction, the TSL courses on this site take the node-based route from first principles, and the Living Planet course builds one shader up in small steps, which is the format I would have wanted when I was learning this.

F.A.Q

Frequently asked questions

What is the difference between a vertex shader and a fragment shader?

The vertex shader runs once per vertex and outputs where that vertex lands on screen; the fragment shader runs once per covered pixel and outputs a colour. The fragment shader runs vastly more often — millions of times per frame on a full-screen effect versus a handful for the vertex shader — which is why moving work from fragment to vertex is one of the biggest performance levers available.

Do I need to be good at maths to write shaders?

You need comfort with vectors, dot products and basic trigonometry — roughly school-level, applied. You do not need calculus or linear algebra theory. What matters far more than mathematical depth is the reframing: thinking in terms of "what colour is this one pixel" rather than "draw this shape". People who get stuck are usually stuck on that, not on the arithmetic.

Why does my shader look different on mobile?

Usually float precision. Mobile GPUs default to mediump in fragment shaders, which has far less range and precision than desktop highp, so large coordinates, accumulated values, or high-frequency noise band or break up. Declare precision explicitly and test on a real device — a desktop browser in device-emulation mode uses the desktop GPU and will not reproduce it.

Can I use if-statements in a shader?

Yes, but understand the cost. GPUs execute threads in lockstep groups, so when a condition varies across a group both branches run and the unused results are discarded. A branch on a uniform is fine — every thread agrees. A branch on a per-pixel value costs you both sides, which is why idiomatic shader code prefers mix, step and smoothstep.

How do I debug a shader?

You cannot set a breakpoint, so you output values as colour. Assign the variable you are suspicious of to the output and look at the picture: is the UV a red-green gradient as expected, is the normal pointing where you think, is that float in 0–1 or wildly outside it. Binary-search by commenting out sections. It is crude and it is genuinely how everyone does it.

Should I learn GLSL or WGSL first?

GLSL, for learning. The available material — Shadertoy, The Book of Shaders, a decade of tutorials — is vastly deeper, and the concepts transfer completely. For production work aimed at both WebGL and WebGPU, write in TSL instead, which compiles to both. WGSL is worth reading eventually, particularly for compute work.

How do I add a custom shader to a Three.js material?

Three ways, increasingly modern. ShaderMaterial gives you full control and no built-in lighting. onBeforeCompile lets you patch the built-in material shaders with string replacement, which works and is fragile. Node materials with TSL let you assign node graphs to specific slots — colorNode, positionNode — keeping the built-in lighting while replacing the part you care about. The third is what I would use on new work.

Are shaders bad for performance?

They are usually the opposite — a shader replaces megabytes of textures with kilobytes of maths, and moves per-vertex animation off the CPU entirely. What is bad for performance is an expensive fragment shader running over a full screen at high pixel density, particularly with several stacked transparent layers. Cost scales with pixels covered, not with how clever the code is.

Try it

Tools and courses on this site

The next step is writing one

Shaders are not learnable by reading — the change-a-number-and-look loop is the whole teaching mechanism. TSL is the version of that loop that runs on both WebGL and WebGPU.

Read the TSL guide

Keep reading

Would rather have this built than build it? Interactive 3D Website Development is the service page for it, or see everything I do.