Deform Three.js Geometry with Shader Noise
Dynamic geometry deformation in Three.js is most efficiently achieved
by calculating noise directly on the GPU using custom vertex shaders.
This approach avoids the massive CPU-to-GPU bandwidth bottleneck
associated with modifying geometry vertices in JavaScript on every
frame. By implementing a GLSL noise function inside a
ShaderMaterial and animating it with a time uniform, you
can create smooth, high-performance wave, terrain, or organic liquid
effects in real time.
1. Set Up the Custom ShaderMaterial and Uniforms
To pass dynamic data (like time and noise parameters) from your
JavaScript code to the GPU, you must define a set of
uniforms. These variables are updated on every frame in the
render loop.
import * as THREE from 'three';
// Define the uniforms to control the animation and noise scale
const uniforms = {
uTime: { value: 0.0 },
uNoiseFrequency: { value: 2.0 },
uNoiseStrength: { value: 0.4 }
};
// Create a highly detailed geometry so the deformation is smooth
const geometry = new THREE.PlaneGeometry(5, 5, 128, 128);
// Instantiate the ShaderMaterial with custom shaders
const material = new THREE.ShaderMaterial({
vertexShader: vertexShaderSource,
fragmentShader: fragmentShaderSource,
uniforms: uniforms,
wireframe: true // Enables easy visualization of the deformation
});
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);2. Write the Vertex Shader with GLSL Noise
The vertex shader is responsible for manipulating the position of each vertex. Rather than sampling an actual 2D image texture, we use a math-based Simplex or Perlin noise algorithm directly inside the GLSL code.
The following vertex shader uses a 3D Simplex noise function. It
takes the vertex’s X and Y positions along with the elapsed time
(uTime) as the 3D coordinate inputs to calculate a dynamic
Z-axis displacement.
// vertexShaderSource
uniform float uTime;
uniform float uNoiseFrequency;
uniform float uNoiseStrength;
varying vec2 vUv;
varying float vElevation;
// Description : GLSL 2D simplex noise utility template
// Author : Ian McEwan, Ashima Arts.
vec3 permute(vec3 x) { return mod(((x*34.0)+1.0)*x, 289.0); }
float snoise(vec2 v){
const vec4 C = vec4(0.211324865405187, 0.366025403784439,
-0.577350269189626, 0.024390243902439);
vec2 i = floor(v + dot(v, C.yy) );
vec2 x0 = v - i + dot(i, C.xx) ;
vec2 i1;
i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
vec4 x12 = x0.xyxy + C.xxzz;
x12.xy -= i1;
i = mod(i, 289.0);
vec3 p = permute( permute( i.y + vec3(0.0, i1.y, 1.0 ))
+ i.x + vec3(0.0, i1.x, 1.0 ));
vec3 m = max(0.5 - vec3(dot(x0,x0), dot(x12.xy,x12.xy),
dot(x12.zw,x12.zw)), 0.0);
m = m*m ;
m = m*m ;
vec3 x = 2.0 * fract(p * C.www) - 1.0;
vec3 h = abs(x) - 0.5;
vec3 a0 = x - floor(x + 0.5);
vec3 g = sin(x0.xxy) * vec3(1.0) + cos(x0.yyx) * vec3(1.0); // Simple perturbation
vec3 o = a0 * 3.0;
vec3 r = g - o;
return 130.0 * dot(m, r);
}
void main() {
vUv = uv;
// Generate 2D noise using vertex coordinates and animate it over time
vec2 noiseInput = position.xy * uNoiseFrequency + vec2(uTime);
float elevation = snoise(noiseInput) * uNoiseStrength;
// Apply the elevation to the local coordinate's Z axis
vec3 newPosition = position;
newPosition.z += elevation;
// Pass elevation value to the fragment shader for dynamic styling
vElevation = elevation;
gl_Position = projectionMatrix * modelViewMatrix * vec4(newPosition, 1.0);
}3. Write the Fragment Shader for Coloring
The fragment shader colors the pixels of the deformed mesh. By
utilizing the vElevation variable passed from the vertex
shader, you can dynamically change the color of the geometry based on
its current deformed height.
// fragmentShaderSource
varying vec2 vUv;
varying float vElevation;
void main() {
// Map elevation to a color gradient (e.g., deep blue in valleys, bright cyan on peaks)
vec3 lowColor = vec3(0.05, 0.15, 0.4);
vec3 highColor = vec3(0.0, 0.9, 0.8);
// Normalize the elevation factor for color mixing
float mixFactor = (vElevation + 0.4) * 1.2;
vec3 finalColor = mix(lowColor, highColor, clamp(mixFactor, 0.0, 1.0));
gl_FragColor = vec4(finalColor, 1.0);
}4. Animate the Time Uniform in the Render Loop
To make the noise texture move dynamically, you must update the
uTime uniform value using a Three.js clock inside your
recursive animation loop. This ensures that the noise calculation
continuously shifts coordinate space on the GPU.
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
// Get total elapsed time in seconds
const elapsedTime = clock.getElapsedTime();
// Send the updated time value to the GPU
material.uniforms.uTime.value = elapsedTime;
renderer.render(scene, camera);
}
// Start the animation cycle
animate();