跳ね返り——反発係数と壁との衝突
物理シミュレーションで欠かせない要素の一つが衝突です。ボールが壁に当たって跳ね返る——当たり前の現象ですが、コードで正確に実装するにはいくつかの考え方が必要です。
今回は壁との衝突を題材に、衝突検出・衝突応答・反発係数の基礎を学びます。
衝突検出: 境界チェック
半径 r の円(ボール)がキャンバスの壁に衝突しているかどうかを検出するのはシンプルです。
// 左壁: ボールの左端が0より左に出たら衝突
if (x - r < 0) { /* 衝突 */ }
// 右壁: ボールの右端がWより右に出たら衝突
if (x + r > W) { /* 衝突 */ }
// 上壁
if (y - r < 0) { /* 衝突 */ }
// 下壁
if (y + r > H) { /* 衝突 */ }
これは「ボールが既に壁に重なっている」状態の検出です(事後検出)。高速に動くボールが1フレームで壁を突き抜ける場合には対処が必要ですが、通常の速度では問題ありません。
速度の反転: vx = -vx * restitution
衝突に対する応答として、最もシンプルな方法は「壁に垂直な速度成分を反転させる」ことです。
// 左壁・右壁との衝突: X方向の速度を反転
if (x - r < 0 || x + r > W) {
vx = -vx;
}
// 上壁・下壁との衝突: Y方向の速度を反転
if (y - r < 0 || y + r > H) {
vy = -vy;
}
さらに反発係数(e または restitution)を掛けることで、衝突のたびに速度を減衰させます。
// 反発係数 e = 1: 完全弾性衝突(速度が変わらない)
// 反発係数 e = 0: 完全非弾性衝突(壁に貼り付く)
var e = 0.8;
if (x + r > W) {
x = W - r; // 位置を補正(壁の外に出ないように)
vx = -vx * e; // 速度を反転して反発係数を掛ける
}
位置補正(x = W - r)を忘れると、ボールが壁の中に埋まったままになることに注意してください。
4方向に跳ね返るボール
var x = W / 2, y = H / 2;
var vx = 220, vy = -160;
var r = 18;
var e = 0.85;
function loop(ts) {
if (!loop.last) loop.last = ts;
var dt = Math.min((ts - loop.last) / 1000, 0.05);
loop.last = ts;
x += vx * dt;
y += vy * dt;
if (x - r < 0) { x = r; vx = Math.abs(vx) * e; }
if (x + r > W) { x = W - r; vx = -Math.abs(vx) * e; }
if (y - r < 0) { y = r; vy = Math.abs(vy) * e; }
if (y + r > H) { y = H - r; vy = -Math.abs(vy) * e; }
ctx.fillStyle = '#0d1117';
ctx.fillRect(0, 0, W, H);
ctx.strokeStyle = '#2a3a4a';
ctx.lineWidth = 2;
ctx.strokeRect(1, 1, W - 2, H - 2);
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.fillStyle = '#00e5ff';
ctx.fill();
ctx.strokeStyle = '#7ffffe';
ctx.lineWidth = 2;
ctx.stroke();
ctx.fillStyle = '#4a6a7a';
ctx.font = '12px monospace';
ctx.fillText('vx=' + vx.toFixed(0) + ' vy=' + vy.toFixed(0), 8, 20);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop); 衝突のたびに速度が少し小さくなり、最終的にほぼ静止します(e=0.85なので衝突ごとに速度が0.85倍になります)。
反発係数のインタラクティブ比較
複数のボールが同じタイミングで始まり、異なる反発係数で跳ね返るデモです。
var configs = [
{e: 1.0, color: '#ff6b6b', label: 'e=1.0'},
{e: 0.7, color: '#4ecdc4', label: 'e=0.7'},
{e: 0.4, color: '#95e67d', label: 'e=0.4'},
];
var balls = configs.map(function(c, i) {
return {
x: (i + 1) * W / (configs.length + 1),
y: 30,
vx: 0,
vy: 0,
e: c.e,
color: c.color,
label: c.label
};
});
var GRAVITY = 400;
function loop(ts) {
if (!loop.last) loop.last = ts;
var dt = Math.min((ts - loop.last) / 1000, 0.05);
loop.last = ts;
ctx.fillStyle = '#0d1117';
ctx.fillRect(0, 0, W, H);
ctx.fillStyle = '#1a2a2a';
ctx.fillRect(0, H - 8, W, 8);
balls.forEach(function(b) {
b.vy += GRAVITY * dt;
b.x += b.vx * dt;
b.y += b.vy * dt;
if (b.y + 14 > H - 8) {
b.y = H - 8 - 14;
b.vy = -Math.abs(b.vy) * b.e;
if (Math.abs(b.vy) < 1) b.vy = 0;
}
ctx.beginPath();
ctx.arc(b.x, b.y, 14, 0, Math.PI * 2);
ctx.fillStyle = b.color;
ctx.fill();
ctx.fillStyle = b.color;
ctx.font = '11px monospace';
ctx.textAlign = 'center';
ctx.fillText(b.label, b.x, 16);
ctx.textAlign = 'left';
});
requestAnimationFrame(loop);
}
requestAnimationFrame(loop); e=1.0(赤)は完全弾性衝突で永遠に跳ね続けます。e=0.4(緑)はすぐに動かなくなります。
重力込みの跳ね返り
重力と跳ね返りを組み合わせると、徐々に低くなるバウンスが実現できます。これはボールが床で跳ねる現実的な挙動です。
var GRAVITY = 400;
var e = 0.78;
var balls = [];
for (var i = 0; i < 6; i++) {
balls.push({
x: 40 + i * (W - 80) / 5,
y: 20 + Math.random() * 60,
vx: (Math.random() - 0.5) * 100,
vy: 0,
r: 10 + i * 3,
hue: i * 60
});
}
function loop(ts) {
if (!loop.last) loop.last = ts;
var dt = Math.min((ts - loop.last) / 1000, 0.05);
loop.last = ts;
ctx.fillStyle = '#0d1117';
ctx.fillRect(0, 0, W, H);
ctx.fillStyle = '#1a1a1a';
ctx.fillRect(0, H - 12, W, 12);
balls.forEach(function(b) {
b.vy += GRAVITY * dt;
b.x += b.vx * dt;
b.y += b.vy * dt;
if (b.x - b.r < 0) { b.x = b.r; b.vx = Math.abs(b.vx); }
if (b.x + b.r > W) { b.x = W - b.r; b.vx = -Math.abs(b.vx); }
if (b.y + b.r > H - 12) {
b.y = H - 12 - b.r;
b.vy = -Math.abs(b.vy) * e;
b.vx *= 0.98;
if (Math.abs(b.vy) < 2) { b.vy = 0; }
}
ctx.beginPath();
ctx.arc(b.x, b.y, b.r, 0, Math.PI * 2);
ctx.fillStyle = 'hsl(' + b.hue + ',80%,60%)';
ctx.fill();
var shadowW = b.r * 2 * (1 - (H - 12 - b.y) / (H - 12));
var shadowAlpha = 0.3 * (1 - (H - 12 - b.y) / (H - 12));
ctx.beginPath();
ctx.ellipse(b.x, H - 14, Math.max(1, shadowW * 0.5), 3, 0, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(0,0,0,' + shadowAlpha + ')';
ctx.fill();
});
requestAnimationFrame(loop);
}
requestAnimationFrame(loop); 影を追加することで高さ感が生まれます。ボールが床に近づくほど影が大きくなります。
複数のボールが壁に跳ね返る
複数のボールを配列で管理し、それぞれ独立に壁との衝突を処理するデモです。
var balls = [];
for (var i = 0; i < 12; i++) {
var angle = Math.random() * Math.PI * 2;
var speed = 100 + Math.random() * 200;
balls.push({
x: 20 + Math.random() * (W - 40),
y: 20 + Math.random() * (H - 40),
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
r: 8 + Math.random() * 12,
hue: Math.random() * 360
});
}
var e = 0.9;
function loop(ts) {
if (!loop.last) loop.last = ts;
var dt = Math.min((ts - loop.last) / 1000, 0.05);
loop.last = ts;
ctx.fillStyle = 'rgba(13,17,23,0.4)';
ctx.fillRect(0, 0, W, H);
balls.forEach(function(b) {
b.x += b.vx * dt;
b.y += b.vy * dt;
if (b.x - b.r < 0) { b.x = b.r; b.vx = Math.abs(b.vx) * e; }
if (b.x + b.r > W) { b.x = W - b.r; b.vx = -Math.abs(b.vx) * e; }
if (b.y - b.r < 0) { b.y = b.r; b.vy = Math.abs(b.vy) * e; }
if (b.y + b.r > H) { b.y = H - b.r; b.vy = -Math.abs(b.vy) * e; }
ctx.beginPath();
ctx.arc(b.x, b.y, b.r, 0, Math.PI * 2);
ctx.fillStyle = 'hsla(' + b.hue + ',80%,60%,0.85)';
ctx.fill();
});
requestAnimationFrame(loop);
}
requestAnimationFrame(loop); 残像エフェクト(rgba(13,17,23,0.4)で毎フレーム薄く塗る)を使って動きを強調しています。
衝突の精度向上: 位置補正の重要性
速度を反転するだけでなく、位置補正(ボールを壁の外に出ないように戻す)が重要です。
if (x + r > W) {
// × 位置補正なし: ボールが壁の中に入り込む可能性がある
vx = -vx * e;
// ○ 位置補正あり: ボールを正確に壁の表面に置く
x = W - r;
vx = -Math.abs(vx) * e;
}
位置補正がないと、次フレームでも衝突判定が発動し続け、ボールが振動(ジッター)したり、壁に埋まったりします。
まとめ
この回でやったこと:
- 壁との衝突検出(
x + r > Wなどのシンプルな境界チェック)を実装した - 速度反転(
vx = -vx * e)と位置補正(x = W - r)を組み合わせた正しい衝突応答を学んだ - 反発係数
e(0〜1)で完全非弾性〜完全弾性衝突を表現した - 重力と跳ね返りを組み合わせたバウンスアニメーションを作った
次回はボール同士の衝突——円と円の当たり判定を実装します。2つのボールがぶつかって跳ね返る計算は、運動量保存の法則を使います。