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();