遊戲中的碰撞檢測Collision Detection
前言
而這篇文有三個主要目的 :
- 對遊戲中的碰撞感興趣,卻不會寫程式的人可以了解原理
- 讓有能力實作的人,可以跟著文章寫出精確的多邊形碰撞檢測
- 自己的學習筆記
讓我們開始吧。
這次要來介紹的主題是分離軸碰撞檢測(Separating Axis Theorem, SAT)
分離軸定理通常用語檢測兩個多邊形或多邊形與圓之間的碰撞,跟所有演算法一樣他具有一定的優勢與缺點。
我會慢慢講解背後的原理,並使用程式碼做簡易的範例。
範例所使用的語言為Java Script,加上自製的向量函式庫,但我想觀念懂了應該不會有太大問題。
矩形碰撞檢測
我想先從最簡單的碰撞檢測開始講,這樣比較好讓各位了解為什麼需要分離軸檢測,所以先從一般的矩形碰撞開始。
AABB碰撞檢測(Axis-aligned Bounding Box): 為了方邊物體之間進行碰撞檢測運算,通常會對物體創建一個長方形將其包圍,AABB包圍盒也被稱為軸對齊包圍盒。
一般二維的AABB包圍盒具備兩項特點:
- 以矩形包圍物體
- 矩形的每條邊,皆與坐標系的軸垂直
簡單來說就是用矩形把物體包起來,檢查矩形之間是否發生碰撞
如下圖,黑框及為包圍盒,在做碰撞檢測時,只需要檢查包圍盒之間是否發生碰撞:
那我們就直接來看看AABB碰撞盒是如何運作的吧。
這裡有兩個矩形A、B,Box A最小邊為A.min、最大邊為A.max,而Box B同理。
如上圖所示,當A.max < B.min時,代表兩物體之間仍有縫隙,沒有發生碰撞。
上圖可以明顯地看到A與B發生碰撞,當A.max > B.min時,代表兩物體之間沒有縫隙,發生碰撞。
但前面只討論B在A右側,當B在A的左側時,條件就要稍微改一下,當B在A的左側時,B.max > A.min時發生碰撞。
將兩張圖的結果合在一起,整理成code:
if (A.max > B.min && B.max > A.min)
Console.log("Collided");
為什麼使用 AND 來判斷?
只要A在B的右邊,那麼A.max就會永遠大於B.min,因為A.max > B.min是用在A在左邊的碰撞判斷,所以只要沒有發生碰撞,就代表只有一個條件會是True,碰撞無法成立,所以使用 AND。
那麼當A在B的上面或下面呢?
方法是一樣,對Y軸上的min、max判斷即可。
讓我們對著圖片來看程式碼 :
function RectCollision(r1, r2) {
// 這邊因為(X,Y)在方塊中心,所以在取得min、max時,要 +/- width/2
// Rect1
var minX1 = r1.x - r1.width / 2,
maxX1 = r1.x + r1.width / 2,
minY1 = r1.y - r1.height / 2,
maxY1 = r1.y + r1.height / 2;
// Rect2
var minX2 = r2.x - r2.width / 2,
maxX2 = r2.x + r2.width / 2,
minY2 = r2.y - r2.height / 2,
maxY2 = r2.y + r2.height / 2;
if (maxX1 > minX2 && maxX2 > minX1 &&
maxY1 > minY2 && maxY2 > minY1) {
return true;
}
else
return false;
}
// 完整原始碼在結尾與感想的最後。
測試結果:
那麼另外一種情況如何? 如上圖,我們能明顯的看出兩物體沒有發生碰撞,但是用AABB碰撞檢測,答案卻是…發生碰撞。
AABB碰撞檢測算法雖然計算方法簡單、速度快,但卻有幾個問題:
- 當物體旋轉時就無法檢查
- 只能檢查矩型物體
那麼要如何解決這兩個問題呢?
就是本文的主題「SAT碰撞檢測」,這個方法可以完美的解決這兩個問題。
讓我們進入下一階段。
什麼是分離軸檢測?
如果有兩個凸多邊形,在任意角度下的投影皆有重疊,代表物體發生碰撞,否則只要有縫隙,就代表沒有碰撞。
簡單來說,就是如果能在兩個物體間找到一條線來分離它們,那麼就代表這兩個物體之間沒有發生碰撞。
上圖中你可以看到第一排的物體之間有縫隙,所以能夠輕鬆地畫出一條線來分離它們,但第二排就沒辦法,因為這兩個物體已經相撞,之間沒有縫隙,所以找不出一條線來當分離線。
而分離線不只一條 :
到目前為止已經大概了解什麼是分離線,那要如何利用分離線來做碰撞檢測? 很簡單,只要檢查兩物體之間是否存在分離線即可,因為只要找到一條分離線就代表物體之間有縫隙,表示沒有發生碰撞。
而所謂的分離軸就是與分離線垂直的一條線,透過分離軸上物體的投影是否重疊,來判斷是否發生碰撞。
所以我們能透過分離軸檢查旋轉的物體。
假設向量P是45度的延長線,由上圖可知,如果我們將A、B的「四個角投影在P向量上」,可以得到它們的min、max,接下來就能透過min、max來判斷碰撞。
這時應該會有幾個問題:
- 如何取得分離軸上的投影min、max?
- 如何取得分離軸P?
但是在解決那兩個問題前,先來看看前面這段話「四個角投影在P向量上」:
- 要如何取得旋轉後的4個角?
- 要如何計算其中一個角落在P向量上的投影?
所以在前往下一個階段之前,要先補充一些數學的知識。
知識補充1–向量
知識補充1與2這兩段,可以先跳過,不影響閱讀,等到有看不懂的地方再回來看也行。
為了解釋如何取得投影與分離軸,所以需要先補充向量的知識。
數學向量忘記的話,這裡推薦幾部教學影片:
Q仔高中數學教室 : 向量的內積、向量內積的幾何意義與座標表示法
向量的內積:
我們可以透過兩向量之間的夾角來計算點積: $$ A\cdot B=\left | A \right |\left | B \right |cos \theta $$ 或使用座標來計算: $$ \vec{A}\cdot \vec{B}=A_{x}B_{x}+A_{y}B_{y} $$ 有了這兩個公式後,就可以開始來證明文章需要的公式了
正射影 :
$$ A\cdot B=\left | A \right |\left | B \right |cos \theta $$ $$ cos \theta = \frac{A\cdot B}{\left | \vec{A} \right |\left | \vec{B} \right |}=\frac{\left |\vec{P} \right |}{\left |\vec{A} \right |} $$ 然後我們就會得到向量P的長度 $$ \left |\vec{P} \right |=\frac{(\vec{A} \cdot \vec{B})\left |\vec{A} \right |}{\left |\vec{A} \right |\left |\vec{B} \right |}=\frac{\vec{A}\cdot \vec{B}}{\left |\vec{B} \right |} $$ 但這裡求出的P是長度(正射影長),只有大小,沒有方向
回到上圖可以觀察出P向量的方向是延著B向量,所以只要讓 純量P 乘上 單位向量B,就能得到A在B上的正射影。 $$ \vec{P} = \frac{\vec{A} \cdot \vec{B}}{\left | \vec{B} \right |}\ast \frac{\vec{B}}{\left | \vec{B} \right |}=\frac{(\vec{A} \cdot \vec{B})\vec{B}}{\left | \vec{B} \right |^{2}} $$
所謂的單位向量就是大小為1的方向向量,而純量只有大小,兩個相乘即可得到長度為P且方向為B的向量 (如果還是不太懂的話可以自己畫圖證明看看)。
所以正射影是A向量在B向量上的分量。
了解正射影後,就能取得物體在分離軸上的投影了。
法向量 :
定義:垂直於平面的向量。
$$ \vec{A}\cdot \vec{B}=A_{x}B_{x}+A_{y}B_{y} $$
從點積公式可以得出,當兩向量垂直時,向量內積會是零。
假設A向量為(3, 4),求A的法向量,那麼我們只要將A帶進去,並湊一個能滿足等式為零的參數就是法向量。
$$ (3)(B_{x}) + (4)(B_{y}) = 0 $$
這時B向量會有無限多組合,但是有其中兩組(4, -3)、(-4, 3),剛好就是A向量的座標互換並加負號。
所以我們可以得到下圖結果:
了解如何取得法向量後,就能取得需要檢查的分離軸了。
有了向量、內積、正射影長、法向量這些基本資訊後,先來建立一個簡易的向量函式庫吧
function Vector(x, y) {
this.x = x;
this.y = y;
}
// 取得這個自己的長度
Vector.prototype.length = function () {
return Math.sqrt(this.x * this.x + this.y * this.y);
}
// 取得自己與vec2的內積
Vector.prototype.dot = function (vec2) {
return this.x * vec2.x + this.y * vec2.y;
}
// 取得自己在vec2上的正射影長
Vector.prototype.projectLengthOnto = function (vec2) {
var dotProduct = this.dot(vec2);
var len = vec2.length();
return dotProduct / len;
}
// 取得自己的左法向量
Vector.prototype.normalL = function () {
return new Vector(-this.y, this.x);
}
// 取得自己的右法向量
Vector.prototype.normalR = function () {
return new Vector(this.y, -this.x);
}
使用方式:
var vec1 = new Vector(3, 4);// 建立vec1向量
var vec2 = new Vector(1, 0);// 建立vec2向量
vec1.length() // => 5
vec1.dot(vec2) // => 3
vec1.projectLengthOnto(vec2) // =>3
vec1.normalL() // = (-4, 3)
vec1.normalR() // = (4, -3)
知識補充2–旋轉變換
為了解釋如何取得物體旋轉後的角落,所以需要先補充旋轉變換的知識。
其作用為以原點為中心旋轉θ角:
如上圖,座標平面上$L = \overline{OP}$ 且點$P(x_{1}, y_{1})$滿足$x_{1} = L*\cos \alpha, y_{1} = L*\sin \alpha$
那麼,以原點O為中心,將這個點以逆時針旋轉 $\theta$角後得到${P}'(x_{2}, y_{2})$
方法一:
先取得$\overline{OP}$與$\alpha$,那麼${P}'$就是$( L*\cos (\alpha +\theta ), L*\sin (\alpha +\theta ) )$
但在電腦中取得OP長要透過畢氏定理開根號來取得,加上只有P點不知道α角的時候,要用Math.atan2(y, x)先取得α的角度後再計算,比較麻煩。
所以有一個改良版:
和角公式:
$$ \begin{cases} \cos(\alpha+\theta )= \cos \alpha \cos \theta - \sin \alpha \sin \theta \\ \sin(\alpha+\theta ) = \sin \alpha\cos \theta + \cos \alpha\sin \theta \end{cases} $$
所以
$$ \begin{cases} \cos(\alpha+\theta ) = \frac{x_{2}}{L} = \frac{x_{1}}{L}\times \cos\theta - \frac{y_{1}}{L} \times \sin\theta \\ \sin(\alpha+\theta ) = \frac{y_{2}}{L} = \frac{y_{1}}{L}\times \cos\theta - \frac{x_{1}}{L} \times \sin\theta \end{cases} $$
同乘L後即可得到 $$ \begin{cases} x_{2}= x_{1}\cos \theta-y_{1} \sin\theta \\ y_{2}= y_{1}\cos \theta+x_{1} \sin\theta \end{cases} $$
而這個就是我們要的旋轉公式
矩陣表示法:
$$ \begin{bmatrix} x_{2} \\ y_{2} \end{bmatrix} = \begin{bmatrix}\cos \theta & - \sin \theta \\ \sin \theta & \cos \theta \end{bmatrix}\begin{bmatrix} x_{1} \\ y_{1} \end{bmatrix} $$
而這矩陣就是所謂的旋轉矩陣,未來電腦圖學會很常用到。
講解完後,把這段公式加入我們的向量函式庫吧。
angle為弧度,並以原點(0,0)為中心旋轉angle角:
Vector.prototype.rotate = function (angle) {
var new_x = (this.x * Math.cos(angle)) - (this.y * Math.sin(angle));
var new_y = (this.x * Math.sin(angle)) + (this.y * Math.cos(angle));
this.x = new_x;
this.y = new_y;
}
如何取得分離軸上的投影?
有了投影公式後,接下來只要把A、B的四個角投影在P向量上就能知道min、max了。
這時卻出現了一個狀況,如果兩個物體方向不一樣呢?
由上圖可知,當兩物體方向不同時,只要在4個角落中選一個最小和最大的,就是min、max。
那我們要如何透過投影來判斷碰撞?
由上圖可以看到,A.max、B.min在P向量上的投影圖。
當B.min > A.max時,代表他們之間有分離線,所以沒有碰撞。當位置交換時,A.min > B.max時代表有間距。
讓我們把結果整理成code :
if (B.min > A.max || A.min > B.max)
Console.log("分離");// isSeparated
else
Console.log("碰撞");// isCollided
這邊選擇True為分離是因為這是分離軸檢測,我認為這樣比較符合。
了解如何取得min、max,並如何判斷後,就可以進入下個階段了。
編寫程式碼-投影、旋轉與判斷
第一步,我們需要取得矩形上的四個角,這時就需要前面的「知識補充2-旋轉變換」
如果直接將四個角套用旋轉公式的話,就會像左圖一樣,物體繞中心旋轉。
但我們想要的是右圖中,物體原地旋轉的效果,所以要將旋轉公式做點更動。
如上圖的步驟,我們需要的旋轉是以矩形中心為參考點做旋轉,而原公式以原點做旋轉,所以需要先將物體平移到原點,旋轉完後再將其平移回來。
$$ \begin{cases} x_{1}' = [(x_{1}-x_{0})\cos\theta-(y_{1}-y_{0})\sin\theta] + x_{0} \\ y_{1}' = [(y_{1}-y_{0})\cos\theta+(x_{1}-x_{0})\sin\theta] + y_{0} \end{cases} $$
把這個公式加入向量函式庫:
// angle:弧度, refP:參考點
// 作用: 以refP為參考點,旋轉angle角
Vector.prototype.rotateRefPoint = function (angle, refP) {
let new_x = (this.x - refP.x) * Math.cos(angle) - (this.y - refP.y) * Math.sin(angle) + refP.x;
let new_y = (this.y - refP.y) * Math.cos(angle) + (this.x - refP.x) * Math.sin(angle) + refP.y;
this.x = new_x;
this.y = new_y;
}
然後讓我們來建立一個基本的物體:
先簡單寫出Box的結構
function Box(x, y, w, h) {
this.pos = new Vector(x, y);// 中心
this.w = w;
this.h = h;
// 以順時針紀錄矩形的四個角
this.corners = [
new Vector(w / 2, -h / 2),
new Vector(w / 2, h / 2),
new Vector(-w / 2, h / 2),
new Vector(-w / 2, -h / 2)
];
// 假設角度是45
this.directionAngle = toRadio(45);// 將45換成弧度
this.getVertices = function () {
var vertices = [];
// 順時針走訪角落
for (var i = 0; i < this.corners.length; i++) {
var p1 = this.corners[i];
var vec = new Vector(this.pos.x + p1.x, this.pos.y + p1.y);
// 將各個角以物體中心為參考點來旋轉
vec.rotateRefPoint(this.directionAngle, this.pos);
vertices.push(vec);
}
// 最後回傳以物體中心為參考點選轉後的角落
return vertices;
}
}
接下來只要呼叫getVertices()就能取得旋轉後四個角的陣列了 :
// 取得box1的4個角
var boxA_Vertices = boxA.getVertices();
第二步,參考"如何取得分離軸上的投影"的說明,取得box1在分離軸上的min、max投影。而box2也是一樣的方法 :
// 假設分離軸為45度角
var axis = new Vector(1, -1);
// 取得box1的4個角
var boxA_Vertices = box1.getVertices();
// 先以第一個角當初始值
// vec.projectLengthOnto(axis) : 取得vec在axis上的投影長
var min_proj_boxA = boxA_Vertices[0].projectLengthOnto(axis);
var min_index_boxA = 0;
var max_proj_boxA = boxA_Vertices[0].projectLengthOnto(axis);
var max_index_boxA = 0;
// 再從剩下的3個角選出最大和最小投影
for (var i = 1; i < 4; i++) {
var current = boxA_Vertices[i].projectLengthOnto(axis);
// 選擇最小投影
if (current < min) {
min_proj_boxA = current;
min_index_boxA = i;
}
// 選擇最大投影
if (current > max) {
max_proj_boxA = current;
max_index_boxA = i;
}
}
最後,當我們有了box1和box2的min、max後,就可以檢查他們是否在axis這個軸上碰撞
if (min_proj_boxB > max_proj_boxA || min_proj_boxA > max_proj_boxB)
Console.log("分離");// isSeparated
else
Console.log("碰撞");// isCollided
讓我們把剛剛"第二步"的min、max取得整理一下,變成可呼叫的方法:
// getMinMax(頂點陣列,分離軸)
function getMinMax(vertices, axis) {
// 先以第一個角落投影為標準
var min_DotProduct = vertices[0].projectLengthOnto(axis),
max_DotProduct = vertices[0].projectLengthOnto(axis);
for (var i = 1; i < vertices.length; i++) {
// 取得當前要比對的投影長度
var temp = vertices[i].projectLengthOnto(axis);
// 如果比當前最小的更小,紀錄它
if (temp < min_DotProduct) {
min_DotProduct = temp;
min_index = i;
}
// 如果比當前最小的更大,紀錄它
if (temp > max_DotProduct) {
max_DotProduct = temp;
max_index = i;
}
}
var result = {
min: min_DotProduct,
max: max_DotProduct
};
// 最後傳回一個物件包含min、max屬性
return result;
}
如何取得分離軸?
在SAT介紹的時候也提到,兩物體間有無限多條分離軸,難不成要真的從0度~360度的分離軸全部投影一變嗎?
其實不需要,我們只需要沿著物體所有邊上的法向量當作分離軸作檢查,即可判斷。
由上圖可知,如果我們將三角形向左平移,直到與五角形投影重疊時,代表他們發生碰撞。
而綠色那條分離線,可以當作五角形守備的領域,只要有東西進到這裡,就代表碰撞。
如上圖,將五角形每個邊上的檢查線都畫出來後,只要取跟檢查線垂直的軸,就是我們需要的分離軸。
只要有任何物體進入綠色分離線的包圍區,就代表物體與五角形發生碰撞。
那麼要如何取得邊上的法向量?
這時候就是補充知識1-法向量派上用場的時候了。
如上圖,對每個邊向量取法向量即可,而我這邊以順時針、取左法向量為例子。
可以發現,在矩形中會有兩組方向相反的法向量,所以可以透過優化找出重複的法向量增加效率,但通常只對矩形有效果。
先讓讓我們在Box物件中再加入getNorms()這個方法 :
function Box(x, y, w, h) {
// ...省略...
this.getNorms = function () {
var vertices = this.getVertices();// 取得頂點
var norms = [];
var p1, p2, n;
// 順時鐘
for (let i = 1; i < vertices.length; i++) {
p1 = vertices[i - 1];
p2 = vertices[i];
// 取得這個邊的左法向量
n = new Vector(p2.x - p1.x, p2.y - p1.y).normalL();
// 加入這個法向量
norms.push(n);
}
// 補上最後一個邊
p1 = vertices[vertices.length - 1];
p2 = vertices[0];
n = new Vector(p2.x - p1.x, p2.y - p1.y).normalL();
norms.push(n);
// 最後傳回這個物體所有邊上的左法向量
return norms;
}
}
有了頂點取得的方式、投影大小的判斷加上邊上法向量的取得,我相信你已經有足夠的知識來完成SAT碰撞檢測了。
粗糙的矩形檢測
讓我們來看看針對矩形的程式碼,這段程式碼單純是了解檢查過程,如果你對判斷的方法不是很清楚的話希望你花一些時間看一下,後面會在寫出一個整理過的寫法。
這裡我假設A方塊的法向量是P、Q,B方塊的法向量是S、R,手動對它們一一檢查
function SAT_Collision(boxA, boxB) {
var vertices_boxA = boxA.getVertices();
var vertices_boxB = boxB.getVertices();
var norms_boxA = boxA.getNorm();
var norms_boxB = boxB.getNorm();
// 假設boxA的法向量為P、Q,boxB為R、S
// boxA、boxB在P、Q檢查軸上的投影
var MinMax_PA = getMinMax(vertices_boxA, norms_boxA[0]);
var MinMax_PB = getMinMax(vertices_boxB, norms_boxA[0]);
var MinMax_QA = getMinMax(vertices_boxA, norms_boxA[1]);
var MinMax_QB = getMinMax(vertices_boxB, norms_boxA[1]);
// boxA、boxB在R、S檢查軸上的投影
var MinMax_RA = getMinMax(vertices_boxA, norms_boxB[0]);
var MinMax_RB = getMinMax(vertices_boxB, norms_boxB[0]);
var MinMax_SA = getMinMax(vertices_boxA, norms_boxB[1]);
var MinMax_SB = getMinMax(vertices_boxB, norms_boxB[1]);
// 在分離軸上是否分離
var separate_P = MinMax_PB.min_proj > MinMax_PA.max_proj ||
MinMax_PA.min_proj > MinMax_PB.max_proj;
var separate_Q = MinMax_QB.min_proj > MinMax_QA.max_proj ||
MinMax_QA.min_proj > MinMax_QB.max_proj;
var separate_R = MinMax_RB.min_proj > MinMax_RA.max_proj ||
MinMax_RA.min_proj > MinMax_RB.max_proj;
var separate_S = MinMax_SB.min_proj > MinMax_SA.max_proj ||
MinMax_SA.min_proj > MinMax_SB.max_proj;
var isSeparated = separate_P || separate_Q || separate_R || separate_S;
if(isSeparated)
Console.log("分離");// isSeparated
else
Console.log("碰撞");// isCollided
}
測試結果:
但上面那個只適用在矩形上,而且Code又醜又長,所以把它整理一下。
編寫程式碼–多邊形與多邊形
雖然這裡的寫法也還是沒有完全優化,其實你可以將normal_polygonA、B合併後在一起走訪,但我想範例這樣比較容易了解。
// true:兩物體分離, false:兩物體碰撞
function SAT_Collision(polygonA, polygonB) {
// 取得多邊形每個邊上的法向量,回傳陣列
var normal_polygonA = polygonA.getNorm(),
normal_polygonB = polygonB.getNorm();
// 取得多邊形的頂點陣列,回傳陣列
var vertices_polygonA = polygonA.getVertices(),
vertices_polygonB = polygonB.getVertices();
var isSeparated = false;
// 透過迴圈走訪多邊形A的法向量,來檢查是否分離
for (var i = 0; i < normal_polygonA.length; i++) {
var minMax_A = getMinMax(vertices_polygonA, normal_polygonA[i]);
var minMax_B = getMinMax(vertices_polygonB, normal_polygonA[i]);
isSeparated = (minMax_B.min > minMax_A.max || minMax_A.min > minMax_B.max);
// 只要發現有一條分離線,就代表物體沒有發生碰撞
if (isSeparated) return true;
}
// 透過迴圈走訪多邊形B的法向量,來檢查是否分離
for (let i = 0; i < normal_polygonB.length; i++) {
var minMax_A = getMinMax(vertices_polygonA, normal_polygonB[i]);
var minMax_B = getMinMax(vertices_polygonB, normal_polygonB[i]);
isSeparated = (minMax_B.min > minMax_A.max || minMax_A.min > minMax_B.max);
if (isSeparated) return true;
}
// 如果所有法向量都檢查過後,沒有發現分離,代表兩物體碰撞
return false;
}
由Code可以看到,只要物體間有分離,就可以跳出並回傳結果,不需要再去檢查B的法向量。
要注意這邊的isSeparated在分離時為Ture、碰撞時為False,如果你要以碰撞作檢查的話記得加上NOT。
使用範例:
function update(dt) {
polygonA.update(dt);
polygonB.update(dt);
var isCollided = !SAT_Collision(polygonA, polygonB);
if(isCollided){
// ...發生碰撞後要做的事...
}
}
測試結果:
原始碼在最後。
編寫程式碼–多邊形與圓
已經完成範例,但還沒編寫文章
假設今天是N多邊形與圓形,大致的做法就是,一樣取得多邊形的法向量做分離軸檢測,然後最後再多補上一條分離軸,為圓心到多邊形最近的頂點,這樣就能正確的判斷圓形。
SAT的總結
關於SAT的優缺點:
- SAT的方法是些假設兩物體是分離的,並在檢查中只要成功找到正確的分離軸,就直接跳出,所以當物體都是分離狀態時,SAT的效率是非常高的。
- 只要物體之間有碰撞,SAT就必須檢查所有的法向量,來確保物體之間沒有分離線,越多的物體發生碰撞,效率也就越低。
- 因為SAT是採用所有法向量來作檢測,當多邊形的邊越多時,效率也會越來越低,但可以透過找出正負相反的法向量來減少檢查次數。
- SAT雖然無法檢查凹多邊形,但是能透過將多個凸多邊形組合成凹多邊形的形狀,來作碰撞檢測。
- SAT在運算時能夠取得碰撞物體間的最小穿透量(MTV, Minimum translation vector),所以能夠處理物體間的碰撞回饋,並進一步地達成剛體動力學的模擬。
所以SAT碰撞在做過效能調整後,是很好的碰撞檢測演算法,靈活度也很高,是很多物理引擎會採用的檢測法之一。
還有另一個凸邊形的高效演算法GJK以疊代地生成單形以對兩個凸集求閔可夫斯基和,有興趣可以研究看看。
如果對碰撞方法有興趣的話可以參考這篇:
Bullet physics engin軟體工程師-Erwin Coumans : Collision Detection for Real-Time Simulation
其他相關資源:
- SAT (Separating Axis Theorem)
- 2D Collision Detection
- 2D polygon-based collision detection and response
- Video Game Physics Tutorial
多物體的碰撞優化
當我們要檢查多個物體碰撞時,通常會直接對所有物體互相進行判斷,就像下面這段Code:
基本枚舉法的Code:
// shapes是指所有的物體陣列
function update(dt) {
// ...更新物體狀態...
for (var i = 0; i < shapes.length; i++) {
for (var j = 0; j < shapes.length; j++) {
// 如果是同個物體就跳過
if (i == j) continue;
var isCollided = !SAT_Collision(shapes[i], shapes[j]);
if(isCollided){
// ...碰撞事件...
}
}
}
}
用這種最直觀的方式檢查,時間複雜度會達到O(n!),當場景複雜,需要檢測的物體變多後,用枚舉的方式檢測可能會導致遊戲延遲。
其中最大的問題就是,當場景中有兩個距離非常遠的物體,遠到根本不可能發生碰撞,卻照樣對他們進行檢測,導致效能的浪費。
簡單的介紹一個優化方法 – AABB Tree :
建立ABT的方法跟建立二元樹的方法一樣,來看執行步驟:
- Step1. 把A加入樹中,作為根結點。
- Step2. 把B加入樹中,判斷是否與根結點A有碰撞,如果有的話就在繼續比較子節點,如果都沒有與子節點有碰撞,就把B加入子節點後,如果與節點都沒有碰撞的話,就把A、B當作一個新區域,並生成A、B的父節點(橘色圈圈)。
- Step3. 把C加入樹中,方法跟Step2一樣,最後得到上圖中的樹。
假如我要檢查A物體時,只需要檢查同在橘色區域的B物體即可。
AABB Tree在建立時,就先把有機會碰撞的放在同一區,有了這樣的結構後,要判斷碰撞的效率就提高了,因為只需判斷與該物體同區域的物體即可,並不需要全部檢查,使得時間複雜度縮減到O(log n)。
而其他多物體碰撞的演算法還有:
四叉樹(Quad Trees)、八叉樹(octree)、二元分割樹(BSP tree)、kd樹、球體樹(sphere tree)、R樹(R tree)、碰撞投影、光線投影等等…,有興趣的可以在自己研究。
動態物體間的碰撞檢測
這裡為大家提供一些解決的方向,我目前知道有這種解決方法,但礙於能力不足,只研究到觀念部分。
在我們前面講的碰撞檢測方法都只適用在靜態,什麼意思呢?
在遊戲中做的碰撞檢測其實是以離散時間來模擬,因此在每個瞬間,物體的位置和方向是靜止的,就像快照一樣,若物體的移動速度與其相對尺寸來說不是太快的話,這種方法是可行的,事實上在許多物理引擎中,都是使用這種方法。
但對於較小且高速的移動物體,這種方法就會失效,想像現在有個很小的物體,他每次更新時的移動幅度大於碰撞體的尺寸,就會發生「穿隧」的問題。
解決方法
掃描形狀(swept shape):
對一個物體的位置、速率與加速度取線性穿插,得到一個時間段的物體快照,再透過掃描的形狀做檢測。
形狀掃描對動態物體來說,是一個有效的檢測技術,能保證不錯過快照之間的碰撞。
缺點是,若物體的行進路線為曲線甚至旋轉,僅透過線性穿插計算,其結果是不準確的,所以要再根據狀況使用更精準的技術。
其實還有很多更好的解決方案,有興趣的可以在自己研究。
Source Code
執行範例:
我的SAT碰撞執行範例 : ver0.2.1-polygon-merge
Source Code:
- 文章中的範例Source Code: https://github.com/md9830415/Collision-Detection-article
- 自製的向量函式庫 : https://github.com/md9830415/JS-Vector2D
- SAT碰撞檢測 Code: https://github.com/beadx6ggwp/Collision-SAT
額外應用:
- 最小位移量MTV Code: https://github.com/beadx6ggwp/Collision-MTV
結尾感想
那麼為什麼要對碰撞這麼刁鑽,就是為了能模擬物理效果,當碰撞的處理達到一定的程度後,就可以邁向下一個階段「剛體動力學」與「物理模擬」,讓電腦中的物體越來越接近現實,有趣吧。
光是碰撞的處理就有這麼多複雜的技術問題,就可以了解高階遊戲開發工程師是多麼厲害的一群人了。
為了做出更好的遊戲,還要很多東西要學,為了能有效的使用電腦資源,要學會作業系統架構、演算法、資料結構並精通程式語言,要能在螢幕中顯示各種效果、視角操控,還要學習電腦圖學、線性代數,為了處理物理模擬,還要學習古典物理、高等微積分,而這些東西到了三維空間後,又更加複雜,還有專案管理等等附加技能,加上遊戲工程師們也不斷的在進化,搞不好一輩子都學不完呢。
終於寫完了,這篇文中我嘗試用各種插圖來解釋一些觀念,不知不覺就打了這麼多,我Coding技巧很差,範例執行的效率可能沒有很好,但我想對這篇文想表達的內容影響不大。
最後,感謝你的觀看,希望你在閱讀後能有些收穫。
對這文章有任何問題,歡迎在下方留言提出意見,或是E-mail與我聯絡davidmd9830415@gmail.com。
大概就是這樣了,掰掰。
首次發表時間 : 2017/8 高二升三暑假準備工科賽時製作
參考資源:
- Game Engine Architecture, Second Edition.pdf : here
- Video Game Physics Tutorial : here
- Collision Detection for Real-Time Simulation : here
- Collision Detection – contact generation and GPU acceleration : here
- Physics – Collision in 2 dimensions : here
- Collision Detection Using the Separating Axis Theorem : here
- SAT (Separating Axis Theorem) : here
- 2D Collision Detection : here
- 2D polygon-based collision detection and response : here
- Collision detection : here
- Collision resolution : here
- Introductory Guide to AABB Tree Collision Detection : here
- How to Create a Custom Physics Engine : here
- Vector maths – a primer for games programmers : here
- 平面上基本的線性變換:旋轉、鏡射、伸縮、推移 : here
- 二階方陣表示的線性變換 : here
- AABB包围盒算法,在2D碰撞检测中的实现 : here
- “等一下,我碰!”——常见的2D碰撞检测 : here
- Core HTML5 Canvas: Graphics, Animation, and Game Development