前言
上一篇文章我們建立了粒子系統的基礎,其中用了一個簡化版的碰撞處理。但如果你想讓模擬更逼真——讓兩顆球碰撞後的行為像真正的撞球一樣合理——我們就需要請出物理學的老朋友:動量守恆和能量守恆。
除了碰撞之外,這篇文章還會探討另一個優美的物理現象:簡諧運動(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;
}
但光是偵測到碰撞還不夠,我們還需要:
- 分離重疊的物體:避免兩個圓卡在一起
- 計算碰撞後的新速度:根據物理定律
先來處理分離:
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 的好處是不需要顯式儲存速度——位置差就隱含了速度資訊。它也比歐拉積分更穩定,特別適合約束系統(如彈簧網格)。
小結
這篇文章我們深入了兩個物理模擬的核心主題:
- 彈性碰撞:從碰撞偵測到動量守恆的完整實作,讓你能做出真實的撞球效果
- 簡諧運動與彈簧:從簡單的 sin 波到虎克定律,再到彈簧網格的布料模擬
物理模擬的迷人之處在於:你不需要手動描述每一幀的動畫,你只需要定義規則,然後讓物理定律自己生成結果。這種「emergent behavior(湧現行為)」正是計算之美的體現。
延伸閱讀
- Daniel Shiffman《The Nature of Code》第二、三章 — 力與振盪
- 2D Physics Engine Tutorial — 更深入的物理引擎數學
- Matter.js — 如果你想要一個成熟的 2D 物理引擎,不必自己寫碰撞偵測
- Verlet Integration 相關論文:Thomas Jakobsen 的經典布料模擬文章