Tutorial · Intermediate · ~50 min
Three.js · GLSL · ShaderMaterial · Uniforms

Custom ShaderMaterial in Three.js

ShaderMaterial gives full control over GLSL vertex and fragment shaders while keeping Three.js's scene graph, matrices, and geometry handling. This tutorial covers the complete workflow: uniforms, varyings, texture sampling, procedural patterns, and animated effects.

1Minimal ShaderMaterial skeleton

import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.160/build/three.module.js'; const mat = new THREE.ShaderMaterial({ vertexShader: /* glsl */ ` void main() { // modelViewMatrix, projectionMatrix, position are injected by Three.js gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); } `, fragmentShader: /* glsl */ ` void main() { gl_FragColor = vec4(0.13, 0.77, 0.37, 1.0); // solid green } `, }); const mesh = new THREE.Mesh(new THREE.SphereGeometry(1, 64, 32), mat); scene.add(mesh);
Three.js automatically prepends projectionMatrix, modelViewMatrix, normalMatrix, position, normal, uv, and uv2 declarations to ShaderMaterial shaders. RawShaderMaterial does not (you declare everything yourself).

2Uniforms and updating them

const mat = new THREE.ShaderMaterial({ uniforms: { u_time: { value: 0 }, u_color: { value: new THREE.Color(0x22c55e) }, u_scale: { value: 2.5 }, }, vertexShader: /* glsl */ ` uniform float u_time; uniform float u_scale; void main() { vec3 pos = position; pos.y += sin(pos.x * u_scale + u_time) * 0.2; // wave deformation gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0); } `, fragmentShader: /* glsl */ ` uniform vec3 u_color; void main() { gl_FragColor = vec4(u_color, 1.0); } `, }); // Update in the render loop — direct property write, no method needed renderer.setAnimationLoop(t => { mat.uniforms.u_time.value = t * 0.001; renderer.render(scene, camera); });

3Varyings: pass data vertex → fragment

const mat = new THREE.ShaderMaterial({ vertexShader: /* glsl */ ` varying vec2 vUv; varying vec3 vNormal; varying vec3 vWorldPos; void main() { vUv = uv; vNormal = normalize(normalMatrix * normal); vWorldPos = (modelMatrix * vec4(position, 1.0)).xyz; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); } `, fragmentShader: /* glsl */ ` varying vec2 vUv; varying vec3 vNormal; varying vec3 vWorldPos; void main() { // Simple Lambertian lighting vec3 lightDir = normalize(vec3(1.0, 2.0, 1.5)); float diff = max(dot(vNormal, lightDir), 0.0); // UV-based colour with lighting vec3 col = vec3(vUv, 0.5); // R=u, G=v, B=0.5 gl_FragColor = vec4(col * (0.2 + 0.8 * diff), 1.0); } `, });

4Texture sampling

const loader = new THREE.TextureLoader(); const tex = loader.load('/shared/textures/noise.png'); const mat = new THREE.ShaderMaterial({ uniforms: { u_map: { value: tex }, u_time: { value: 0 }, }, vertexShader: /* glsl */ ` varying vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); } `, fragmentShader: /* glsl */ ` uniform sampler2D u_map; uniform float u_time; varying vec2 vUv; void main() { // Scroll the UV over time vec2 scrolledUv = vUv + vec2(u_time * 0.1, 0.0); vec4 texel = texture2D(u_map, scrolledUv); gl_FragColor = texel; } `, });

5Procedural noise pattern

// Include a GLSL hash + value noise helper; no external texture needed const noiseFrag = /* glsl */ ` precision highp float; varying vec2 vUv; uniform float u_time; float hash(vec2 p) { p = fract(p * vec2(234.34, 435.345)); p += dot(p, p + 34.23); return fract(p.x * p.y); } float noise(vec2 p) { vec2 i = floor(p), f = fract(p); vec2 u = f * f * (3.0 - 2.0 * f); return mix(mix(hash(i + vec2(0,0)), hash(i + vec2(1,0)), u.x), mix(hash(i + vec2(0,1)), hash(i + vec2(1,1)), u.x), u.y); } float fbm(vec2 p) { float v = 0.0, a = 0.5; for (int i = 0; i < 5; i++) { v += noise(p) * a; p *= 2.1; a *= 0.5; } return v; } void main() { float n = fbm(vUv * 4.0 + u_time * 0.3); vec3 col = mix(vec3(0.05, 0.1, 0.2), vec3(0.1, 0.8, 0.5), n); gl_FragColor = vec4(col, 1.0); } `;

6ShaderMaterial vs RawShaderMaterial

// ShaderMaterial automatically injects: // - Built-in uniforms: modelMatrix, viewMatrix, projectionMatrix, // modelViewMatrix, normalMatrix, cameraPosition // - Built-in attributes: position, normal, uv, tangent, color // - #define directives for lights if lights:true const mat = new THREE.ShaderMaterial({ lights: true, // inject THREE_MAX_LIGHTS defines and light uniforms fog: true, // inject fog uniforms u_fogColor, u_fogNear, u_fogFar depthWrite: true, transparent: false, // ShaderMaterial can mix Three.js chunks via #include <common> etc. }); // RawShaderMaterial: NO automatic injections // You must declare precision, all uniforms, and all attributes yourself. // Use when you want the smallest possible shader with no overhead. const rawMat = new THREE.RawShaderMaterial({ vertexShader: ` precision highp float; attribute vec3 position; uniform mat4 projectionMatrix; uniform mat4 modelViewMatrix; void main() { gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); } `, fragmentShader: ` precision highp float; void main() { gl_FragColor = vec4(1.0, 0.5, 0.0, 1.0); } `, });
When debugging a shader, add console.log(mat.vertexShader) — Three.js shows the final shader source including all injected code, which makes it easier to understand what's being compiled.