Write a Shader
An editable fragment shader that recompiles as you type, with the driver’s own errors underneath.
What you should come away with: That a shader is a function from a pixel coordinate to a colour, and that the errors are readable once something shows them to you.
Assumes: Colour & Gamma. It will still make sense without it, but that one comes first.
Nine labs have handed you sliders onto shaders somebody else wrote. This one hands over the keyboard: the editor at the foot of the page holds a , and the next animation frame after you change a character compiles it and draws with the result.
What makes shaders feel hard is not the mathematics. It is that a mistake produces a black rectangle and a message in a console nobody was looking at, so the first hour goes on guessing which of thirteen lines the driver objected to. Here the compiler’s own words appear under the canvas, the line number is corrected to match your editor, and the last shader that worked stays on screen while you repair the one that does not.
The whole picture is one function, run once per pixel#
A fragment shader is a function. Its inputs are a coordinate and a few values you supply; its output is four floats written to gl_FragColor — red, green, blue and alpha. It runs once for every pixel that gets drawn, and each run knows nothing about any other: it cannot read a neighbour’s colour and has no way to loop over the image. Everything it produces comes from the coordinate it was handed.
- grid
- 20 × 11
- calls
- 220
- the canvas below
- 1,327,104
The starter is that comparison and little else. length(p) - 0.55 is negative inside the circle and positive outside, and smoothstep(-0.02, 0.02, d) turns the sign into a 0 or a 1 with a ramp four hundredths of a unit wide — the entire antialiasing of that edge. Swap it for step and the edge becomes a staircase of pixels.
The independence buys the speed: calls that cannot see each other may run in any order, so the hardware runs thousands at once. The canvas below is 768 CSS pixels across in a full-width window and the lab clamps the device-pixel ratio at two, so on a retina screen it is 1536 by 864 device pixels — 1,327,104 invocations of your function per frame, close to eighty million a second. You never write that loop; writing its body is the whole job.
There is no geometry — three vertices, and none of them are the picture#
A fragment shader has to be run over something, and in every earlier lab that something was a model — vertices, a buffer, a matrix, a camera. Here the vertex buffer holds six floats. The corners are (-1, -1), (3, -1) and (-1, 3) — one triangle, twice the width and twice the height of the screen, drawn by a single gl.drawArrays(gl.TRIANGLES, 0, 3).
vUv reaches 2 at those far corners, and no pixel you can see gets a value above 1.#A quad would do the same job with two triangles and a seam through the middle of the picture, and the 2×2 pixel blocks that seam crosses get shaded for both of them. One oversized triangle has no interior edge: one primitive, three vertices, twenty-four bytes of buffer. It is the standard shape of full-screen work — post-processing, tone mapping, a blur — where the geometry is a formality.
The vertex shader is six lines and never changes. It writes gl_Position straight from the attribute, because the attribute is already in : no model matrix, no view, no projection. Nothing from The Model Matrix applies here, because there is no model to place. Its other statement is vUv = aPosition * 0.5 + 0.5, which turns those −1…1 corners into the 0…1 the fragment stage reads.
Uniforms are the arguments, and every invocation is handed the same ones#
Four names are declared above your code and exist whether you use them or not. vUv is a : interpolated across the triangle and different in every invocation, the only input that changes from pixel to pixel. uTime, uResolution and uKnob are — one value each, set before the draw call and identical in all 1,327,104 invocations that follow. Which things vary and which do not is most of the vocabulary.
uResolution is the canvas in device pixels rather than CSS pixels, and it matters the moment you care about shape. A distance measured in vUv is stretched, because the canvas is wider than it is tall; p.x *= uResolution.x / uResolution.y undoes that, and without the line the disc arrives as an ellipse 1.78 times as wide as it is high.
- uKnob
- 1.00
- 0.08 × uKnob
- 0.080
- radius sweeps
- 0.47 – 0.63
sin(uTime), 0.08 × uKnob either side of it. At the top of the slider the circle breathes between 0.39 and 0.71; at zero both outlines land on the edge of the disc and it stops moving, because the term is still computed and then multiplied by nothing.#uKnob is a float between 0 and 2 wired to nothing in particular: multiply something by it and you have a slider onto whatever number you were about to hard-code. The rings preset spends it on ring frequency, 14.0 + uKnob * 30.0. At the top of its range the bright part of each ring is about seven tenths of a pixel wide on this canvas — narrower than the thing sampling it — so what you see is not rings but the noise of sampling them too rarely, which is Textures & Sampling arriving from the other side.
uTime is seconds since the canvas started, and animation is nothing more than a term in an expression. No state carries from one frame to the next, because there is nowhere to put it: a fragment shader cannot remember. When you need it to — a particle whose position now depends on where it was last frame — you need a buffer and a different kind of shader, which is Compute & Particles.
The compiler tells you what is wrong, once something shows you#
Break it and see; the fourth preset breaks it for you, with a vec3 assigned to a vec4 and a missing semicolon. Three things then happen that would not happen in an ordinary project. The last program that linked stays bound, so your picture survives the typo. is printed verbatim. And the line number is put back where you can use it.
The wording comes from your graphics driver and differs between machines, which is worth knowing before you paste a message into a search engine. The shapes do not differ. Nearly everything you will hit is one of three things: a missing semicolon, which the compiler cannot notice until it has read the next statement, so it names the line after the one you must change; a type that will not convert, because will not turn a vec3 into a vec4 for you; and a name that does not exist, usually a swizzle with a letter that is not in the vector.
The messages will also name things you did not write. This is GLSL ES 1.00, the dialect WebGL 1 speaks: attribute, varying, and a colour assigned to gl_FragColor. WebGL 2 spells the same ideas as in, out and an output you declare; the WebGPU labs are in . The ideas carry across, the keywords do not.
What I got wrong here: a test that passed with every canvas blank#
Every lab on this site, this one included, is loaded in a real browser by a smoke test that asks whether its canvas drew anything at all. The first version of that test took a screenshot of the page and measured how much detail the picture held. The belief underneath it was that a screenshot of a canvas shows what is on the canvas — true of nearly every element on a page, and not of this one.
The readings came back healthy, so there was nothing to look into. Headless Chromium does not composite WebGL content into a capture at all, and this site’s pages carry a blueprint grid that showed through the transparent canvas and measured as detail: the test was grading the background and reporting it as a picture. A blank lab and a working one produced the same verdict, and the verdict was pass.
Nothing on screen could have shown this, because the screen was right and the test was wrong. What caught it was mutation testing — blanking the WebGL path on purpose to see whether the suite noticed, and it did not. The test reads the canvas inside the page now, drawing it into a 2D context and counting distinct colours and the spread of luminance across twelve frames, because a lab’s own animation callback and the sampler race for position within a frame. WebGL discards its drawing buffer the moment it is composited, so the canvas component the other WebGL labs share asks for preserveDrawingBuffer only when the test sets a flag before the app loads; in normal use it is off, and free.
Fixed in commit de6a44b. What holds it now is the check the canvas actually drew something in test/render.smoke.ts, and its thresholds were measured rather than guessed: a canvas that drew nothing comes back with one colour and no spread at all, so the floors sit at three of each. The first guess at those numbers failed two healthy labs, which is the same mistake pointing the other way.
Now type into it#
The instrument below holds nothing still: four presets, the knob, the compiler’s output, and a text area that rebuilds the program on the next frame after every keystroke. It answers every question at once, which is why it cannot isolate a single one.
Start by changing a number. The 0.55 is the radius; make it 0.2 and the disc shrinks, make it 2.0 and it swallows the frame. Then delete a semicolon on purpose, so that the first time the panel turns red it is because you meant it to. Switching preset replaces what is in the editor, so copy anything you want to keep first; the share link carries the preset and the knob but not your source.
The shader is thirteen lines, the compiler answers within a frame, and a wrong guess costs you the time it takes to read one sentence. That loop — type, look, read the error, type again — is what writing shaders consists of, and it is why the ones you find in the wild are usually short.
Already declared for you: vUv, uTime, uResolution, uKnob
What the compiler said
Compiled — running above
The canvas keeps the last shader that compiled, so a typo leaves your picture on screen instead of replacing it with black. That is a deliberate choice and not how a real toolchain behaves: in a normal project a fragment shader that fails to compile gives you a blank surface and a message in a console you were not looking at, which is most of why shaders feel harder than they are.
The error text above is the graphics driver’s own, not this site’s. Line numbers are adjusted for the preamble you cannot see, so they point where you would actually look — the driver counts a few lines further down.
Where that leaves you
You can now write a fragment shader as a function from a coordinate to a colour, and read a driver’s complaint as a line number and one of three familiar shapes rather than as a wall.
This did not teach you what a shader costs. Nothing on this page measures anything, so an expression a hundred times more expensive than the one it replaced looks exactly the same while you type it — Draw Calls & Instancing is the only lab here that reports a measurement, and what it measures is the CPU.