ブラウザで動く物理エンジンを自前で書いた話
このトピックを深掘りする
Canvas vs DOM・インタラクティブUI
CanvasとDOMの使い分け・CSSインタラクション・カスタム物理エンジン・ポートフォリオ設計
全5本中 5 本目。
次に読む記事
Canvas vs DOM — 203個のコンポーネントで選んだ基準と実測データ
203個のインタラクティブコンポーネントをCanvas APIとDOMに振り分けた判断基準を、実例と実測データで解説する。
203個のCanvas/DOMコンポーネントで踏んだバグ Top 10 — 実データから見えたパターン
203個のインタラクティブコンポーネントを開発する過程で実際に遭遇したバグをgit履歴から集計し、頻出パターンと修正手法をTop 10形式でまとめた。
CSSだけで作るインタラクティブUI — transform, transition, custom propertiesの実践
FidgetSpinner、InfiniteKnob、ToggleSwitchなどのDOM+CSSベースのインタラクティブUIで活用したtransform、transition、custom propertiesのCSS技法を実例で解説する。
シリーズ全体の流れを見ながら、次に読む記事へ進めます。 初めての方はホームへ →
なぜ自前で書いたのか
ポートフォリオサイトに物理演算を使ったインタラクションを入れたかった。最初はMatter.jsを検討したが、バンドルサイズが約300KBと重い。使いたい機能は「重力」「衝突判定」「バウンド」だけ。
必要な機能だけを180行で実装することにした。
設計の概要
Body型と8つの関数で構成される、プレーンオブジェクトベースの物理エンジンだ。クラスを使わずReact stateとの相性を最優先にした。
// src/utils/physics.ts の構造
type Body = {
x: number
y: number
vx: number
vy: number
radius: number
mass: number
restitution: number
}
function applyGravity(body: Body, dt: number) {
body.vy += 980 * dt // 9.8m/s² をピクセルスケールに
}
function detectCollision(a: Body, b: Body): boolean {
const dx = a.x - b.x
const dy = a.y - b.y
const dist = Math.sqrt(dx * dx + dy * dy)
return dist < a.radius + b.radius
}パフォーマンスの工夫
Grid空間分割・固定タイムステップ・Canvas/DOM使い分けの3つで、200パーティクルでも60fpsを維持できる。
1. 空間分割(Grid-based)
全ペアの衝突判定は O(n²) になるので、画面をグリッドに分割して近傍のみチェック:
function buildGrid(bodies: Body[], cellSize: number) {
const grid = new Map<string, Body[]>()
for (const body of bodies) {
const key = `\${Math.floor(body.x / cellSize)},\${Math.floor(body.y / cellSize)}`
let bucket = grid.get(key)
if (!bucket) {
bucket = []
grid.set(key, bucket)
}
bucket.push(body)
}
return grid
}2. requestAnimationFrame + 固定タイムステップ
可変フレームレートでも物理演算が安定するよう、固定タイムステップで積分:
const FIXED_DT = 1 / 60
let accumulator = 0
function gameLoop(timestamp: number) {
const dt = (timestamp - lastTime) / 1000
accumulator += dt
while (accumulator >= FIXED_DT) {
updatePhysics(FIXED_DT)
accumulator -= FIXED_DT
}
render()
requestAnimationFrame(gameLoop)
}3. Canvas vs DOM の使い分け
| 要素 | 描画方法 | 理由 |
|---|---|---|
| パーティクル(100+個) | Canvas | DOM操作のオーバーヘッド回避 |
| バッジ(〜20個) | DOM + CSS Transform | アクセシビリティ・テキスト選択 |
| 気泡シート | Canvas | タッチイベントの応答性 |
結果
- バンドルサイズ: Matter.js 300KB → 自前実装 4KB(gzip後)
- 60fps維持: 200個のパーティクルでも安定
- Lighthouse Performance: 98点
小さなスコープなら、ライブラリを使わない選択肢も十分にある。
使った道具たち
記事末尾の「道具棚」セクションに、この実装で参考にした書籍を置いた。物理シミュレーションの基礎を学ぶなら、まずはこの2冊がおすすめ。
よくある質問
- Q. ポートフォリオサイトに物理エンジンを自作するのは現実的ですか?
- 使う機能が「重力・衝突判定・バウンド」に限られるなら現実的。180行で実装でき、Matter.jsの300KBを4KBに削減できた。
- Q. 物理演算で60fpsを維持するにはどうすればよいですか?
- Grid空間分割でO(n²)を軽減し、固定タイムステップで物理演算を安定させ、Canvas/DOMをエンティティ数で使い分けることで200パーティクルでも60fpsを維持できる。
- Q. Canvas APIとDOMの使い分け基準は何ですか?
- パーティクル100個以上はCanvas、バッジ20個程度はDOM + CSS Transformを使う。アクセシビリティとテキスト選択が必要なものはDOM。