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.
Checking the stable version of the documentation...
進階向量運算
平面
點積與單位向量有另一個有趣的性質。想像有一個通過原點且垂直於該向量的平面。平面會將整個空間劃分為正(平面上方)與負(平面下方)區域,而且(與一般認知不同)這些運算同樣適用於 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 。