You are currently viewing Mastering WebGL Shader Materials in Three.js

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:

  1. Vertex Shader – modifies vertex positions (shape, movement, deformation).
  2. 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

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

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

JavaScript
// vertex.glsl
void main() {
  gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}

Fragment Shader

JavaScript
// fragment.glsl
void main() {
  gl_FragColor = vec4(0.2, 0.7, 1.0, 1.0); // light blue
}

JavaScript

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

JavaScript
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

JavaScript
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

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:

  • uTime uniform that animates both vertex displacement and color.
  • sine wave motion on the Z-axis to create gentle surface ripples.
  • 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

JavaScript
varying vec3 vNormal;

void main() {
  vNormal = normalize(normalMatrix * normal);
  gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}

Fragment Shader

JavaScript
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 vNormal from 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

JavaScript
varying vec2 vUv;
void main() {
  vUv = uv;
  gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}

Fragment Shader

JavaScript
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

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

ConceptDescription
Vertex ShaderControls vertex position and geometry deformation
Fragment ShaderControls per-pixel color, texture, lighting
UniformsVariables passed from JS to shaders
VaryingsPass values from vertex to fragment shaders
GLSL Functionssin()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.

Please follow and like us:

Leave a Reply