進階向量運算
平面
點積與單位向量有另一個有趣的性質。想像有一個通過原點且垂直於該向量的平面。平面會將整個空間劃分為正(平面上方)與負(平面下方)區域,而且(與一般認知不同)這些運算同樣適用於 2D:
垂直於表面的單位向量稱為 單位法向量 ,通常簡稱為 法線。法線常見於平面、3D 幾何(用於判斷面或頂點的朝向)等場合。 法線 本質上就是 單位向量 ,但因其用途而有此名稱。(就像我們會稱 (0,0) 為「原點」一樣!)。
這個平面會通過原點,且其表面垂直於該單位向量(或 法線)。向量所指的一側是正半空間,另一側則是負半空間。在 3D 中概念完全相同,不同的是平面是無限延伸的表面(想像一張無限大的平坦紙張,固定在原點並可任意擺放),而不是一條線。
點到平面的距離
了解了平面的定義後,讓我們回來談點積。當你將一個 單位向量 與任意 空間中的點 做點積時(沒錯,這次是向量和位置之間的點積),會得到 該點到平面的距離:
var distance = normal.dot(point)
var distance = normal.Dot(point);
而且這不僅僅是絕對值距離,如果點位於負半空間,那麼距離也會是負值:
這讓我們可以判斷一個點位於平面的哪一側。
偏離原點
我知道你在想什麼!目前這些都不錯,但*現實中的*平面其實可以在空間中任何地方,而不只是通過原點。你想要更「真實」的*平面*運算,而且*現在*就想試試看。
別忘了,平面不只將空間分成兩個部分,還具有*極性*。也就是說,即使有兩個平面完全重疊,它們的正負半空間仍可能相反。
基於這個特性,我們可以用一個 法線 N 和一個 距離原點的標量 D 來完整描述一個平面。因此,一個平面就用 N 和 D 表示。例如:
在 3D 運算中,Godot 提供了 Plane 內建型別來處理這些計算。
基本上,N 和 D 可以表示空間中任意一個平面,不論是 2D 還是 3D(取決於 N 的維度),運算方式都相同。和前述的概念一樣,只是 D 代表從原點出發,沿著 N 方向到平面的距離。舉例來說,假設你想要找到平面上的某一點,只需:
var point_in_plane = N*D
var pointInPlane = N * D;
這樣會將法線向量拉長(縮放)到剛好碰到該平面。這個運算乍看之下可能有點混亂,但其實非常簡單。如果我們要計算某點到平面的距離,也是類似做法,只是需要調整距離:
var distance = N.dot(point) - D
var distance = N.Dot(point) - D;
相同的事可以利用內建函式達成:
var distance = plane.distance_to(point)
var distance = plane.DistanceTo(point);
這樣會回傳正值或負值的距離。
若要翻轉平面的極性,只需將 N 和 D 都取負號即可。這樣會得到一個同樣位置的平面,但正負半空間會對調:
N = -N
D = -D
N = -N;
D = -D;
Godot 也在 Plane 中提供了這個運算操作,因此你可以直接這樣寫:
var inverted_plane = -plane
var invertedPlane = -plane;
所以記住,平面最實用的地方就是可以計算點到平面的距離。那麼,什麼情境下需要這樣的計算呢?來看幾個例子。
在 2D 中建立平面
平面當然不會憑空出現,它必須被建構。在 2D 裡建立平面很簡單,你可以用一個法線(單位向量)和一個點,或是空間中兩個點來建立。
如果已經有一個法線和一個點,那幾乎所有資訊都具備了。這時只要用法線與該點做點積,就能求出 D。
var N = normal
var D = normal.dot(point)
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)
// Calculate vector from `a` to `b`.
var dvec = pointA.DirectionTo(pointB);
// Rotate 90 degrees.
var normal = new Vector2(dvec.Y, -dvec.X);
// Alternatively (depending the desired side of the normal):
// var normal = new 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)
var N = normal;
var D = normal.Dot(pointA);
// this works the same
// var D = normal.Dot(pointB);
在 3D 空間中做類似的事情會稍微複雜一些,後面會進一步說明。
平面的應用範例
以下是一個平面應用的範例。假設你有一個 凸 多邊形,例如矩形、梯形、三角形,或任何沒有邊向內凹的多邊形。
針對多邊形的每一個邊,我們計算經過該邊的平面。有了這些平面,你就可以做很多事情,例如判斷一個點是否在多邊形內部。
我們檢查所有平面,如果存在某個平面使得點到該平面的距離為正,則該點在多邊形外部。若沒有這樣的平面,則該點就在多邊形內部。
程式碼範例如下:
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
var inside = true;
foreach (var p in planes)
{
// check if distance to plane is positive
if (p.DistanceTo(point) > 0)
{
inside = false;
break; // with one that fails, it's enough
}
}
很酷吧?其實還有更進一步的應用!只要再多做一點點,你就可以用類似邏輯來判斷兩個凸多邊形是否重疊。這就是所謂的分離軸定理(SAT),大多數物理引擎都用這個原理來偵測碰撞。
對於單一點,只要檢查是否有平面回傳正距離就知道該點是否在外面。對於另一個多邊形,則必須找到一個平面,使得*所有*另一多邊形的點對該平面計算時都為正距離。這個檢查動作要對 A 的平面配 B 的點,再對 B 的平面配 A 的點各做一次:
程式碼範例如下:
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!")
var overlapping = true;
foreach (Plane plane in planesOfA)
{
var allOut = true;
foreach (Vector3 point in pointsOfB)
{
if (plane.DistanceTo(point) < 0)
{
allOut = false;
break;
}
}
if (allOut)
{
// 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
foreach (Plane plane in planesOfB)
{
var allOut = true;
foreach (Vector3 point in pointsOfA)
{
if (plane.DistanceTo(point) < 0)
{
allOut = false;
break;
}
}
if (allOut)
{
overlapping = false;
break;
}
}
}
if (overlapping)
{
GD.Print("Polygons Collided!");
}
可以看到,平面真的非常實用,而這只是一小部分應用而已。你可能會好奇,如果遇到非凸多邊形怎麼辦?通常會先把凹多邊形拆分成多個凸多邊形,或是用 BSP 之類的技術(不過現在已經比較少用了)。
3D 碰撞偵測
這裡再多分享一點知識,感謝你耐心讀到這裡!雖然這不一定能馬上拿來用(因為 Godot 本身就有很完善的碰撞偵測),但幾乎所有物理引擎和碰撞偵測函式庫都用到這個原理:)
還記得之前提到,將 2D 的凸形狀轉換成一組 2D 平面對碰撞偵測很有用嗎?你可以藉此判斷一個點是否在凸形狀內,或是兩個 2D 凸形狀是否有重疊。
這套方法在 3D 也適用。如果兩個 3D 多面體發生碰撞,你就無法找到分離平面;反之,如果找得到分離平面,表示兩者絕對沒有碰撞。
補充一下,「分離平面」的意思是:多邊形 A 的所有頂點都在平面一側,多邊形 B 的所有頂點都在平面另一側。這個平面一定是 A 或 B 某一個面的平面。
不過在 3D 空間中,這個方法有個小問題:有些情況下可能找不到分離平面。例如下圖這種情形:
為了避免這種狀況,還需要針對一些額外的平面進行測試;這些平面,是由多邊形 A 和 B 的邊分別做外積(叉積)產生的
因此最終的演算法大致如下:
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!")
var overlapping = true;
foreach (Plane plane in planesOfA)
{
var allOut = true;
foreach (Vector3 point in pointsOfB)
{
if (plane.DistanceTo(point) < 0)
{
allOut = false;
break;
}
}
if (allOut)
{
// 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
foreach (Plane plane in planesOfB)
{
var allOut = true;
foreach (Vector3 point in pointsOfA)
{
if (plane.DistanceTo(point) < 0)
{
allOut = false;
break;
}
}
if (allOut)
{
overlapping = false;
break;
}
}
}
if (overlapping)
{
foreach (Vector3 edgeA in edgesOfA)
{
foreach (Vector3 edgeB in edgesOfB)
{
var normal = edgeA.Cross(edgeB);
if (normal.Length() == 0)
{
continue;
}
var maxA = float.MinValue; // tiny number
var minA = float.MaxValue; // 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.
foreach (Vector3 point in pointsOfA)
{
var distance = normal.Dot(point);
maxA = Mathf.Max(maxA, distance);
minA = Mathf.Min(minA, distance);
}
var maxB = float.MinValue; // tiny number
var minB = float.MaxValue; // huge number
foreach (Vector3 point in pointsOfB)
{
var distance = normal.Dot(point);
maxB = Mathf.Max(maxB, distance);
minB = Mathf.Min(minB, distance);
}
if (minA > maxB || minB > maxA)
{
// not overlapping!
overlapping = false;
break;
}
}
if (!overlapping)
{
break;
}
}
}
if (overlapping)
{
GD.Print("Polygons Collided!");
}
更多資訊
想了解更多在 Godot 中使用向量運算的資訊,請參考下列文章:
如果你想要更深入的說明, 建議觀看 3Blue1Brown 的優質影片系列 Essence of Linear Algebra 。