Drag the cloth with mouse or finger:
Drag the cloth with mouse or finger:
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.
Stores particles, applies gravity and wind, integrates motion, solves constraints, and handles collisions with the sphere and floor.
Uses a THREE.PlaneGeometry as the visible cloth mesh, then copies particle positions
into its vertex buffer every frame.
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);
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);
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. |
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);
}
}
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.
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;
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);
}
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.
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.
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);
}
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.
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);
}
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.
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);
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;
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.
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.
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();
}
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.
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:
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.
The demo does not use a giant physics engine, but it still feels good because it combines a few strong ideas:
Verlet integration is very well suited for particles connected by constraints.
Structural and diagonal links are enough to produce believable fabric behavior.
Dragging a point of the fabric makes the system feel tactile and responsive.
Pushing particles out of a sphere or above a floor is cheap and visually convincing.
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. |