Shaders: the two programs you run on the GPU
The vertex shader and fragment shader named in the flagship lesson are actual programs, written in a C-like language called GLSL and compiled to run on the GPU itself, and the three kinds of variables that connect them explain exactly how a value computed once per vertex ends up shading every pixel in between.
Shaders: the two programs you run on the GPU
The pipeline lesson named the two programmable stages — vertex shader, fragment shader — and showed you two toy examples so short they looked almost like a formality. They weren't a simplification for the sake of teaching; that really is what a shader is: a small program, written in GLSL (OpenGL Shading Language), compiled by the driver, and executed directly on the GPU's own cores, not in JavaScript, not on the CPU at all. This lesson is about what that program actually contains — the handful of concepts (attributes, uniforms, varyings) that every shader you'll ever read or write is built from.
The framing question: a vertex shader runs once per vertex and a fragment shader runs once per fragment, but a lot of visual information — a color, a lighting direction, a normal — needs to flow from the vertex stage into the fragment stage, smoothly, across every pixel of a triangle. How does data cross that gap, and what happens to it on the way?
GLSL: a real language, compiled for a different machine
GLSL looks like C: typed variables, functions, a main() entry point, curly braces, semicolons. It is not JavaScript wearing a costume — it's compiled, ahead of a draw call, into actual GPU instructions, by the graphics driver. That compile step matters because it ties directly into the WebGL state machine lesson: a vertex shader and a fragment shader are each compiled separately, then linked together into a single program object, and it's that linked program you tell WebGL's state machine to make active before a draw call. If either shader fails to compile, or the two don't agree on the varyings passing between them, linking fails and nothing draws — a very common source of a black screen.
GLSL's type system is built around vectors and matrices as first-class citizens, because that's what graphics math is made of: vec2, vec3, vec4 for 2/3/4-component vectors, mat3, mat4 for matrices, plus float, int, bool. A distinctive feature is swizzling — addressing a vector's components by name in any order or combination: somePosition.xyz, someColor.rgb, even someVec4.xy to grab just the first two components. .xyzw and .rgba are literally aliases for the same four slots; graphics code uses whichever name reads better for the data (position vs. color).
The vertex shader: inputs and one required output
The vertex shader runs once for every vertex the draw call touches. It receives two kinds of input:
- Attributes — per-vertex data pulled straight from the vertex buffers (geometry and buffers):
position,normal,uv, maybe a vertex color. A different value for every single vertex. - Uniforms — values that are constant for the entire draw call, the same for every vertex the shader runs on this frame. The matrices from the previous lesson are the canonical example:
modelViewProjectiondoesn't change from vertex to vertex, so it's a uniform, not an attribute.
The one thing a vertex shader is required to produce is gl_Position, the vertex's location in clip space — exactly the multiply you saw twice now. But it can also emit extra outputs for the fragment shader to use later, called varyings.
The fragment shader: interpolated inputs, one color output
The fragment shader runs once for every fragment the rasterizer produced — every candidate pixel a triangle covers. Its inputs are:
- The varyings the vertex shader wrote, but not the raw per-vertex values — the interpolated values for this specific fragment's position inside the triangle (more on exactly how in a moment).
- Uniforms, same ones available to the vertex shader if needed — a light color, a texture sampler, the same matrices.
Its required output is a single color: gl_FragColor (a vec4 of red, green, blue, alpha) in the WebGL1-era GLSL this module uses. That's it — one program, one number in (well, several), one color out, run millions of times a frame.
The three qualifiers: the entire mental model in one table
Everything above collapses into three keywords, and once these are automatic, GLSL stops being mysterious.
| Qualifier | Set by | Visible to | Changes how often |
|---|---|---|---|
attribute | the vertex buffers | vertex shader only | once per vertex |
uniform | JavaScript, before the draw call | both shaders | once per draw call (constant for the whole thing) |
varying | written by the vertex shader | read (interpolated) by the fragment shader | vertex shader writes once per vertex; fragment shader reads once per fragment |
varying is the one that does something genuinely interesting, and it's worth tying explicitly back to a line from the flagship lesson: rasterization interpolates the vertex attributes across the triangle's face. A varying is the mechanism that makes that interpolation something you can actually use. The vertex shader writes one value per vertex — say, a surface normal, one for each of the triangle's three corners. Those are three separate numbers. But the fragment shader doesn't run at the corners; it runs once for every one of possibly thousands of fragments inside the triangle. For each of those, the GPU doesn't just pick the nearest corner's value — it computes a weighted blend of the three corner values, weighted by how close the fragment is to each corner (technically, using the fragment's barycentric coordinates within the triangle). Dead center gets roughly an equal mix of all three; near one corner, that corner's value dominates.
A real pair, not a toy: normal-based directional shading
Here's a step up from the flat-orange-triangle example in the flagship lesson — a vertex shader that transforms position as before but also passes the vertex's normal down as a varying, and a fragment shader that uses the interpolated normal to compute simple directional lighting.
// Vertex shader
attribute vec3 position;
attribute vec3 normal;
uniform mat4 modelViewProjection;
uniform mat3 normalMatrix; // transforms normals into the same space as lightDir
varying vec3 vNormal; // written once per vertex, interpolated per fragment
void main() {
vNormal = normalMatrix * normal;
gl_Position = modelViewProjection * vec4(position, 1.0);
}// Fragment shader
precision mediump float;
varying vec3 vNormal; // the interpolated normal for THIS fragment
uniform vec3 lightDir; // constant for the whole draw call
void main() {
vec3 n = normalize(vNormal);
// dot(n, lightDir): how directly this fragment's surface faces the light.
// 1.0 = facing it head-on, 0.0 = perpendicular, negative = facing away.
float brightness = max(dot(n, lightDir), 0.0);
gl_FragColor = vec4(vec3(brightness), 1.0);
}Every fragment across a triangle's face runs the exact same two lines of math, but because vNormal is a slightly different, smoothly interpolated value at every fragment, the resulting brightness varies smoothly too — a flat triangle shaded so it appears to curve.
Three.js writes these for you (until you ask otherwise)
You will write very few raw shaders like the ones above in ordinary Three.js use. A MeshStandardMaterial, MeshPhongMaterial, or any built-in material is really a shader template: Three.js generates a full vertex shader and fragment shader pair from your material's properties plus every light in the scene, wires up the attributes from your geometry and the uniforms from your material's settings, compiles and links the program, and hands it to the state machine. That's the "generate far more elaborate versions of those two shaders" line from the flagship lesson, made concrete — a MeshStandardMaterial might generate hundreds of lines of GLSL handling multiple light types, shadows, and texture sampling, all from a JavaScript object with a color and a roughness.
When you need something the built-in materials don't offer — a custom visual effect, a procedural pattern, something driven by a value no material property exposes — Three.js gives you ShaderMaterial (which wraps your GLSL with some Three.js conveniences and default uniforms) and RawShaderMaterial (no conveniences, you supply everything, including precision and any built-in-looking uniforms yourself). Either way, you're handing Three.js the exact two strings of GLSL source shown above, and it does the compile-link-activate work through WebGL underneath.
Where this goes next
You now know what the two programs actually are, what data can move between them, and the interpolation trick that turns per-vertex numbers into a per-fragment gradient. What you haven't seen yet is how a whole scene of many meshes, lights, and a camera gets organized and re-rendered every single frame — the parent-child transform chain this lesson's normal matrix and last lesson's model matrix both depend on. That's the scene graph and the render loop.
Go deeper
- WebGL Fundamentals — Shaders and GLSL — A thorough tour of GLSL's type system, qualifiers, and the compile/link step this lesson only summarized.
- The Book of Shaders — Builds fragment-shader intuition from first principles with live, editable GLSL — the best place to actually practice writing one.
- Three.js manual — Custom BufferGeometry — Shows the attributes a ShaderMaterial expects to receive from your geometry, connecting this lesson back to the buffers lesson.
Check yourself
Answer out loud, as if an interviewer asked. If you hand-wave, reread that section.
- What language are shaders written in, and where do they actually run once compiled — be specific about what 'compiled' means here.
- Define attribute, uniform, and varying: who sets each one, which shader stage(s) can read it, and how often does its value change?
- A vertex shader writes a varying normal that differs at each of a triangle's three corners. What value does a fragment shader in the middle of that triangle actually receive, and how is it computed?
- Explain why a flat-shaded low-poly sphere would look faceted if normals were passed as uniforms instead of varyings.
- What is the one required output of a vertex shader, and the one required output of a fragment shader?
- What does it mean for Three.js to 'generate' a shader from a MeshStandardMaterial, and when would you reach for ShaderMaterial instead?
- Why does linking two separately compiled shaders into one program matter, and what happens if that link step fails?