沒錯~我就硬是不要給把標題打成『彈跳球世界V』,咬我啊~
這篇是斜面碰撞
的後篇
~
今天要來補完我們在上一篇沒有解決掉的reposition
和反射
速度運算的部分。
之前提到的我會把源碼更新在Github Repo上面,大家可以在這邊自行clone :D
然後這裡是repo的Github Page
雖然說前面有講解過原理,但是這邊我們是稍微講解一些源碼中的細節:
這邊把之前的圖重新貼一次,讓大家能更方便的對照程式中向量運算的規則。
斜面碰撞
主要的核心基本上還是在animateBall這個在每圈RAF循環
(我們把requestAnimationFrame的遞迴操作稱為RAF循環)中會被觸發的方法,而最重要的計算則是在checkBoundary(),也就是碰撞偵測
和反彈計算
的部分。
整個checkBoundary的流程有4
大重點:
v'/v ≒ 1- (a加速度向量・h向量)/ v平方
normalVelocity = dist.para(ball.velocity.projection(dist),-1);
// 也就是把d向量的單位向量,去以『球速在d向量上的投影長度』為倍數做擴增,接著再顛倒過來
tangentVelocity = ball.velocity.subtract(normalVelocity);
//像我們之前說用『扣除』的方式去取得沿反射面方向的速度分量
// 最後把這兩個分量加起來,就會得到反射後的速度向量
這邊我把checkBoundary()
的源碼也貼上來,方便大家對照
checkBoundary(dt) {
// 這邊就是碰撞偵測(第一重點)
this.boundary.walls.forEach((o, i) => {
const vectorAB = new Vector2D(
o[1].x - o[0].x,
o[1].y - o[0].y
)
this.balls.forEach((ball, index) => {
const vectorAToBall = new Vector2D(
ball.x - o[0].x,
ball.y - o[0].y
);
const vectorBToBall = new Vector2D(
ball.x - o[1].x,
ball.y - o[1].y
);
const vectorAToBallProj = vectorAToBall.project(vectorAB);
const vectorBToBallProj = vectorBToBall.project(vectorAB);
const distVector = vectorAToBall.subtract(vectorAToBallProj);
const dist = distVector.length();
const collisionDetection =
dist < ball.radius &&
vectorAToBallProj.length() < vectorAB.length() &&
vectorBToBallProj.length() < vectorAB.length();
if (collisionDetection) {
// 這邊是要先做reposition的部分(第二重點)
// 這邊的算法是利用『插進牆壁後的球到牆壁距離+球的半徑 = (球在正確的碰撞點到已經插進牆壁這一幀的實際距離, 也就是deltaS)*sin(90度 - 入射角)』
// 這邊一連串的動作是要把deltaS從純量轉換成向量,以便用substract方法去把球的位置倒回去正確的碰撞點
// perp 是牆壁的單位法向量
const perp = vectorAB.perp(1);
// 這邊因為單位法向量的n必須要跟球的來向大致相反(也就是向量要夾超過90度),而perp本身又沒有辦法確定到底是取到正或反的法向量,所以要補一個防呆
if (perp.dotProduct(ball.velocity) > 0) {
perp.scaleBy(-1)
}
// 球速向量和牆壁的夾角
const angle = Vector2D.angleBetween(ball.velocity, vectorAB);
// 我們可以藉由算 (球半徑+球到牆壁距離向量和牆壁法向量的內積)/sin(球速與牆壁夾角) 來取得deltaS
const deltaS = (ball.radius + distVector.dotProduct(perp)) / Math.sin(angle);
// 把球速轉化成單位向量,接著再擴張deltaS倍,這樣就能取的到底要倒回去多少距離才能來到正確的碰撞點
let displ = ball.velocity.para(deltaS);
ball.x -= displ.x * dt;
ball.y -= displ.y * dt;
//到這邊就reposition完畢~
// 這邊的vcor是我們之前有提到過,加速度和幀間誤差的相互關係會導致球被額外加速一小段距離,而這邊可以藉由乘以vcor 這個參數來抵銷多餘的加速量(第三重點)
var vcor = 1 - ball.gravity.dotProduct(displ.multiply(dt)) / ball.velocity.lengthSquared();
// 原速度乘以vcor
var Velo = ball.velocity.multiply(vcor);
// 這邊就是取球速於牆壁法線方向的分量
var normalVelo = distVector.para(Velo.projection(distVector));
// 這邊則是取球速平行於牆壁的分量
var tangentVelo = Velo.subtract(normalVelo);
// 兩者合併就是反射後的速度
ball.velocity = tangentVelo.addScaled(normalVelo, -ball.friction);
}
})
})
}
穿牆的bug
之所以會發生,是因為動畫是frame by frame
的。
有時候球在接近牆壁時,在這一幀
還沒碰到牆壁,但是在下一幀
卻已經完全穿越過牆壁
了,這就導致碰撞偵測
完全沒有被觸發。
延伸閱讀:避免子彈穿越牆壁
其實這個現象在物理模擬的領域有一個正式的名稱: 穿隧效應(Tunneling Effect)
符合下列條件的情形,穿隧效應
發生的機率會比較高:
其實物理模擬的碰撞偵測是一種很深的學問,而我在這個案例裡面用的方法是所謂的『離散式碰撞偵測(Discrete collision detection)
』,通常如果要實現高速物體的碰撞偵測,要使用『連續式碰撞偵測(Continuous collision detection (CCD) )
』。
連續式碰撞偵測
的原理是依靠預先演算物體未來的前進軌跡,偵測其前進軌跡上是否有障礙物,計算出可能發生的碰撞,還有碰撞的時間點
和碰撞位置
,然後準確地在那個時間點
去把物體
移位到指定的座標,避免穿隧效應
發生。
不過很遺憾,在這個系列我其實沒有打算把這個領域的知識擴展到這麼深..
(因為那樣可能就要把整個鐵人賽的內容給砸下去了...)
但是如果真的要在前端環境實現CCD
,其實可以依賴一些現有的物理引擎
,例如
以上五篇,包含今天的內容~就是彈跳球動畫的實作 :D ~
如果對程式原理/過程中有任何的疑問,也希望可以在留言區提出~ 我會盡可能地做解答!