Attention: Here be dragons

This is the latest (unstable) version of this documentation, which may document features not available in or compatible with released stable versions of Godot.

進階向量運算

平面

點積與單位向量有另一個有趣的性質。想像有一個通過原點且垂直於該向量的平面。平面會將整個空間劃分為正(平面上方)與負(平面下方)區域,而且(與一般認知不同)這些運算同樣適用於 2D:

../../_images/tutovec10.png

垂直於表面的單位向量稱為 單位法向量 ,通常簡稱為 法線。法線常見於平面、3D 幾何(用於判斷面或頂點的朝向)等場合。 法線 本質上就是 單位向量 ,但因其用途而有此名稱。(就像我們會稱 (0,0) 為「原點」一樣!)。

這個平面會通過原點,且其表面垂直於該單位向量(或 法線)。向量所指的一側是正半空間,另一側則是負半空間。在 3D 中概念完全相同,不同的是平面是無限延伸的表面(想像一張無限大的平坦紙張,固定在原點並可任意擺放),而不是一條線。

點到平面的距離

了解了平面的定義後,讓我們回來談點積。當你將一個 單位向量 與任意 空間中的點 做點積時(沒錯,這次是向量和位置之間的點積),會得到 該點到平面的距離

var distance = normal.dot(point)

而且這不僅僅是絕對值距離,如果點位於負半空間,那麼距離也會是負值:

../../_images/tutovec11.png

這讓我們可以判斷一個點位於平面的哪一側。

偏離原點

我知道你在想什麼!目前這些都不錯,但*現實中的*平面其實可以在空間中任何地方,而不只是通過原點。你想要更「真實」的*平面*運算,而且*現在*就想試試看。

別忘了,平面不只將空間分成兩個部分,還具有*極性*。也就是說,即使有兩個平面完全重疊,它們的正負半空間仍可能相反。

基於這個特性,我們可以用一個 法線 N 和一個 距離原點的標量 D 來完整描述一個平面。因此,一個平面就用 N 和 D 表示。例如:

../../_images/tutovec12.png

在 3D 運算中,Godot 提供了 Plane 內建型別來處理這些計算。

基本上,N 和 D 可以表示空間中任意一個平面,不論是 2D 還是 3D(取決於 N 的維度),運算方式都相同。和前述的概念一樣,只是 D 代表從原點出發,沿著 N 方向到平面的距離。舉例來說,假設你想要找到平面上的某一點,只需:

var point_in_plane = N*D

這樣會將法線向量拉長(縮放)到剛好碰到該平面。這個運算乍看之下可能有點混亂,但其實非常簡單。如果我們要計算某點到平面的距離,也是類似做法,只是需要調整距離:

var distance = N.dot(point) - D

相同的事可以利用內建函式達成:

var distance = plane.distance_to(point)

這樣會回傳正值或負值的距離。

若要翻轉平面的極性,只需將 N 和 D 都取負號即可。這樣會得到一個同樣位置的平面,但正負半空間會對調:

N = -N
D = -D

Godot 也在 Plane 中提供了這個運算操作,因此你可以直接這樣寫:

var inverted_plane = -plane

所以記住,平面最實用的地方就是可以計算點到平面的距離。那麼,什麼情境下需要這樣的計算呢?來看幾個例子。

在 2D 中建立平面

平面當然不會憑空出現,它必須被建構。在 2D 裡建立平面很簡單,你可以用一個法線(單位向量)和一個點,或是空間中兩個點來建立。

如果已經有一個法線和一個點,那幾乎所有資訊都具備了。這時只要用法線與該點做點積,就能求出 D。

var N = normal
var D = normal.dot(point)

若是拿空間中的兩個點來建立平面,事實上會有兩個平面經過這兩點,它們的法線方向相反但所在空間相同。你需要先取得這兩點的方向向量,然後將它旋轉 90 度(順時針或逆時針)來得到法線:

# Calculate vector from `a` to `b`.
var dvec = point_a.direction_to(point_b)
# Rotate 90 degrees.
var normal = Vector2(dvec.y, -dvec.x)
# Alternatively (depending the desired side of the normal):
# var normal = Vector2(-dvec.y, dvec.x)

接下來的步驟就和前述範例一樣了。你使用 point_a 或 point_b 都可以,因為它們都在同一個平面上:

var N = normal
var D = normal.dot(point_a)
# this works the same
# var D = normal.dot(point_b)

在 3D 空間中做類似的事情會稍微複雜一些,後面會進一步說明。

平面的應用範例

以下是一個平面應用的範例。假設你有一個 多邊形,例如矩形、梯形、三角形,或任何沒有邊向內凹的多邊形。

針對多邊形的每一個邊,我們計算經過該邊的平面。有了這些平面,你就可以做很多事情,例如判斷一個點是否在多邊形內部。

我們檢查所有平面,如果存在某個平面使得點到該平面的距離為正,則該點在多邊形外部。若沒有這樣的平面,則該點就在多邊形內部。

../../_images/tutovec13.png

程式碼範例如下:

var inside = true
for p in planes:
    # check if distance to plane is positive
    if (p.distance_to(point) > 0):
        inside = false
        break # with one that fails, it's enough

很酷吧?其實還有更進一步的應用!只要再多做一點點,你就可以用類似邏輯來判斷兩個凸多邊形是否重疊。這就是所謂的分離軸定理(SAT),大多數物理引擎都用這個原理來偵測碰撞。

對於單一點,只要檢查是否有平面回傳正距離就知道該點是否在外面。對於另一個多邊形,則必須找到一個平面,使得*所有*另一多邊形的點對該平面計算時都為正距離。這個檢查動作要對 A 的平面配 B 的點,再對 B 的平面配 A 的點各做一次:

../../_images/tutovec14.png

程式碼範例如下:

var overlapping = true

for p in planes_of_A:
    var all_out = true
    for v in points_of_B:
        if (p.distance_to(v) < 0):
            all_out = false
            break

    if (all_out):
        # a separating plane was found
        # do not continue testing
        overlapping = false
        break

if (overlapping):
    # only do this check if no separating plane
    # was found in planes of A
    for p in planes_of_B:
        var all_out = true
        for v in points_of_A:
            if (p.distance_to(v) < 0):
                all_out = false
                break

        if (all_out):
            overlapping = false
            break

if (overlapping):
    print("Polygons Collided!")

可以看到,平面真的非常實用,而這只是一小部分應用而已。你可能會好奇,如果遇到非凸多邊形怎麼辦?通常會先把凹多邊形拆分成多個凸多邊形,或是用 BSP 之類的技術(不過現在已經比較少用了)。

3D 碰撞偵測

這裡再多分享一點知識,感謝你耐心讀到這裡!雖然這不一定能馬上拿來用(因為 Godot 本身就有很完善的碰撞偵測),但幾乎所有物理引擎和碰撞偵測函式庫都用到這個原理:)

還記得之前提到,將 2D 的凸形狀轉換成一組 2D 平面對碰撞偵測很有用嗎?你可以藉此判斷一個點是否在凸形狀內,或是兩個 2D 凸形狀是否有重疊。

這套方法在 3D 也適用。如果兩個 3D 多面體發生碰撞,你就無法找到分離平面;反之,如果找得到分離平面,表示兩者絕對沒有碰撞。

補充一下,「分離平面」的意思是:多邊形 A 的所有頂點都在平面一側,多邊形 B 的所有頂點都在平面另一側。這個平面一定是 A 或 B 某一個面的平面。

不過在 3D 空間中,這個方法有個小問題:有些情況下可能找不到分離平面。例如下圖這種情形:

../../_images/tutovec22.png

為了避免這種狀況,還需要針對一些額外的平面進行測試;這些平面,是由多邊形 A 和 B 的邊分別做外積(叉積)產生的

../../_images/tutovec23.png

因此最終的演算法大致如下:

var overlapping = true

for p in planes_of_A:
    var all_out = true
    for v in points_of_B:
        if (p.distance_to(v) < 0):
            all_out = false
            break

    if (all_out):
        # a separating plane was found
        # do not continue testing
        overlapping = false
        break

if (overlapping):
    # only do this check if no separating plane
    # was found in planes of A
    for p in planes_of_B:
        var all_out = true
        for v in points_of_A:
            if (p.distance_to(v) < 0):
                all_out = false
                break

        if (all_out):
            overlapping = false
            break

if (overlapping):
    for ea in edges_of_A:
        for eb in edges_of_B:
            var n = ea.cross(eb)
            if (n.length() == 0):
                continue

            var max_A = -1e20 # tiny number
            var min_A = 1e20 # huge number

            # we are using the dot product directly
            # so we can map a maximum and minimum range
            # for each polygon, then check if they
            # overlap.

            for v in points_of_A:
                var d = n.dot(v)
                max_A = max(max_A, d)
                min_A = min(min_A, d)

            var max_B = -1e20 # tiny number
            var min_B = 1e20 # huge number

            for v in points_of_B:
                var d = n.dot(v)
                max_B = max(max_B, d)
                min_B = min(min_B, d)

            if (min_A > max_B or min_B > max_A):
                # not overlapping!
                overlapping = false
                break

        if (not overlapping):
            break

if (overlapping):
   print("Polygons collided!")

更多資訊

想了解更多在 Godot 中使用向量運算的資訊,請參考下列文章:

如果你想要更深入的說明, 建議觀看 3Blue1Brown 的優質影片系列 Essence of Linear Algebra