10. Jakobsen · 2001
Verlet fabric
About
Thomas Jakobsen's Verlet cloth: a grid of points, distance constraints, gravity. Two corners are pinned. A slow sine is the wind. Drag any point and the rest of the mesh follows.
Drag a point on the cloth to tug it.
Browser APIs
- Canvas 2D
- Pointer Events
- requestAnimationFrame
- matchMedia
If WebGL is missing, the demo draws a message on the canvas instead of a blank frame. prefers-reduced-motion freezes the first still.
Source
(function () {
const PARAMS = {
color: "#6ee7b7",
wind: 1,
stiffness: 3
};
function hexToRgb(hex) {
const n = parseInt(String(hex).replace("#", ""), 16);
if (isNaN(n)) return [110, 231, 183];
return [n >> 16 & 255, n >> 8 & 255, n & 255];
}
const canvas = document.getElementById("c");
const fallback = document.getElementById("fallback");
const ctx = canvas.getContext("2d");
if (!ctx) {
fallback.hidden = false;
fallback.textContent = "Canvas 2D is not available in this browser.";
return;
}
const COLS = 16;
const ROWS = 12;
const pts = [];
const springs = [];
let builtW = 0;
let builtH = 0;
let grab = -1;
let t = 0;
let last = 0;
let raf = 0;
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
function link(a, b) {
const pa = pts[a], pb = pts[b];
springs.push({ a, b, rest: Math.hypot(pb.x - pa.x, pb.y - pa.y) });
}
function build(w, h) {
pts.length = 0;
springs.length = 0;
grab = -1;
const left = w * 0.18;
const top = h * 0.08;
const gw = w * 0.64;
const gh = h * 0.62;
const cs = gw / (COLS - 1);
const rs = gh / (ROWS - 1);
for (let j = 0; j < ROWS; j++) {
for (let i = 0; i < COLS; i++) {
const x = left + i * cs;
const y = top + j * rs;
pts.push({ x, y, ox: x, oy: y, pin: j === 0 && (i === 0 || i === COLS - 1) });
}
}
for (let j = 0; j < ROWS; j++) {
for (let i = 0; i < COLS; i++) {
const n = i + j * COLS;
if (i < COLS - 1) link(n, n + 1);
if (j < ROWS - 1) link(n, n + COLS);
}
}
builtW = w;
builtH = h;
}
function resize() {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const w = Math.max(1, Math.floor(canvas.clientWidth * dpr));
const h = Math.max(1, Math.floor(canvas.clientHeight * dpr));
if (canvas.width !== w || canvas.height !== h) {
canvas.width = w;
canvas.height = h;
}
if (Math.abs(w - builtW) > 4 || Math.abs(h - builtH) > 4) {
build(w, h);
for (let i = 0; i < 70; i++) step(0.016);
}
}
function pointer(ev) {
const r = canvas.getBoundingClientRect();
const sx = canvas.width / r.width;
const sy = canvas.height / r.height;
return { x: (ev.clientX - r.left) * sx, y: (ev.clientY - r.top) * sy };
}
canvas.addEventListener("pointerdown", function (ev) {
canvas.setPointerCapture(ev.pointerId);
const p = pointer(ev);
let best = 1e9, idx = 0;
for (let i = 0; i < pts.length; i++) {
const d = Math.hypot(pts[i].x - p.x, pts[i].y - p.y);
if (d < best) { best = d; idx = i; }
}
grab = idx;
pts[idx].x = p.x;
pts[idx].y = p.y;
pts[idx].ox = p.x;
pts[idx].oy = p.y;
});
canvas.addEventListener("pointermove", function (ev) {
if (grab < 0) return;
const p = pointer(ev);
pts[grab].x = p.x;
pts[grab].y = p.y;
pts[grab].ox = p.x;
pts[grab].oy = p.y;
});
function release() { grab = -1; }
canvas.addEventListener("pointerup", release);
canvas.addEventListener("pointercancel", release);
function step(dt) {
const g = 2800 * dt * dt;
const wind = Math.sin(t * 1.3) * 90 * PARAMS.wind * dt * dt;
for (let i = 0; i < pts.length; i++) {
const p = pts[i];
if (p.pin || i === grab) continue;
const vx = (p.x - p.ox) * 0.99;
const vy = (p.y - p.oy) * 0.99;
p.ox = p.x;
p.oy = p.y;
p.x += vx + wind;
p.y += vy + g;
}
const iter = Math.max(1, PARAMS.stiffness | 0);
for (let k = 0; k < iter; k++) {
for (let s = 0; s < springs.length; s++) {
const sp = springs[s];
const a = pts[sp.a];
const b = pts[sp.b];
let dx = b.x - a.x;
let dy = b.y - a.y;
const d = Math.hypot(dx, dy) || 1e-4;
const diff = (d - sp.rest) / d * 0.5;
dx *= diff;
dy *= diff;
const aLock = a.pin || sp.a === grab;
const bLock = b.pin || sp.b === grab;
if (!aLock) { a.x += dx; a.y += dy; }
if (!bLock) { b.x -= dx; b.y -= dy; }
}
}
}
function draw() {
const w = canvas.width;
const h = canvas.height;
ctx.fillStyle = "#0b0c0e";
ctx.fillRect(0, 0, w, h);
const rgb = hexToRgb(PARAMS.color);
const rest2 = springs.length ? springs[0].rest * springs[0].rest : 1;
for (let j = 0; j < ROWS - 1; j++) {
for (let i = 0; i < COLS - 1; i++) {
const a = pts[i + j * COLS];
const b = pts[i + 1 + j * COLS];
const c = pts[i + (j + 1) * COLS];
const d = pts[i + 1 + (j + 1) * COLS];
const nx = (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x);
const shade = 0.28 + 0.72 * Math.max(0, Math.min(1, nx / (rest2 * 1.6)));
ctx.fillStyle = "rgba(" + rgb[0] + "," + rgb[1] + "," + rgb[2] + "," + (0.22 + shade * 0.55) + ")";
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.lineTo(d.x, d.y);
ctx.lineTo(c.x, c.y);
ctx.closePath();
ctx.fill();
}
}
ctx.strokeStyle = "rgba(" + rgb[0] + "," + rgb[1] + "," + rgb[2] + ",0.85)";
ctx.lineWidth = Math.max(0.8, w / 520);
ctx.beginPath();
for (let s = 0; s < springs.length; s++) {
const a = pts[springs[s].a];
const b = pts[springs[s].b];
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
}
ctx.stroke();
}
function frame(now) {
const dt = Math.min(0.033, last ? (now - last) * 0.001 : 0.016);
last = now;
t += dt;
resize();
if (!reduced) step(dt);
draw();
if (!reduced) raf = requestAnimationFrame(frame);
}
if (reduced) {
resize();
for (let i = 0; i < 40; i++) step(0.016);
draw();
} else {
raf = requestAnimationFrame(frame);
}
})();