Tutorial · Intermediate · ~50 min
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.