1. 程式人生 > >2D空間碰撞檢測

2D空間碰撞檢測

http://blog.lxjwlt.com/front-end/2014/09/04/2D-collide-detection.html

lxjwlt's blog

2D空間碰撞檢測

在遊戲中要模擬物體間的一次碰撞,我們需要做的有:碰撞檢測和碰撞行為。碰撞檢測指判斷物體之間是否發生了碰撞。碰撞行為是指如果物體間發生了碰撞,物體狀態應該如何改變。本文將簡要地介紹一下碰撞檢測。

首先要提的是,碰撞檢測又分為兩個階段:

  • broad phase(粗略):獲取最有可能發生碰撞的物體集合。
  • narrow phase(精密):對可能發生碰撞的物體之間進行碰撞檢測。

以下內容介紹的是narrow phase階段。

簡易碰撞檢測

一般的2D遊戲只會用到的形狀有:矩形和圓形,比如超級瑪麗,坦克大戰這類遊戲,所以要檢測三種碰撞:矩形和矩形、圓形和圓形、矩形和圓形。

矩形碰撞矩形

判斷矩形之間的碰撞不難,程式碼如下:

rectB.x > rectA.x - rectB.
width && rectB.x < rectA.x + rectA.width + rectB.width && rectB.y > rectA.y - rectB.height && rectB.y < rectA.y + rectA.height + rectB.height

圓形碰撞圓形

圓形之間的碰撞就更簡單了,只要兩圓的圓小於兩圓的半徑之和就能認定兩圓發生了碰撞,程式碼形式如下:

Math.sqrt(Math.pow(circleA.x - circleB.x, 2) + Math.pow(circleA.y - circleB.y, 2)) < circleA.radius + circleB.radius

圓形和矩形的碰撞

當我們要檢測圓形和矩形之間的碰撞,我們可以將圓形看成一個矩形,用矩形與矩形之間的那套方法來檢測碰撞。

這種檢測方法不太精確,圓形越大就越不精確,比如圓形和矩形的頂角發生碰撞,但是在不需要精確判斷的遊戲中,這種方法是可行的,程式碼如下:

circle.x > rect.x - circle.radius &&
circle.x < rect.x + rect.width + circle.radius &&
circle.y > rect.y - circle.radius &&
circle.y > rect.y + rect.height + circle.radius

分離軸定理

前面提到的這些檢測方法非常簡單,足夠應對一般的2D遊戲。但是這些方法都不精確,不穩妥,不能應對所有的情況,比如矩形和矩形的碰撞檢測方法,無法檢測有一定旋轉角度的矩形之間的碰撞。

2D遊戲有個較為穩定的碰撞檢測方法:分離軸定理。這個方法能夠檢測凸多邊形之間的碰撞同時也解決了物體碰撞後分離和反彈等一系列問題。

另外,分離軸定理會用到向量計算,下面會一一提到。

原理

通俗來說,分離軸定理的原理就是:用光線從各個角度照射進行檢測的兩個物體,在垂直於光線的位置放置一堵牆,觀察兩個物體在牆上的投影,如果在某個角度下,兩者的投影不重疊,意味著這兩個物體之間有空隙,兩者不重疊,即沒有發生碰撞。如果在所有角度下,這兩個物體的投影都是重疊的,意味著兩者重疊,即發生了碰撞。

用下圖闡述分離軸的原理:

分離軸原理

三角形和矩形在某一個投影軸上的投影不重疊,所以兩者沒有發生碰撞。

投影軸

如果我們每個角度都對物體進行投影檢測,這無疑是最保險的,但是這樣做花費會非常大而且也是沒有必要的。

我們多次觀察會發現,需要檢測的投影軸都垂直於多邊形的邊,所以實際上需要的投影軸的數量等同於多邊形的邊的數量:

投影軸

比如上圖,我們只需要6條投影軸,而且這些投影軸都垂直於多邊形的某一條邊。所以當我們要對兩個多邊形進行碰撞檢測,通過求出各條邊的垂直線,我們就能夠找到所有投影軸。

問題是:如何求一條邊的垂直線呢?這問題用向量很容易解決。我們先給出向量的定義:

var Vector = function(x, y) {
    this.x = x;
    this.y = y;
};

現在我們先要用向量來表示多邊形的某一條邊,原理看下圖:

邊的向量

所以邊為兩點的向量之差:

// 向量求差
Vector.prototype.subtract = function(vector) {
    return new Vector(this.x - vector.x, this.y - vector.y);
};

/*
    假設已知兩個點point1和point2
*/
var v1, v2, edge;

// 兩點的向量
v1 = new Vector(point1.x, point1.y);
v2 = new Vector(point1.x, point2.y);

// 兩點向量的差 就是 邊的向量
edge = v2.subtract(v1);

現在我們知道了邊向量的求法,接下來就是解決如何求一個向量的垂直向量:

垂直向量

根據上圖,我們能夠為Vector新增一個求垂直向量的方法:

Vector.prototype.prependicular = function() {
    return new Vector(this.y, -this.x);
};

由於投影軸不需要定義長度,所以可以說,投影軸是邊向量的法向量:

// 求向量長度 
Vector.prototype.getMagnitude = function() {
    return Math.sqrt(Math.pow(this.x, 2) + Math.pow(this.y, 2));
};
// 求單位向量
Vector.prototype.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;
};

// 投影軸的單位向量
var axisVector = edge.prependicular().normalize();

投影

我們有了投影軸,就能夠計算出多邊形在該軸上的投影。多邊形在某一投影軸上的投影就是:該多邊形各個頂點在該投影軸上的投射點所組成的直線(最大點和最小點之間):

投影

為了求一個點在某軸線上的投射點,我們要用到向量的點乘計算。請看下圖:

投射點

所以一個點與投影軸(單位向量)的點積就是其投射點:

// 求向量點積
Vector.prototype.dotProduct = function(vector) {
    return this.x * vector.x + this.y * vector.y;
};

/*
    假設存在:
     - 點point
     - 投影軸向量axisVector
*/
var pointVector = new Vector(point.x, point.y);
// 投射點
pointVector.dotProduct(axisVector);

最後,我們只需要遍歷多邊形的頂點,找出這些點相對於投影軸的投射點,這些投射點的最大值和最小值構成了投影。

有了以上方法,我們通過分離軸定理就能夠檢測碰撞了:

最小平移量

遊戲中判斷髮生碰撞的依據是:兩個物體發生重疊了。所以當我們監測到兩個物體發生碰撞的時候,這兩個物體是重疊在一起。這導致的問題有:

  • 程式碼方面,碰撞後會改變執行狀態,如果不馬上分離這兩個物體,很可能下一幀,兩者依舊處於重疊狀態,那麼此時兩者執行狀態又會被改變,依次下來,就產生了死迴圈,兩個物體會一直粘合在一起。
  • 如果兩個物體重疊部分面積非常的大,玩家覺察到明顯的不真實,因為現實中,兩個能夠發生碰撞的物體是不會重疊的。

所以遊戲中兩個物體發生了碰撞,我們需要馬上分開這兩個物體。為了分開兩個物體,我們需要找到兩者分離所需要的最小移動距離和移動方向,即最小平移量

對於分離軸定理來說,最小平移量的求解是非常簡單的,可以說是“順手而已”。此話怎講?

這是因為我們知道,用分離軸定理判斷碰撞的過程中,我們需要為多邊形的每條邊對應的投影軸上進行投影,然後對比兩個物體在該軸上投影是否都用重疊,這過程中,我們就能夠算出兩者投影的重疊部分,理論上,我們可以說,兩者的最小投影重疊部分就是最小平移距離,而平移方向則是該投影所對應的投影軸的方向。

最小向量

上圖我們可以看出,最小平移量不過就是投影的重疊部分和此時的投影軸,前者決定最小平移距離,後者決定平移方向。

我們來看一下,加上最小平移量後,前一個demo能達到什麼效果:

有了最小平移量,我們能輕易實現物體碰撞反彈效果:

至此,我們已經介紹完分離軸定理和最小平移量,這兩個演算法能幫助我們很好地處理2D遊戲中絕大部分的碰撞檢測,但同時我們也應該知道,分離軸定理是不適用於凹多邊形的碰撞檢測。

js效能優化

我們知道,60幀才能保證遊戲畫面的流暢,這意味著,js程式碼的執行要在16ms以內完成,所以哪怕是1ms也是值得我們去爭取的。由於可用的時間非常短,我們需要儘可能地提升js的執行效率,影響js效率的一個非常關鍵因素就是js的垃圾回收。在js執行過程中,垃圾回收大概會佔用10%的時間,如果在短時間內js程式碼不斷地進行垃圾回收,那麼整個畫面就會有明顯的卡頓,所以我們要降低垃圾回收的執行頻率。

在js程式碼執行過程,瀏覽器會監測程式佔用的記憶體空間,如果佔用大小超過分配的記憶體大小,瀏覽器會進行垃圾回收,遍歷並釋放一些不用的記憶體空間。

我們在寫程式碼時,為了方便計算或者傳遞資料,我們會建立臨時的陣列或者物件。在一般的程式中這做法是沒問題的,但是在遊戲執行中,這就產生大量的臨時記憶體空間,也就是所謂的垃圾。假設有個函式在執行過程中會建立兩個臨時物件,想想看,一秒60幀來算,也就是一秒執行60次,那就是120個臨時物件。所以在遊戲中如果處理不當,短時間內就輕易地產生大量的垃圾,造成垃圾回收機制被頻繁的觸發。

改善這一問題的方法就是:事先建立好相應的物件或陣列,要使用的時候直接拿來用:

var tempArr = [];

function func() {
    var result = tempArr;

    // 一系列操作
    // ...

    return result;
}

當然這樣的解決方案也是有不足之處的,比如有個函式是遞迴執行的,同時它執行過程中也需要臨時的陣列或物件,那麼上述解決方法是不適用的。

於是有人也提出一個方法,就是建立一個方法來管理這些臨時空間,預設一些臨時空間,每次需要臨時空間,該方法則檢視是否有可用的臨時空間,如果有,則分配出去,否則建立新的空間。這方法不錯,不過我想這不是瀏覽器的工作嗎?

下面會介紹一些常見情況,在這些情況下會產生一些我們意識不到的臨時的記憶體空間。

會產生垃圾哦

有時候我們需要清空陣列,也許我們這麼做:

// bad
var arr = [1, 2, 3, 4];

arr = []; 

這樣,實際上是建立了另一個新的陣列[],而棄用了原陣列[1,2,3,4]。所以為了真正地複用這塊記憶體空間,我們可以這樣清空陣列:

// good
arr.length = 0;

當然,有時候我們只需要刪除陣列中的某段資料,我們會使用splice來實現:

// 清除陣列前四個元素,並以新陣列的形式返回這四個元素
arr.splice(0, 4); 

然而splice方法在刪除陣列中某段元素的同時,也會將這些元素建立為新的陣列。如果我們的目的只是清除元素,我們需要自定義一個新的方法:

// 用於刪除arr[index] ~ arr[index + num - 1]
var removeItem = function(arr, index, num) {
    var i, len;
    for (i = index + num, len = arr.length; i < len; i++) {
        arr[i - num] = arr[i];
    }
    arr.length = len - num;
};

在繪製動畫幀的過程中,要儘量避免用函式直接返回一個物件,比如獲取canvas的滑鼠座標,我們會用一個函式對滑鼠座標進行轉換,最後將x和y包裝進一個物件中返回:

var getLoc = function(canvas, x, y) {
    var locX, locY;

    // 一系列操作
    // ...

    return {
        x: locX,
        y: locY
    };
};

然而,這樣的方法會產生一個臨時的物件,即產生了垃圾,針對這點,我們的解決方法是分開獲取x和y,避免產生垃圾:

var getLocX = function(canvas, x) { /* ... */ },
    getLocY = function(canvas, y) { /* ... */ };

總而言之,當我們著手改善垃圾回收機制的時候,除了自己寫的程式碼我們還需要留意其中用到的js原生方法,比如陣列的spliceconcat

以上。

參考

碰撞檢測:

垃圾回收相關: