Home

10. Jakobsen · 2001

Verlet fabric

Colors and knobs

These rewrite the live file. Copy HTML picks up whatever you set.

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