Tutorial
⏱️ ~50 minutes 🎓 Intermediate 🛠️ GLSL · Three.js · ShaderMaterial

WebGL Shaders Intro — GLSL Vertex & Fragment

Shaders are small programs that run on the GPU for every vertex and every pixel. This tutorial teaches GLSL from scratch: built-in variables, types, varyings, uniforms, and how to achieve vertex displacement and coloured gradients using Three.js ShaderMaterial.

Prerequisites

GLSL Data Types Quick Reference

GLSL is a C-like language. It has scalar, vector and matrix types. These are the ones you'll use most:

Type Description Example
float 32-bit floating point float t = 2.0;
int Integer (use floats for arithmetic) int i = 3;
bool Boolean bool flag = true;
vec2 2D vector vec2 uv = vec2(0.5, 0.5);
vec3 3D vector (also colour RGB) vec3 pos = position;
vec4 4D vector (RGBA, homogeneous coords) gl_FragColor = vec4(1,0,0,1);
mat3 3×3 matrix mat3 m = mat3(1.0); (identity)
mat4 4×4 matrix Projection/model matrices
sampler2D 2D texture handle texture2D(uTex, vUv)

Component access uses .xyzw or .rgba (aliases): vec3 c = vec3(1,0.5,0); c.r = 1.0; c.xy = vec2(0, 1);

Swizzling lets you reorder: vec4 v = pos.xyzz; or vec2 flip = v.yx;

Your First ShaderMaterial

A ShaderMaterial expects two GLSL strings: a vertex shader (runs per vertex) and a fragment shader (runs per pixel/fragment).

import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.168/build/three.module.js';

const material = new THREE.ShaderMaterial({
  vertexShader: /* glsl */`
    void main() {
      // projectionMatrix * modelViewMatrix = MVP matrix
      gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
    }
  `,
  fragmentShader: /* glsl */`
    void main() {
      // gl_FragColor = vec4(R, G, B, A)
      gl_FragColor = vec4(0.2, 0.6, 1.0, 1.0); // solid blue
    }
  `,
});

const mesh = new THREE.Mesh(new THREE.SphereGeometry(1, 32, 32), material);
scene.add(mesh);

Three.js automatically injects built-in uniforms into your shader: projectionMatrix, modelViewMatrix, modelMatrix, viewMatrix, normalMatrix. Built-in attributes: position, normal, uv, color.

Varyings — Passing Data Vert → Frag

The vertex shader outputs a varying variable; the GPU interpolates it across the triangle and the fragment shader receives the interpolated value:

const material = new THREE.ShaderMaterial({
  vertexShader: /* glsl */`
    varying vec2 vUv;     // ← declare as varying (output)
    void main() {
      vUv = uv;           // uv is a built-in attribute (0–1 over surface)
      gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
    }
  `,
  fragmentShader: /* glsl */`
    varying vec2 vUv;     // ← same varying (input, now interpolated)
    void main() {
      // vUv.x = 0 on left edge, 1 on right
      // vUv.y = 0 on bottom, 1 on top
      gl_FragColor = vec4(vUv.x, vUv.y, 0.5, 1.0);
    }
  `,
});

You'll see a gradient: red increases left→right, green increases bottom→top, blue is constant 0.5. This is the foundation of all UV-based texturing and gradients.

Uniforms — Time-Driven Animation

uniform variables come from JavaScript and are the same for every vertex/fragment in a single draw call:

const material = new THREE.ShaderMaterial({
  uniforms: {
    uTime:  { value: 0.0 },
    uColor: { value: new THREE.Color(0x00ffcc) },
  },
  vertexShader: /* glsl */`
    uniform float uTime;
    varying vec2 vUv;
    void main() {
      vUv = uv;
      gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
    }
  `,
  fragmentShader: /* glsl */`
    uniform float uTime;
    uniform vec3 uColor;
    varying vec2 vUv;
    void main() {
      float pulse = 0.5 + 0.5 * sin(uTime * 2.0 + vUv.y * 6.28);
      gl_FragColor = vec4(uColor * pulse, 1.0);
    }
  `,
});

// Update in animate():
material.uniforms.uTime.value = time * 0.001; // convert ms to seconds

Vertex Displacement

The vertex shader can modify the mesh shape at runtime — this runs entirely on the GPU and costs nothing on the CPU:

vertexShader: /* glsl */`
  uniform float uTime;
  varying vec3 vNormal;

  void main() {
    vNormal = normal;

    // Displace along normal using sine wave
    float freq = 3.0;
    float amp  = 0.15;
    float wave = sin(position.y * freq + uTime * 2.0)
               * cos(position.x * freq + uTime * 1.5);
    vec3 displaced = position + normal * amp * wave;

    gl_Position = projectionMatrix * modelViewMatrix * vec4(displaced, 1.0);
  }
`

Important: vertex displacement doesn't update the mesh's normals for lighting. For correct lighting on displaced geometry, recalculate normals in the shader or use MeshNormalMaterial to debug the normals first.

Fragment Colour Techniques

Useful GLSL built-in functions for colouring:

Function What it does Range in
sin(x) / cos(x) Oscillator. Combine with uTime to animate. ℝ → [-1,1]
smoothstep(e0, e1, x) Smooth 0→1 between e0 and e1. Great for edges. [e0,e1] → [0,1]
mod(x, y) x modulo y. Creates repeating patterns. ℝ → [0,y)
fract(x) Fractional part of x. fract(uv*10.) → tiling. ℝ → [0,1)
mix(a, b, t) Linear interpolation. mix(red, blue, vUv.x). t ∈ [0,1]
length(v) Magnitude of vector. For radial patterns. vec → float ≥ 0
dot(a, b) Dot product. Used for diffuse lighting. vec, vec → float
normalize(v) Unit vector. Direction without magnitude. vec → unit vec

Example — radial gradient with animated pulse:

fragmentShader: /* glsl */`
  uniform float uTime;
  varying vec2 vUv;
  void main() {
    vec2 centered = vUv - 0.5;       // shift origin to center
    float dist = length(centered);   // 0 at center, 0.5 at edge

    float rings = sin(dist * 20.0 - uTime * 3.0); // animated rings
    float brightness = smoothstep(-1.0, 1.0, rings);

    vec3 col = mix(vec3(0.0, 0.1, 0.3), vec3(0.0, 0.8, 1.0), brightness);
    gl_FragColor = vec4(col, 1.0);
  }
`

Complete Wave Shader

Putting everything together — a sphere with animated vertex displacement and matching fragment colour:

<script type="module">
import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.168/build/three.module.js';

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
renderer.outputColorSpace = THREE.SRGBColorSpace;
document.body.appendChild(renderer.domElement);

const scene = new THREE.Scene();
scene.background = new THREE.Color(0x04080f);
const camera = new THREE.PerspectiveCamera(60, innerWidth / innerHeight, 0.01, 100);
camera.position.set(0, 0, 3);

const mat = new THREE.ShaderMaterial({
  uniforms: { uTime: { value: 0 } },
  vertexShader: /* glsl */`
    uniform float uTime;
    varying float vDisplace;
    void main() {
      float wave = sin(position.y * 4.0 + uTime * 2.0)
                 * cos(position.x * 3.0 + uTime * 1.5);
      vDisplace = wave; // pass to fragment
      vec3 newPos = position + normal * 0.12 * wave;
      gl_Position = projectionMatrix * modelViewMatrix * vec4(newPos, 1.0);
    }
  `,
  fragmentShader: /* glsl */`
    varying float vDisplace;
    void main() {
      float t = 0.5 + 0.5 * vDisplace;
      vec3 col = mix(vec3(0.0, 0.2, 0.5), vec3(0.1, 0.9, 0.7), t);
      gl_FragColor = vec4(col, 1.0);
    }
  `,
});

const mesh = new THREE.Mesh(new THREE.SphereGeometry(1, 64, 64), mat);
scene.add(mesh);

window.addEventListener('resize', () => {
  camera.aspect = innerWidth / innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(innerWidth, innerHeight);
});

let prev = performance.now();
(function loop(t) {
  requestAnimationFrame(loop);
  mat.uniforms.uTime.value = t * 0.001;
  mesh.rotation.y += (t - prev) / 1000 * 0.3;
  prev = t;
  renderer.render(scene, camera);
})(performance.now());
</script>

Continue Learning

🛠

Experiment in Playground

Write, run and tweak Three.js / GLSL shaders directly in your browser — no setup required.

Open Playground → View Simulation ↗