Interactive Cloth Simulation in Three.js

Drag the cloth with mouse or finger:

1. Big-picture architecture

The simulation is split into two main halves: physics and rendering. The physics side keeps track of where each cloth point should move. The rendering side takes those positions and updates the mesh on screen.

Physics side

Stores particles, applies gravity and wind, integrates motion, solves constraints, and handles collisions with the sphere and floor.

Rendering side

Uses a THREE.PlaneGeometry as the visible cloth mesh, then copies particle positions into its vertex buffer every frame.

The key idea is simple: the mesh is only for drawing. The real simulation lives in the particle data.

2. Scene setup

The first part of the code creates the Three.js scene, camera, renderer, lights, floor, and collision sphere. None of this is cloth-specific yet. It just builds the 3D world where the cloth will exist.

const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0b1020);

const camera = new THREE.PerspectiveCamera(
  45,
  window.innerWidth / window.innerHeight,
  0.1,
  1200
);
camera.position.set(0, 20, 260);

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);

Why this matters

The cloth will be easier to see if the camera is slightly back from the object and the scene has both ambient and directional light. The floor and sphere help sell the illusion that the cloth is interacting with real objects.

const sphereRadius = 32;
const spherePos = new THREE.Vector3(0, -28, 0);

const sphere = new THREE.Mesh(
  new THREE.SphereGeometry(sphereRadius, 40, 28),
  new THREE.MeshStandardMaterial({ color: 0x8fb8ff })
);
sphere.position.copy(spherePos);
scene.add(sphere);

3. The configuration object

The CONFIG object controls the behavior of the cloth. This is where the simulation becomes easy to tune without rewriting any physics logic.

const CONFIG = {
  clothWidth: 180,
  clothHeight: 130,
  segmentsX: 30,
  segmentsY: 22,
  mass: 0.12,
  gravity: 1400,
  damping: 0.985,
  iterations: 6,
  windStrength: 10,
  floorY: -120,
  dragRadius: 18
};
Setting What it affects
segmentsX, segmentsY How many points the cloth has. More segments look smoother but cost more CPU time.
gravity How strongly the cloth is pulled downward.
damping How quickly motion dies out. Lower values make it lose energy faster.
iterations How many passes the solver makes to enforce constraints. More iterations make the cloth stiffer.
windStrength How much sideways motion and flutter the cloth gets.

4. The particle model

The cloth is represented as a grid of small points called particles. Each particle stores:

class Particle {
  constructor(x, y, z, mass, pinned = false) {
    this.position = new THREE.Vector3(x, y, z);
    this.previous = new THREE.Vector3(x, y, z);
    this.original = new THREE.Vector3(x, y, z);
    this.acceleration = new THREE.Vector3();
    this.mass = mass;
    this.invMass = 1 / mass;
    this.pinned = pinned;
  }

  addForce(force) {
    this.acceleration.addScaledVector(force, this.invMass);
  }

  integrate(dtSq) {
    if (this.pinned || this === selectedParticle) {
      this.acceleration.set(0, 0, 0);
      return;
    }

    const velocity = this.position.clone()
      .sub(this.previous)
      .multiplyScalar(CONFIG.damping);

    const next = this.position.clone()
      .add(velocity)
      .addScaledVector(this.acceleration, dtSq);

    this.previous.copy(this.position);
    this.position.copy(next);
    this.acceleration.set(0, 0, 0);
  }
}

Why store the previous position?

This is the heart of Verlet integration. Instead of storing a separate velocity vector, the code estimates velocity from the difference between position and previous. That gives a very stable and simple way to simulate cloth and ropes.

Verlet integration is popular for cloth because it handles many constraints well and stays simpler than a full rigid-body system.

5. Turning particles into a cloth grid

After defining the particle class, the code creates a 2D grid of particles. The top row is marked as pinned, which means it stays fixed in place while the rest of the cloth hangs below it.

for (let v = 0; v <= segY; v++) {
  for (let u = 0; u <= segX; u++) {
    const x = (u / segX - 0.5) * CONFIG.clothWidth;
    const y = (0.5 - v / segY) * CONFIG.clothHeight + 40;
    const z = 0;
    const pinned = v === 0;
    particles.push(new Particle(x, y, z, CONFIG.mass, pinned));
  }
}

The cloth points are arranged in rows and columns, and the top edge is pinned with: const pinned = v === 0;

Indexing helper

Since the particles are stored in a flat array, the index(u, v) function converts 2D grid coordinates into a one-dimensional array index.

function index(u, v) {
  return u + v * (segX + 1);
}

6. Constraints are what make it act like fabric

If the particles were only influenced by gravity, they would just fall apart. The cloth shape is preserved by adding constraints between nearby particles.

The code creates three kinds of links:

if (u < segX) {
  constraints.push([index(u, v), index(u + 1, v), restX]);
}
if (v < segY) {
  constraints.push([index(u, v), index(u, v + 1), restY]);
}
if (u < segX && v < segY) {
  constraints.push([index(u, v), index(u + 1, v + 1), diagRest]);
  constraints.push([index(u + 1, v), index(u, v + 1), diagRest]);
}

Each constraint stores two particle indices and a target distance. During each frame, the solver nudges the particles so that they stay close to that rest length.

The solver function

function satisfyConstraint(p1, p2, distance) {
  diff.subVectors(p2.position, p1.position);
  const currentDist = diff.length();
  if (currentDist === 0) return;

  const correction = diff.multiplyScalar(1 - distance / currentDist);

  const lock1 = isLocked(p1);
  const lock2 = isLocked(p2);

  if (!lock1 && !lock2) {
    const half = correction.multiplyScalar(0.5);
    p1.position.add(half);
    p2.position.sub(half);
  } else if (lock1 && !lock2) {
    p2.position.sub(correction);
  } else if (!lock1 && lock2) {
    p1.position.add(correction);
  }
}

This function measures how far apart two particles currently are, compares that against their desired rest distance, and then corrects their positions.

The more times you run the constraint solver per frame, the tighter and less stretchy the cloth feels.

7. Force application and Verlet integration

Each frame, every movable particle receives gravity and wind. Then its next position is computed using Verlet integration.

for (const p of particles) {
  p.addForce(gravity);
  p.addForce(wind);
  p.integrate(dtSq);
}

How the math feels conceptually

The code is effectively saying: “take the current position, keep some of the previous motion, add acceleration, and that becomes the next position.” Damping is applied to stop the motion from growing forever.

const velocity = this.position.clone()
  .sub(this.previous)
  .multiplyScalar(CONFIG.damping);

const next = this.position.clone()
  .add(velocity)
  .addScaledVector(this.acceleration, dtSq);

That small piece of logic is enough to create a very believable cloth-like motion when combined with constraints.

8. Collisions with the floor and sphere

Once the particles move, the code prevents them from passing through the floor or inside the sphere.

if (p.position.y < CONFIG.floorY) {
  p.position.y = CONFIG.floorY;
}

tmpVec.subVectors(p.position, spherePos);
const len = tmpVec.length();
if (len < sphereRadius) {
  tmpVec.normalize().multiplyScalar(sphereRadius);
  p.position.copy(spherePos).add(tmpVec);
}

What the sphere collision does

The vector from the sphere center to the particle is measured. If the particle is inside the sphere, the code pushes it outward onto the sphere’s surface. That is a fast and effective way to keep the cloth wrapped over the obstacle.

This demo uses simple positional collision correction. It is lightweight and good for visuals, but not a full physics engine.

9. Making the cloth interactive with mouse and touch

A big reason this demo feels nice is that it uses pointerdown, pointermove, and pointerup instead of separate mouse and touch handlers. Pointer events unify both input types.

renderer.domElement.addEventListener("pointerdown", onPointerDown);
renderer.domElement.addEventListener("pointermove", onPointerMove);
renderer.domElement.addEventListener("pointerup", onPointerUp);
renderer.domElement.addEventListener("pointercancel", onPointerUp);

How selection works

When the user presses on the canvas, the code raycasts into the cloth mesh. If the ray hits the cloth, it finds the nearest unpinned particle to that hit point and marks it as the selected particle.

const hits = raycaster.intersectObject(clothMesh, false);
if (hits.length === 0) return;

const nearest = findClosestParticle(hits[0].point);
if (!nearest) return;

selectedParticle = nearest;

How dragging works

Once a particle is selected, the code creates a drag plane facing the camera and passing through the particle’s current position. As the pointer moves, the ray is intersected against that plane, and the particle is moved to the new intersection point.

camera.getWorldDirection(cameraNormal).normalize();
dragPlane.setFromNormalAndCoplanarPoint(cameraNormal, selectedParticle.position);

if (raycaster.ray.intersectPlane(dragPlane, dragPoint)) {
  selectedParticle.position.copy(dragPoint);
  selectedParticle.previous.copy(dragPoint);
}

Notice that both position and previous are updated while dragging. That prevents the particle from snapping violently when the user lets go.

10. Why pinned and dragged particles are treated specially

In the particle integrator, pinned particles and the currently dragged particle skip normal physics motion:

if (this.pinned || this === selectedParticle) {
  this.acceleration.set(0, 0, 0);
  return;
}

This is important for two reasons:

The code also resets pinned particles back to their original positions during constraint solving:

if (p.pinned) {
  p.position.copy(p.original);
  p.previous.copy(p.original);
}

That guarantees the top edge remains stable even after the solver and collisions run.

11. Updating the visible mesh

The cloth simulation uses particles internally, but the visible cloth is a THREE.PlaneGeometry. Every frame, the code copies each particle position into the geometry’s position buffer.

function updateClothGeometry() {
  const pos = clothGeometry.attributes.position;
  for (let i = 0; i < particles.length; i++) {
    const p = particles[i].position;
    pos.setXYZ(i, p.x, p.y, p.z);
  }
  pos.needsUpdate = true;
  clothGeometry.computeVertexNormals();
}

Why recompute normals?

As the cloth bends, its surface lighting should change. Recomputing vertex normals ensures the light reacts to the cloth’s changing shape instead of staying flat and incorrect.

12. The animation loop

The render loop is short and clean:

function animate() {
  requestAnimationFrame(animate);

  const dt = Math.min(clock.getDelta(), 1 / 30);
  simulate(dt);
  updateClothGeometry();

  renderer.render(scene, camera);
}

Each frame does three important things:

  1. advance the physics simulation,
  2. copy the new particle positions into the mesh,
  3. render the updated scene.

Why clamp delta time?

Math.min(clock.getDelta(), 1 / 30) prevents extremely large time steps if the browser stalls for a moment. Without that clamp, the cloth could jump or explode after a lag spike.

13. Why this implementation feels convincing

The demo does not use a giant physics engine, but it still feels good because it combines a few strong ideas:

Stable motion

Verlet integration is very well suited for particles connected by constraints.

Simple but effective constraints

Structural and diagonal links are enough to produce believable fabric behavior.

Direct manipulation

Dragging a point of the fabric makes the system feel tactile and responsive.

Fast collisions

Pushing particles out of a sphere or above a floor is cheap and visually convincing.

14. Practical improvements you can add next

This demo is already solid, but there are several easy upgrades if you want better realism or better performance.

Upgrade Benefit
Bend constraints Reduces unrealistic folding and makes the fabric feel less like a loose net.
Spatial partitioning Helps if you add self-collision later and need faster neighborhood queries.
Texture map Makes the cloth look like real fabric instead of a plain colored surface.
Orbit controls Lets the user rotate around the cloth for a better view.
GPU-based simulation Useful for much larger cloth grids when CPU performance becomes a bottleneck.
The best next upgrade for realism is usually bend constraints. The best next upgrade for visuals is a textured material with better lighting.