← Blog
PhysicsArchitecturePerformanceCanvas API

自前物理エンジン180行の限界点 — Matter.jsを捨てた理由と、カバーできなかったこと

更新 2026年5月27日16 分で読める

このトピックを深掘りする

Canvas vs DOM・インタラクティブUI

CanvasとDOMの使い分け・CSSインタラクション・カスタム物理エンジン・ポートフォリオ設計

全5本中 4 本目。

このトピックの記事一覧

シリーズ全体の流れを見ながら、次に読む記事へ進めます。 初めての方はホームへ →

なぜMatter.jsを使わなかったか

sakimytocomには211のインタラクティブコンポーネントがある。そのうち物理シミュレーションが必要なのは約30個。GravityBadges、BubbleWrap、Breakout、Pinball、NewtonsCradleなど、「重力で落ちる」「ぶつかって跳ね返る」系のコンポーネントだ。

最初はMatter.jsを検討した。2Dゲーム物理のデファクトスタンダードで、拘束ソルバー、摩擦、スタッキング、多角形衝突とフル機能が揃っている。だが数字を見て考えが変わった。

Matter.js physics.ts
バンドルサイズ (minified) ~300KB ~4KB
gzip後 ~80KB ~1.5KB
初期化コスト Engine + World + Render 関数呼び出しのみ
Tree-shaking 不可(クラスベース) 可能(関数エクスポート)

ポートフォリオサイトの初期ロードに300KBの物理エンジンを載せる気にはなれなかった。ユーザーが最初に触れるのはHeroセクションで、物理が動くのはスクロール後のGravityBadgesからだ。そのために全員に300KB払わせるのは設計として間違っている。

判断基準はシンプルだった。「必要な90%を4KBでカバーし、残り10%は個別に書く」。結果的にこの判断は正しかったが、10%の部分で予想以上に手こずることになる。

physics.ts の全体像

src/utils/physics.ts は166行。型定義2つと関数8つで構成される。

Body型

export type Vec2 = {
  x: number
  y: number
}

export type Body = {
  id: string
  pos: Vec2
  vel: Vec2
  acc: Vec2
  mass: number
  radius: number
  restitution: number
  isStatic: boolean
}

Vec2 は2次元ベクトル。Body は物理オブジェクトの全状態を持つ。isStatic フラグで壁やパドルなどの固定オブジェクトを同じ型で扱える。restitution は反発係数で、1.0なら完全弾性衝突、0.0ならエネルギーを全て吸収する。

createBody — デフォルト値つきファクトリ

export function createBody(partial: Partial<Body> & { id: string }): Body {
  return {
    pos: { x: 0, y: 0 },
    vel: { x: 0, y: 0 },
    acc: { x: 0, y: 0 },
    mass: 1,
    radius: 20,
    restitution: 0.7,
    isStatic: false,
    ...partial,
  }
}

id だけ必須にしてあとはスプレッドで上書き。クラスではなくプレーンオブジェクトなので、React stateとの相性がいい。JSONシリアライズも自然にできる。

力と積分

export function applyGravity(body: Body, g = 9.8): void {
  if (body.isStatic) return
  body.acc.y += g
}

export function applyForce(body: Body, force: Vec2): void {
  if (body.isStatic) return
  body.acc.x += force.x / body.mass
  body.acc.y += force.y / body.mass
}

export function integrate(body: Body, dt: number): void {
  if (body.isStatic) return
  body.vel.x += body.acc.x * dt
  body.vel.y += body.acc.y * dt
  body.pos.x += body.vel.x * dt
  body.pos.y += body.vel.y * dt
  body.acc.x = 0
  body.acc.y = 0
}

積分にはSymplectic Euler法を使っている。vel += acc * dt してから pos += vel * dt する順番がポイントで、通常のEuler法(pos を先に更新)よりエネルギー保存が良い。物理シミュレーションの精度はVerlet積分やRK4のほうが上だが、ゲーム用途ではSymplectic Eulerで十分。加速度は積分後にゼロリセットする。毎フレーム力を再適用する設計だ。

衝突検出と衝突応答

export function detectCollision(a: Body, b: Body): { normal: Vec2; depth: number } | null {
  const dx = b.pos.x - a.pos.x
  const dy = b.pos.y - a.pos.y
  const dist = Math.sqrt(dx * dx + dy * dy)
  const minDist = a.radius + b.radius
  if (dist >= minDist || dist === 0) return null
  return {
    normal: { x: dx / dist, y: dy / dist },
    depth: minDist - dist,
  }
}

衝突検出は円-円のみ。2つの円の中心間距離が半径の和より小さければ衝突。dist === 0 のガードは完全に重なった場合のゼロ除算防止。

AABB(軸平行バウンディングボックス)によるブロードフェーズは入れていない。O(n^2)の全ペア比較だが、30体程度なら435回の比較で済む。Math.sqrt 1回と比較2回の軽い処理なので、100体でも問題にならない。

export function resolveCollision(
  a: Body,
  b: Body,
  collision: { normal: Vec2; depth: number },
): void {
  const { normal, depth } = collision

  // 位置補正(めり込み解消)
  const totalMass = a.mass + b.mass
  if (!a.isStatic && !b.isStatic) {
    const ratio = b.mass / totalMass
    a.pos.x -= normal.x * depth * ratio
    a.pos.y -= normal.y * depth * ratio
    b.pos.x += normal.x * depth * (1 - ratio)
    b.pos.y += normal.y * depth * (1 - ratio)
  } else if (!a.isStatic) {
    a.pos.x -= normal.x * depth
    a.pos.y -= normal.y * depth
  } else if (!b.isStatic) {
    b.pos.x += normal.x * depth
    b.pos.y += normal.y * depth
  }

  // インパルス計算
  const relVelX = b.vel.x - a.vel.x
  const relVelY = b.vel.y - a.vel.y
  const relVelDotNormal = relVelX * normal.x + relVelY * normal.y
  if (relVelDotNormal > 0) return  // すでに離れている

  const restitution = Math.min(a.restitution, b.restitution)
  const inverseMassSum = (a.isStatic ? 0 : 1 / a.mass) + (b.isStatic ? 0 : 1 / b.mass)
  if (inverseMassSum === 0) return

  const impulseMag = (-(1 + restitution) * relVelDotNormal) / inverseMassSum

  if (!a.isStatic) {
    a.vel.x -= (impulseMag / a.mass) * normal.x
    a.vel.y -= (impulseMag / a.mass) * normal.y
  }
  if (!b.isStatic) {
    b.vel.x += (impulseMag / b.mass) * normal.x
    b.vel.y += (impulseMag / b.mass) * normal.y
  }
}

衝突応答はインパルスベース。まず質量比でめり込みを解消し、次に反発係数を考慮したインパルスを速度に適用する。isStatic な物体の逆質量を0として扱うことで、壁との衝突が自然に動く。

step関数 — ワンフレームの全処理

export function step(bodies: Body[], dt: number, bounds: { width: number; height: number }): void {
  for (const body of bodies) applyGravity(body)
  for (const body of bodies) integrate(body, dt)
  for (let i = 0; i < bodies.length; i++) {
    for (let j = i + 1; j < bodies.length; j++) {
      const collision = detectCollision(bodies[i], bodies[j])
      if (collision) resolveCollision(bodies[i], bodies[j], collision)
    }
  }
  for (const body of bodies) constrainToBounds(body, bounds.width, bounds.height)
}

重力 → 積分 → 衝突検出・応答 → 境界制約。この順番で1フレーム分の物理演算が完結する。コンポーネント側は step(bodies, dt, { width, height }) を呼ぶだけ。

うまくいったこと(カバーできた90%)

physics.tsの3機能(重力・円衝突・境界反射)だけで、30個中25個のコンポーネントが動作する。以下が代表例だ。

GravityBadges

スキルバッジが画面内に物理落下し、ドラッグで投げられる。createBody でバッジごとにBodyを生成し、ドラッグ中は isStatic = true にして手動で位置を更新。離した瞬間に isStatic = false に戻してドラッグ中の移動量から速度を計算する。physics.tsの想定ユースケースそのもの。

BubbleWrap

プチプチを潰したときのパーティクル飛散。各パーティクルをBodyにして applyForce で放射状に飛ばし、重力で自然に落下させる。パーティクルは小さいので円の近似で全く問題ない。

Breakout / Pinball

ブロック崩しとピンボール。ボールの反射はまさに円-円衝突と境界反射の組み合わせ。パドルやフリッパーは isStatic なBodyとして衝突応答に参加させる。Pinballのフリッパーは回転が必要だが、角度計算は個別実装してフリッパー先端の円で衝突を取った。

NewtonsCradle

ニュートンのゆりかご。5つの球が一列に並び、端の球を引いて離すと反対側の球が飛び出す。運動量保存とエネルギー保存がインパルスベース衝突応答で自然に再現できた。restitution: 0.99 にしてほぼ完全弾性衝突。

これらのコンポーネントに共通するのは「円が動いて、ぶつかって、跳ね返る」というパターンだ。physics.tsはまさにこのパターンに特化している。

限界点(カバーできなかった10%)

拘束ソルバー、摩擦、スタッキング、CCD、多角形衝突の5つはphysics.tsでカバーできず、個別に20〜50行の専用コードで対処した。

1. 拘束ソルバー — DoublePendulum

二重振り子は2つの振り子が棒で連結された系。棒の長さは常に一定でなければならない。これは「拘束条件」であり、physics.tsにはない。

Matter.jsなら Constraint オブジェクトで棒を表現し、反復ソルバーが位置を修正してくれる。自前では別のアプローチを取った。ラグランジュ力学で角度の運動方程式を直接導出し、角度ベースでシミュレーションする。

// ラグランジュ方程式から導出した角加速度
const alpha1 = (-g * (2 * m1 + m2) * sin(theta1)
  - m2 * g * sin(theta1 - 2 * theta2)
  - 2 * sin(theta1 - theta2) * m2
    * (omega2 * omega2 * l2 + omega1 * omega1 * l1 * cos(theta1 - theta2)))
  / (l1 * (2 * m1 + m2 - m2 * cos(2 * theta1 - 2 * theta2)))

166行の汎用エンジンでは無理だが、20行の専用コードで正確に動く。汎用を諦めて用途に最適化したほうが精度が出る典型例。

2. 摩擦 — Cloth Simulation

布シミュレーションではパーティクル間をバネで接続する。バネ-ダンパーモデルの「ダンパー」部分が摩擦の代替だ。physics.tsのインパルス応答には摩擦の接線成分がない。

// バネ-ダンパーモデル
const springForce = -k * (dist - restLength)
const dampingForce = -d * relativeVelocity
const totalForce = springForce + dampingForce

摩擦をphysics.tsに追加することは可能だったが、使うコンポーネントがClothとRope程度だったので個別実装にした。汎用エンジンに機能を足すと全体の複雑度が上がる。使用頻度の低い機能のために全コンポーネントがそのコストを払うのは避けたい。

3. スタッキング — Sokoban

箱を積み上げる物理は意外と難しい。複数の物体が接触した状態で静止する「スタッキング安定性」は、反復ソルバーとスリープ(静止判定)が必要になる。

Sokoban(倉庫番)ではグリッドベースの移動に逃げた。物理シミュレーションではなく、タイル単位の離散的な移動ルールで箱を動かす。結果的にこのほうがゲーム性も上がった。物理的にリアルな箱の挙動よりも、パズルとしてのルールの明確さが重要だった。

4. 連続衝突検出(CCD)

高速で移動する物体が薄い壁をすり抜ける「トンネリング」問題。離散的な位置更新では、1フレームで壁の向こう側に移動してしまうことがある。

Matter.jsにはCCDオプションがあるが、physics.tsにはない。対処法は2つ。dtを小さく保つ(16msを超えないようにclamp)か、速度の上限を設定する。

const clampedDt = Math.min(dt, 0.016)
const maxSpeed = bounds.width * 0.5 // 1フレームで画面の半分以上は移動しない
const speed = Math.sqrt(body.vel.x ** 2 + body.vel.y ** 2)
if (speed > maxSpeed) {
  body.vel.x = (body.vel.x / speed) * maxSpeed
  body.vel.y = (body.vel.y / speed) * maxSpeed
}

厳密なCCDではないが、ポートフォリオのインタラクションでは十分に機能する。

5. 多角形衝突(SAT)

Separating Axis Theorem(分離軸定理)による多角形同士の衝突判定は実装していない。全てのオブジェクトを円で近似している。

Tetrisのブロックは回転時の衝突が問題になりそうだが、グリッドベースの当たり判定で処理している。物理エンジンの出番ではない。Breakoutのブロックも、ブロック側を isStatic な円として扱い、見た目だけ矩形にしている。わずかにめり込みが見えることがあるが、ゲーム速度では気にならない。

パフォーマンス比較

ベンチマークはChrome 131、M2 MacBook Air、100体の円を自由落下させて衝突させた場合。

指標 Matter.js physics.ts
1フレームのstep() ~2.0ms ~0.3ms
初期化 ~5ms (Engine + World) ~0.1ms (配列生成)
メモリ (100体) ~2MB ~50KB
バンドルサイズ (min) ~300KB ~4KB
Tree-shaking 不可 可能(関数単位)

physics.tsが速い理由は単純だ。機能が少ない。摩擦計算なし、スリープ判定なし、ブロードフェーズなし、拘束ソルバーなし。物体数が1000を超えるとO(n^2)の衝突検出がボトルネックになるが、ポートフォリオの用途では100体を超えることがほぼない。

「アーケードゲームには十分。リアルシミュレーションには不足」。これがphysics.tsの正確なポジションだ。

教訓

「必要な90%を4KBでカバーし、残り10%は個別実装」が最適解だった。以下の3つが具体的な学びだ。

フルスペックのライブラリを入れる前に、本当に必要な機能を数える

Matter.jsの機能一覧を見ると全部欲しくなる。だが実際に使うのは重力、円衝突、境界反射の3つだった。30個のコンポーネントのうち25個はこの3つで事足りる。残り5個が必要とする拘束・摩擦・SATは、それぞれ20-50行の個別実装で済んだ。

汎用ライブラリの300KBと、専用コードの4KB + 個別実装200行。後者のほうがバンドルサイズも小さく、各コンポーネントの物理挙動を自由にチューニングできる。

90%で十分なら自作が勝つ

バンドルサイズだけの話ではない。自前実装は挙動を完全に理解している。バグが出たときにデバッグが速い。パフォーマンスチューニングの余地も大きい。GravityBadgesの反発係数を0.3にしたい、BubbleWrapのパーティクルには重力を弱めにしたい、といった調整が関数の引数1つで済む。

残り10%は個別実装のほうが品質が高い

DoublePendulumをphysics.tsの拘束ソルバーで実装していたら、反復回数の調整やドリフト補正で苦労しただろう。ラグランジュ力学で直接解いたほうが物理的に正確で、コードも短い。ClothのバネダンパーもMatter.jsの拘束より挙動が自然だ。用途に最適化した専用コードは、汎用ソリューションに勝る。

166行の物理エンジンは「全てを解く」ためのものではない。「よくあるパターンを一瞬で片付ける」ためのものだ。その割り切りが、211コンポーネントのポートフォリオを4KBの物理コストで動かしている。

よくある質問

Q. Matter.jsの代わりに自作物理エンジンを使うメリットは何ですか?
バンドルサイズが300KBから4KBに削減でき、Tree-shakingも可能になる。さらに挙動を完全に理解しているためデバッグが速く、コンポーネントごとの物理チューニングも容易。
Q. 166行の物理エンジンでカバーできない機能は何ですか?
拘束ソルバー(二重振り子など)、摩擦、スタッキング安定性、連続衝突検出(CCD)、多角形衝突(SAT)の5つ。それぞれ20〜50行の専用コードで個別に対処した。
Q. 自作物理エンジンで何体まで安定して動作しますか?
O(n²)の全ペア衝突検出で100体程度まで問題なく動作する。1000体を超えるとボトルネックになるが、ポートフォリオの用途では100体を超えることがほぼない。