Cloth Simulation: How to Create Realistic Fabric Physics With Code
Cloth simulation is one of the most visually satisfying physics effects you can build in a browser. A grid of particles connected by invisible springs, acted on by gravity and wind, produces fabric that drapes, ripples, tears, and flows with surprising realism. The math is elegant, the code is compact, and the results look like magic.
This article builds a complete cloth simulation from scratch using JavaScript and the HTML Canvas. We start with the core physics engine (Verlet integration + constraints), then layer on features: wind, tearing, mouse interaction, and different fabric types. Every example runs live in your browser.
1. Basic Verlet Particle Grid
The foundation of cloth simulation is a grid of particles connected by distance constraints. Each particle stores its current and previous position. We use Verlet integration to update positions: the next position is extrapolated from the current and previous positions plus acceleration. This approach is more stable than Euler integration for constrained systems because we can directly manipulate positions to satisfy constraints.
<!DOCTYPE html><html><body style="margin:0;background:#111">
<canvas id="c"></canvas>
<script>
const c=document.getElementById('c'),x=c.getContext('2d');
const W=c.width=600,H=c.height=450;
const COLS=20,ROWS=15,SPACING=20,GRAVITY=0.5,DAMPING=0.99;
const ITERATIONS=5;
const particles=[],constraints=[];
// Create particle grid
for(let row=0;row<ROWS;row++){
for(let col=0;col<COLS;col++){
const px=100+col*SPACING,py=40+row*SPACING;
particles.push({x:px,y:py,ox:px,oy:py,pinned:row===0});
}
}
// Create constraints (horizontal + vertical springs)
for(let row=0;row<ROWS;row++){
for(let col=0;col<COLS;col++){
const i=row*COLS+col;
if(col<COLS-1)constraints.push({a:i,b:i+1,len:SPACING});
if(row<ROWS-1)constraints.push({a:i,b:i+COLS,len:SPACING});
}
}
function update(){
// Verlet integration
for(const p of particles){
if(p.pinned)continue;
const vx=(p.x-p.ox)*DAMPING;
const vy=(p.y-p.oy)*DAMPING;
p.ox=p.x;p.oy=p.y;
p.x+=vx;p.y+=vy+GRAVITY;
}
// Constraint relaxation
for(let iter=0;iter<ITERATIONS;iter++){
for(const c of constraints){
const a=particles[c.a],b=particles[c.b];
const dx=b.x-a.x,dy=b.y-a.y;
const dist=Math.sqrt(dx*dx+dy*dy);
const diff=(c.len-dist)/dist*0.5;
const ox=dx*diff,oy=dy*diff;
if(!a.pinned){a.x-=ox;a.y-=oy}
if(!b.pinned){b.x+=ox;b.y+=oy}
}
}
}
function draw(){
update();
x.fillStyle='#111';x.fillRect(0,0,W,H);
// Draw constraints as lines
x.strokeStyle='rgba(180,140,255,0.6)';x.lineWidth=1;
for(const c of constraints){
const a=particles[c.a],b=particles[c.b];
x.beginPath();x.moveTo(a.x,a.y);x.lineTo(b.x,b.y);x.stroke();
}
// Draw pinned particles
x.fillStyle='#ff6b6b';
for(const p of particles){
if(p.pinned){x.beginPath();x.arc(p.x,p.y,3,0,Math.PI*2);x.fill()}
}
x.fillStyle='#fff';x.font='14px monospace';
x.fillText('Basic Verlet Cloth — '+COLS+'x'+ROWS+' particles, '+constraints.length+' springs',10,25);
requestAnimationFrame(draw);
}
draw();
</script>
</body></html>
The key insight is the constraint relaxation loop. After moving particles by gravity, we iterate through every spring and push/pull connected particles toward the correct distance. Multiple iterations (5 here) make the cloth stiffer. The top row is pinned (immovable), so the cloth hangs like a curtain.
2. Interactive Mouse Dragging
Cloth becomes magical when you can grab and drag it. We find the nearest particle to the mouse and move it to follow the cursor. When released, the cloth swings naturally from its own momentum.
<!DOCTYPE html><html><body style="margin:0;background:#111">
<canvas id="c"></canvas>
<script>
const c=document.getElementById('c'),x=c.getContext('2d');
const W=c.width=600,H=c.height=450;
const COLS=25,ROWS=18,SP=18,G=0.4,DAMP=0.99,ITER=5;
const pts=[],cons=[];
let drag=null,mx=0,my=0;
for(let r=0;r<ROWS;r++)for(let cl=0;cl<COLS;cl++){
const px=80+cl*SP,py=30+r*SP;
pts.push({x:px,y:py,ox:px,oy:py,pinned:r===0&&cl%4===0});
}
for(let r=0;r<ROWS;r++)for(let cl=0;cl<COLS;cl++){
const i=r*COLS+cl;
if(cl<COLS-1)cons.push({a:i,b:i+1,len:SP});
if(r<ROWS-1)cons.push({a:i,b:i+COLS,len:SP});
}
c.addEventListener('mousedown',e=>{
const rect=c.getBoundingClientRect();
mx=e.clientX-rect.left;my=e.clientY-rect.top;
let best=40,bi=-1;
pts.forEach((p,i)=>{if(!p.pinned){const d=Math.hypot(p.x-mx,p.y-my);if(d<best){best=d;bi=i}}});
drag=bi>=0?bi:null;
});
c.addEventListener('mousemove',e=>{
const rect=c.getBoundingClientRect();
mx=e.clientX-rect.left;my=e.clientY-rect.top;
});
c.addEventListener('mouseup',()=>drag=null);
function update(){
if(drag!==null){pts[drag].x=mx;pts[drag].y=my;pts[drag].ox=mx;pts[drag].oy=my}
for(const p of pts){
if(p.pinned||pts.indexOf(p)===drag)continue;
const vx=(p.x-p.ox)*DAMP,vy=(p.y-p.oy)*DAMP;
p.ox=p.x;p.oy=p.y;
p.x+=vx;p.y+=vy+G;
if(p.y>H-5){p.y=H-5;p.oy=p.y}
}
for(let it=0;it<ITER;it++){
for(const cn of cons){
const a=pts[cn.a],b=pts[cn.b];
const dx=b.x-a.x,dy=b.y-a.y;
const d=Math.sqrt(dx*dx+dy*dy)||0.001;
const diff=(cn.len-d)/d*0.5;
const ox=dx*diff,oy=dy*diff;
const ai=pts.indexOf(a)===drag,bi2=pts.indexOf(b)===drag;
if(!a.pinned&&!ai){a.x-=ox;a.y-=oy}
if(!b.pinned&&!bi2){b.x+=ox;b.y+=oy}
}
}
}
function draw(){
update();
x.fillStyle='#111';x.fillRect(0,0,W,H);
// Draw cloth as filled triangles
for(let r=0;r<ROWS-1;r++)for(let cl=0;cl<COLS-1;cl++){
const i=r*COLS+cl;
const a=pts[i],b=pts[i+1],cc=pts[i+COLS],d=pts[i+COLS+1];
// Shade based on stretch
const s1=Math.hypot(b.x-a.x,b.y-a.y)/SP;
const hue=260-Math.min(s1-1,0.5)*120;
x.fillStyle='hsla('+hue+',70%,60%,0.5)';
x.beginPath();x.moveTo(a.x,a.y);x.lineTo(b.x,b.y);x.lineTo(d.x,d.y);x.closePath();x.fill();
x.beginPath();x.moveTo(a.x,a.y);x.lineTo(d.x,d.y);x.lineTo(cc.x,cc.y);x.closePath();x.fill();
}
x.fillStyle='#ff6b6b';
pts.forEach(p=>{if(p.pinned){x.beginPath();x.arc(p.x,p.y,4,0,Math.PI*2);x.fill()}});
x.fillStyle='#fff';x.font='14px monospace';
x.fillText('Click and drag the cloth — color shows stretch',10,25);
requestAnimationFrame(draw);
}
draw();
</script>
</body></html>
Notice how the cloth is now rendered as filled triangles instead of wireframe lines. The color shifts based on how stretched each segment is — purple when relaxed, shifting toward red under tension. Only every 4th particle on the top row is pinned, creating natural draping between attachment points.
3. Wind Force
Wind transforms static cloth into a living, breathing surface. The force on each triangle depends on its surface normal and the wind direction — panels facing the wind billow out, while those parallel to it slip through. Adding time-varying noise creates realistic gusting.
<!DOCTYPE html><html><body style="margin:0;background:#111">
<canvas id="c"></canvas>
<script>
const c=document.getElementById('c'),x=c.getContext('2d');
const W=c.width=600,H=c.height=450;
const COLS=30,ROWS=20,SP=14,G=0.3,DAMP=0.995,ITER=5;
const pts=[],cons=[];
let t=0;
for(let r=0;r<ROWS;r++)for(let cl=0;cl<COLS;cl++){
const px=50+cl*SP,py=30+r*SP;
pts.push({x:px,y:py,ox:px,oy:py,pinned:r===0,fx:0,fy:0});
}
for(let r=0;r<ROWS;r++)for(let cl=0;cl<COLS;cl++){
const i=r*COLS+cl;
if(cl<COLS-1)cons.push({a:i,b:i+1,len:SP});
if(r<ROWS-1)cons.push({a:i,b:i+COLS,len:SP});
}
function applyWind(){
const windX=2.5+Math.sin(t*0.02)*1.5;
const windY=0.3+Math.cos(t*0.015)*0.3;
for(let r=0;r<ROWS-1;r++)for(let cl=0;cl<COLS-1;cl++){
const i=r*COLS+cl;
const a=pts[i],b=pts[i+1],cc=pts[i+COLS];
// Triangle normal (cross product)
const abx=b.x-a.x,aby=b.y-a.y;
const acx=cc.x-a.x,acy=cc.y-a.y;
const nz=abx*acy-aby*acx; // z-component of normal
// Wind force proportional to normal facing wind
const noise=Math.sin(cl*0.3+t*0.03)*Math.cos(r*0.2+t*0.02)*0.5;
const force=(windX*(acy-aby)+windY*(abx-acx))*0.001+noise*0.1;
const fx=windX*Math.abs(force),fy=windY*Math.abs(force);
a.fx+=fx;a.fy+=fy;b.fx+=fx;b.fy+=fy;cc.fx+=fx;cc.fy+=fy;
}
}
function update(){
t++;
for(const p of pts){p.fx=0;p.fy=0}
applyWind();
for(const p of pts){
if(p.pinned)continue;
const vx=(p.x-p.ox)*DAMP,vy=(p.y-p.oy)*DAMP;
p.ox=p.x;p.oy=p.y;
p.x+=vx+p.fx;p.y+=vy+G+p.fy;
}
for(let it=0;it<ITER;it++){
for(const cn of cons){
const a=pts[cn.a],b=pts[cn.b];
const dx=b.x-a.x,dy=b.y-a.y;
const d=Math.sqrt(dx*dx+dy*dy)||0.001;
const diff=(cn.len-d)/d*0.5;
const ox=dx*diff,oy=dy*diff;
if(!a.pinned){a.x-=ox;a.y-=oy}
if(!b.pinned){b.x+=ox;b.y+=oy}
}
}
}
function draw(){
update();
x.fillStyle='#111';x.fillRect(0,0,W,H);
for(let r=0;r<ROWS-1;r++)for(let cl=0;cl<COLS-1;cl++){
const i=r*COLS+cl;
const a=pts[i],b=pts[i+1],cc=pts[i+COLS],d=pts[i+COLS+1];
const depth=r/ROWS;
const hue=210+cl*2;
x.fillStyle='hsla('+hue+',60%,'+(40+depth*20)+'%,0.7)';
x.beginPath();x.moveTo(a.x,a.y);x.lineTo(b.x,b.y);x.lineTo(d.x,d.y);x.closePath();x.fill();
x.beginPath();x.moveTo(a.x,a.y);x.lineTo(d.x,d.y);x.lineTo(cc.x,cc.y);x.closePath();x.fill();
}
// Wind indicator
const windX=2.5+Math.sin(t*0.02)*1.5;
x.strokeStyle='rgba(255,255,255,0.4)';x.lineWidth=2;
x.beginPath();x.moveTo(30,H-30);x.lineTo(30+windX*20,H-30);x.stroke();
x.fillStyle='#fff';x.font='14px monospace';
x.fillText('Wind-blown cloth — force varies with surface normal + noise',10,25);
requestAnimationFrame(draw);
}
draw();
</script>
</body></html>
The wind calculation uses a simplified surface-normal approach: for each triangle in the mesh, we compute how much it faces the wind direction. Triangles perpendicular to the wind receive maximum force. The sine/cosine noise creates gusting that varies across the cloth surface, so different parts billow at different times — just like real fabric.
4. Cloth Tearing
Tearing is dramatically simple to implement: when a spring stretches beyond a threshold, remove it. The particles on either side of the break fly apart, and the cloth rips open. Click and drag hard to tear the fabric.
<!DOCTYPE html><html><body style="margin:0;background:#111">
<canvas id="c"></canvas>
<script>
const c=document.getElementById('c'),x=c.getContext('2d');
const W=c.width=600,H=c.height=450;
const COLS=30,ROWS=20,SP=14,G=0.35,DAMP=0.99,ITER=5;
const TEAR_DIST=SP*2.2;
const pts=[],cons=[];
let drag=null,mx=0,my=0;
for(let r=0;r<ROWS;r++)for(let cl=0;cl<COLS;cl++){
const px=50+cl*SP,py=30+r*SP;
pts.push({x:px,y:py,ox:px,oy:py,pinned:r===0&&cl%3===0});
}
for(let r=0;r<ROWS;r++)for(let cl=0;cl<COLS;cl++){
const i=r*COLS+cl;
if(cl<COLS-1)cons.push({a:i,b:i+1,len:SP,active:true});
if(r<ROWS-1)cons.push({a:i,b:i+COLS,len:SP,active:true});
}
c.addEventListener('mousedown',e=>{
const rect=c.getBoundingClientRect();
mx=e.clientX-rect.left;my=e.clientY-rect.top;
let best=30,bi=-1;
pts.forEach((p,i)=>{if(!p.pinned){const d=Math.hypot(p.x-mx,p.y-my);if(d<best){best=d;bi=i}}});
drag=bi>=0?bi:null;
});
c.addEventListener('mousemove',e=>{
const rect=c.getBoundingClientRect();
mx=e.clientX-rect.left;my=e.clientY-rect.top;
});
c.addEventListener('mouseup',()=>drag=null);
function update(){
if(drag!==null){pts[drag].x=mx;pts[drag].y=my;pts[drag].ox=mx;pts[drag].oy=my}
for(const p of pts){
if(p.pinned||pts.indexOf(p)===drag)continue;
const vx=(p.x-p.ox)*DAMP,vy=(p.y-p.oy)*DAMP;
p.ox=p.x;p.oy=p.y;
p.x+=vx;p.y+=vy+G;
if(p.y>H-5){p.y=H-5;p.oy=p.y}
}
for(let it=0;it<ITER;it++){
for(const cn of cons){
if(!cn.active)continue;
const a=pts[cn.a],b=pts[cn.b];
const dx=b.x-a.x,dy=b.y-a.y;
const d=Math.sqrt(dx*dx+dy*dy)||0.001;
// Tear if stretched too far
if(d>TEAR_DIST){cn.active=false;continue}
const diff=(cn.len-d)/d*0.5;
const ox=dx*diff,oy=dy*diff;
const ai=pts.indexOf(a)===drag,bi2=pts.indexOf(b)===drag;
if(!a.pinned&&!ai){a.x-=ox;a.y-=oy}
if(!b.pinned&&!bi2){b.x+=ox;b.y+=oy}
}
}
}
function draw(){
update();
x.fillStyle='#111';x.fillRect(0,0,W,H);
const active=cons.filter(c=>c.active).length;
// Draw active constraints
x.lineWidth=1;
for(const cn of cons){
if(!cn.active)continue;
const a=pts[cn.a],b=pts[cn.b];
const stretch=Math.hypot(b.x-a.x,b.y-a.y)/cn.len;
const r2=Math.min(255,Math.floor((stretch-1)*500));
x.strokeStyle='rgb('+r2+','+(180-r2*0.5)+',255)';
x.beginPath();x.moveTo(a.x,a.y);x.lineTo(b.x,b.y);x.stroke();
}
x.fillStyle='#ff6b6b';
pts.forEach(p=>{if(p.pinned){x.beginPath();x.arc(p.x,p.y,3,0,Math.PI*2);x.fill()}});
x.fillStyle='#fff';x.font='14px monospace';
x.fillText('Drag hard to tear — '+active+' springs active',10,25);
requestAnimationFrame(draw);
}
draw();
</script>
</body></html>
The tearing threshold is set to 2.2x the rest length. Springs that stretch beyond this are deactivated permanently. The color shifts from blue to red as springs approach their breaking point, giving visual warning before the tear happens. Try dragging a particle quickly downward to rip a hole, or slowly pull the bottom edge to create a clean tear line.
5. Structural + Shear + Bend Springs
Real cloth uses three types of springs for realistic behavior: structural springs connect immediate neighbors (resist stretching), shear springs connect diagonal neighbors (resist skewing), and bend springs connect every-other neighbor (resist folding). This three-spring model produces much more fabric-like motion.
<!DOCTYPE html><html><body style="margin:0;background:#111">
<canvas id="c"></canvas>
<script>
const c=document.getElementById('c'),x=c.getContext('2d');
const W=c.width=600,H=c.height=450;
const COLS=22,ROWS=16,SP=18,G=0.35,DAMP=0.99,ITER=6;
const pts=[],cons=[];
const DIAG=SP*Math.SQRT2;
for(let r=0;r<ROWS;r++)for(let cl=0;cl<COLS;cl++){
const px=80+cl*SP,py=30+r*SP;
pts.push({x:px,y:py,ox:px,oy:py,pinned:r===0&&(cl===0||cl===COLS-1||cl===Math.floor(COLS/2))});
}
function addSpring(a,b,len,type){cons.push({a,b,len,type})}
for(let r=0;r<ROWS;r++)for(let cl=0;cl<COLS;cl++){
const i=r*COLS+cl;
// Structural (horizontal + vertical)
if(cl<COLS-1)addSpring(i,i+1,SP,'struct');
if(r<ROWS-1)addSpring(i,i+COLS,SP,'struct');
// Shear (diagonals)
if(cl<COLS-1&&r<ROWS-1)addSpring(i,i+COLS+1,DIAG,'shear');
if(cl>0&&r<ROWS-1)addSpring(i,i+COLS-1,DIAG,'shear');
// Bend (skip one)
if(cl<COLS-2)addSpring(i,i+2,SP*2,'bend');
if(r<ROWS-2)addSpring(i,i+COLS*2,SP*2,'bend');
}
// Stiffness per type
const stiffness={struct:0.5,shear:0.3,bend:0.1};
function update(){
for(const p of pts){
if(p.pinned)continue;
const vx=(p.x-p.ox)*DAMP,vy=(p.y-p.oy)*DAMP;
p.ox=p.x;p.oy=p.y;
p.x+=vx;p.y+=vy+G;
if(p.y>H-5){p.y=H-5;p.oy=p.y}
}
for(let it=0;it<ITER;it++){
for(const cn of cons){
const a=pts[cn.a],b=pts[cn.b];
const dx=b.x-a.x,dy=b.y-a.y;
const d=Math.sqrt(dx*dx+dy*dy)||0.001;
const diff=(cn.len-d)/d*stiffness[cn.type];
const ox=dx*diff,oy=dy*diff;
if(!a.pinned){a.x-=ox;a.y-=oy}
if(!b.pinned){b.x+=ox;b.y+=oy}
}
}
}
const colors={struct:'rgba(100,200,255,0.5)',shear:'rgba(255,200,100,0.3)',bend:'rgba(100,255,100,0.15)'};
let showType='all';
c.addEventListener('click',()=>{
const types=['all','struct','shear','bend'];
showType=types[(types.indexOf(showType)+1)%types.length];
});
function draw(){
update();
x.fillStyle='#111';x.fillRect(0,0,W,H);
for(const cn of cons){
if(showType!=='all'&&cn.type!==showType)continue;
const a=pts[cn.a],b=pts[cn.b];
x.strokeStyle=colors[cn.type];x.lineWidth=cn.type==='struct'?1.5:1;
x.beginPath();x.moveTo(a.x,a.y);x.lineTo(b.x,b.y);x.stroke();
}
x.fillStyle='#ff6b6b';
pts.forEach(p=>{if(p.pinned){x.beginPath();x.arc(p.x,p.y,4,0,Math.PI*2);x.fill()}});
const total={struct:0,shear:0,bend:0};
cons.forEach(c=>total[c.type]++);
x.fillStyle='#fff';x.font='14px monospace';
x.fillText('Click to toggle: '+showType.toUpperCase()+' — struct:'+total.struct+' shear:'+total.shear+' bend:'+total.bend,10,25);
requestAnimationFrame(draw);
}
draw();
</script>
</body></html>
Click the canvas to cycle through showing all springs, structural only, shear only, or bend only. Notice how structural springs (blue) form the grid, shear springs (orange) cross diagonally to prevent the cloth from collapsing into a rhombus, and bend springs (green) skip one particle to resist folding. Each type has different stiffness values, which you can tune to create different fabric feels.
6. Flag Simulation
A waving flag pins only the left edge and applies constant wind from the right. Combined with turbulent noise, this creates the classic flag-in-the-breeze effect. We add a flagpole for visual context.
<!DOCTYPE html><html><body style="margin:0;background:#1a1a2e">
<canvas id="c"></canvas>
<script>
const c=document.getElementById('c'),x=c.getContext('2d');
const W=c.width=600,H=c.height=450;
const COLS=28,ROWS=16,SP=12,G=0.15,DAMP=0.995,ITER=5;
const pts=[],cons=[];
let t=0;
const FLAG_X=80,FLAG_Y=60;
for(let r=0;r<ROWS;r++)for(let cl=0;cl<COLS;cl++){
const px=FLAG_X+cl*SP,py=FLAG_Y+r*SP;
pts.push({x:px,y:py,ox:px,oy:py,pinned:cl===0,fx:0,fy:0});
}
for(let r=0;r<ROWS;r++)for(let cl=0;cl<COLS;cl++){
const i=r*COLS+cl;
if(cl<COLS-1)cons.push({a:i,b:i+1,len:SP});
if(r<ROWS-1)cons.push({a:i,b:i+COLS,len:SP});
if(cl<COLS-1&&r<ROWS-1)cons.push({a:i,b:i+COLS+1,len:SP*Math.SQRT2});
}
function noise(x,y,t){return Math.sin(x*0.3+t)*Math.cos(y*0.2+t*0.7)*Math.sin(t*0.5+x*0.1)}
function update(){
t+=0.03;
for(const p of pts)p.fx=p.fy=0;
// Wind force per triangle
for(let r=0;r<ROWS-1;r++)for(let cl=0;cl<COLS-1;cl++){
const i=r*COLS+cl;
const a=pts[i],b=pts[i+1],cc=pts[i+COLS];
const n=noise(cl,r,t*3);
const windStr=3.0+n*1.5;
const fx=windStr*0.15,fy=n*0.08;
a.fx+=fx;a.fy+=fy;b.fx+=fx;b.fy+=fy;cc.fx+=fx;cc.fy+=fy;
}
for(const p of pts){
if(p.pinned)continue;
const vx=(p.x-p.ox)*DAMP,vy=(p.y-p.oy)*DAMP;
p.ox=p.x;p.oy=p.y;
p.x+=vx+p.fx;p.y+=vy+G+p.fy;
}
for(let it=0;it<ITER;it++){
for(const cn of cons){
const a=pts[cn.a],b=pts[cn.b];
const dx=b.x-a.x,dy=b.y-a.y;
const d=Math.sqrt(dx*dx+dy*dy)||0.001;
const diff=(cn.len-d)/d*0.5;
if(!a.pinned){a.x-=dx*diff;a.y-=dy*diff}
if(!b.pinned){b.x+=dx*diff;b.y+=dy*diff}
}
}
}
function draw(){
update();
x.fillStyle='#1a1a2e';x.fillRect(0,0,W,H);
// Flagpole
x.strokeStyle='#888';x.lineWidth=4;
x.beginPath();x.moveTo(FLAG_X,FLAG_Y-20);x.lineTo(FLAG_X,H-30);x.stroke();
x.fillStyle='#aaa';x.beginPath();x.arc(FLAG_X,FLAG_Y-20,5,0,Math.PI*2);x.fill();
// Flag as filled triangles with stripe pattern
for(let r=0;r<ROWS-1;r++)for(let cl=0;cl<COLS-1;cl++){
const i=r*COLS+cl;
const a=pts[i],b=pts[i+1],cc=pts[i+COLS],d=pts[i+COLS+1];
// Red/white/blue stripes
let color;
const stripe=Math.floor(r/(ROWS/3));
if(stripe===0)color='rgba(200,50,50,0.85)';
else if(stripe===1)color='rgba(240,240,240,0.85)';
else color='rgba(50,80,200,0.85)';
// Shade by depth (simulated 3D)
const avgX=(a.x+b.x+cc.x+d.x)/4;
const shade=0.7+0.3*Math.sin((avgX-FLAG_X)*0.02+t*2);
x.globalAlpha=shade;
x.fillStyle=color;
x.beginPath();x.moveTo(a.x,a.y);x.lineTo(b.x,b.y);x.lineTo(d.x,d.y);x.closePath();x.fill();
x.beginPath();x.moveTo(a.x,a.y);x.lineTo(d.x,d.y);x.lineTo(cc.x,cc.y);x.closePath();x.fill();
x.globalAlpha=1;
}
x.fillStyle='#fff';x.font='14px monospace';
x.fillText('Flag simulation — left edge pinned, wind from right + turbulence',10,25);
requestAnimationFrame(draw);
}
draw();
</script>
</body></html>
The flag pins only the leftmost column of particles to the pole. Wind blows rightward with turbulent noise that varies by position and time. The sine-based shading on the triangles creates a convincing 3D ripple effect. The stripe pattern (red/white/blue) makes the wave motion highly visible.
7. Self-Collision With Sphere
Cloth draped over an object is one of the classic demonstrations. We add a sphere to the scene and push cloth particles outward whenever they penetrate the sphere's surface. The cloth drapes, slides, and bunches against the obstacle.
<!DOCTYPE html><html><body style="margin:0;background:#111">
<canvas id="c"></canvas>
<script>
const c=document.getElementById('c'),x=c.getContext('2d');
const W=c.width=600,H=c.height=450;
const COLS=30,ROWS=25,SP=12,G=0.3,DAMP=0.995,ITER=6;
const pts=[],cons=[];
const sphere={x:300,y:260,r:80};
let mx=300,my=260;
for(let r=0;r<ROWS;r++)for(let cl=0;cl<COLS;cl++){
const px=120+cl*SP,py=20+r*SP;
pts.push({x:px,y:py,ox:px,oy:py,pinned:r===0&&cl%5===0});
}
for(let r=0;r<ROWS;r++)for(let cl=0;cl<COLS;cl++){
const i=r*COLS+cl;
if(cl<COLS-1)cons.push({a:i,b:i+1,len:SP});
if(r<ROWS-1)cons.push({a:i,b:i+COLS,len:SP});
}
c.addEventListener('mousemove',e=>{
const rect=c.getBoundingClientRect();
mx=e.clientX-rect.left;my=e.clientY-rect.top;
sphere.x=mx;sphere.y=my;
});
function update(){
for(const p of pts){
if(p.pinned)continue;
const vx=(p.x-p.ox)*DAMP,vy=(p.y-p.oy)*DAMP;
p.ox=p.x;p.oy=p.y;
p.x+=vx;p.y+=vy+G;
if(p.y>H-5){p.y=H-5;p.oy=p.y}
}
for(let it=0;it<ITER;it++){
for(const cn of cons){
const a=pts[cn.a],b=pts[cn.b];
const dx=b.x-a.x,dy=b.y-a.y;
const d=Math.sqrt(dx*dx+dy*dy)||0.001;
const diff=(cn.len-d)/d*0.5;
if(!a.pinned){a.x-=dx*diff;a.y-=dy*diff}
if(!b.pinned){b.x+=dx*diff;b.y+=dy*diff}
}
// Sphere collision
for(const p of pts){
if(p.pinned)continue;
const dx=p.x-sphere.x,dy=p.y-sphere.y;
const d=Math.sqrt(dx*dx+dy*dy);
if(d<sphere.r+2){
const push=(sphere.r+2)/d;
p.x=sphere.x+dx*push;
p.y=sphere.y+dy*push;
}
}
}
}
function draw(){
update();
x.fillStyle='#111';x.fillRect(0,0,W,H);
// Sphere
const grad=x.createRadialGradient(sphere.x-20,sphere.y-20,10,sphere.x,sphere.y,sphere.r);
grad.addColorStop(0,'#555');grad.addColorStop(1,'#222');
x.fillStyle=grad;x.beginPath();x.arc(sphere.x,sphere.y,sphere.r,0,Math.PI*2);x.fill();
x.strokeStyle='rgba(255,255,255,0.2)';x.lineWidth=1;x.stroke();
// Cloth
for(let r=0;r<ROWS-1;r++)for(let cl=0;cl<COLS-1;cl++){
const i=r*COLS+cl;
const a=pts[i],b=pts[i+1],cc=pts[i+COLS],d=pts[i+COLS+1];
const hue=30+r*3;
x.fillStyle='hsla('+hue+',50%,55%,0.6)';
x.beginPath();x.moveTo(a.x,a.y);x.lineTo(b.x,b.y);x.lineTo(d.x,d.y);x.closePath();x.fill();
x.beginPath();x.moveTo(a.x,a.y);x.lineTo(d.x,d.y);x.lineTo(cc.x,cc.y);x.closePath();x.fill();
}
x.fillStyle='#ff6b6b';
pts.forEach(p=>{if(p.pinned){x.beginPath();x.arc(p.x,p.y,3,0,Math.PI*2);x.fill()}});
x.fillStyle='#fff';x.font='14px monospace';
x.fillText('Move mouse to drag sphere under cloth',10,25);
requestAnimationFrame(draw);
}
draw();
</script>
</body></html>
Sphere collision is handled inside the constraint loop: for every particle inside the sphere, we push it outward along the particle-to-center vector until it sits on the sphere surface. Running this check within the constraint iterations (not just once per frame) prevents particles from tunneling through the sphere during fast motion. Move the mouse to slide the sphere under the cloth and watch it drape and bunch realistically.
8. Interactive Cloth Playground
This final example combines everything: a large cloth grid with wind, tearing, sphere collision, and adjustable parameters. Left-click drags cloth, right-click tears, and the sphere follows the mouse when holding Shift.
<!DOCTYPE html><html><body style="margin:0;background:#0a0a1a">
<canvas id="c"></canvas>
<script>
const c=document.getElementById('c'),x=c.getContext('2d');
const W=c.width=600,H=c.height=500;
const COLS=35,ROWS=25,SP=13,G=0.3,DAMP=0.995,ITER=5;
const TEAR=SP*2.5;
const pts=[],cons=[];
let t=0,drag=null,mx=0,my=0,pmx=0,pmy=0,shift=false;
let windOn=true,sphereOn=true;
const sphere={x:300,y:320,r:60};
for(let r=0;r<ROWS;r++)for(let cl=0;cl<COLS;cl++){
const px=40+cl*SP,py=30+r*SP;
pts.push({x:px,y:py,ox:px,oy:py,pinned:r===0&&cl%4===0,fx:0,fy:0});
}
for(let r=0;r<ROWS;r++)for(let cl=0;cl<COLS;cl++){
const i=r*COLS+cl;
if(cl<COLS-1)cons.push({a:i,b:i+1,len:SP,active:true});
if(r<ROWS-1)cons.push({a:i,b:i+COLS,len:SP,active:true});
if(cl<COLS-1&&r<ROWS-1)cons.push({a:i,b:i+COLS+1,len:SP*Math.SQRT2,active:true});
}
c.addEventListener('mousedown',e=>{
e.preventDefault();
const rect=c.getBoundingClientRect();
mx=pmx=e.clientX-rect.left;my=pmy=e.clientY-rect.top;
if(e.button===2){
// Right click: tear nearby springs
for(const cn of cons){
if(!cn.active)continue;
const a=pts[cn.a],b=pts[cn.b];
const cx2=(a.x+b.x)/2,cy2=(a.y+b.y)/2;
if(Math.hypot(cx2-mx,cy2-my)<20)cn.active=false;
}
return;
}
let best=30,bi=-1;
pts.forEach((p,i)=>{if(!p.pinned){const d=Math.hypot(p.x-mx,p.y-my);if(d<best){best=d;bi=i}}});
drag=bi>=0?bi:null;
});
c.addEventListener('mousemove',e=>{
const rect=c.getBoundingClientRect();
pmx=mx;pmy=my;
mx=e.clientX-rect.left;my=e.clientY-rect.top;
shift=e.shiftKey;
});
c.addEventListener('mouseup',()=>drag=null);
c.addEventListener('contextmenu',e=>e.preventDefault());
function update(){
t+=0.02;
if(shift&&sphereOn){sphere.x+=(mx-sphere.x)*0.1;sphere.y+=(my-sphere.y)*0.1}
if(drag!==null){pts[drag].x=mx;pts[drag].y=my;pts[drag].ox=mx;pts[drag].oy=my}
// Wind
for(const p of pts)p.fx=p.fy=0;
if(windOn){
for(const p of pts){
const n=Math.sin(p.x*0.02+t*2)*Math.cos(p.y*0.015+t*1.5);
p.fx+=(1.5+n*0.8)*0.08;
p.fy+=n*0.03;
}
}
for(const p of pts){
if(p.pinned||pts.indexOf(p)===drag)continue;
const vx=(p.x-p.ox)*DAMP,vy=(p.y-p.oy)*DAMP;
p.ox=p.x;p.oy=p.y;
p.x+=vx+p.fx;p.y+=vy+G+p.fy;
if(p.y>H-5){p.y=H-5;p.oy=p.y-vy*0.3}
if(p.x<2){p.x=2}if(p.x>W-2){p.x=W-2}
}
for(let it=0;it<ITER;it++){
for(const cn of cons){
if(!cn.active)continue;
const a=pts[cn.a],b=pts[cn.b];
const dx=b.x-a.x,dy=b.y-a.y;
const d=Math.sqrt(dx*dx+dy*dy)||0.001;
if(d>TEAR){cn.active=false;continue}
const diff=(cn.len-d)/d*0.5;
const ai=pts.indexOf(a)===drag,bi2=pts.indexOf(b)===drag;
if(!a.pinned&&!ai){a.x-=dx*diff;a.y-=dy*diff}
if(!b.pinned&&!bi2){b.x+=dx*diff;b.y+=dy*diff}
}
if(sphereOn){
for(const p of pts){
if(p.pinned)continue;
const dx=p.x-sphere.x,dy=p.y-sphere.y;
const d=Math.sqrt(dx*dx+dy*dy);
if(d<sphere.r+2){const push=(sphere.r+2)/d;p.x=sphere.x+dx*push;p.y=sphere.y+dy*push}
}
}
}
}
function draw(){
update();
x.fillStyle='#0a0a1a';x.fillRect(0,0,W,H);
// Sphere
if(sphereOn){
const grad=x.createRadialGradient(sphere.x-15,sphere.y-15,5,sphere.x,sphere.y,sphere.r);
grad.addColorStop(0,'#444');grad.addColorStop(1,'#1a1a1a');
x.fillStyle=grad;x.beginPath();x.arc(sphere.x,sphere.y,sphere.r,0,Math.PI*2);x.fill();
x.strokeStyle='rgba(255,255,255,0.15)';x.lineWidth=1;x.stroke();
}
// Cloth triangles
for(let r=0;r<ROWS-1;r++)for(let cl=0;cl<COLS-1;cl++){
const i=r*COLS+cl;
const a=pts[i],b=pts[i+1],cc=pts[i+COLS],d=pts[i+COLS+1];
// Check if all edges exist
const hasTop=cons.some(c=>c.active&&((c.a===i&&c.b===i+1)||(c.a===i+1&&c.b===i)));
const hasLeft=cons.some(c=>c.active&&((c.a===i&&c.b===i+COLS)||(c.a===i+COLS&&c.b===i)));
if(!hasTop&&!hasLeft)continue;
const hue=220+Math.sin(r*0.3+cl*0.2+t)*30;
const light=45+r*0.8;
x.fillStyle='hsla('+hue+',55%,'+light+'%,0.65)';
x.beginPath();x.moveTo(a.x,a.y);x.lineTo(b.x,b.y);x.lineTo(d.x,d.y);x.closePath();x.fill();
x.beginPath();x.moveTo(a.x,a.y);x.lineTo(d.x,d.y);x.lineTo(cc.x,cc.y);x.closePath();x.fill();
}
// Pins
x.fillStyle='#ff6b6b';
pts.forEach(p=>{if(p.pinned){x.beginPath();x.arc(p.x,p.y,3,0,Math.PI*2);x.fill()}});
// UI
const active=cons.filter(c=>c.active).length;
x.fillStyle='#fff';x.font='13px monospace';
x.fillText('Left-drag: move | Right-click: tear | Shift+move: sphere | '+active+' springs',10,22);
requestAnimationFrame(draw);
}
draw();
</script>
</body></html>
This playground combines all the techniques from the previous examples. The cloth has structural and shear springs, wind turbulence, sphere collision, tearing on over-stretch or right-click, and interactive dragging. The triangle rendering skips quads where all connecting springs have torn, so holes in the cloth actually appear as holes in the rendering. Hold Shift and move the mouse to push the sphere through the cloth.
The Physics Behind Cloth
Real cloth simulation in production (Pixar, game engines, fashion CAD) uses the same principles we covered, scaled up:
- Mass-spring model — what we built: particles + springs + Verlet integration. Simple, fast, good enough for real-time. Used in many games and creative coding projects
- Position Based Dynamics (PBD) — used in Unreal Engine and NVIDIA FleX. Instead of forces, it directly solves position constraints. More stable at large timesteps
- Finite Element Method (FEM) — used in film VFX (Houdini, Maya nCloth). Models cloth as a continuous elastic sheet discretized into triangles. Most accurate but slowest
- XPBD — Extended PBD, adds compliance (stiffness control) to PBD. Used in modern game engines for mixed soft/rigid body simulation
Key parameters that control fabric feel:
- Stretch stiffness — how much the cloth resists being pulled. High = denim, low = silk
- Shear stiffness — resistance to diagonal deformation. High = canvas, low = knit
- Bend stiffness — resistance to folding. High = cardboard, low = chiffon
- Damping — energy loss per frame. High = heavy wool, low = light polyester
- Constraint iterations — more iterations = stiffer cloth, but costs more CPU
Performance Tips
Cloth simulation is O(n) per particle and O(n) per constraint per iteration, so total cost is O(particles + constraints × iterations) per frame. For smooth 60fps:
- Keep grids under 50×50 (2,500 particles) for real-time Canvas 2D rendering
- Use spatial hashing for self-collision if the cloth folds onto itself (we skipped self-collision for simplicity)
- Reduce constraint iterations for softer fabrics — silk needs only 2-3 iterations, denim needs 8+
- Use WebGL/WebGPU for larger simulations — compute shaders can run constraint solving on the GPU
- Sub-stepping — run physics multiple times per render frame with smaller dt for more stability without more iterations
Want to explore more physics-driven art? Visit Lumitree to discover unique generative micro-worlds, or browse the full collection of creative coding tutorials for more visual programming guides.