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.
- Completed the Three.js Basics tutorial (or equivalent experience)
- Basic algebra — dot products and normalisation will appear
- No prior GLSL knowledge needed
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>