物理シミュレーション 作り方 9選 — バネから慣性スクラブまで
「物理シミュレーション 作り方」の基本は、requestAnimationFrame が毎フレーム速度と位置を計算し、その結果を transform だけで画面に反映することです。ライブラリを使わない9つのデモは、ばね追従・重力・振り子から慣性スクラブまで、コピペすればそのまま指の動きに反応します。
自動再生 · タイルを押すとその項目へ · すべて1つのzip
9つの並びは派手さではなく、力がどこから来るかの順です。まず、力が1つだけの3つ — 目標へ引くばね(01)、下へ引く重力(02)、角度が高さに変わる振り子(03)です。次は、多数の粒が同じ場に反応する4つ — カーソルが磁石になる鉄粉(04)、反射でスコアを積むパドル(05)、隣どうしが引っ張り合う布(06)、進む向きに姿勢をそろえる流れ場(07)です。最後の2つは人の手そのものが力になる場面で、箱を振る慣性(08)と、離した速度で滑る慣性スクラブ(09)で締めます。舞台の色はインク4(01・05・08・09)・黄2(02・07)・橙1(03)・紙2(04・06)の配分で巡らせています。ポインタの位置を角度に変える場面はマウス追従で作る3D場面9選、音を絵に変える場面はオーディオビジュアライザー、線が伸びていく動きはSVGチャートの描画にあります。
01スプリング追従
クリックした場所が目標になると、チップはばね定数と減衰の値どおりにそこを通り過ぎ、戻って落ち着きます。位置は translate だけで動かしており、プレビューは累積面積 11.9%・移動フレーム 23/23 を記録しました。ポインタを追うカーソルチップや、ドラッグした後に元の場所へ戻るツールに使えます。
function step(now) {
// 準陰的オイラー法: 先に加速度を反映してから位置を動かす。dt はフレーム間隔(秒)
var dt = Math.min((now - last) / 1000, 0.033);
last = now;
var k = Number(stiffIn.value) * 60;
var c = Number(dampIn.value) * 1.5;
vel.x += (k * (target.x - pos.x) - c * vel.x) * dt;
vel.y += (k * (target.y - pos.y) - c * vel.y) * dt;
pos.x += vel.x * dt;
pos.y += vel.y * dt;
frame++;
if (frame % 2 === 0) {
trail[2] = trail[1]; trail[1] = trail[0]; trail[0] = { x: pos.x, y: pos.y };
}
paint();
var settled = Math.abs(target.x - pos.x) < 0.5 && Math.abs(target.y - pos.y) < 0.5
&& Math.abs(vel.x) < 4 && Math.abs(vel.y) < 4;
if (settled) {
pos.x = target.x; pos.y = target.y; vel.x = 0; vel.y = 0;
trail = [ { x: pos.x, y: pos.y }, { x: pos.x, y: pos.y }, { x: pos.x, y: pos.y } ];
paint(); running = false; return;
}
requestAnimationFrame(step);
}
02重力ドロップ
4つのボールが落ちて床に当たり、反発係数スライダーの値の分だけ跳ね返ります。ボール同士は重なった分を押しのけるだけの簡易接触で、プレビューは累積面積 5.9%・移動フレーム 22/23 を記録しました。落ちてくるファイルのアイコンや、ゲームのような報酬エフェクトに使えます。
// 床との衝突: 反発係数の分だけ強く跳ね返し、残り速度が遅ければ床に置いて終える
if (b.y + b.size >= floor) {
b.y = floor - b.size;
if (Math.abs(b.vy) > 40) { b.vy = -b.vy * e; }
else { b.vy = 0; b.done = true; }
}
// ボール同士: 重なった分だけ互いに押す (簡易接触 — 速度の交換はしない)
for (var j = i + 1; j < balls.length; j++) {
var o = balls[j];
var dx = o.x - b.x, dy = o.y - b.y, gap = (b.size + o.size) / 2;
var dist = Math.sqrt(dx * dx + dy * dy);
if (dist > 0 && dist < gap) {
var push = (gap - dist) / 2, nx = dx / dist, ny = dy / dist;
b.x -= nx * push; b.y -= ny * push;
o.x += nx * push; o.y += ny * push;
}
}
03振り子スイング
おもりを引いて離すと、角加速度が -(g/L)·sinθ と減衰で決まり、角度が高さに変わって振動がひとりでに続きます。回転軸は固定点なので、rotate の transform-origin を腕の上端に置きます。プレビューは累積面積 5.6%・移動フレーム 23/23 です。
function step(now) {
var dt = Math.min((now - last) / 1000, 0.033);
last = now;
// 角加速度 = -(g/L)·sinθ - 減衰。角度が高さ(エネルギー)に変わるのが振り子の物理
// angle と avel の単位は度、-(g/L)·sinθ はラジアン/s² — RAD を掛けて度へそろえる
avel += (-(G / LEN) * Math.sin(angle / RAD) * RAD - DAMP * avel) * dt;
angle += avel * dt;
paint();
if (Math.abs(angle) < 0.2 && Math.abs(avel) < 0.6) {
angle = 0; avel = 0; paint(); running = false; return;
}
requestAnimationFrame(step);
}
// 腕の全体が固定点を軸に回る — transform-origin を上辺(吊るした点)へ
.pd__arm {
position: absolute; left: 50%; top: 10px; width: 0; height: 118px;
transform-origin: 50% 0;
}
.pd__rod {
position: absolute; left: -1px; top: 0; width: 2px; height: 100%;
background: rgba($subject-cream, .8);
}
04マグネットパーティクル
8×4の格子に置いた鉄粉32個が、カーソルの磁石から距離の二乗に反比例する力を受けて、押しのけられたり引き寄せられたりします。手を止めると定位置のばねがすべてを元の並びへ戻し、プレビューは累積面積 18.8%・移動フレーム 23/23 を記録しました。ヒーローセクションの背景や、カーソルまわりに集まるナビのハイライトに使えます。
function step(now) {
var dt = Math.min((now - last) / 1000, 0.033);
last = now;
var lively = now - idleAt < 1600, allCalm = true;
for (var i = 0; i < dots.length; i++) {
var d = dots[i];
var dx = d.x - mag.x, dy = d.y - mag.y;
var r2 = dx * dx + dy * dy;
// 簡易クーロン: 力 = 符号 / (r² + 軟化) — 距離の二乗に反比例させ、0 除算も防ぐ
var f = (attract ? -5200 : 4200) / (r2 + 900);
var len = Math.sqrt(r2) || 1;
d.vx += (dx / len) * f * dt * 60;
d.vy += (dy / len) * f * dt * 60;
// 定位置のばね + 減衰 — 鉄粉が散らばらずに戻ってくる
d.vx += (d.hx - d.x) * 46 * dt;
d.vy += (d.hy - d.y) * 46 * dt;
d.vx *= 0.86; d.vy *= 0.86;
d.x += d.vx * dt; d.y += d.vy * dt;
if (Math.abs(d.vx) + Math.abs(d.vy) > 3 || Math.abs(d.hx - d.x) + Math.abs(d.hy - d.y) > 0.4) { allCalm = false; }
d.el.style.transform = 'translate(' + d.x + 'px, ' + d.y + 'px)';
}
if (!lively && allCalm) { paintDots(); running = false; return; }
requestAnimationFrame(step);
}
05パドルバウンド
パドルがマウスを追い、ボールは壁と3×6のレンガに当たって跳ね返りながらスコアを積み上げます。当たった位置が水平成分を決めるので、パドルの端で受けるほど鋭い角度で飛びます。プレビューは累積面積 6.2%・移動フレーム 23/23 です。
// パドル反射 — 当たった位置で水平成分が折れる (法線反射のゲーム盤版)
var pyNow = padY();
if (vy > 0 && by + 12 >= pyNow && by + 12 <= pyNow + PH && bx + 12 > px && bx < px + PW) {
var off = (bx + 6 - (px + PW / 2)) / (PW / 2);
vx = SPEED * off * 0.9;
vy = -Math.abs(vy);
by = pyNow - 12;
}
// レンガ衝突 — 重なったマスを消し、抜けた辺の側へ反射する
for (var i = 0; i < bricks.length; i++) {
var b = bricks[i];
if (!b.alive) { continue; }
var bxx = scaled(BX + b.c * (BW + GAP)), byy = BY + b.r * (BH + GAP);
if (bx + 12 > bxx && bx < bxx + scaled(BW) && by + 12 > byy && by < byy + BH) {
b.alive = false;
b.el.classList.add('is-hit');
score += 1; paintScore();
var overX = bx + 6 - (bxx + scaled(BW) / 2), overY = by + 6 - (byy + BH / 2);
if (Math.abs(overX) > Math.abs(overY)) { vx = overX > 0 ? Math.abs(vx) : -Math.abs(vx); }
else { vy = overY > 0 ? Math.abs(vy) : -Math.abs(vy); }
break;
}
}
.pb__brick--r0 { background: $stage-orange; }
.pb__brick--r1 { background: $color; }
.pb__brick--r2 { background: $subject-cream; }
.pb__brick.is-hit { visibility: hidden; }
06布の揺れ
12×5の点の格子は上端の1行が固定点で、カーソルから半径 36px 以内に入った点だけが押されます。押された点は隣の点の変位を少し追うので波が横へ広がり、定位置のばねと減衰が元の並びに戻します。プレビューは累積面積 12.9%・移動フレーム 23/23 でした。
for (var i = 0; i < dots.length; i++) {
var d = dots[i];
if (d.r === 0) { continue; } // 固定点は揺らさない
var x = d.hx + d.ox, y = d.hy + d.oy;
// カーソル半径 36px の中の点を押しのける — カーソルが通る道が波の始点
var dx = x - ptr.x, dy = y - ptr.y, dist2 = dx * dx + dy * dy;
if (dist2 < 1296 && dist2 > 0.01) {
var dist = Math.sqrt(dist2), push = (1 - dist / 36) * 900;
d.vx += (dx / dist) * push * dt;
d.vy += (dy / dist) * push * dt;
allCalm = false;
}
// 隣接の引き合い — 左・右・上の点の今の変位を少し追う (布の横糸)
var L = dots[d.r * COLS + Math.max(0, d.c - 1)];
var R = dots[d.r * COLS + Math.min(COLS - 1, d.c + 1)];
var U = dots[Math.max(0, d.r - 1) * COLS + d.c];
d.vx += ((L.ox + R.ox) / 2 - d.ox) * 60 * dt;
d.vy += ((L.oy + R.oy + U.oy) / 3 - d.oy) * 60 * dt;
// 定位置のばね + 減衰
d.vx += -d.ox * 90 * dt; d.vy += -d.oy * 90 * dt;
d.vx *= 0.9; d.vy *= 0.9;
d.ox += d.vx * dt; d.oy += d.vy * dt;
if (Math.abs(d.ox) + Math.abs(d.oy) > 0.25 || Math.abs(d.vx) + Math.abs(d.vy) > 2) { allCalm = false; }
}
07フローフィールド
カーソルが動いた向きがそのまま風になり、36本のダッシュがそろって同じ方向へ流れます。速さは 190 で頭打ちになり、向きは Math.atan2 の値を rotate に渡しているので、粒は進む方向へ傾きます。プレビューは累積面積 19.1%・移動フレーム 23/23 です。
var gust = now - windUntil < 900; // 直前の入力の風が生きている間は操舵が強い
for (var i = 0; i < parts.length; i++) {
var p = parts[i];
// 流れ場: すべてのダッシュが同じ方向へ操舵される — 力はカーソルの速度から来る
p.vx += (wind.x - p.vx) * (gust ? 2.4 : 0.4) * dt;
p.vy += (wind.y - p.vy) * (gust ? 2.4 : 0.4) * dt;
var sp = Math.sqrt(p.vx * p.vx + p.vy * p.vy);
if (sp > 190) { p.vx *= 190 / sp; p.vy *= 190 / sp; }
p.x += p.vx * dt;
p.y += p.vy * dt;
p.ang = Math.atan2(p.vy, p.vx) * 180 / Math.PI;
// 端から反対側へ戻ってくる — 流れ場の空間がひと続きにつながっている
if (p.x > w + 8) { p.x = -8; } else if (p.x < -8) { p.x = w + 8; }
if (p.y > h + 8) { p.y = -8; } else if (p.y < -8) { p.y = h + 8; }
}
08慣性ボックス
箱をつかんで振ると、箱の加速度が逆向きの力としてボールに掛かり、壁では 0.7 の反発で跳ね返ります。手を離すと箱だけがばねで中央へ戻り、ボールはその間も転がり続けます。プレビューは累積面積 11.8%・移動フレーム 21/23 でした。
function step(now) {
var dt = Math.min((now - last) / 1000, 0.033);
last = now;
var inner = innerW(), allCalm = true;
if (!dragging) {
// 手を離した後、箱はばねで中央へ戻る
boxVX += (-90 * boxX - 9 * boxVX) * dt;
boxX += boxVX * dt;
}
for (var i = 0; i < balls.length; i++) {
var b = balls[i];
// 慣性: 箱の加速度と逆向きの力がボールに掛かり、壁では反発で跳ね返る
b.vx += -boxAX * 1.1 * dt;
b.x += b.vx * dt;
if (b.x < 0) { b.x = 0; if (Math.abs(b.vx) > 20) { b.vx = -b.vx * 0.7; } else { b.vx = 0; } }
if (b.x > inner - b.size) { b.x = inner - b.size; if (Math.abs(b.vx) > 20) { b.vx = -b.vx * 0.7; } else { b.vx = 0; } }
if (Math.abs(b.vx) > 1 || Math.abs(boxVX) > 2 || Math.abs(boxX) > 0.5) { allCalm = false; }
}
09慣性スクラブ
リストを強くはじいて離すと、直前 120ms ぶんのサンプルから求めた速度で滑り、摩擦で減速してから一番近いカードへ吸着します。9つの中で動く面積が最も広く、累積面積 33.3%・1フレーム最大 10.5%・移動フレーム 20/23 を記録しました。ギャラリーのカルーセルや、ストーリーフィードの横スワイプに使えます。
function step(now) {
var dt = Math.min((now - last) / 1000, 0.033);
last = now;
// 指が触れている間は物理を回さない — 回すと摩擦減速とスナップのばねが引く手と争う
if (dragging) { requestAnimationFrame(step); return; }
if (settling) {
// カードへ吸着する最後のばね — 目標は一番近いマス
var target = snapTarget();
vx += (150 * (target - x) - 13 * vx) * dt;
x += vx * dt;
if (Math.abs(target - x) < 0.4 && Math.abs(vx) < 6) {
x = target; vx = 0; settling = false; paint(); running = false; return;
}
} else {
// 摩擦減速 — フレーム比率と無関係に 60fps 基準の 0.91^dt で指数減衰させる
vx *= Math.pow(0.91, dt * 60);
x += vx * dt;
var c = clampX(x);
if (c !== x) { x = c; vx = 0; }
if (Math.abs(vx) < 160) { settling = true; vx = 0; }
}
paint();
requestAnimationFrame(step);
}
どこで壊れるか — 落とし穴
1つ目は 05 のレンガが透明に見えてしまった不具合です。JavaScript が付けていたクラスは pb__brick--r0c0 の形でしたが、色を決めるルールは .pb__brick--r0 だったので、セレクタがどれとも一致せず背景色が適用されませんでした。行のクラスを別に付け足して直しました — 列まで含んだ名前は1マスを名指しするためのもので、色は行で決まるという、別々の役目だったわけです。
2つ目は 09 で、指が触れている間も rAF ループが摩擦減速とスナップのばねを回していたことです。ドラッグしている指と物理演算が同じ x を奪い合い、指を右へ動かしているのにカードが左へ戻ろうとしました。step() の先頭に if (dragging) { requestAnimationFrame(step); return; } を置き、指が離れるまで物理演算を止めることで直りました。
3つ目は 03 の単位です — angle と avel は度で持っているのに、-(g/L)·sinθ が返すのはラジアン/s² の角加速度でした。そのまま足していたので復元項だけが 180/π(およそ57倍)小さくなり、30度から離しても6秒で0.9度しか動かず、振り子はぶら下がったままでした。それでも、どのゲートも赤くなりませんでした — 格子のプレビューは CSS の @keyframes pdSwing(±26度)で動いていて、計測が見ているのはそのプレビューなので、壊れている間も 03 の節に書いたのと同じ累積面積 5.6%・移動フレーム 23/23 を記録していました。直したのは var G = 1800, LEN = 118, DAMP = 0.4, RAD = 180 / Math.PI; の定数行と、ラジアンの角加速度に RAD を掛けて度へそろえる1行です(G は 22 px/s² から 02 と同じ目盛りの 1800 px/s² へ — 単位だけ直しても 22 px/s²・腕 118px では周期が14.6秒で、やはり振れません)。測り直すと6秒で中心を8回通過し、最大26.9度、6秒後でも7.28度で、周期はおよそ1.6秒です。9つともプレビューは rAF ループとは別のキーフレームなので、レンダーのゲートを通ったことは、人が触ったときにどう動くかを保証しません。
4つ目は時間そのものです。9つとも dt を Math.min((now - last) / 1000, 0.033) で抑えています。タブをバックグラウンドに回してから戻ると now - last が数秒になり、上限がなければ1フレームで速度に数秒ぶんの力が入って画面の外へ飛びます。挙動の違いを確かめたいときは 01 と 02 のスライダーを動かしてみてください — 下の表のとおり、値を1つ変えるだけで動きの性格が変わります。
| 名前 | 変えた値 | 感じ |
|---|---|---|
| ばねを硬く | 01 のばねスライダー 3 → 6 (k = 値 × 60) | 到達が早まり、行き過ぎが大きい |
| 減衰を強く | 01 の減衰スライダー 8 → 12 (c = 値 × 1.5) | 行き過ぎが減り、滑らかに収まる |
| 反発を上げる | 02 の反発スライダー 0.62 → 0.88 | ボールが跳ね返り続けて床を鳴らす |
9つをまとめたアーカイブのパスワードは qc5pgrv8 で、開くとデモごとに素の HTML 版と React 版が1組ずつ入っています。
アクセシビリティ (reduced-motion)
9つとも role="application" と説明用の aria-label を持ち、ポインタでできることは方向キーでもできます — 目標を動かす(01・04・06)、角度を変える(03)、パドルを送る(05)、風を吹かせる(07)、箱を揺らす(08)、カードを1枚ずつ送る(09)。02 は Enter とスペースで落とし直します。数字が変わる場面には aria-live="polite" を置き、振り子の角度(03)とスコア(05)を読み上げます。prefers-reduced-motion: reduce では二重に止めます。JavaScript は reduce.matches を見て揺れをスキップし、目標や吸着先へ直接置きます(01・03・09)。CSS はプレビューのキーフレームを animation: none で止め、それぞれの場面を一番読みやすい静止の1枚に固定します — 動きが消えても、何を触る画面なのかは残ります。
FAQ
ライブラリなしで本当に足りますか?
毎フレームの計算は requestAnimationFrame、画面への反映は transform だけで、9つとも外部のコードを読み込んでいません。剛体の回転や連結した関節が必要になるところからは専用エンジンの出番ですが、追従・落下・振り子・慣性の範囲なら20行ほどの積分ループで足ります(requestAnimationFrame のドキュメント)。
スマホのタッチでも同じように動きますか?
入力はすべて Pointer Events なので、マウスと指の区別なく同じ関数へ届きます(Pointer Events のドキュメント)。指で引く場面はブラウザのスクロールに取られないよう touch-action: none を指定し、横方向だけを使う 09 は縦スクロールを残すため touch-action: pan-y にしてあります。
端末でフレームレートが違っても速さは変わりませんか?
9つとも位置と速度を dt(秒)で積分しているので、120Hz でも 60Hz でも同じ時間に同じ距離を進みます。09 の摩擦だけは掛け算の繰り返しなので Math.pow(0.91, dt * 60) と書き、1フレームぶんの減衰率を実際の経過時間に合わせて伸び縮みさせています。