oobe.

JavaScriptで物理エンジンを自作した話 — PLAYGROUNDの仕組み

#JavaScript #Canvas #物理演算 #Web制作

当サイトのトップページに、技術スタックのボールを掴んで投げられる「PLAYGROUND」というセクションがあります。実はこれ、物理エンジンのライブラリ(Matter.js など)を一切使わず、約150行のプレーンな JavaScript で動いています。

「物理演算」と聞くと難しそうに感じますが、必要な要素を分解すると、高校物理レベルの計算の積み重ねです。この記事では、その仕組みをコード付きで解説します。

なぜライブラリを使わなかったのか

Matter.js のような優秀な物理エンジンは存在しますが、今回はあえて自作しました。理由は3つあります。

  1. 必要な機能が少ない — 円同士の衝突と重力だけなら、ライブラリの数百KBは過剰です
  2. 読み込みを軽くしたい — 当サイトは Astro 製の静的サイトで、パフォーマンスを最優先しています
  3. 仕組みを見せたかった — 「作れます」と言うより、動くものを見せる方が早いからです

全体の構成 — 状態と描画ループ

各ボールは「位置」と「速度」だけを持つオブジェクトです。

type Body = {
  x: number;  y: number;   // 位置
  vx: number; vy: number;  // 速度
  r: number;               // 半径
  label: string;           // 表示する技術名
};

これを requestAnimationFrame のループで毎フレーム更新(step)→ 描画(draw)します。ゲームエンジンでもこの構造は同じで、物理演算の本体は step 関数に集約されます。

重力・空気抵抗・壁の反射

1フレームごとに、速度へ重力を足し、速度を位置へ足す。これだけで「落下」が表現できます。

const GRAVITY = 0.34; // 毎フレーム下向きに加速
const AIR = 0.995;    // 空気抵抗(速度を少しずつ減衰)
const BOUNCE = 0.55;  // 反発係数(1で完全反射)

b.vy += GRAVITY;
b.vx *= AIR;
b.vy *= AIR;
b.x += b.vx;
b.y += b.vy;

// 床に当たったら反転して減衰
if (b.y + b.r > H) {
  b.y = H - b.r;
  b.vy = -Math.abs(b.vy) * BOUNCE;
}

ポイントは BOUNCE = 0.55 のような定数の調整です。数式が正しくても、係数次第で「ゴムボール」にも「濡れた雑巾」にもなります。ここは理論ではなく、何度も触って気持ちいい値を探しました。

円同士の衝突 — 重なりを解消して速度を交換

物理エンジンらしさの核心は衝突処理です。円同士なら判定はシンプルで、中心間の距離が半径の合計より小さければ衝突です。

const dx = b.x - a.x, dy = b.y - a.y;
const dist = Math.hypot(dx, dy);
if (dist < a.r + b.r) {
  // 1. めり込んだ分だけ押し戻す(位置補正)
  const nx = dx / dist, ny = dy / dist; // 衝突方向の単位ベクトル
  const overlap = (a.r + b.r - dist) / 2;
  a.x -= nx * overlap;  a.y -= ny * overlap;
  b.x += nx * overlap;  b.y += ny * overlap;

  // 2. 近づく向きの速度成分を反発させる(速度解決)
  const vn = (b.vx - a.vx) * nx + (b.vy - a.vy) * ny;
  if (vn < 0) {
    const imp = (-(1 + BOUNCE) * vn) / 2;
    a.vx -= imp * nx;  a.vy -= imp * ny;
    b.vx += imp * nx;  b.vy += imp * ny;
  }
}

厳密な剛体シミュレーションでは質量や回転も考慮しますが、「見て楽しい」が目的なら、この位置補正 + 速度交換の簡易版で十分それらしく動きます。すべてのペアを二重ループで総当たりしていますが、ボールは14個なので計算量は問題になりません。

「掴んで投げる」の実装

ドラッグ操作は、掴んでいる間はボールをポインタ位置に固定し、直前フレームとの差分を速度として与えるだけです。

if (grabbed) {
  grabbed.x = pointer.x;
  grabbed.y = pointer.y;
  // ポインタの移動量 ≒ 投げたときの初速になる
  grabbed.vx = (pointer.x - pointer.px) * 0.9;
  grabbed.vy = (pointer.y - pointer.py) * 0.9;
}

離した瞬間、最後に計算された速度がそのまま残るので、勢いよく動かして離せば「投げた」ことになります。慣性の実装というと大げさに聞こえますが、実体はこの4行です。

見えないところの配慮

演出のためのコードこそ、パフォーマンスへの配慮が必要です。PLAYGROUND では次の対策を入れています。

  • 画面外では停止IntersectionObserver でセクションが見えている間だけループを回す
  • prefers-reduced-motion 対応 — 動きを減らす設定のユーザーには、アニメーションなしの静止配置で表示
  • devicePixelRatio の上限を2に制限 — 高DPIディスプレイでの無駄な描画負荷を防止

飾りのアニメーションがサイト全体を重くしては本末転倒です。この点は、遊び心のある演出を入れるときほど気をつけています。

まとめ

  • 物理演算の基本は「速度に重力を足す、位置に速度を足す」の繰り返し
  • 円同士の衝突は「距離判定 → 位置補正 → 速度交換」で十分それらしくなる
  • ドラッグ&スローは、ポインタの移動差分を速度に変換するだけ
  • 演出には IntersectionObserverprefers-reduced-motion などの配慮をセットで

ライブラリに頼らず仕組みから作れると、デザインの要望に合わせた細かい調整が自由にできます。トップページの PLAYGROUND で実際に投げて遊んでみてください。

このような「ちょっと触りたくなる仕掛け」を含めたWebサイト制作のご相談は、お問い合わせからお気軽にどうぞ。