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

バネの力——フックの法則でバネを作る

バネは物理シミュレーションの中で最も重要な要素の一つです。バネの力を理解すれば、ロープ・布・柔らかい体・ゼリーのような弾性物体をすべて同じ仕組みで表現できます。

今回はフックの法則からスタートして、バネチェーンによるロープシミュレーションまで実装します。


フックの法則

バネの力は「自然長からの伸び縮みに比例した力」です。これをフックの法則と呼びます。

F = -k * (x - L0)
  • k — バネ定数(硬さ)。大きいほどバネが硬い
  • x — 現在のバネの長さ
  • L0 — 自然長(力がゼロになる長さ)
  • F — バネが発揮する力(符号がマイナス = 伸びたら縮もうとする)

1次元の例:

var k = 200;    // バネ定数
var L0 = 100;   // 自然長(100px)

// 毎フレーム
var stretch = x - L0;        // 伸び量(負なら縮んでいる)
var Fspring = -k * stretch;  // バネ力(復元力)
ax = Fspring / mass;
vx += ax * dt;
x += vx * dt;

x < L0 のとき力は正(右向き)、x > L0 のとき力は負(左向き)——常に自然長に引き戻そうとする力です。


バネに繋がれたボール

壁に固定されたバネの先端にボールを付けた古典的な系を実装します。

壁に繋がれたバネとボール
var anchorX = 60, anchorY = H / 2;
var L0 = 120;
var k = 300;
var mass = 1.0;
var x = anchorX + L0 + 60;
var y = anchorY;
var vx = 0;
var r = 16;

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

var dx = x - anchorX;
var len = Math.abs(dx);
var stretch = len - L0;
var Fspring = -k * stretch * (dx / (len || 1));
var ax = Fspring / mass;

vx += ax * dt;
x += vx * dt;

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

ctx.fillStyle = '#333';
ctx.fillRect(0, anchorY - 40, 60, 80);

var segments = 12;
ctx.beginPath();
ctx.moveTo(anchorX, anchorY);
for (var i = 0; i <= segments; i++) {
  var t = i / segments;
  var px = anchorX + (x - r - anchorX) * t;
  var amplitude = (i > 0 && i < segments) ? 8 * Math.sin(i * Math.PI / 2) : 0;
  var py = anchorY + amplitude;
  ctx.lineTo(px, py);
}
ctx.strokeStyle = '#888';
ctx.lineWidth = 2;
ctx.stroke();

ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.fillStyle = '#00e5ff';
ctx.fill();

ctx.strokeStyle = 'rgba(0,229,255,0.3)';
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
ctx.beginPath();
ctx.moveTo(anchorX, anchorY);
ctx.lineTo(anchorX + L0, anchorY);
ctx.stroke();
ctx.setLineDash([]);

requestAnimationFrame(loop);
}
requestAnimationFrame(loop);

ボールが自然長から外れると復元力が働き、自然長を中心に振動します。これが単振動です。


減衰: F_damping = -c * v

現実のバネは永遠に振動し続けません。摩擦や空気抵抗によって徐々に振動が小さくなります。これを減衰(damping)と呼びます。

減衰力は速度に比例した、速度と逆向きの力です。

var c = 0.8;  // 減衰係数

// バネ力と減衰力を合成
var Fspring = -k * stretch;
var Fdamping = -c * vx;
var ax = (Fspring + Fdamping) / mass;

c の値によって振動の収まり方が変わります:

  • c が小さい → ゆっくり減衰(長く振動する)
  • c が大きい → 素早く収束(すぐに止まる)
  • c が大きすぎる → 振動せずにゆっくり平衡点に近づく(過減衰
減衰あり・なしのバネ振動の比較
var anchorX = 60;
var L0 = 100;
var k = 250;
var mass = 1.0;

var springs = [
{y: H * 0.35, x: anchorX + L0 + 70, vx: 0, c: 0,   color: '#ff6b6b', label: '減衰なし'},
{y: H * 0.65, x: anchorX + L0 + 70, vx: 0, c: 2.5, color: '#4ecdc4', label: '減衰あり(c=2.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.fillStyle = '#333';
ctx.fillRect(0, 0, 60, H);

springs.forEach(function(s) {
  var dx = s.x - anchorX;
  var stretch = Math.abs(dx) - L0;
  var Fspring = -k * stretch * Math.sign(dx);
  var Fdamp = -s.c * s.vx;
  s.vx += ((Fspring + Fdamp) / mass) * dt;
  s.x += s.vx * dt;

  var segments = 14;
  ctx.beginPath();
  ctx.moveTo(anchorX, s.y);
  for (var i = 0; i <= segments; i++) {
    var t = i / segments;
    var px = anchorX + (s.x - 14 - anchorX) * t;
    var amp = (i > 0 && i < segments) ? 7 * Math.sin(i * Math.PI / 2) : 0;
    ctx.lineTo(px, s.y + amp);
  }
  ctx.strokeStyle = '#666';
  ctx.lineWidth = 2;
  ctx.stroke();

  ctx.beginPath();
  ctx.arc(s.x, s.y, 14, 0, Math.PI * 2);
  ctx.fillStyle = s.color;
  ctx.fill();

  ctx.fillStyle = s.color;
  ctx.font = '12px monospace';
  ctx.fillText(s.label, W - 160, s.y - 20);
});

requestAnimationFrame(loop);
}
requestAnimationFrame(loop);

赤(減衰なし)は永遠に振動し続けます。水色(減衰あり)は徐々に振幅が小さくなり、最終的に静止します。


バネチェーン(複数のノードを繋ぐ)

複数のノード(点)をバネで繋ぐと、ロープや鎖のような柔軟な物体を表現できます。

各ノードは位置と速度を持ちます。隣接するノード間でバネ力を計算して適用します。

var nodes = [];
for (var i = 0; i < 8; i++) {
  nodes.push({ x: 100 + i * 30, y: H/2, vx: 0, vy: 0 });
}

// 毎フレーム: バネ力を計算
for (var i = 0; i < nodes.length - 1; i++) {
  var a = nodes[i];
  var b = nodes[i + 1];
  var dx = b.x - a.x;
  var dy = b.y - a.y;
  var dist = Math.sqrt(dx*dx + dy*dy);
  var stretch = dist - L0;

  // 正規化された方向ベクトル
  var nx = dx / dist;
  var ny = dy / dist;

  // バネ力(引き合う力)
  var F = k * stretch;
  a.vx += (F * nx) / mass * dt;
  a.vy += (F * ny) / mass * dt;
  b.vx -= (F * nx) / mass * dt;
  b.vy -= (F * ny) / mass * dt;
}
ロープシミュレーション(8ノードのバネチェーン)
var N = 10;
var L0 = (W - 80) / (N - 1);
var k = 800;
var damping = 6;
var mass = 0.2;
var GRAVITY = 300;

var nodes = [];
for (var i = 0; i < N; i++) {
nodes.push({
  x: 40 + i * L0,
  y: 60,
  vx: 0,
  vy: 0,
  fixed: (i === 0 || i === N - 1)
});
}

nodes[Math.floor(N / 2)].vy = 200;

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

for (var i = 0; i < N - 1; i++) {
  var a = nodes[i];
  var b = nodes[i + 1];
  var dx = b.x - a.x;
  var dy = b.y - a.y;
  var dist = Math.sqrt(dx * dx + dy * dy) || 0.001;
  var stretch = dist - L0;
  var F = k * stretch;
  var nx = dx / dist;
  var ny = dy / dist;

  if (!a.fixed) {
    a.vx += (F * nx) / mass * dt;
    a.vy += (F * ny) / mass * dt;
  }
  if (!b.fixed) {
    b.vx -= (F * nx) / mass * dt;
    b.vy -= (F * ny) / mass * dt;
  }
}

nodes.forEach(function(n) {
  if (n.fixed) return;
  n.vy += GRAVITY * dt;
  n.vx += (-damping * n.vx) / mass * dt;
  n.vy += (-damping * n.vy) / mass * dt;
  n.x += n.vx * dt;
  n.y += n.vy * dt;
  if (n.y > H - 10) { n.y = H - 10; n.vy *= -0.3; }
});

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

ctx.beginPath();
ctx.moveTo(nodes[0].x, nodes[0].y);
for (var i = 1; i < N; i++) {
  ctx.lineTo(nodes[i].x, nodes[i].y);
}
ctx.strokeStyle = '#4ecdc4';
ctx.lineWidth = 3;
ctx.stroke();

nodes.forEach(function(n) {
  ctx.beginPath();
  ctx.arc(n.x, n.y, n.fixed ? 7 : 5, 0, Math.PI * 2);
  ctx.fillStyle = n.fixed ? '#ff6b6b' : '#95e67d';
  ctx.fill();
});

requestAnimationFrame(loop);
}
requestAnimationFrame(loop);

両端を固定した紐が、初速を与えられた中間ノードによって波打ちます。バネ定数を大きくすると硬い紐、小さくすると柔らかい紐になります。


マウスドラッグでボールを引っ張る

インタラクティブ性を加えましょう。マウスでバネのボールを引っ張り、放すと振動が始まるデモです。

マウスドラッグでボールを引っ張るバネ
var anchorX = W / 2, anchorY = 60;
var L0 = 100;
var k = 280;
var damping = 2.0;
var mass = 1.0;

var ball = {x: W/2, y: anchorY + L0, vx: 0, vy: 0};
var dragging = false;

// mx/my はCanvasDemoが自動注入(スケール補正済み)
c.addEventListener('mousedown', function() {
var dx = mx - ball.x;
var dy = my - ball.y;
if (dx*dx + dy*dy < 600) dragging = true;
});
c.addEventListener('touchstart', function() {
var dx = mx - ball.x;
var dy = my - ball.y;
if (dx*dx + dy*dy < 1200) dragging = true;
}, {passive:true});
c.addEventListener('mouseup', function() { if (dragging) { ball.vx=0; ball.vy=0; dragging=false; } });
c.addEventListener('touchend', function() { if (dragging) { ball.vx=0; ball.vy=0; dragging=false; } });

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

if (dragging) {
  ball.x = mouseX;
  ball.y = mouseY;
  ball.vx = 0;
  ball.vy = 0;
} else {
  var dx = ball.x - anchorX;
  var dy = ball.y - anchorY;
  var dist = Math.sqrt(dx*dx + dy*dy) || 0.001;
  var stretch = dist - L0;
  var F = -k * stretch;
  var nx = dx / dist, ny = dy / dist;
  var ax = (F * nx - damping * ball.vx) / mass;
  var ay = (F * ny - damping * ball.vy) / mass + 300;
  ball.vx += ax * dt;
  ball.vy += ay * dt;
  ball.x += ball.vx * dt;
  ball.y += ball.vy * dt;
}

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

ctx.beginPath();
ctx.moveTo(anchorX, anchorY);
ctx.lineTo(ball.x, ball.y);
ctx.strokeStyle = '#888';
ctx.lineWidth = 3;
ctx.stroke();

ctx.beginPath();
ctx.arc(anchorX, anchorY, 8, 0, Math.PI * 2);
ctx.fillStyle = '#ff6b6b';
ctx.fill();

ctx.beginPath();
ctx.arc(ball.x, ball.y, 18, 0, Math.PI * 2);
ctx.fillStyle = dragging ? '#ffff00' : '#00e5ff';
ctx.fill();

ctx.fillStyle = '#666';
ctx.font = '12px monospace';
ctx.fillText('ボールをドラッグして引っ張る', 8, H - 10);

requestAnimationFrame(loop);
}
requestAnimationFrame(loop);

ボールをドラッグして引っ張り、放すとバネの復元力で揺れ始めます。減衰によって徐々に振幅が小さくなります。


まとめ

この回でやったこと:

  • フックの法則 F = -k * (x - L0) を実装し、壁に繋がれたバネを作った
  • 減衰力 F = -c * v を加えて、振動が収束するバネを実装した
  • 複数ノードをバネで繋いだバネチェーンでロープシミュレーションを作った
  • マウスドラッグによるインタラクティブなバネを実装した

次回は「跳ね返り」を実装します。反発係数の概念と、壁との衝突応答を詳しく解説します。