“碰乜鬼嘢啊,碰走曬我滴靚牌”。想到“碰”就自然聯想到了“麻將”這一偉大發明。當然除了“碰”,洗牌的時候也充滿了各種『碰撞』。
好了,不廢話。直入主題——碰撞檢測。
在 2D 環境下,常見的碰撞檢測方法如下:
- 外接圖形判別法
- 軸對稱包圍盒(Axis-Aligned Bounding Box),即無旋轉矩形。
- 圓形碰撞
- 光線投射法
- 分離軸定理
- 其他
- 地圖格子劃分
- 畫素檢測
下文將由易到難的順序介紹上述各種碰撞檢測方法:外接圖形判別法 > 其他 > 光線投射法 > 分離軸定理。
另外,有一些場景只要我們約定好限定條件,也能實現我們想要的碰撞,如下面的碰壁反彈:
當球碰到邊框就反彈(如x/y軸方向速度取反
)。
1 2 |
if(ball.left < 0 || ball.right > rect.width) ball.velocityX = -ball.velocityX if(ball.top < 0 || ball.bottom > rect.height) ball.velocityY = -ball.velocityY |
再例如當一個人走到 100px
位置時不進行跳躍,就會碰到石頭等等。
因此,某些場景只需通過設定到適當的引數即可。
外接圖形判別法
軸對稱包圍盒(Axis-Aligned Bounding Box)
概念:判斷任意兩個(無旋轉)矩形的任意一邊是否無間距,從而判斷是否碰撞。
演算法:
1 2 3 4 |
rect1.x < rect2.x + rect2.width && rect1.x + rect1.width > rect2.x && rect1.y < rect2.y + rect2.height && rect1.height + rect1.y > rect2.y |
線上執行示例(先點選執行示例以獲取焦點,下同):
缺點:
- 相對侷限:兩物體必須是矩形,且均不允許旋轉(即關於水平和垂直方向上對稱)。
- 對於包含著圖案(非填滿整個矩形)的矩形進行碰撞檢測,可能存在精度不足的問題。
- 物體運動速度過快時,可能會在相鄰兩動畫幀之間快速穿越,導致忽略了本應碰撞的事件發生。
適用案例:
- (類)矩形物體間的碰撞。
圓形碰撞(Circle Collision)
概念:通過判斷任意兩個圓形的圓心距離是否小於兩圓半徑之和,若小於則為碰撞。
判斷兩圓心距離是否小於兩半徑之和:
1 2 3 |
Math.sqrt(Math.pow(circleA.x - circleB.x, 2) + Math.pow(circleA.y - circleB.y, 2)) < circleA.radius + circleB.radius |
線上執行示例:
缺點:
- 與『軸對稱包圍盒』類似
適用案例:
- (類)圓形的物體,如各種球類碰撞。
其他
地圖格子劃分
概念:將地圖(場景)劃分為一個個格子。地圖中參與檢測的物件都儲存著自身所在格子的座標,那麼你即可以認為兩個物體在相鄰格子時為碰撞,又或者兩個物體在同一格才為碰撞。另外,採用此方式的前提是:地圖中所有可能參與碰撞的物體都要是格子單元的大小或者是其整數倍。
實現方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
// 通過特定標識指定(非)可行區域 map = [ [0, 0, 1, 1, 1, 0, 0, 0, 0], [0, 1, 1, 0, 0, 1, 0, 0, 0], [0, 1, 0, 0, 0, 0, 1, 0, 0], [0, 1, 0, 0, 0, 0, 1, 0, 0], [0, 1, 1, 1, 1, 1, 1, 0, 0] ], // 設定角色的初始位置 player = {left: 2, top: 2} // 移動前(後)判斷角色的下一步的動作(如不能前行) ... |
線上執行示例:
缺點:
- 適用場景侷限。
適用案例:
- 推箱子、踩地雷等
畫素檢測
概念:以畫素級別檢測物體之間是否存在重疊,從而判斷是否碰撞。
實現方法有多種,下面列舉在 Canvas 中的兩種實現方式:
- 如下述的案例中,通過將兩個物體在 offscreen canvas 中判斷同一位置(座標)下是否同時存在非透明的畫素。
- 利用 canvas 的
globalCompositeOperation = 'destination-in'
屬性。該屬性會讓兩者的重疊部分會被保留,其餘區域都變成透明。因此,若存在非透明畫素,則為碰撞。
注意,當待檢測碰撞物體為兩個時,第一種方法需要兩個 offscreen canvas,而第二種只需一個。
offscreen canvas:與之相關的是 offscreen rendering。正如其名,它會在某個地方進行渲染,但不是螢幕。“某個地方”其實是記憶體。渲染到記憶體比渲染到螢幕更快。—— Offscreen Rendering
當然,我們這裡並不是利用 offscreen render
的效能優勢,而是利用 offscreen canvas
儲存獨立物體的畫素。換句話說:onscreen canvas 只是起展示作用,碰撞檢測是在 offscreen canvas 中進行。
另外,由於需要逐畫素檢測,若對整個 Canvas 內所有畫素都進行此操作,無疑會浪費很多資源。因此,我們可以先通過運算得到兩者相交區域,然後只對該區域內的畫素進行檢測即可。
下面示例展示了第一種實現方式:
缺點:
- 因為需要檢查每一畫素來判定是否碰撞,效能要求比較高。
適用案例:
- 需要以畫素級別檢測物體是否碰撞。
光線投射法(Ray Casting)
概念:通過檢測兩個物體的速度向量是否存在交點,且該交點滿足一定條件。
對於下述拋小球入桶的案例:畫一條與物體的速度向量相重合的線(#1
),然後再從另一個待檢測物體出發,連線到前一個物體,繪製第二條線(#2
),根據兩條線的交點位置來判定是否發生碰撞。
在小球飛行的過程中,需要不斷計算兩直線的交點。
當滿足以下兩個條件時,那麼應用程式就可以判定小球已落入桶中:
- 兩直線交點在桶口的左右邊沿間
- 小球位於第二條線(
#2
)下方
線上執行示例:
優點:
- 適合運動速度快的物體
缺點:
- 適用範圍相對侷限。
適用案例:
- 拋球運動進桶。
分離軸定理(Separating Axis Theorem)
概念:通過判斷任意兩個 凸多邊形
在任意角度下的投影是否均存在重疊,來判斷是否發生碰撞。若在某一角度光源下,兩物體的投影存在間隙,則為不碰撞,否則為發生碰撞。
在程式中,遍歷所有角度是不現實的。那如何確定 投影軸
呢?其實投影軸的數量與多邊形的邊數相等即可。
以較高抽象層次判斷兩個凸多邊形是否碰撞:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
function polygonsCollide(polygon1, polygon2) { var axes, projection1, projection2 // 根據多邊形獲取所有投影軸 axes = polygon1.getAxes() axes.push(polygon2.getAxes()) // 遍歷所有投影軸,獲取多邊形在每條投影軸上的投影 for(each axis in axes) { projection1 = polygon1.project(axis) projection2 = polygon2.project(axis) // 判斷投影軸上的投影是否存在重疊,若檢測到存在間隙則立刻退出判斷,消除不必要的運算。 if(!projection1.overlaps(projection2)) return false } return true } |
上述程式碼有幾個需要解決的地方:
- 如何確定多邊形的各個投影軸
- 如何將多邊形投射到某條投影軸上
- 如何檢測兩段投影是否發生重疊
投影軸
如下圖所示,我們使用一條從 p1 指向 p2 的向量來表示多邊形的某條邊,我們稱之為邊緣向量。在分離軸定理中,還需要確定一條垂直於邊緣向量的法向量,我們稱之為“邊緣法向量”。
投影軸平行於邊緣法向量。投影軸的位置不限,因為其長度是無限的,故而多邊形在該軸上的投影是一樣的。該軸的方向才是關鍵的。
1 2 3 4 5 6 7 8 |
// 以原點(0,0)為始,頂點為末。最後通過向量減法得到 邊緣向量。 var v1 = new Vector(p1.x, p1.y) v2 = new Vector(p2.x, p2.y) // 首先得到邊緣向量,然後再通過邊緣向量獲得相應邊緣法向量(單位向量)。 // 兩向量相減得到邊緣向量 p2p1(注:上面應該有個右箭頭,以表示向量)。 // 設向量 p2p1 為(A,B),那麼其法向量通過 x1x2+y1y2 = 0 可得:(-B,A) 或 (B,-A)。 axis = v1.edge(v2).normal() |
以下是向量物件的部分實現,具體可看原始碼。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 |
var Vector = function(x, y) { this.x = x this.y = y } Vector.prototype = { // 獲取向量大小(即向量的模),即兩點間距離 getMagnitude: function() { return Math.sqrt(Math.pow(this.x, 2), Math.pow(this.y, 2)) }, // 點積的幾何意義之一是:一個向量在平行於另一個向量方向上的投影的數值乘積。 // 後續將會用其計算出投影的長度 dotProduct: function(vector) { return this.x * vector.x + this.y + vector.y }, // 向量相減 得到邊 subtarct: function(vector) { var v = new Vector() v.x = this.x - vector.x v.y = this.y - vector.y return v }, edge: function(vector) { return this.substract(vector) }, // 獲取當前向量的法向量(垂直) perpendicular: function() { var v = new Vector() v.x = this.y v.y = 0 - this.x return v }, // 獲取單位向量(即向量大小為1,用於表示向量方向),一個非零向量除以它的模即可得到單位向量 normalize: function() { var v = new Vector(0, 0) m = this.getMagnitude() if(m !== 0) { v.x = this.x / m v.y = this.y /m } return v }, // 獲取邊緣法向量的單位向量,即投影軸 normal: function() { var p = this.perpendicular() return p .normalize() } } |
更多關於向量的知識可通過其它渠道學習。
投影
投影的大小:通過將一個多邊形上的每個頂點與原點(0,0)組成的向量,投影在某一投影軸上,然後保留該多邊形在該投影軸上所有投影中的最大值和最小值,這樣即可表示一個多邊形在某投影軸上的投影了。
判斷兩多邊形的投影是否重合:projection1.max > projection2.min && project2.max > projection.min
為了易於理解,示例圖將座標軸原點(0,0)
放置於三角形邊1
投影軸的適當位置。
由上述可得投影物件:
1 2 3 4 5 6 7 8 9 10 11 |
// 用最大和最小值表示某一凸多邊形在某一投影軸上的投影位置 var Projection = function (min, max) { this.min this.max } projection.prototype = { // 判斷兩投影是否重疊 overlaps: function(projection) { return this.max > projection.min && projection.max > this.min } } |
如何得到向量在投影軸上的長度?
向量的點積的其中一個幾何含義是:一個向量在平行於另一個向量方向上的投影的數值乘積。
由於投影軸是單位向量(長度為1
),投影的長度為 x1 * x2 + y1 * y2
1 2 3 4 5 6 7 8 9 10 11 12 |
// 根據多邊形的每個定點,得到投影的最大和最小值,以表示投影。 function project = function (axis) { var scalars = [], v = new Vector() this.points.forEach(function (point) { v.x = point.x v.y = point.y scalars.push(v.dotProduct(axis)) }) return new Projection(Math.min.apply(Math, scalars), Math.max,apply(Math, scalars)) } |
圓形與多邊形之間的碰撞檢測
由於圓形可近似地看成一個有無數條邊的正多邊形,而我們不可能按照這些邊一一進行投影與測試。我們只需將圓形投射到一條投影軸上即可,這條軸就是圓心與多邊形頂點中最近的一點的連線,如圖所示:
因此,該投影軸和多邊形自身的投影軸就組成了一組待檢測的投影軸了。
而對於圓形與圓形之間的碰撞檢測依然是最初的兩圓心距離是否小於兩半徑之和。
分離軸定理的整體程式碼實現,可檢視以下案例:
優點:
- 精確
缺點:
- 不適用於凹多邊形
適用案例:
- 任意凸多邊形和圓形。
更多關於分離軸定理的資料:
- Separating Axis Theorem (SAT) explanation
- Collision detection and response
- Collision detection Using the Separating Axis Theorem
- SAT (Separating Axis Theorem)
- Separation of Axis Theorem (SAT) for Collision Detection
延伸:最小平移向量(MIT)
通常來說,如果碰撞之後,相撞的雙方依然存在,那麼就需要將兩者分開。分開之後,可以使原來相撞的兩物體彼此彈開,也可以讓他們黏在一起,還可以根據具體需要來實現其他行為。不過首先要做的是,還是將兩者分開,這就需要用到最小平移向量(Minimum Translation Vector, MIT)。
碰撞效能優化
若每個週期都需要對全部物體進行兩兩判斷,會造成浪費(因為有些物體分佈在不同區域,根本不會發生碰撞)。所以,大部分遊戲都會將碰撞分為兩個階段:粗略和精細(broad/narrow)。
粗略階段(Broad Phase)
Broad phase 能為你提供有可能碰撞的實體列表。這可通過一些特殊的資料結構實現,它們能為你提供資訊:實體存在哪裡和哪些實體在其周圍。這些資料結構可以是:四叉樹(Quad Trees)、R樹(R-Trees)或空間雜湊對映(Spatial Hashmap)等。
讀者若感興趣,可以自行查閱相關資訊。
精細階段(Narrow Phase)
當你有了較小的實體列表,你可以利用精細階段的演算法(如上述講述的碰撞演算法)得到一個確切的答案(是否發生碰撞)。
最後
無論你碰不碰,我都會自摸️✌️。
完!