Introduction to Shaders in Three.js

Understanding the graphics pipeline and how to write your first shader.

Let's talk about Shaders!

(I'm so excited. Shaders are my favorite!)

Let's start by explaining what they are. Shaders are small programs that run directly on your GPU. Unlike JavaScript, which runs on your CPU one instruction at a time, shaders execute in parallel across many, many GPU cores simultaneously. This massive parallelism is what makes real-time 3D graphics possible.

In Three.js, every material you use, whether it's MeshBasicMaterial, MeshStandardMaterial, etc., it is actually compiled into shaders behind the scenes.

When you write custom shaders, you're taking direct control of how your geometry is positioned and how every pixel is colored!

There are two types of shaders you need to understand. First there is the Vertex Shader and then there is the Fragment Shader. They work together as a pipeline, with the vertex shader running first and its output feeding into the fragment shader.

The Vertex Shader

The vertex shader is responsible for positioning individual vertices in 3D space. Every vertex in your geometry passes through this shader exactly once. Its primary job is to transform vertex positions from model space (local coordinates) through world space and view space into clip space (the final 2D coordinates on your screen).

The Vertex Shader is where you handle:

  • Displacement: Moving vertices based on noise, time, or other data to create waves, terrain, or animated effects.
  • Projection: The mathematical transformation that converts 3D coordinates into 2D screen positions.
  • Passing data to the fragment shader: Any information you want to use for coloring (like UVs, normals, or custom values) must be passed through the vertex shader.

The minimum output of a vertex shader is gl_Position, a 4-component vector representing the final clip-space position of the vertex.

glsl
void main() {
  // Transform the vertex position to clip space
  gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}

The Fragment Shader

The fragment shader runs after the vertex shader and is responsible for the color of every single pixel (technically called a "fragment") that falls between your vertices. While the vertex shader runs once per vertex, the fragment shader runs once per pixel, which translates into potentially millions of times per frame.

The Fragment Shader is where you control:

  • Color: The RGB values of each pixel.
  • Brightness: How light or dark the surface appears.
  • Texture sampling: Reading color data from images and applying it to the surface.
  • Lighting calculations: Computing how light interacts with the surface.
  • Alpha/transparency: How see-through each pixel is.

The output of a fragment shader is gl_FragColor (or in modern GLSL, an out variable), a 4-component vector representing RGBA color.

glsl
void main() {
  // Output a solid red color
  gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
}

Vertex vs Fragment

The best metaphor that I've come across is that you can think of the relationship between these two shaders like building a house.

The Vertex Shader is the architect who draws the blueprints and creates the structure. Where the walls meet, where the roof peaks, and where the foundation sits. Without the architect's work, there's no shape to work with.

The Fragment Shader is the interior designer who decides what color to paint the walls and where the lights go. The designer fills in every surface with color, texture, and atmosphere.

You can't paint walls that don't exist (fragment shader needs vertex shader output), and blueprints alone don't make a livable space (vertices without fragments would be invisible). They're partners in a strict sequence. Architect (Vertex Shader) goes first, and then the designer (Fragment Shader) goes second.

Uniforms and Attributes are the bridge

Shaders run on the GPU, but your application logic lives in JavaScript on the CPU. Data needs to flow from one to the other. This happens through two mechanisms called Uniforms and Attributes.

Uniforms are constant values

Uniforms are values that stay the same for every vertex and every fragment during a single draw call. They're "uniform" across the entire mesh.

Common uniforms include:

  • Time: For animations (float)
  • Resolution: Screen dimensions (vec2)
  • Colors: Theme colors or tints (vec3)
  • Matrices: Camera and model transformations (mat4)
  • Textures: Images to sample from (sampler2D)

Three.js automatically provides several built-in uniforms like projectionMatrix, modelViewMatrix, and cameraPosition. You can also define your own.

javascript
// In JavaScript
const material = new THREE.ShaderMaterial({
  uniforms: {
    uTime: { value: 0.0 },
    uColor: { value: new THREE.Color(0x00ff00) }
  },
  vertexShader: vertexShader,
  fragmentShader: fragmentShader
});

// In your animation loop
material.uniforms.uTime.value = elapsedTime;
glsl
// In GLSL (accessible in both vertex and fragment shaders)
uniform float uTime;
uniform vec3 uColor;

Attributes are per-vertex data

Attributes are values that differ for each vertex. They're stored in the geometry's buffer and can include stuff like:

  • position: The vertex's XYZ coordinates (vec3)
  • normal: The direction the surface faces (vec3)
  • uv: Texture coordinates (vec2)
  • color: Per-vertex colors (vec3)
  • custom attributes: Any per-vertex data you need

Attributes are only available in the vertex shader. If you need attribute data in the fragment shader, you must pass it through using a varying (or in modern GLSL, an out/in pair).

glsl
// Vertex shader
attribute vec3 position;  // Built-in: vertex position
attribute vec2 uv;        // Built-in: texture coordinates
attribute vec3 normal;    // Built-in: surface normal

varying vec2 vUv;         // Pass UV to fragment shader

void main() {
  vUv = uv;  // Copy attribute to varying
  gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
glsl
// Fragment shader
varying vec2 vUv;  // Receive UV from vertex shader

void main() {
  // Use the interpolated UV coordinates
  gl_FragColor = vec4(vUv, 0.0, 1.0);
}
←   HTML overlays and labelsHow to think about creating shaders   →