Custom Shaders for Stunning 3D Visual Effects
Three.js is an incredibly powerful library for 3D graphics in the browser, and while its built-in materials (like MeshStandardMaterial or MeshPhongMaterial) are great for most use cases, sometimes you need more control and creativity.
That’s where shaders come in — they let you directly control how vertices and pixels (fragments) are processed on the GPU. In this tutorial, we’ll explore WebGL shaders in Three.js, focusing on how to create custom shader materials, understand the vertex and fragment shaders, and apply them to 3D objects with examples.
What Are Shaders?
Shaders are small programs written in GLSL (OpenGL Shading Language). They run on the GPU to control how 3D objects are drawn.
There are two main shader types:
- Vertex Shader – modifies vertex positions (shape, movement, deformation).
- Fragment Shader – controls pixel color (lighting, patterns, textures, etc).
In Three.js, you use shaders via the THREE.ShaderMaterial or THREE.RawShaderMaterial classes.
Setting Up the Scene
Let’s begin with a simple Three.js scene setup that we’ll reuse across examples.
HTML
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Mastering WebGL Shader Materials in Three.js | Dev School</title>
<style>body { margin: 0; }</style>
</head>
<body>
<script type="x-shader/x-vertex" id="vertexShader">
...
</script>
<script type="x-shader/x-fragment" id="fragmentShader">
...
</script>
<canvas id="shaderCanvas"></canvas>
<script type="module" src="script.js"></script>
</body>
</html>JavaScript
import * as THREE from 'https://unpkg.com/three@0.158.0/build/three.module.js';
const scene = new THREE.Scene();
scene.background = new THREE.Color(0xdddddd);
const camera = new THREE.PerspectiveCamera(75, window.innerWidth/window.innerHeight, 0.1, 1000);
const renderer = new THREE.WebGLRenderer({ canvas: document.getElementById('shaderCanvas') });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
camera.position.z = 3;
// Animation loop
function animate() {
requestAnimationFrame(animate);
renderer.render(scene, camera);
}
animate();
Now that we have a basic scene and camera, let’s move into shaders.
Example 1: Solid Color Shader
We’ll start simple — a sphere that uses a shader to display a solid color.
Vertex Shader
// vertex.glsl
void main() {
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
Fragment Shader
// fragment.glsl
void main() {
gl_FragColor = vec4(0.2, 0.7, 1.0, 1.0); // light blue
}
JavaScript
const geometry = new THREE.SphereGeometry(1, 32, 32);
const material = new THREE.ShaderMaterial({
vertexShader: document.getElementById('vertexShader').textContent,
fragmentShader: document.getElementById('fragmentShader').textContent,
});
const sphere = new THREE.Mesh(geometry, material);
scene.add(sphere);
This is the simplest shader — every pixel is painted with a uniform color.
Example 2: Animated Gradient Shader
Let’s make things more dynamic by introducing time and color blending.
Vertex Shader
uniform float uTime;
void main() {
vec3 newPosition = position;
newPosition.z += sin(position.x * 5.0 + uTime) * 0.1;
gl_Position = projectionMatrix * modelViewMatrix * vec4(newPosition, 1.0);
}
Fragment Shader
uniform float uTime;
void main() {
vec3 color = vec3(0.5 + 0.5 * sin(uTime), 0.5, 1.0);
gl_FragColor = vec4(color, 1.0);
}
JavaScript
const vertexShaderCode = document.getElementById('vertexShader').textContent;
const fragmentShaderCode = document.getElementById('fragmentShader').textContent;
const clock = new THREE.Clock();
const material = new THREE.ShaderMaterial({
uniforms: {
uTime: { value: 0.0 },
},
vertexShader: vertexShaderCode,
fragmentShader: fragmentShaderCode,
});
const plane = new THREE.Mesh(new THREE.PlaneGeometry(2, 2, 64, 64), material);
scene.add(plane);
function animate() {
requestAnimationFrame(animate);
material.uniforms.uTime.value = clock.getElapsedTime();
renderer.render(scene, camera);
}
animate();
Here, we added:
- A
uTimeuniform that animates both vertex displacement and color. - A sine wave motion on the Z-axis to create gentle surface ripples.
- A dynamic color that shifts over time — think of a shimmering lake surface.
Example 3: Lighting-like Effect (Fake Normal-Based Shader)
Let’s create a material that reacts to surface direction, simulating basic lighting.
Vertex Shader
varying vec3 vNormal;
void main() {
vNormal = normalize(normalMatrix * normal);
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
Fragment Shader
varying vec3 vNormal;
void main() {
float intensity = dot(vNormal, vec3(0.0, 0.0, 1.0));
vec3 color = vec3(0.3, 0.5, 1.0) * intensity;
gl_FragColor = vec4(color, 1.0);
}
Explanation:
- We pass
vNormalfrom vertex to fragment shader. - Using the dot product, we calculate brightness relative to the light direction.
- The result gives a simple, soft-lit look without complex lighting calculations.
Example 4: Texture Mixing Shader
Now let’s blend a texture with color dynamically.
Vertex Shader
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
Fragment Shader
uniform sampler2D uTexture;
uniform float uTime;
varying vec2 vUv;
void main() {
vec2 uv = vUv;
uv.y += sin(uv.x * 10.0 + uTime) * 0.02;
vec4 texColor = texture2D(uTexture, uv);
vec3 finalColor = texColor.rgb * vec3(0.8 + 0.2 * sin(uTime));
gl_FragColor = vec4(finalColor, 1.0);
}
JavaScript
const vertexShaderCode = document.getElementById('vertexShader').textContent;
const fragmentShaderCode = document.getElementById('fragmentShader').textContent;
const clock = new THREE.Clock();
const texture = new THREE.TextureLoader().load('texture.jpg');
const material = new THREE.ShaderMaterial({
uniforms: {
uTime: { value: 0.0 },
uTexture: { value: texture },
},
vertexShader: vertexShaderCode,
fragmentShader: fragmentShaderCode,
});
const plane = new THREE.Mesh(new THREE.PlaneGeometry(3, 3, 100, 100), material);
scene.add(plane);
function animate() {
requestAnimationFrame(animate);
material.uniforms.uTime.value = clock.getElapsedTime();
renderer.render(scene, camera);
}
animate();You can get any of your jpg (or even png) file, rename it to texture.jpg(or png) and test it
You’ll now see the texture waving gently like fabric — using pure shader math!
Key Concepts Recap
| Concept | Description |
|---|---|
| Vertex Shader | Controls vertex position and geometry deformation |
| Fragment Shader | Controls per-pixel color, texture, lighting |
| Uniforms | Variables passed from JS to shaders |
| Varyings | Pass values from vertex to fragment shaders |
| GLSL Functions | sin(), dot(), normalize() — used for procedural animation and effects |
Going Further
Once you grasp these basics, you can explore:
- Noise functions for clouds, fire, and waves
- Normal mapping and bump effects
- Post-processing shaders (like bloom or color grading)
- ShaderToy-like procedural effects
Three.js makes integrating shaders fun and approachable. Combining it with tools like GLSL Sandbox or ShaderToy is a great way to experiment with visual ideas before embedding them in your app.
