一定空間で跳ね返るデモ——2つの矩形のシミュレーション完成
いよいよ最終回です。このシリーズで学んできたすべての概念を統合し、2つの矩形が閉じた空間で衝突・回転・跳ね返り続けるデモを完成させます。
このシリーズ(episode 1〜9)で積み上げてきたもの:
- 慣性と速度(
v += a*dt,x += v*dt) - 力と加速度(
F = ma、重力) - オイラー法(シンプレクティック版)
- バネの力(フックの法則)
- 壁との反射(反発係数)
- 円の衝突(インパルス法)
- 位置補正(セパレーション)
- 回転(慣性モーメント・トルク・角速度)
- AABB/SATによる矩形衝突
これらすべてがひとつのシミュレーションに合流します。
シミュレーションの全体設計
フルシミュレーションの設計は3つの層に分けて考えます。
┌─────────────────────────────────┐
│ 状態管理(State) │
│ 物体リスト、境界ボックス設定 │
├─────────────────────────────────┤
│ 更新ループ(Update) │
│ 1. 力の積算 │
│ 2. 積分(速度・位置・角度) │
│ 3. 壁との衝突処理 │
│ 4. 物体間の衝突処理 │
│ 5. 位置補正 │
├─────────────────────────────────┤
│ 描画(Render) │
│ クリア → 各物体 → デバッグ情報 │
└─────────────────────────────────┘
この分離が保たれていると、後からルールを追加したり変更したりするのが容易になります。
矩形オブジェクトの定義
矩形の完全な状態をひとつのオブジェクトで表します。
var rects = [
{
x: W/4, // 重心X座標
y: H/4, // 重心Y座標
vx: 120, // X方向速度(px/s)
vy: 80, // Y方向速度(px/s)
w: 60, // 幅
h: 40, // 高さ
omega: 1.5, // 角速度(rad/s)
angle: 0, // 現在の角度
mass: 1, // 質量
get I() { // 慣性モーメント(ゲッタで自動計算)
return this.mass * (this.w*this.w + this.h*this.h) / 12;
}
},
{
x: 3*W/4, y: 3*H/4,
vx: -80, vy: -120,
w: 80, h: 50,
omega: -1.0, angle: 0.5,
mass: 1.5,
get I() { return this.mass*(this.w*this.w+this.h*this.h)/12; }
}
];
壁との衝突(回転矩形)
回転矩形と壁の衝突は、矩形の4頂点を計算してから、はみ出た頂点を検出するアプローチを使います。
function wallCollide(r, e) {
var verts = getVertices(r);
verts.forEach(function(v) {
if (v.x < 0) {
var penetration = -v.x;
r.x += penetration;
if (r.vx < 0) r.vx = -r.vx * e;
// 頂点の壁への衝突がトルクを発生させる
var ry = v.y - r.y;
r.omega -= (ry * r.vx * 0.1) / r.I;
}
// 他の壁も同様...
});
}
完全なシミュレーションデモ
var g = 180, e = 0.70;
var rects = [
{x:W*0.3,y:H*0.25,vx:150,vy:60,w:64,h:42,omega:1.5,angle:0.2,mass:1.0,hue:10},
{x:W*0.7,y:H*0.6,vx:-90,vy:-140,w:80,h:50,omega:-0.9,angle:0.6,mass:1.6,hue:190},
];
function inertia(r) { return r.mass*(r.w*r.w+r.h*r.h)/12; }
function getVerts(r) {
var hw=r.w/2,hh=r.h/2,c=Math.cos(r.angle),s=Math.sin(r.angle);
return [[-hw,-hh],[hw,-hh],[hw,hh],[-hw,hh]].map(function(p){
return {x:r.x+p[0]*c-p[1]*s, y:r.y+p[0]*s+p[1]*c};
});
}
function applyWall(r) {
var I = inertia(r);
var verts = getVerts(r);
verts.forEach(function(v) {
var pen, nx, ny;
if (v.x < 0) {
pen = -v.x; nx=1; ny=0;
} else if (v.x > W) {
pen = v.x-W; nx=-1; ny=0;
} else if (v.y < 0) {
pen = -v.y; nx=0; ny=1;
} else if (v.y > H) {
pen = v.y-H; nx=0; ny=-1;
} else { return; }
r.x += nx * pen * 0.5;
r.y += ny * pen * 0.5;
var rvx = r.vx - r.omega * (v.y - r.y);
var rvy = r.vy + r.omega * (v.x - r.x);
var vn = rvx * nx + rvy * ny;
if (vn >= 0) return;
var rx = v.x - r.x, ry = v.y - r.y;
var rxn = rx * nx + ry * ny;
var denom = 1/r.mass + (rx*ny - ry*nx)*(rx*ny - ry*nx)/I;
var impulse = -(1+e) * vn / denom;
r.vx += impulse * nx / r.mass;
r.vy += impulse * ny / r.mass;
r.omega += (rx * ny - ry * nx) * impulse / I;
});
}
function satCollide(ra, rb) {
var va=getVerts(ra), vb=getVerts(rb);
var axes=[];
[va,vb].forEach(function(v){
for(var i=0;i<v.length;i++){
var n=v[(i+1)%v.length];
var ex=n.x-v[i].x, ey=n.y-v[i].y, len=Math.hypot(ex,ey)||1;
axes.push({x:-ey/len,y:ex/len});
}
});
var minOv=Infinity,mtvX=0,mtvY=0;
for(var ax of axes){
var minA=Infinity,maxA=-Infinity,minB=Infinity,maxB=-Infinity;
va.forEach(function(v){var p=v.x*ax.x+v.y*ax.y;minA=Math.min(minA,p);maxA=Math.max(maxA,p);});
vb.forEach(function(v){var p=v.x*ax.x+v.y*ax.y;minB=Math.min(minB,p);maxB=Math.max(maxB,p);});
var ov=Math.min(maxA,maxB)-Math.max(minA,minB);
if(ov<=0) return null;
if(ov<minOv){minOv=ov;mtvX=ax.x;mtvY=ax.y;}
}
return {overlap:minOv,nx:mtvX,ny:mtvY};
}
function resolveRects(ra, rb, mtv) {
var dx=rb.x-ra.x, dy=rb.y-ra.y;
if(dx*mtv.nx+dy*mtv.ny<0){mtv.nx=-mtv.nx;mtv.ny=-mtv.ny;}
var Ia=inertia(ra), Ib=inertia(rb);
var cx=(ra.x+rb.x)/2, cy=(ra.y+rb.y)/2;
var rax=cx-ra.x,ray=cy-ra.y,rbx=cx-rb.x,rby=cy-rb.y;
var vrelX=(ra.vx-ra.omega*ray)-(rb.vx-rb.omega*rby);
var vrelY=(ra.vy+ra.omega*rax)-(rb.vy+rb.omega*rbx);
var vn=vrelX*mtv.nx+vrelY*mtv.ny;
if(vn>0){
var corr=mtv.overlap*0.5;
var tm=ra.mass+rb.mass;
ra.x-=mtv.nx*corr*(rb.mass/tm);
ra.y-=mtv.ny*corr*(rb.mass/tm);
rb.x+=mtv.nx*corr*(ra.mass/tm);
rb.y+=mtv.ny*corr*(ra.mass/tm);
return;
}
var ra_cross=rax*mtv.ny-ray*mtv.nx;
var rb_cross=rbx*mtv.ny-rby*mtv.nx;
var denom=1/ra.mass+1/rb.mass+ra_cross*ra_cross/Ia+rb_cross*rb_cross/Ib;
var impulse=-(1+e)*vn/denom;
ra.vx+=impulse*mtv.nx/ra.mass; ra.vy+=impulse*mtv.ny/ra.mass;
ra.omega+=ra_cross*impulse/Ia;
rb.vx-=impulse*mtv.nx/rb.mass; rb.vy-=impulse*mtv.ny/rb.mass;
rb.omega-=rb_cross*impulse/Ib;
var corr=mtv.overlap*0.4;
var tm=ra.mass+rb.mass;
ra.x-=mtv.nx*corr*(rb.mass/tm);
ra.y-=mtv.ny*corr*(rb.mass/tm);
rb.x+=mtv.nx*corr*(ra.mass/tm);
rb.y+=mtv.ny*corr*(ra.mass/tm);
}
function drawRect(r) {
var verts=getVerts(r);
ctx.save();
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='hsla('+r.hue+',75%,50%,0.85)';
ctx.fill();
ctx.strokeStyle='hsl('+r.hue+',80%,75%)';
ctx.lineWidth=2;
ctx.stroke();
ctx.fillStyle='rgba(255,255,0,0.8)';
ctx.beginPath(); ctx.arc(r.x,r.y,4,0,Math.PI*2); ctx.fill();
var speed=Math.hypot(r.vx,r.vy);
if(speed>10){
var sc=0.2;
ctx.beginPath(); ctx.moveTo(r.x,r.y); ctx.lineTo(r.x+r.vx*sc,r.y+r.vy*sc);
ctx.strokeStyle='rgba(255,255,0,0.5)'; ctx.lineWidth=1.5; ctx.stroke();
}
ctx.restore();
}
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.vx *= (1 - 0.2*dt);
r.vy *= (1 - 0.2*dt);
r.omega *= (1 - 0.5*dt);
r.x += r.vx * dt;
r.y += r.vy * dt;
r.angle += r.omega * dt;
});
rects.forEach(function(r){ applyWall(r); });
var mtv=satCollide(rects[0],rects[1]);
if(mtv) resolveRects(rects[0],rects[1],mtv);
ctx.fillStyle='rgba(13,17,23,0.55)';
ctx.fillRect(0,0,W,H);
ctx.strokeStyle='#2a4a5a';
ctx.lineWidth=2;
ctx.strokeRect(1,1,W-2,H-2);
rects.forEach(drawRect);
ctx.fillStyle='#4a6a7a';
ctx.font='11px monospace';
ctx.fillText('v0='+(Math.hypot(rects[0].vx,rects[0].vy)|0)+' v1='+(Math.hypot(rects[1].vx,rects[1].vy)|0),8,H-8);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop); 2つの矩形が重力の影響を受けながら回転し、壁と互いに衝突して跳ね返り続けます。黄色い点が重心、黄色の線が速度ベクトルです。
デバッグ用: 速度ベクトルと衝突点の可視化
実際の開発では、物理シミュレーションのデバッグに視覚化が欠かせません。以下のような情報を描くと問題の特定が素早くなります。
// 衝突点の可視化
function drawContactPoint(x, y) {
ctx.beginPath();
ctx.arc(x, y, 5, 0, Math.PI * 2);
ctx.fillStyle = '#ff0000';
ctx.fill();
}
// 法線の可視化
function drawNormal(x, y, nx, ny, length) {
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x + nx * length, y + ny * length);
ctx.strokeStyle = '#00ff00';
ctx.lineWidth = 2;
ctx.stroke();
}
// 慣性モーメントと角速度の表示
function drawInfo(rect) {
ctx.fillStyle = '#ffffff';
ctx.font = '11px monospace';
ctx.fillText('ω=' + rect.omega.toFixed(2), rect.x - 20, rect.y - 30);
}
まとめ: 物理エンジンへの発展
このシリーズでゼロから実装した内容は、実用的な物理エンジンの核心部分です。
| このシリーズで学んだこと | 実際の物理エンジンの対応 |
|---|---|
| 慣性・速度・deltaTime | 時間積分・ステッパー |
| 力の合成・加速度 | 力ソルバー |
| オイラー法・中点法 | 積分器(Integrator) |
| バネ・減衰 | コンストレイント |
| 壁・円の衝突 | コリジョン検出(CD) |
| インパルス法 | コリジョン応答(CR) |
| 位置補正・反復 | PGS(投影ガウスザイデル) |
| 回転・慣性モーメント | 剛体(Rigid Body) |
| AABB / SAT | ブロード/ナローフェーズ |
次のステップ: 本格的な物理エンジン
このシリーズの先には、より洗練されたライブラリが待っています:
Matter.js — JavaScriptの2D物理エンジン。このシリーズで学んだ概念がすべてAPIとして整備されています。ブラウザ上でそのまま動かせます。
Rapier — Rustで書かれた高速な物理エンジン。WebAssembly経由でブラウザからも使えます。2D・3Dの両方に対応。
Box2D — ゲーム開発で最も広く使われてきた2D物理エンジン。多くのゲームエンジン(Unity等)のベースになっています。
シリーズ全体のまとめ
| 回 | テーマ | 核心 |
|---|---|---|
| #01 | 慣性と速度 | x += vx * dt、deltaTime |
| #02 | 力と加速度 | F = ma、重力、空気抵抗 |
| #03 | オイラー法 | 数値積分、誤差、中点法 |
| #04 | バネ | フックの法則、減衰、バネチェーン |
| #05 | 跳ね返り | 反発係数、壁衝突、位置補正 |
| #06 | 円の衝突 | 当たり判定、インパルス法 |
| #07 | 位置補正 | セパレーション、イテレーション |
| #08 | 回転 | トルク、慣性モーメント、角速度 |
| #09 | 矩形衝突 | AABB、SAT、法線計算 |
| #10 | 統合デモ | 全要素の統合、完成形 |
「物理シミュレーションは難しい」と思っていた方も、分解してみれば x += vx * dt という一行の更新式の積み重ねに過ぎないことが分かったはずです。
コードで世界のルールを作る——これがビジュアルコーディングの物理シミュレーションです。ここで学んだ土台を使って、ぜひ自分だけの物理世界を作り上げてください。