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

矩形と矩形の衝突——AABB衝突とSAT定理

円の衝突では「距離 < 半径の和」という判定が使えました。矩形(長方形)の衝突はもう少し複雑です。回転がなければAABBで十分ですが、回転がある場合は分離軸定理(SAT)が必要になります。


AABB(Axis-Aligned Bounding Box)とは

AABBは「軸に平行なバウンディングボックス」の略です。Axis-Aligned = 座標軸に平行、という意味で、回転していない矩形を指します。

AABB同士の衝突判定は非常に効率的です。X方向とY方向それぞれに「重なりがあるか」を確認するだけです。

AのX範囲: [ax, ax+aw]
BのX範囲: [bx, bx+bw]

X方向に重なりがある: ax < bx+bw && ax+aw > bx
Y方向に重なりがある: ay < by+bh && ay+ah > by

両方重なっている = 衝突

コードで書くと:

function aabbCollide(a, b) {
  return (
    a.x < b.x + b.w &&
    a.x + a.w > b.x &&
    a.y < b.y + b.h &&
    a.y + a.h > b.y
  );
}

最小重複方向(MTV)の計算

衝突が検出されたら、どの方向にどれだけ押せば重なりが解消されるかを計算します。

function getAABBOverlap(a, b) {
  // 各方向の重なり量
  var overlapX = Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x);
  var overlapY = Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y);

  // 重なりが少ない方向(最小移動で解消できる方向)
  if (overlapX < overlapY) {
    // X方向に押し出す
    var signX = a.x < b.x ? -1 : 1;
    return { nx: signX, ny: 0, overlap: overlapX };
  } else {
    // Y方向に押し出す
    var signY = a.y < b.y ? -1 : 1;
    return { nx: 0, ny: signY, overlap: overlapY };
  }
}

MTV(最小移動ベクトル)の方向に overlap 量だけ押し出すと、重なりが解消されます。


AABB衝突のデモ

AABBを使った矩形衝突のデモ
var G = 400, E = 0.4;
// 初期位置は画面内にランダム配置(重なりなし)
var specs = [
{x:60,  y:40,  w:70, h:45, hue:10},
{x:220, y:60,  w:55, h:55, hue:60},
{x:380, y:30,  w:90, h:35, hue:120},
{x:100, y:140, w:60, h:50, hue:200},
{x:450, y:80,  w:50, h:60, hue:270},
];
var rects = specs.map(function(s) {
return {x:s.x, y:s.y, vx:(Math.random()-0.5)*120, vy:20+Math.random()*60,
        w:s.w, h:s.h, mass:1, hue:s.hue};
});

function aabb(a, b) {
var ox = Math.min(a.x+a.w, b.x+b.w) - Math.max(a.x, b.x);
var oy = Math.min(a.y+a.h, b.y+b.h) - Math.max(a.y, b.y);
if (ox<=0||oy<=0) return null;
return ox<oy ? {nx:a.x<b.x?-1:1,ny:0,ov:ox} : {nx:0,ny:a.y<b.y?-1:1,ov:oy};
}

function resolve(a, b, m) {
var dvn = (a.vx-b.vx)*m.nx + (a.vy-b.vy)*m.ny;
if (dvn < 0) {
  var imp = -(1+E)*dvn*0.5;
  a.vx+=imp*m.nx; a.vy+=imp*m.ny;
  b.vx-=imp*m.nx; b.vy-=imp*m.ny;
}
var c = m.ov*0.55;
a.x-=m.nx*c*0.5; a.y-=m.ny*c*0.5;
b.x+=m.nx*c*0.5; b.y+=m.ny*c*0.5;
}

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

rects.forEach(function(r) {
  r.vy+=G*dt; r.x+=r.vx*dt; r.y+=r.vy*dt;
  if(r.x<0){r.x=0;r.vx=Math.abs(r.vx)*E;}
  if(r.x+r.w>W){r.x=W-r.w;r.vx=-Math.abs(r.vx)*E;}
  if(r.y<0){r.y=0;r.vy=Math.abs(r.vy)*E;}
  if(r.y+r.h>H){r.y=H-r.h;r.vy=-Math.abs(r.vy)*E;r.vx*=0.82;}
});

// 複数イテレーションで衝突を安定させる
for (var iter=0;iter<3;iter++) {
  for (var i=0;i<rects.length-1;i++) {
    for (var j=i+1;j<rects.length;j++) {
      var m=aabb(rects[i],rects[j]);
      if(m) resolve(rects[i],rects[j],m);
    }
  }
}

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

rects.forEach(function(r) {
  ctx.fillStyle='hsl('+r.hue+',70%,48%)';
  ctx.fillRect(r.x,r.y,r.w,r.h);
  ctx.strokeStyle='hsl('+r.hue+',70%,72%)';
  ctx.lineWidth=1.5; ctx.strokeRect(r.x,r.y,r.w,r.h);
});

requestAnimationFrame(loop);
}
requestAnimationFrame(loop);

矩形が落下して積み重なります。AABB判定で接触を検出し、MTVで重なりを解消しています。


SAT(分離軸定理)の概念

AABBは回転していない矩形にしか使えません。回転を含む任意の凸多角形にはSAT(Separating Axis Theorem: 分離軸定理)が使われます。

SAT定理:

2つの凸多角形が重なっていないなら、必ず「分離軸」が存在する。

分離軸とは、その軸方向に射影したとき2つの図形の射影が重ならない軸のことです。逆に言えば、全ての可能な軸方向で射影が重なっているなら、2つの図形は必ず衝突しています。

試すべき軸は、各図形の各辺の法線方向です。矩形同士なら最大4軸(2辺×2図形)を試します。

function projectOnAxis(vertices, ax, ay) {
  var min = Infinity, max = -Infinity;
  vertices.forEach(function(v) {
    var proj = v.x * ax + v.y * ay;
    min = Math.min(min, proj);
    max = Math.max(max, proj);
  });
  return { min: min, max: max };
}

function satCollide(verticesA, verticesB, axes) {
  var minOverlap = Infinity;
  var mtvAxis = null;

  for (var i = 0; i < axes.length; i++) {
    var ax = axes[i].x, ay = axes[i].y;
    var pA = projectOnAxis(verticesA, ax, ay);
    var pB = projectOnAxis(verticesB, ax, ay);

    var overlap = Math.min(pA.max, pB.max) - Math.max(pA.min, pB.min);
    if (overlap <= 0) return null;  // 分離軸を発見 → 衝突なし

    if (overlap < minOverlap) {
      minOverlap = overlap;
      mtvAxis = { x: ax, y: ay };
    }
  }

  return { overlap: minOverlap, axis: mtvAxis };
}

回転矩形の頂点計算

回転した矩形の4頂点の座標を計算するには、重心から各コーナーへの変換が必要です。

function getVertices(rect) {
  var hw = rect.w / 2, hh = rect.h / 2;
  var cos = Math.cos(rect.angle);
  var sin = Math.sin(rect.angle);

  // 4つのローカル頂点
  var corners = [
    {x: -hw, y: -hh},
    {x:  hw, y: -hh},
    {x:  hw, y:  hh},
    {x: -hw, y:  hh}
  ];

  // 回転変換してワールド座標に変換
  return corners.map(function(c) {
    return {
      x: rect.x + c.x * cos - c.y * sin,
      y: rect.y + c.x * sin + c.y * cos
    };
  });
}

そして各辺の法線(辺の方向に垂直なベクトル)がSATの試験軸になります:

function getAxes(vertices) {
  var axes = [];
  for (var i = 0; i < vertices.length; i++) {
    var next = (i + 1) % vertices.length;
    var edgeX = vertices[next].x - vertices[i].x;
    var edgeY = vertices[next].y - vertices[i].y;
    var len = Math.hypot(edgeX, edgeY);
    // 法線(辺を90度回転)
    axes.push({ x: -edgeY / len, y: edgeX / len });
  }
  return axes;
}

回転矩形同士のSAT衝突デモ

回転する矩形同士のSAT衝突検出
function getVerts(r) {
var hw = r.w/2, hh = r.h/2;
var cos = Math.cos(r.angle), sin = Math.sin(r.angle);
return [
  {x: r.x + (-hw)*cos - (-hh)*sin, y: r.y + (-hw)*sin + (-hh)*cos},
  {x: r.x + ( hw)*cos - (-hh)*sin, y: r.y + ( hw)*sin + (-hh)*cos},
  {x: r.x + ( hw)*cos - ( hh)*sin, y: r.y + ( hw)*sin + ( hh)*cos},
  {x: r.x + (-hw)*cos - ( hh)*sin, y: r.y + (-hw)*sin + ( hh)*cos},
];
}
function project(verts, ax, ay) {
var min = Infinity, max = -Infinity;
verts.forEach(function(v){var p=v.x*ax+v.y*ay;min=Math.min(min,p);max=Math.max(max,p);});
return {min,max};
}
function satTest(va, vb) {
var axes = [];
[va, vb].forEach(function(v) {
  for (var i=0;i<v.length;i++) {
    var n=v[(i+1)%v.length], e={x:n.x-v[i].x,y:n.y-v[i].y};
    var len=Math.hypot(e.x,e.y)||1;
    axes.push({x:-e.y/len,y:e.x/len});
  }
});
var minOv=Infinity,mtvX=0,mtvY=0;
for (var ax of axes) {
  var pa=project(va,ax.x,ax.y),pb=project(vb,ax.x,ax.y);
  var ov=Math.min(pa.max,pb.max)-Math.max(pa.min,pb.min);
  if (ov<=0) return null;
  if (ov<minOv){minOv=ov;mtvX=ax.x;mtvY=ax.y;}
}
return {overlap:minOv,nx:mtvX,ny:mtvY};
}

var rects = [
{x:W*0.3,y:H/2,vx:100,vy:-30,w:70,h:40,angle:0,omega:0.8,mass:1,hue:0},
{x:W*0.7,y:H/2,vx:-80,vy:40,w:55,h:45,angle:0.5,omega:-1.2,mass:1,hue:180},
];

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

rects.forEach(function(r) {
  r.x+=r.vx*dt; r.y+=r.vy*dt; r.angle+=r.omega*dt;
  var hw=r.w/2,hh=r.h/2;
  if(r.x-hw<0){r.x=hw;r.vx=Math.abs(r.vx)*0.8;}
  if(r.x+hw>W){r.x=W-hw;r.vx=-Math.abs(r.vx)*0.8;}
  if(r.y-hh<0){r.y=hh;r.vy=Math.abs(r.vy)*0.8;}
  if(r.y+hh>H){r.y=H-hh;r.vy=-Math.abs(r.vy)*0.8;}
});

var va=getVerts(rects[0]),vb=getVerts(rects[1]);
var col=satTest(va,vb);
var colliding=!!col;

if (col) {
  var cx=(rects[0].x+rects[1].x)/2,cy=(rects[0].y+rects[1].y)/2;
  var dx=rects[1].x-rects[0].x,dy=rects[1].y-rects[0].y;
  if(dx*col.nx+dy*col.ny<0){col.nx=-col.nx;col.ny=-col.ny;}
  var e=0.7;
  var dvn=(rects[0].vx-rects[1].vx)*col.nx+(rects[0].vy-rects[1].vy)*col.ny;
  if(dvn<0){
    var imp=-(1+e)*dvn*0.5;
    rects[0].vx+=imp*col.nx;rects[0].vy+=imp*col.ny;
    rects[1].vx-=imp*col.nx;rects[1].vy-=imp*col.ny;
  }
  rects[0].x-=col.nx*col.overlap*0.5;rects[0].y-=col.ny*col.overlap*0.5;
  rects[1].x+=col.nx*col.overlap*0.5;rects[1].y+=col.ny*col.overlap*0.5;
}

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

rects.forEach(function(r) {
  var verts=getVerts(r);
  ctx.beginPath();
  ctx.moveTo(verts[0].x,verts[0].y);
  for(var i=1;i<verts.length;i++) ctx.lineTo(verts[i].x,verts[i].y);
  ctx.closePath();
  ctx.fillStyle=colliding?'hsla('+r.hue+',80%,60%,0.7)':'hsla('+r.hue+',70%,50%,0.8)';
  ctx.fill();
  ctx.strokeStyle='hsl('+r.hue+',80%,80%)';
  ctx.lineWidth=2;
  ctx.stroke();
});

ctx.fillStyle=colliding?'#ff6b6b':'#4ecdc4';
ctx.font='13px monospace';
ctx.fillText(colliding?'衝突中':'衝突なし',8,20);

requestAnimationFrame(loop);
}
requestAnimationFrame(loop);

2つの矩形が衝突すると色が変わります。回転しながらぶつかっても正確に衝突検出されます。


AABBとSATの使い分け

手法対応形状速度実装難易度
AABB回転なし矩形のみ非常に速い簡単
OBB回転矩形速い中程度
SAT任意の凸多角形やや遅い複雑
GJK任意の凸形状速い難しい

実際のゲームエンジンでは、最初にAABBで大まかな絞り込み(ブロードフェーズ)をして、候補が絞れたらSATで精密判定(ナローフェーズ)するのが定番のパターンです。


まとめ

この回でやったこと:

  • AABB衝突判定(X・Y方向それぞれの重なり確認)を実装した
  • MTVの計算(最小重複方向の特定)で位置補正の方向を求めた
  • SAT(分離軸定理)の概念——全軸方向で射影が重なれば衝突——を学んだ
  • 回転矩形の頂点計算と辺の法線軸の計算を実装した
  • 回転矩形同士のSAT衝突デモを作成した

次回はシリーズの総まとめ。慣性・衝突・回転・位置補正すべてを統合した、2つの矩形が箱の中で跳ね回るフルデモを完成させます。