物體碰撞前後總動能不變稱為彈性碰撞(Elastic collision),在維基百科〈Elastic collision〉中可以查詢到,二維的彈性碰撞在位置及速度以向量表示時,公式會如下,其中角括號表示向量內積:
根據這個公式,可以在 Body
定義 collideWith
方法:
class Body {
constructor(coordinate, velocity, mass = 1) {
this.coordinate = coordinate;
this.velocity = velocity;
this.mass = mass;
}
...
collideWith(body) {
const d = sub(this.coordinate, body.coordinate);
const m = body.mass / (this.mass + body.mass);
this.velocity = sub(
this.velocity,
d.mult(
2 * m / pow(d.mag(), 2) * p5.Vector.dot(
sub(this.velocity, body.velocity),
sub(this.coordinate, body.coordinate)
)
)
);
}
...
}
問題在於如何判斷何時發生碰撞?直覺的想法是兩個圓的圓心距離等於半徑時,可惜的是,如同〈邊界反彈〉談過的,因為影格在時間上是不連續的,真正的碰撞時間可能發生在影格之間,也就是兩個圓的圓心距離等於半徑時可能發生在影格之間,這你是要怎麼呼叫 collideWith
呢?
那就在下個影格時,看看兩個圓的圓心距離是否小於等於半徑呢?例如:
乍看是還行,不過程式運行一段時間後,可能會發生兩個圓碰撞後糾纏在一起的情況,這是因為兩個圓其實是彼此穿透,然後才碰撞交換動能,在接下來的影格以碰撞後的速度移動後,移動距離不夠,造成兩個圓仍然彼此穿透,這時又判斷是該發生碰撞了…結果就一直黏在一起了…XD
解決的方式其實類似〈邊界反彈〉的出發點,穿透後必須自行修正座標,然而顯然地,這邊不能單純地用反射的方式來計算,怎麼辦呢?
這邊的做法是,若判斷兩個圓的圓心距離小於等於半徑和時,表示直接繪製的話,會是穿透的狀態,這時計算上一影格時的位置:
接著計算這時圓心間的距離,兩個圓的相對速度大小,求得接下來還要多久會發生碰撞,以程式碼表示的話會是:
// sub 是 p5.Vector.sub 的封裝
function timeBeforeCollision(b1, b2, d, tolerant) {
// 退回
const preC1 = sub(b1.coordinate, b1.velocity);
const preC2 = sub(b2.coordinate, b2.velocity);
// 相對速度
const rv = sub(b1.velocity, b2.velocity).mag();
// 還要多久碰撞
return (sub(preC1, preC2).mag() + tolerant - d) / rv;
}
因為浮點數計算會有誤差,這會導致糾纏還是會發生一下,這部份可以用 tolerant
設定容許的誤差來克服。
若求得的時間差是 t
,接下來就可以求得兩個圓發生碰撞時的圓心位置:
function coordinateAfterTime(b, t) {
return add(b.coordinate, p5.Vector.mult(b.velocity, t));
}
function collisionCoordinate(b, t) {
const preC = coordinateAfterTime(b, -1)
return add(preC, p5.Vector.mult(b.velocity, t));
}
將圓移動至求得的位置後,就可以呼叫 collideWith
進行動能交換了,接下來離下個影格的時間還剩 1 - t
(在我們模擬的世界中,影格間的時間就是一個時間單位),利用這段時間,以及動能交換後的速度來移動,就會是碰撞後,在下個影格時該有的位置:
// 用剩餘時間移動
c1.body.coordinate = coordinateAfterTime(b1, 1 - t);
c2.body.coordinate = coordinateAfterTime(b2, 1 - t);
記得!碰撞的時間發生在兩個影格間時,你是看不到兩個圓接觸的畫面的,只會看到碰撞後下個影格時該在的位置,底下是完整的程式示範:
可以將以上的 checkCollision
,擴展為支援多個圓:
function checkCollision(circles, tolerant = 0.5) {
const copied = circles.map(c => c.body.copy());
for(let i = 0; i < copied.length; i++) {
const b1 = circles[i].body;
for(let j = 0; j < copied.length; j++) {
if(i != j) { // 不與自身碰撞
const b2 = copied[j];
const d = circles[i].radius + circles[j].radius;
if(sub(b1.coordinate, b2.coordinate).mag() <= d) {
// 還要多久碰撞
const t = timeBeforeCollision(b1, b2, d, tolerant);
// 碰撞時的位置
b1.coordinate = collisionCoordinate(b1, t);
b2.coordinate = collisionCoordinate(b2, t);
circles[i].body.collideWith(b2);
// 用剩餘時間移動
b1.coordinate = coordinateAfterTime(b1, 1 - t);
}
}
}
}
}
底下是四個圓的模擬:
來點有趣的事好了,如果有個圓是被釘死而無法撼動的話會怎樣呢?如果自身就是無法撼動的圓,就不用動態交換,也就是 collideWith
直接 return
,如果碰撞的對象無法撼動,可以將對方的質量當成無限大來看,也就是碰撞公式的 m2/(m1 + m2)
,也就是 1 / (m1/m2 + 1)
中的 m2
會是無限大,這時結果就是 1。
class Body {
constructor(coordinate, velocity, mass = 1, shakable = true) {
this.coordinate = coordinate;
this.velocity = velocity;
this.mass = mass;
this.shakable = shakable;
}
…
collideWith(body) {
if(!this.shakable) {
return;
}
const d = sub(this.coordinate, body.coordinate);
const m = body.shakable ? body.mass / (this.mass + body.mass) : 1;
this.velocity = sub(
this.velocity,
d.mult(
2 * m / pow(d.mag(), 2) * p5.Vector.dot(
sub(this.velocity, body.velocity),
sub(this.coordinate, body.coordinate)
)
)
);
}
…
}
結果就是,當一個圓撞上一個無法撼動的圓時,就是直接反彈,來看看模擬結果:
一個有趣的結果是,如果圓無法撼動,它又有速度會如何?這像是有個被釘在移動帶的圓,可以移動,但其他圓無法撼動它,在其他圓撞上無法撼動的圓時,無法撼動的圓基於自身速度與質量的動能,會附加至撞上的圓,這些圓自身的動能,又會被反射回自身,結果就會越來越快。
這並不是奇怪的結果,被釘在移動帶的圓,就相當於移動帶在提供穩定的動能,其他圓撞上後會吸收這些動能,結果就會越來越快。
如果你想要一個會移動的圓,又不想要撞上它的圓越來越快,就是讓該圓無法撼動,速度為 0,每次的影格都直接改變該圓座標就可以了。