#07 ビジュアルコーディング 物理シミュレーション

突き抜けない剛体——衝突後の位置補正

前回の衝突実装には隠れた問題がありました。ボールが高速で移動するとき、1フレームで壁や他のボールを「突き抜けて」しまう現象——これをトンネリングと呼びます。

また、複数のボールが密集すると位置補正が干渉し合い、ボールが振動したり壁にめり込んだりします。今回はこれらの問題を段階的に解決します。


突き抜け問題(トンネリング)とは

x += vx * dt の更新では、1フレームの移動量が vx * dt です。

vx = 2000 px/s
dt = 0.016 s(60fps)
移動量 = 2000 * 0.016 = 32 px/フレーム

もし壁の厚みが0px(無限に薄い壁)であれば、32pxを飛び越えて壁の向こう側に出てしまいます。ボールの直径よりも大きな移動量なら、ボール同士の衝突も検出できません。

// ✗ 問題: 高速ボールが壁を突き抜ける
x += vx * dt;  // x が 0 → -40 に飛んでしまう

// ○ 解決: 壁でクランプして位置を補正
x += vx * dt;
if (x - r < 0) {
  x = r;              // 壁の内側に戻す
  vx = -vx * e;       // 速度を反転
}

壁との衝突ではクランプ(x = r)が有効ですが、ボール同士の衝突では少し複雑になります。


位置補正(Positional Correction)

ボール同士が重なってしまったとき、重なりの分だけ互いを押し離すのが位置補正(セパレーション)です。

var overlap = (a.r + b.r) - dist;  // 重なり量

if (overlap > 0) {
  // 法線方向に重なりの半分ずつ押す
  a.x -= nx * overlap * 0.5;
  a.y -= ny * overlap * 0.5;
  b.x += nx * overlap * 0.5;
  b.y += ny * overlap * 0.5;
}

質量が異なる場合は、重い方を少なく動かします:

var totalMass = a.mass + b.mass;
var corrA = overlap * (b.mass / totalMass);  // Bが重いほどAが多く動く
var corrB = overlap * (a.mass / totalMass);

a.x -= nx * corrA;
a.y -= ny * corrA;
b.x += nx * corrB;
b.y += ny * corrB;
位置補正なし vs あり——重なり具合の比較
var scenarios = [
{label: '補正なし', correct: false, offsetX: -W/4},
{label: '補正あり', correct: true,  offsetX:  W/4},
];

var balls = scenarios.map(function(s) {
return {
  a: {x: s.offsetX + W/2 - 40, y: H/2, vx: 200, vy: 0, r: 24, mass: 1},
  b: {x: s.offsetX + W/2 + 40, y: H/2, vx: -200, vy: 0, r: 24, mass: 1},
  correct: s.correct,
  label: s.label,
  offsetX: s.offsetX
};
});

function resolveVelocity(a, b, e) {
var dx = b.x - a.x, dy = b.y - a.y;
var dist = Math.hypot(dx, dy) || 0.001;
var nx = dx / dist, ny = dy / dist;
var dvn = (a.vx - b.vx) * nx + (a.vy - b.vy) * ny;
if (dvn > 0) return;
var impulse = -(1 + e) * dvn / (1/a.mass + 1/b.mass);
a.vx += impulse / a.mass * nx; a.vy += impulse / a.mass * ny;
b.vx -= impulse / b.mass * nx; b.vy -= impulse / b.mass * ny;
}

function resolvePosition(a, b) {
var dx = b.x - a.x, dy = b.y - a.y;
var dist = Math.hypot(dx, dy) || 0.001;
var overlap = (a.r + b.r) - dist;
if (overlap <= 0) return;
var nx = dx / dist, ny = dy / dist;
a.x -= nx * overlap * 0.5; a.y -= ny * overlap * 0.5;
b.x += nx * overlap * 0.5; b.y += ny * overlap * 0.5;
}

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.strokeStyle = '#333';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(W/2, 0); ctx.lineTo(W/2, H);
ctx.stroke();

balls.forEach(function(sc) {
  var a = sc.a, b = sc.b;
  a.x += a.vx * dt; a.y += a.vy * dt;
  b.x += b.vx * dt; b.y += b.vy * dt;

  for (var side of ['a', 'b']) {
    var ball = sc[side];
    if (ball.x - ball.r < 0) { ball.x = ball.r; ball.vx = Math.abs(ball.vx) * 0.85; }
    if (ball.x + ball.r > W) { ball.x = W - ball.r; ball.vx = -Math.abs(ball.vx) * 0.85; }
    if (ball.y - ball.r < 0) { ball.y = ball.r; ball.vy = Math.abs(ball.vy) * 0.85; }
    if (ball.y + ball.r > H) { ball.y = H - ball.r; ball.vy = -Math.abs(ball.vy) * 0.85; }
  }

  var dist = Math.hypot(b.x - a.x, b.y - a.y);
  if (dist < a.r + b.r) {
    resolveVelocity(a, b, 0.85);
    if (sc.correct) resolvePosition(a, b);
  }

  [a, b].forEach(function(ball, i) {
    var dist2 = Math.hypot(b.x - a.x, b.y - a.y);
    var overlapping = dist2 < a.r + b.r;
    ctx.beginPath();
    ctx.arc(ball.x, ball.y, ball.r, 0, Math.PI * 2);
    ctx.fillStyle = overlapping && !sc.correct ? 'rgba(255,100,100,0.6)' : (i === 0 ? 'rgba(0,229,255,0.8)' : 'rgba(149,230,125,0.8)');
    ctx.fill();
    ctx.strokeStyle = '#fff';
    ctx.lineWidth = 1;
    ctx.stroke();
  });

  ctx.fillStyle = '#aaa';
  ctx.font = '12px monospace';
  ctx.textAlign = 'center';
  ctx.fillText(sc.label, sc.offsetX + W/2, H - 8);
  ctx.textAlign = 'left';
});

requestAnimationFrame(loop);
}
requestAnimationFrame(loop);

左(補正なし)はボールが重なって赤くなります。右(補正あり)は重なりが自動で解消されます。


位置補正の質量比

質量比による位置補正を実装すると、重いボールはほとんど動かず、軽いボールが大きく押し返されます。

function positionCorrection(a, b) {
  var dx = b.x - a.x, dy = b.y - a.y;
  var dist = Math.hypot(dx, dy) || 0.001;
  var overlap = (a.r + b.r) - dist;
  if (overlap <= 0) return;

  var nx = dx / dist, ny = dy / dist;
  var totalMass = a.mass + b.mass;

  // 質量比で補正量を分配
  var percent = 0.8;  // 完全補正率(1.0だとジッター発生のため少し控えめ)
  var corrA = overlap * percent * (b.mass / totalMass);
  var corrB = overlap * percent * (a.mass / totalMass);

  a.x -= nx * corrA;
  a.y -= ny * corrA;
  b.x += nx * corrB;
  b.y += ny * corrB;
}

percent = 0.8 のように完全補正よりやや小さくするのは、ジッターを防ぐためです。複数のボールが接触している場合、1回の補正で完全に解消しようとすると次フレームで逆方向に押されることがあります。


複数ボールが積み重なる

位置補正とイテレーション(補正を複数回繰り返す)を組み合わせると、ボールが積み重なる挙動が実現できます。

複数ボールが積み重なる(位置補正 × イテレーション)
var GRAVITY = 500;
var RESTITUTION = 0.25;
var ITERATIONS = 5;

var balls = [];
for (var i = 0; i < 15; i++) {
balls.push({
  x: W * 0.2 + Math.random() * W * 0.6,
  y: -20 - Math.random() * 200,
  vx: (Math.random() - 0.5) * 80,
  vy: 0,
  r: 14 + Math.random() * 10,
  mass: 1,
  hue: Math.random() * 360
});
}

function loop(ts) {
if (!loop.last) loop.last = ts;
var dt = Math.min((ts - loop.last) / 1000, 0.04);
loop.last = ts;

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) * RESTITUTION; }
  if (b.x + b.r > W) { b.x = W - b.r; b.vx = -Math.abs(b.vx) * RESTITUTION; }
  if (b.y + b.r > H) {
    b.y = H - b.r;
    b.vy = -Math.abs(b.vy) * RESTITUTION;
    b.vx *= 0.9;
  }
});

for (var iter = 0; iter < ITERATIONS; iter++) {
  for (var i = 0; i < balls.length - 1; i++) {
    for (var j = i + 1; j < balls.length; j++) {
      var a = balls[i], b = balls[j];
      var dx = b.x - a.x, dy = b.y - a.y;
      var dist = Math.hypot(dx, dy) || 0.001;
      var overlap = (a.r + b.r) - dist;
      if (overlap <= 0) continue;
      var nx = dx / dist, ny = dy / dist;

      var dvn = (a.vx - b.vx) * nx + (a.vy - b.vy) * ny;
      if (dvn < 0) {
        var impulse = -(1 + RESTITUTION) * dvn * 0.5;
        a.vx += impulse * nx; a.vy += impulse * ny;
        b.vx -= impulse * nx; b.vy -= impulse * ny;
      }

      var corrFactor = 0.5 / ITERATIONS;
      a.x -= nx * overlap * corrFactor;
      a.y -= ny * overlap * corrFactor;
      b.x += nx * overlap * corrFactor;
      b.y += ny * overlap * corrFactor;
    }
  }
}

ctx.fillStyle = '#0d1117';
ctx.fillRect(0, 0, W, H);

ctx.fillStyle = '#1a1a1a';
ctx.fillRect(0, H - 4, W, 4);

balls.forEach(function(b) {
  ctx.beginPath();
  ctx.arc(b.x, b.y, b.r, 0, Math.PI * 2);
  ctx.fillStyle = 'hsl(' + b.hue + ',75%,55%)';
  ctx.fill();
  ctx.strokeStyle = 'hsl(' + b.hue + ',75%,75%)';
  ctx.lineWidth = 1.5;
  ctx.stroke();
});

requestAnimationFrame(loop);
}
requestAnimationFrame(loop);

イテレーションを5回繰り返すことで、底のボールが上のボールの重さを支えるような安定した積み重ねが実現できます。


分離ベクトル(MTV: Minimum Translation Vector)

位置補正に使う「どの方向に押し返すか」のベクトルをMTV(最小移動ベクトル)と呼びます。円の場合は法線方向が常にMTVですが、後でAABBや多角形を扱う際には最も短い移動で重なりを解消できる方向を計算する必要があります。

// 円の場合のMTV
var nx = dx / dist;        // 法線方向
var ny = dy / dist;
var penetration = (a.r + b.r) - dist;  // 重なり深さ

// MTV = (nx, ny) * penetration
var mtvX = nx * penetration;
var mtvY = ny * penetration;

まとめ

この回でやったこと:

  • トンネリング(突き抜け)問題の原因と、壁クランプによる対処を理解した
  • 位置補正(オーバーラップの半分ずつを押し出す)の実装と「補正なし vs あり」の比較を確認した
  • 質量比による位置補正の不均等分配を実装した
  • ITERATIONS(複数回補正)で複数ボールの安定した積み重ねを実現した

次回は回転する物体を実装します。慣性モーメント・トルク・角速度・角加速度——物体が回転しながら飛ぶ挙動をキャンバスで表現します。