前言

上一篇文章我們建立了粒子系統的基礎,其中用了一個簡化版的碰撞處理。但如果你想讓模擬更逼真——讓兩顆球碰撞後的行為像真正的撞球一樣合理——我們就需要請出物理學的老朋友:動量守恆能量守恆

除了碰撞之外,這篇文章還會探討另一個優美的物理現象:簡諧運動(Simple Harmonic Motion)。從鐘擺到彈簧,從潮汐到聲波,簡諧運動是大自然中最基本的週期性運動模式。把它寫成程式碼後,你會發現它能創造出非常舒服的動態效果。

我一直覺得,學物理模擬最棒的地方在於——你寫下幾行公式,按下執行,然後看著螢幕上的物體「自己動起來」,而且動得很合理。那種感覺就像是在跟自然法則對話。


圓形碰撞偵測

兩個圓形是否碰撞,判斷方式非常直覺:兩圓心之間的距離是否小於兩半徑之和

function isColliding(a, b) {
  let d = dist(a.pos.x, a.pos.y, b.pos.x, b.pos.y);
  return d < a.radius + b.radius;
}

但光是偵測到碰撞還不夠,我們還需要:

  1. 分離重疊的物體:避免兩個圓卡在一起
  2. 計算碰撞後的新速度:根據物理定律

先來處理分離:

function separateCircles(a, b) {
  let d = dist(a.pos.x, a.pos.y, b.pos.x, b.pos.y);
  let overlap = (a.radius + b.radius) - d;

if (overlap > 0) { let direction = p5.Vector.sub(a.pos, b.pos).normalize(); let totalMass = a.mass + b.mass; // 按質量比例分配位移 a.pos.add(p5.Vector.mult(direction, overlap * (b.mass / totalMass))); b.pos.sub(p5.Vector.mult(direction, overlap * (a.mass / totalMass))); } }

質量大的物體移動得少,質量小的移動得多——這符合我們的物理直覺。


動量守恆與彈性碰撞

彈性碰撞遵循兩條守恆定律:

  • 動量守恆:$m_1 v_1 + m_2 v_2 = m_1 v_1′ + m_2 v_2’$
  • 動能守恆:$\frac{1}{2}m_1 v_1^2 + \frac{1}{2}m_2 v_2^2 = \frac{1}{2}m_1 v_1’^2 + \frac{1}{2}m_2 v_2’^2$

在二維空間中,碰撞發生在兩圓心連線的方向上。我們只需要處理這個方向上的速度分量,垂直方向的速度不受影響。

完整的二維彈性碰撞公式實作:

function resolveCollision(a, b) {
  let normal = p5.Vector.sub(a.pos, b.pos).normalize();
  let relativeVel = p5.Vector.sub(a.vel, b.vel);
  let velAlongNormal = relativeVel.dot(normal);

// 如果物體正在遠離,不處理 if (velAlongNormal > 0) return;

// 彈性係數(1.0 = 完全彈性,0.0 = 完全非彈性) let restitution = 0.95;

// 計算碰撞衝量 let impulse = -(1 + restitution) * velAlongNormal; impulse /= (1 / a.mass) + (1 / b.mass);

// 施加衝量 let impulseVec = p5.Vector.mult(normal, impulse); a.vel.add(p5.Vector.mult(impulseVec, 1 / a.mass)); b.vel.sub(p5.Vector.mult(impulseVec, 1 / b.mass)); }

這段程式碼的關鍵概念:

  • 法線方向(normal):兩圓心的連線方向,碰撞力沿此方向作用
  • 相對速度在法線上的投影:用 dot 內積計算,如果為正表示物體在遠離,不需要處理
  • 衝量(impulse):碰撞瞬間速度改變量,按照質量反比分配給兩個物體
  • 彈性係數(restitution):1.0 表示不損失能量,低於 1.0 每次碰撞都會損失一些動能

完整的彈性碰撞範例

讓我們把上面的概念組合成一個完整的撞球模擬:

let balls = [];
const NUM_BALLS = 15;

function setup() { createCanvas(800, 600);

for (let i = 0; i < NUM_BALLS; i++) { let r = random(15, 40); let x = random(r, width - r); let y = random(r, height - r); let m = r * 0.5; // 質量與半徑成正比 balls.push({ pos: createVector(x, y), vel: p5.Vector.random2D().mult(random(1, 3)), radius: r, mass: m, color: color(random(100, 255), random(100, 255), random(100, 255)) }); } }

function draw() { background(30, 30, 40);

// 碰撞偵測與處理(所有配對) for (let i = 0; i < balls.length; i++) { for (let j = i + 1; j < balls.length; j++) { if (isColliding(balls[i], balls[j])) { separateCircles(balls[i], balls[j]); resolveCollision(balls[i], balls[j]); } } }

// 更新與繪製 for (let ball of balls) { ball.pos.add(ball.vel); checkWalls(ball);

fill(ball.color); noStroke(); ellipse(ball.pos.x, ball.pos.y, ball.radius * 2); } }

function checkWalls(ball) { if (ball.pos.x - ball.radius < 0) { ball.pos.x = ball.radius; ball.vel.x *= -0.95; } if (ball.pos.x + ball.radius > width) { ball.pos.x = width - ball.radius; ball.vel.x *= -0.95; } if (ball.pos.y - ball.radius < 0) { ball.pos.y = ball.radius; ball.vel.y *= -0.95; } if (ball.pos.y + ball.radius > height) { ball.pos.y = height - ball.radius; ball.vel.y *= -0.95; } }

function isColliding(a, b) { let d = dist(a.pos.x, a.pos.y, b.pos.x, b.pos.y); return d < a.radius + b.radius; }

function separateCircles(a, b) { let d = dist(a.pos.x, a.pos.y, b.pos.x, b.pos.y); let overlap = (a.radius + b.radius) - d; if (overlap > 0) { let dir = p5.Vector.sub(a.pos, b.pos).normalize(); let total = a.mass + b.mass; a.pos.add(p5.Vector.mult(dir, overlap * b.mass / total)); b.pos.sub(p5.Vector.mult(dir, overlap * a.mass / total)); } }

function resolveCollision(a, b) { let normal = p5.Vector.sub(a.pos, b.pos).normalize(); let relVel = p5.Vector.sub(a.vel, b.vel); let velAlongNormal = relVel.dot(normal);

if (velAlongNormal > 0) return;

let restitution = 0.95; let impulse = -(1 + restitution) * velAlongNormal; impulse /= (1 / a.mass) + (1 / b.mass);

let impulseVec = p5.Vector.mult(normal, impulse); a.vel.add(p5.Vector.mult(impulseVec, 1 / a.mass)); b.vel.sub(p5.Vector.mult(impulseVec, 1 / b.mass)); }

你可以試著調整 restitution 的值:設成 1.0 看看永不停歇的碰撞,設成 0.5 看看球怎麼快速失去動能。


簡諧運動(Simple Harmonic Motion)

簡諧運動的數學描述非常優雅:

$$x(t) = A \cdot \sin(\omega t + \phi)$$

其中:

  • $A$ 是振幅
  • $\omega$ 是角頻率
  • $\phi$ 是初始相位
  • $t$ 是時間

在 p5.js 裡,frameCount 就是我們的時間,乘上一個小數當作角頻率:

function draw() {
  background(30);

let x = width / 2 + 200 sin(frameCount 0.03); let y = height / 2;

fill(255, 100, 100); noStroke(); ellipse(x, y, 40, 40); }

這就是最簡單的簡諧運動——一顆球左右來回擺動。但真正有趣的是把多個簡諧運動疊加在一起:

function draw() {
  background(30, 30, 40);

for (let i = 0; i < 20; i++) { let x = width / 2 + 150 sin(frameCount 0.02 + i * 0.3); let y = 50 + i * 28; let size = 10 + 10 sin(frameCount 0.05 + i * 0.5);

fill(100 + i 8, 100, 255 - i 8, 200); noStroke(); ellipse(x, y, size, size); } }

每顆球有不同的相位偏移(i * 0.3),形成了波浪般的視覺效果。這就是相位差的魔力。


彈簧模擬

彈簧是簡諧運動最經典的實體模型。虎克定律告訴我們:

$$F = -k \cdot x$$

其中 $k$ 是彈簧常數,$x$ 是偏離平衡位置的距離。負號表示力的方向永遠指向平衡位置(回復力)。

class Spring {
  constructor(x, y, restLength) {
    this.anchor = createVector(x, y);
    this.restLength = restLength;
    this.k = 0.1;       // 彈簧常數
    this.damping = 0.98; // 阻尼係數
  }

connect(bob) { let force = p5.Vector.sub(bob.pos, this.anchor); let currentLength = force.mag(); let stretch = currentLength - this.restLength;

// 虎克定律:F = -k * x force.normalize(); force.mult(-this.k * stretch);

bob.applyForce(force); }

display(bob) { stroke(150); strokeWeight(2); line(this.anchor.x, this.anchor.y, bob.pos.x, bob.pos.y);

fill(100); noStroke(); ellipse(this.anchor.x, this.anchor.y, 10, 10); } }

class Bob { constructor(x, y) { this.pos = createVector(x, y); this.vel = createVector(0, 0); this.acc = createVector(0, 0); this.mass = 2; this.damping = 0.98; }

applyForce(force) { let f = p5.Vector.div(force, this.mass); this.acc.add(f); }

update() { this.vel.add(this.acc); this.vel.mult(this.damping); this.pos.add(this.vel); this.acc.mult(0); }

display() { fill(255, 100, 100); noStroke(); ellipse(this.pos.x, this.pos.y, 30, 30); } }

完整的彈簧模擬整合:

let spring;
let bob;
let gravity;

function setup() { createCanvas(800, 600); spring = new Spring(width / 2, 50, 200); bob = new Bob(width / 2 + 100, 300); gravity = createVector(0, 0.5); }

function draw() { background(30, 30, 40);

bob.applyForce(gravity); spring.connect(bob); bob.update();

spring.display(bob); bob.display(); }

function mousePressed() { bob.pos.set(mouseX, mouseY); bob.vel.set(0, 0); }

點擊滑鼠可以把球拖到新位置,放開後它會在彈簧的回復力下振盪。調整 k 值可以改變彈簧的硬度——較大的 k 振盪更快,較小的 k 振盪更慢且更柔軟。


多彈簧網格

把彈簧的概念擴展,我們可以做出軟體布料模擬的雛形:

let cols = 12;
let rows = 8;
let spacing = 50;
let points = [];
let springs = [];

function setup() { createCanvas(800, 600);

// 建立節點 for (let j = 0; j < rows; j++) { for (let i = 0; i < cols; i++) { let x = 100 + i * spacing; let y = 50 + j * spacing; let pinned = (j === 0); // 第一排固定 points.push({ pos: createVector(x, y), prev: createVector(x, y), pinned: pinned }); } }

// 建立水平與垂直彈簧 for (let j = 0; j < rows; j++) { for (let i = 0; i < cols; i++) { let idx = j * cols + i; if (i < cols - 1) springs.push([idx, idx + 1]); if (j < rows - 1) springs.push([idx, idx + cols]); } } }

function draw() { background(30, 30, 40);

// Verlet 積分 + 重力 for (let p of points) { if (p.pinned) continue; let vel = p5.Vector.sub(p.pos, p.prev); vel.mult(0.99); // 阻尼 p.prev = p.pos.copy(); p.pos.add(vel); p.pos.y += 0.3; // 重力 }

// 彈簧約束(迭代多次以提高穩定性) for (let iter = 0; iter < 5; iter++) { for (let [a, b] of springs) { let pa = points[a]; let pb = points[b]; let delta = p5.Vector.sub(pb.pos, pa.pos); let d = delta.mag(); let diff = (d - spacing) / d * 0.5;

if (!pa.pinned) pa.pos.add(p5.Vector.mult(delta, diff)); if (!pb.pinned) pb.pos.sub(p5.Vector.mult(delta, diff)); } }

// 繪製 stroke(150, 200, 255, 150); strokeWeight(1); for (let [a, b] of springs) { line(points[a].pos.x, points[a].pos.y, points[b].pos.x, points[b].pos.y); }

for (let p of points) { fill(p.pinned ? color(255, 100, 100) : color(200)); noStroke(); ellipse(p.pos.x, p.pos.y, 6, 6); } }

這裡用了 Verlet 積分而非傳統的歐拉積分。Verlet 的好處是不需要顯式儲存速度——位置差就隱含了速度資訊。它也比歐拉積分更穩定,特別適合約束系統(如彈簧網格)。


小結

這篇文章我們深入了兩個物理模擬的核心主題:

  1. 彈性碰撞:從碰撞偵測到動量守恆的完整實作,讓你能做出真實的撞球效果
  2. 簡諧運動與彈簧:從簡單的 sin 波到虎克定律,再到彈簧網格的布料模擬

物理模擬的迷人之處在於:你不需要手動描述每一幀的動畫,你只需要定義規則,然後讓物理定律自己生成結果。這種「emergent behavior(湧現行為)」正是計算之美的體現。

延伸閱讀

  • Daniel Shiffman《The Nature of Code》第二、三章 — 力與振盪
  • 2D Physics Engine Tutorial — 更深入的物理引擎數學
  • Matter.js — 如果你想要一個成熟的 2D 物理引擎,不必自己寫碰撞偵測
  • Verlet Integration 相關論文:Thomas Jakobsen 的經典布料模擬文章