GODRICH
#cssアニメーション#ボタン#ローディング#トランジション#スクロール#ダッシュボード#実用ui#デザイントレンド

物理エンジン 作り方 9選 — Verletロープからラグドールまで

『物理エンジン 作り方』の鍵は、速度を変数に持たず前フレームとの差だけで次の位置を決めるVerlet積分と、伸びた制約を繰り返し戻す反復解法の2つにあります。9つの実験盤はライブラリなしで動き、Canvas 2Dだけでロープ・布・ゼリー・ラグドールから液滴までを計算し、コードはそのまま貼って動きます。

自動再生 · タイルを押すとその項目へ · すべて1つのzip

9つの並びは、材料が育つ順です。01のロープは14個の点を距離制約でつなぐ最小の物体、02の布は同じ制約を格子へ広げたもの、03のゼリーはその網を輪に巻いて内側に圧力を与えたものです。04と05は回転と関節が加わって剛体と人形になり、06の浮力と07のドミノは水と連鎖という条件をひとつずつ重ねます。08の旗は格子に時間とともに変わる風を送り、09の液体は網を捨てて点そのものを粒子として撒きます — 物理エンジン開発で最後にたどり着く材料です。盤はピクセルを計算するcanvasの特殊効果と同じキャンバスを持ちますが、絵を描く側ではなく物理が計算する側に立っています。

物理エンジン シミュレーションと何が違うか

物理エンジンとは、重力・衝突・ばねといった動きを数式にして、毎フレーム位置を計算する仕組みのことです。先に出た物理シミュレーション 作り方 9選は、速度に力を足して位置を押す道 — ばねの行き過ぎ、自由落下、単振り子、逆二乗の引力、壁の反射 — を選びました。今回の9つの盤は、速度を変数に持たないVerlet積分と、伸びた制約を何度も戻す反復解法で、同じ画面へ別の扉から入ります。どちらが優れているかではなく、計算の出発点が違い、その違いが柔らかい物体を保てるかどうかを分けます。

速度を保存しない積分

Verlet積分は次の位置を「今の位置 + (今の位置 − 前の位置)」だけから決めます。速度を保存しないため、衝突や制約で位置を強制的に戻しても、次のフレームの計算は自動でつじつまが合います。速度を持つ積分では位置を動かした瞬間に速度と食い違いますが、位置しかなければ食い違う相手がそもそもいません。

制約を繰り返し解く構造

距離制約ひとつがするのは、2点を目標の間隔へ戻すことだけです。ロープ・布・ゼリー・ラグドールはすべて、このひとつの操作を盤ごとに数回繰り返して全体を収束させています。01のスライダーで反復数を3から8へ上げると、ロープが目に見えて張ります。

回転まで載る剛体の衝突

04はここに、衝突法線に沿って速度を受け渡す衝撃量と、接触点が中心から外れたぶんだけ生じる回転のトルクを足します。点と一緒に角度も積分すると、図形は転がりながら跳ねていきます。物理エンジン サイト のまとめが挙げる汎用ライブラリの内部でも、この2つの計算が最も重い席を占めます。

01チェーンロープ揺れ

画面で見えるもの

端を掴んで引くと、14個の点を距離制約でつないだロープ全体が、制約を解き戻しながら追ってきます。引っ張って遊ぶヒーローバナーや、ドラッグで開く引き出しの取っ手にそのまま使えます。

チェーンロープ揺れ
新しいタブで開く

01 · チェーンロープ揺れ — 計測 累積面積 10.067% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

requestAnimationFramepointerdownpointermovetranslateY

コードの見どころ

レンダーの実測は累積面積 10.067% ・強度(ピクセル変化量の平均) 135.4 ・動いたフレーム 23/23 です。

  for (var k = 0; k < iters; k++) {
    for (var j = 0; j < N - 1; j++) {
      var a = pts[j], b = pts[j + 1];
      var dx = b.x - a.x, dy = b.y - a.y;
      var d = Math.sqrt(dx * dx + dy * dy) || 0.0001;
      var off = (d - SEG) / d * 0.5;
      var ax = dx * off, ay = dy * off;
      if (!a.pinned && j !== dragging || k === 0 && j !== dragging && !a.pinned) { /* no-op */ }
      var aFree = !a.pinned && j !== dragging;
      var bFree = !b.pinned && j + 1 !== dragging;
      if (aFree && bFree) { a.x += ax; a.y += ay; b.x -= ax; b.y -= ay; }
      else if (aFree) { a.x += ax * 2; a.y += ay * 2; }
      else if (bFree) { b.x -= ax * 2; b.y -= ay * 2; }
    }
  }
  if (dragging >= 0) { pts[dragging].x = dragX; pts[dragging].y = dragY; }

02布の破れ

何が起きるか

網目をつかんで強く引くと、目標距離のtear倍まで伸びた制約だけが切れて、穴が残ります。切れた制約は二度とつながりません。

布の破れ
新しいタブで開く

02 · 布の破れ — 計測 累積面積 11.325% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

requestAnimationFramepointerdownpointermovetranslateY

コードの読み方

実測記録は累積面積 11.325% ・強度 70.5 ・動いたフレーム 23/23 です。

  for (var k = 0; k < 3; k++) {
    for (var j = 0; j < links.length; j++) {
      var L = links[j];
      if (L.dead) continue;
      var a = pts[L.a], b = pts[L.b];
      var dx = b.x - a.x, dy = b.y - a.y;
      var d = Math.sqrt(dx * dx + dy * dy) || 0.0001;
      // 破れ判定 — 目標距離の tear 倍まで伸びた制約は永久に切る
      if (d > L.len * tear) {
        L.dead = true; torn++;
        ripVal.textContent = String(torn);
        continue;
      }
      var off = (d - L.len) / d * 0.5;
      var ax = dx * off, ay = dy * off;
      var aFree = !a.pin && L.a !== dragging;
      var bFree = !b.pin && L.b !== dragging;
      if (aFree && bFree) { a.x += ax; a.y += ay; b.x -= ax; b.y -= ay; }
      else if (aFree) { a.x += ax * 2; a.y += ay * 2; }
      else if (bFree) { b.x -= ax * 2; b.y -= ay * 2; }
    }

03ぷるぷるゼリー

どんな動きか

ゼリーを押すと、押して減った面積のぶんだけ外向きの圧力が強まり、手を離すと元の形へ波打ちながら戻ります。いいねボタンのぷにっとした反応に向いています。

ぷるぷるゼリー
新しいタブで開く

03 · ぷるぷるゼリー — 計測 累積面積 6.381% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

requestAnimationFramepointerdowntranslateY

実測の値

フレームの実測は累積面積 6.381% ・強度 136.5 ・動いたフレーム 23/23 です。

  // (2) 圧力 — 面積の不足分を法線方向の力へ。法線は隣接2点の差分から計算する
  var A = area();
  var press = (AREA0 - A) / AREA0 * k * 7200 * dt * dt;
  for (var v = 0; v < N; v++) {
    var q = pts[v];
    var pr = pts[(v + N - 1) % N], nx2 = pts[(v + 1) % N];
    var tx = nx2.x - pr.x, ty = nx2.y - pr.y;
    var tl = Math.hypot(tx, ty) || 0.0001;
    // 点は反時計回りに並べてある(靴ひも公式で正)。この走査では外向き法線は (ty, -tx) —
    // 符号を逆にすると不足した面積を内側へ押し、ゼリーは自ら折りたたまれる
    q.nx = ty / tl; q.ny = -tx / tl;
    q.x += q.nx * press;
    q.y += q.ny * press;
  }

04転がる衝突

画面で起きること

図形をつかんで投げると、衝突法線に沿って速度を受け渡し、当たり方が中心からずれたぶんだけ転がります。反発スライダーで跳ね返りの強さを変えられます。

転がる衝突
新しいタブで開く

04 · 転がる衝突 — 計測 累積面積 6.933% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

requestAnimationFrametranslaterotate

コードの急所

実測は累積面積 6.933% ・強度 157.5 ・動いたフレーム 23/23 — 9つの盤の中で最も強度が高いです。

function impulse(a, b, n, cx2, cy2, e) {
  var avx = a.x - a.px, avy = a.y - a.py;
  var bvx = b.x - b.px, bvy = b.y - b.py;
  var vRel = (avx - bvx) * n.x + (avy - bvy) * n.y;
  if (vRel > 0) return false;                 // すでに離れていく途中
  var j = -(1 + e) * vRel / (a.im + b.im);
  var jx = n.x * j, jy = n.y * j;
  a.px -= jx * a.im; a.py -= jy * a.im;       // 正方向の衝撃量 = 速度の増加
  b.px += jx * b.im; b.py += jy * b.im;
  // トルク — 接触点の腕が中心からずれていれば角速度へ回す(四角だけが受ける)
  if (a.kind === 'b') {
    var la = toLocal(a, cx2, cy2);
    a.pa -= (la.x * n.y - la.y * n.x) * j * a.im * 0.00013;
  }
  if (b.kind === 'b') {
    var lb = toLocal(b, cx2, cy2);
    b.pa += (lb.x * n.y - lb.y * n.x) * j * b.im * 0.00013;
  }
  return true;
}

05ラグドール関節

どんな動きか

どの関節でも引っ張ると、長さ制約が連鎖で解けて、人形全体が折れたり垂れたりします。クリックで脱力するキャラクターの隠し要素に合います。

ラグドール関節
新しいタブで開く

05 · ラグドール関節 — 計測 累積面積 11.719% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

requestAnimationFramepointerdownatan2

どこを見るか

実測は累積面積 11.719% ・強度 138.7 ・動いたフレーム 23/23 です。

function solveLink(lk, strength) {
  var a = pts[lk.a], b = pts[lk.b];
  var dx = b.x - a.x, dy = b.y - a.y;
  var d = Math.sqrt(dx * dx + dy * dy) || 0.0001;
  var target = d;
  if (d > lk.len) target = lk.len;
  else if (d < lk.min) target = lk.min;
  else return;                               // 範囲内 — そのまま
  var off = (d - target) / d * 0.5 * strength;
  var ox = dx * off, oy = dy * off;
  var aFree = lk.a !== dragging, bFree = lk.b !== dragging;
  if (aFree && bFree) { a.x += ox; a.y += oy; b.x -= ox; b.y -= oy; }
  else if (aFree) { a.x += ox * 2; a.y += oy * 2; }
  else if (bFree) { b.x -= ox * 2; b.y -= oy * 2; }
}

06浮力フロート

画面で起きること

物体を投げ入れると、沈んだ面積のぶんだけ浮力が大きくなり、重力と釣り合う深さで浮かびます。水中では空気より重い流体抵抗が速度を削ります。

浮力フロート
新しいタブで開く

06 · 浮力フロート — 計測 累積面積 9.128% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

requestAnimationFramepointermovetranslateY

コードの読み方

実測は累積面積 9.128% ・強度 88.1 ・動いたフレーム 23/23 です。

  var sinkSum = 0;
  for (var i = 0; i < bodies.length; i++) {
    var b = bodies[i];
    if (i === dragging) { b.x = dragX; b.y = dragY; continue; } // つかんだ物体は手が持つ(px そのまま → 離した瞬間に慣性)
    var bh = b.shape === 'c' ? b.r * 2 : b.h;
    var wl = surfaceY(b.x, t);
    var d = Math.max(0, Math.min(bh, b.y + half(b) - wl)); // 浸水深度
    var full = b.shape === 'c' ? Math.PI * b.r * b.r : b.w * b.h;
    var sub = b.shape === 'c' ? capArea(b.r, d) : b.w * d;
    var ratio = strength * RHO * sub / (b.dens * full);     // 浮力/重力比 — 1 なら釣り合い
    var inW = d > 0.5;
    var damp = inW ? FLUID : AIR;
    var slope = (surfaceY(b.x + 3, t) - surfaceY(b.x - 3, t)) / 6; // 波の傾き → 水平への押し
    var vx = (b.x - b.px) * damp, vy = (b.y - b.py) * damp;
    b.px = b.x; b.py = b.y;
    b.x += vx + G * Math.min(ratio, 1.4) * slope * 0.16 * dt * dt;
    b.y += vy + G * (1 - ratio) * dt * dt;

07連鎖ドミノ

何が見えるか

最初のコマを右へ引くと、倒れる回転が次のコマへ角速度を渡して、連鎖が走ります。間隔スライダーでコマの間隔を広げられます。

連鎖ドミノ
新しいタブで開く

07 · 連鎖ドミノ — 計測 累積面積 12.262% · 動いたフレーム 11/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

requestAnimationFramepointerdownrotate

実測の値

実測は累積面積 12.262% ・強度 93.4 ・動いたフレーム 11/23 で、連鎖が早く終わると静止フレームがそのまま残ります。

  var crit = Math.atan2(HW, H2); // 重心が支点の上に来るしきい角
  var rest = 1.485;              // 床にほぼ寝る角度
  var fell = 0;
  for (var i = 0; i < N; i++) {
    var t = tiles[i];
    var angacc;
    if (t.a >= crit) {
      t.down = true;
      angacc = 2200 / H2 * Math.sin(t.a + 0.18); // 倒れるモーメント — 進むほど大きくなる
    } else {
      angacc = -2600 / H2 * Math.sin(crit - t.a); // 復元モーメント — 立ち戻ろうとする側
    }
    // 角度のVerlet: a += a - pa + angacc·dt²
    var aa = t.a;
    t.a = t.a + (t.a - t.pa) * 0.996 + angacc * dt * dt;
    t.pa = aa;
    if (t.a < 0) { t.a = 0; t.pa = t.a; }            // 床の反発を吸収
    if (t.a > rest) { t.a = rest; t.pa = t.a - (t.pa - t.a) * 0.1; } // 寝て少し跳ねて収まる

08風になびく旗

どんな動きか

旗竿側の1列だけを固定した点の格子が、位相のずれた正弦波の風になびきます。カーソルを盤に乗せると、その場所が距離減衰する突風の源になります。

風になびく旗
新しいタブで開く

08 · 風になびく旗 — 計測 累積面積 25.599% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

requestAnimationFrametranslateY

コードの見どころ

実測は累積面積 25.599% ・強度 109.2 ・動いたフレーム 23/23 — 9つの盤の中で画面が最もよく動きます。

  // (1) 積分 — 正弦波の風 + カーソルの突風 + 重力
  for (var r = 0; r < ROWS; r++) {
    for (var c = 0; c < COLS; c++) {
      var p = pt(r, c);
      if (p.pin) continue;
      var fall = 0.35 + 0.65 * (c / (COLS - 1)); // 旗竿から遠いほど風を多く受ける
      var fx = Math.sin(t * 2.2 + r * 0.9 + c * 0.35) * 520 * base * fall + 90 * base; // 位相変調の正弦波 + 弱い定常流
      var fy = Math.sin(t * 1.6 + c * 0.5) * 140 * base * fall;
      if (gustOn) {
        var dx = p.x - gustX, dy = p.y - gustY;
        var d2 = Math.hypot(dx, dy);
        var g = 3200 * Math.exp(-d2 / 90); // 距離減衰の突風
        if (d2 > 0.5) { fx += dx / d2 * g; fy += dy / d2 * g * 0.35; }
      }
      var vx = (p.x - p.px) * DAMP, vy = (p.y - p.py) * DAMP;
      p.px = p.x; p.py = p.y;
      p.x += vx + fx * dt * dt;
      p.y += vy + (fy + GRAV) * dt * dt;
    }
  }

09はねる液滴

何が起きるか

粒子ごとに近傍密度を計算し、密につまった粒子が押し返し合う圧力で、液滴がまとまったり割れたりします。再び丸くまとまる様子が表面張力に見えます。

はねる液滴
新しいタブで開く

09 · はねる液滴 — 計測 累積面積 21.528% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

requestAnimationFramepointerdowntranslate

実測の値

実測は累積面積 21.528% ・強度 44.5 ・動いたフレーム 23/23 で、粒子が均一に広がるぶん強度は低く出ています。

  // (2) 局所密度 — 近傍半径 h 内の粒子のカーネル w = 1 - r/h の和
  //     O(N²) だが N=120 なのでフレームあたり14,400組 — 十分に軽い
  var dens = new Float64Array(N);
  var nbr = [];               // 近傍リスト(圧力・粘性・表面張力で再度使う)
  for (var a = 0; a < N; a++) nbr.push([]);
  for (var a2 = 0; a2 < N; a2++) {
    for (var b = a2 + 1; b < N; b++) {
      var dx = ps[b].x - ps[a2].x, dy = ps[b].y - ps[a2].y;
      var d2 = dx * dx + dy * dy;
      if (d2 >= h2) continue;
      var d = Math.sqrt(d2) || 0.0001;
      var w = 1 - d / H;
      dens[a2] += w; dens[b] += w;
      nbr[a2].push(b); nbr[b].push(a2);
    }
  }
  var RHO0 = 5.5;             // 目標密度 — このカーネルで安定した液滴が持つ密度(実測のチューニング値)

バリエーション — 値を1つだけ変える

値を1つ変えるだけで盤の性格が変わります — 9つから3つを抜き出しました。

名前 変える値 感じ
制約反復を増やす 01 ソルバー回数 3 → 8 ロープが伸びにくくなりピンと張る
反発係数を上げる 04 反発係数 0.5 → 0.85 当たった図形が長く転がり続ける
風を強くする 08 風係数 0.6 → 1.4 旗の端がより大きくなびく

どこで壊れるか — 落とし穴

ゼリー法線の符号

03の圧力は面積の不足分を外向き法線で押し出しますが、点を反時計回りに並べたこの輪では、外向き法線は (ty, -tx) です。符号を逆に書くと不足分を内側へ押し、ゼリーは自分で折りたたまれてしまいます。描画でも同じ種類の不具合と戦いました — 光沢を画面固定の位置に描くと沈んだ本体とずれるので、今は光沢が本体の重心を追いかけてから縁を重ねています。同じ作り方で作るキャラクターの描き方と時間設計はcss キャラクター アニメーションにまとまっています。

ドミノは連鎖が終わると止まる

07の動いたフレームが11/23にとどまったのは計測の失敗ではなく、各コマは自分の傾きがしきい角を超えて初めて倒れるモーメントを受けるため、連鎖が終わると盤は物理的に静止するからです。間隔の倍率を広げると、渡される角速度が次のコマへ届く前に消えて、途中で止まります。記録された累積面積 12.262% と強度 93.4 も、この静止を含んだ値です。

トルク係数を上げると盤があばれる

04の回転は、接触点の腕が中心から外れた量を角速度へ変える項で、係数は 0.00013 と意図的に小さくあります。この値を大きくすると四角形は当たるたびにスピンが積もって、ビリヤードではなく床の体操になってしまいます。反発係数とトルク係数は必ず一緒に動かさないと手触りが崩れます。形ではなく場面全体を回す仕事は3D 地球儀サイト演出の担当です。

9つをまとめたアーカイブのパスワードは zxsbjc6j で、開くと各盤の index.html がそのまま入っています。

アクセシビリティ (reduced-motion)

9つの盤すべての操作面に role="application" と tabindex="0" を、説明の aria-label を添えています。prefers-reduced-motion: reduce の環境では、ドラッグで動く01・02・03・05は掴んだ点だけを動かし、制約が振動なしに一瞬で追従します。06と09は自動デモを始めず、09は設定が変わった瞬間(changeイベント)まで聞きます。08はカーソルがないときに吹いていた仮想の突風を止めます。04と07は自動デモが周期的に回る盤なので、reduceでも止まりません — ただし07では、ドラッグで最初のコマがしきい角を超えた瞬間、慣性を省略して連鎖の開始速度だけを与えます。人がどの盤に触れた瞬間にも自動デモは途切れ、本物のポインタに切り替わります。動きをどこまで減らすかの基準はWCAGのAnimation from Interactionsに従いました。

FAQ

物理エンジン サイト によく載るmatter.jsのようなライブラリと、この9つの実験盤は何が違うか

どの盤もCanvas 2DとrequestAnimationFrame、Verlet積分、制約の反復だけで動いており、CDNも物理エンジンライブラリも1行ないので、ファイルを開けばすぐ動きます(requestAnimationFrameのドキュメント)。

リフレッシュレートが違う画面でも同じ速さで動くか

力はすべて時間刻みdtの2乗で掛けており、dtは0.033秒で上限を決めています。60Hzでも120Hzでも、ロープと布は同じ時間で同じだけ伸びます。

タッチ画面でも引っ張って投げられるか

入力はPointer Eventsひとつで、マウス・ペン・指を同じイベントで受けます(対応表)。指でロープを掴んだり図形を投げたりする動作は、マウスと同じようにそのまま動きます。イベントの性質はPointer Eventsのドキュメントにまとまっています。

解凍パスワードを入力してください

パスワードはこの記事の本文の中にあります。読み進めると出てきます。