Home

10. Jakobsen · 2001 · Retro techniques

Verlet fabric

Demos are animating. Following your system setting.
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.

Reference

T. Jakobsen, "Advanced Character Physics", Game Developers Conference 2001

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. By default a demo holds a still frame when your system asks for reduced motion; the Motion buttons override that, and a standalone copy animates with #motion=play on its URL.

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;
  let settled = false;
  const motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
  let reduced = motionQuery.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;
    wake();
  });
  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;
    wake();
  });
  function release() { grab = -1; wake(); }
  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);
    else if (!settled) for (let i = 0; i < 40; i++) step(0.016);
    settled = true;
    draw();
    if (!reduced) raf = requestAnimationFrame(frame);
  }

  // Motion. The system's reduced-motion preference decides by default. A
  // standalone copy animates anyway with "#motion=play" in its URL (or holds a
  // frame with "#motion=still"); an embedding page can post {motion: "play"}.
  let motionMode = "auto";
  function applyMotion() {
    const next = motionMode === "still" || (motionMode === "auto" && motionQuery.matches);
    if (next === reduced) return;
    reduced = next;
    last = 0;
    if (reduced) { cancelAnimationFrame(raf); raf = 0; frame(performance.now()); }
    else if (!raf) raf = requestAnimationFrame(frame);
  }
  function setMotion(mode) {
    if (mode === "play" || mode === "still" || mode === "auto") { motionMode = mode; applyMotion(); }
  }
  function readMotionHash() {
    const m = /motion=(play|still|auto)/.exec(location.hash);
    let mode = m ? m[1] : "auto";
    // Embedded in the gallery, the page sets window.__demoMotion before any frame loads.
    try { if (!m && window.parent !== window && typeof window.parent.__demoMotion === "string") mode = window.parent.__demoMotion; } catch (e) { /* cross-origin parent */ }
    setMotion(mode);
  }
  function wake() { if (reduced) frame(performance.now()); }
  if (motionQuery.addEventListener) motionQuery.addEventListener("change", applyMotion);
  else if (motionQuery.addListener) motionQuery.addListener(applyMotion);
  window.addEventListener("hashchange", readMotionHash);
  window.addEventListener("message", function (e) { if (e.data && typeof e.data.motion === "string") setMotion(e.data.motion); });
  window.addEventListener("resize", wake);

  if (reduced) frame(performance.now());
  else raf = requestAnimationFrame(frame);
  readMotionHash();
})();