← Blog
PhysicsTypeScriptGame DevelopmentMath

衝突判定の基礎 — 円と矩形、そしてインパルスベースの衝突応答

7 分で読める

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

ブラウザゲーム開発

ゲームループ・衝突判定・経路探索・迷路生成など、ブラウザゲーム開発の実装パターン集

全7本中 5 本目。

このトピックの記事一覧

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

physics.tsの全体像

sakimytocomの物理エンジンは src/utils/physics.ts に約170行。TypeScriptで書かれたBody型、力の適用、積分、衝突判定、衝突応答、境界制約の6つの関数で構成される。

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

isStatic フラグがあるのは壁やパドルなど「動かない物体」を同じBody型で扱うため。

円同士の衝突判定

2つの円が重なっているかは、中心間の距離と半径の和を比較するだけ:

function detectCollision(a: Body, b: Body) {
  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,
  }
}

dist === 0 のガードは、完全に重なった場合のゼロ除算を防ぐ。実際のBreakoutでボールがパドルに埋まるバグを経験して追加した。

インパルスベースの衝突応答

衝突が検出されたら2つの処理を行う: 位置の分離速度の変更

位置の分離

質量比に応じてめり込みを解消する:

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
}

質量が大きい物体ほど動きにくい。直感に合う。

速度の変更(インパルス)

運動量保存則と反発係数から、衝突後の速度を計算する:

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 impulse = (-(1 + restitution) * relVelDotNormal) / inverseMassSum

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

restitution = 1 なら完全弾性衝突(ElasticCollision)、restitution = 0 なら完全非弾性衝突。Pongのボールは0.95くらいに設定している。

Breakoutでの矩形判定

ブロックとボールの衝突はAABB(軸平行バウンディングボックス)と円の判定になる:

function circleRectCollision(
  circle: { x: number; y: number; radius: number },
  rect: { x: number; y: number; width: number; height: number },
) {
  const closestX = Math.max(rect.x, Math.min(circle.x, rect.x + rect.width))
  const closestY = Math.max(rect.y, Math.min(circle.y, rect.y + rect.height))

  const dx = circle.x - closestX
  const dy = circle.y - closestY
  const dist = Math.sqrt(dx * dx + dy * dy)

  if (dist >= circle.radius) return null

  return {
    normal: dist > 0
      ? { x: dx / dist, y: dy / dist }
      : { x: 0, y: -1 },
    depth: circle.radius - dist,
  }
}

矩形上の最近点を求めてから円との距離を計算する。この手法はどんなアスペクト比の矩形にも対応できる。

積分法の選択

physics.ts ではシンプルなシンプレクティックオイラー法を採用:

function integrate(body: Body, dt: number) {
  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
}

Velocity Verletのほうがエネルギー保存性は高いが、このサイトのユースケース(短時間のインタラクション)では差が視覚的に判別できない。コードの単純さを優先した。

usePhysics hookとの連携

Reactコンポーネント側では usePhysics hookを通じて物理エンジンを利用する:

const { bodiesRef, addBody, applyForce } = usePhysics([
  { id: 'ball', pos: { x: 200, y: 100 }, radius: 10, restitution: 0.9 },
])

hookが requestAnimationFrame ループを管理し、コンポーネントは bodiesRef.current を読んで描画するだけ。物理と描画の関心を分離する設計。

まとめ:衝突判定の実装に使った道具たち

衝突判定と応答の数学的背景は、ゲーム物理学の教科書が最も体系的。道具棚に置いた書籍はどちらもこの分野の定番。