矩陣與轉換
前言
在閱讀本教學之前,建議你先完整閱讀並理解 向量數學 教學,因為本教學需要具備向量的基礎知識。
本教學介紹*轉換*(transformation),以及我們如何在 Godot 中利用矩陣來表示這些轉換。這並不是一份完整的矩陣深入指南。轉換大多用於平移、旋轉與縮放,因此我們會著重於如何用矩陣來表達這些操作。
本指南主要聚焦在 2D,使用 Transform2D 和 Vector2,但其實 3D 的運作方式也非常類似。
備註
如同前一篇教學提到,請記得在 Godot 中,2D 的 Y 軸是*向下*的。這與多數學校裡學的線性代數(Y 軸向上)正好相反。
備註
慣例上,X 軸為紅色,Y 軸為綠色,Z 軸為藍色。本教學的配色遵循這個慣例,不過我們也會用藍色來標示原點向量。
矩陣分量與單位矩陣
單位矩陣代表一個沒有平移、沒有旋轉、沒有縮放的轉換。讓我們先看看單位矩陣,以及它的各個分量與畫面上呈現的樣子之間的關係。
矩陣有行和列,而轉換矩陣對各自的意義有特定的約定。
如上圖所示,紅色的 X 向量對應到矩陣的第一欄,綠色的 Y 向量對應到第二欄。更動這些欄位會改變這些向量。接下來的例子會說明如何操作它們。
你通常不需要直接操作行,因為我們大多處理的是列。不過,你也可以把矩陣的行看作是用來表現哪些向量會對特定方向的移動產生影響。
當我們提到像是 t.x.y 這種寫法時,指的是 X 列向量的 Y 分量,也就是矩陣的左下角。同理,t.x.x 是左上角,t.y.x 是右上角,t.y.y 是右下角,這裡的 t 是 Transform2D。
縮放轉換矩陣
縮放是最容易理解的操作之一。讓我們先把 Godot 標誌放在向量下方,這樣可以直觀看到縮放對物件的效果:
要縮放這個矩陣,只需要將每個分量乘上想要的縮放比例。假設我們要放大為 2 倍,1 乘以 2 變成 2,0 乘以 2 還是 0,結果是這樣:
在程式碼中,只要把每個向量乘上縮放值就可以了:
var t = Transform2D()
# Scale
t.x *= 2
t.y *= 2
transform = t # Change the node's transform to what we calculated.
Transform2D t = Transform2D.Identity;
// Scale
t.X *= 2;
t.Y *= 2;
Transform = t; // Change the node's transform to what we calculated.
如果想恢復原本的大小,只需要將每個分量乘上 0.5。這就是縮放轉換矩陣的全部重點。
如果想從現有的轉換矩陣計算物件的縮放比例,可以對每個列向量使用 length() 方法。
備註
在實際專案中,可以直接用 scaled() 方法來進行縮放。
旋轉轉換矩陣
和剛才一樣,先用 Godot 標誌在單位矩陣下方作為起點:
舉個例子,假設我們想將 Godot 標誌順時針旋轉 90 度。目前 X 軸向右,Y 軸向下。如果我們腦中旋轉這些向量,可以想像新的 X 軸會指向下方,新的 Y 軸會指向左方。
你可以想像同時抓住 Godot 標誌和它的向量,然後繞中心旋轉。轉完後,向量的方向就是此時矩陣的內容。
我們需要用標準座標來表示「下」和「左」,所以 X 設為 (0, 1),Y 設為 (-1, 0)。這正好就是 Vector2.DOWN 和 Vector2.LEFT 的值。這麼做就能達到我們想要的旋轉效果:
如果你還是不太理解上面說明,可以試著:剪一張正方形紙,在上面畫上 X、Y 向量,放在格子紙上,然後旋轉,觀察端點位置的變化。
要用程式碼實作旋轉,我們需要用公式計算矩陣分量。這張圖展示了如何用旋轉角度計算轉換矩陣的公式。如果這部分看起來很難,不用擔心,這已經是你需要學的最難部分。
備註
Godot 以弧度(radian)表示所有旋轉,不使用角度。一圈是 TAU 或 PI*2 弧度,四分之一圈(90 度)是 TAU/4 或 PI/2 弧度。用 TAU 的話,程式碼通常更直覺易懂。
備註
有趣的小知識:Godot 裡不只 Y 軸向下,旋轉也是以順時針為正。這些差異和 Y 向上、逆時針為正的數學系統正好「互相抵消」,所以所有的數學與三角函數行為都一樣。你可以把兩個系統的旋轉都想成「從 X 指向 Y」。
如果要旋轉 0.5 弧度(約 28.65 度),只要將 0.5 代入上面的公式,就能計算出實際的矩陣分量:
在程式碼中這樣實作(把腳本掛在 Node2D):
var rot = 0.5 # The rotation to apply.
var t = Transform2D()
t.x.x = cos(rot)
t.y.y = cos(rot)
t.x.y = sin(rot)
t.y.x = -sin(rot)
transform = t # Change the node's transform to what we calculated.
float rot = 0.5f; // The rotation to apply.
Transform2D t = Transform2D.Identity;
t.X.X = t.Y.Y = Mathf.Cos(rot);
t.X.Y = t.Y.X = Mathf.Sin(rot);
t.Y.X *= -1;
Transform = t; // Change the node's transform to what we calculated.
若要從現有的轉換矩陣計算物件的旋轉角度,可以用 atan2(t.x.y, t.x.x),其中 t 為 Transform2D。
備註
在實際專案中,建議用 rotated() 方法來旋轉。
轉換矩陣的基底
到目前為止,我們只操作了 x 和 y 兩個向量,這兩個向量負責表現旋轉、縮放和/或剪切(進階內容後面會提到)。X 和 Y 向量合稱為轉換矩陣的*基底*(basis)。「基底」和「基底向量」這兩個術語很重要,務必記住。
你可能注意到 Transform2D 其實有三個 Vector2 欄位:x、y 和 origin。其中 origin 不是基底的一部分,但它屬於轉換本身,用來表示位置。從這裡開始,所有例子都會標記 origin 向量。你可以把 origin 當作另一欄,但通常把它當作完全獨立的成分會比較好理解。
請注意,在 3D 裡,Godot 有獨立的 Basis 結構,專門用來存放三個基底的 Vector3。這是因為程式邏輯會變得複雜,將它和 Transform3D (由一個 Basis 和一個 Vector3 原點組成)分開設計會更好。
平移轉換矩陣
更改 origin 向量稱為*平移*(translate)轉換矩陣。平移其實就是把物件「移動」到新位置,但不會有任何旋轉。
我們來看個例子更好理解。這次還是從單位轉換開始,不過我們這次會標示出 origin 向量的位置。
如果要把物件移到 (1, 2),只要將它的 origin 向量設為 (1, 2):
另外還有一個 translated_local() 方法,它和直接更改 origin 的方式不同。translated_local() 會讓物件依照*自身旋轉座標*進行平移。例如,一個已經順時針旋轉 90 度的物件,如果用 translated_local(Vector2.UP),會向右平移。若要*依照全域/父節點座標系*來平移,則應使用 translated()。
備註
Godot 的 2D 採用像素為單位的座標系,所以在實際專案中,平移通常會用到數百這種等級的數值。
總結與實作
現在我們要把前面學到的全都用在同一個轉換上。你可以建立一個專案,新增 Sprite2D 節點,並使用 Godot 標誌當作紋理。
設定平移為 (350, 150)、旋轉 -0.5 弧度、縮放為 3。我貼了截圖和完整程式碼,不過建議你不要看答案自己試著重現一次!
var t = Transform2D()
# Translation
t.origin = Vector2(350, 150)
# Rotation
var rot = -0.5 # The rotation to apply.
t.x.x = cos(rot)
t.y.y = cos(rot)
t.x.y = sin(rot)
t.y.x = -sin(rot)
# Scale
t.x *= 3
t.y *= 3
transform = t # Change the node's transform to what we calculated.
Transform2D t = Transform2D.Identity;
// Translation
t.Origin = new Vector2(350, 150);
// Rotation
float rot = -0.5f; // The rotation to apply.
t.X.X = t.Y.Y = Mathf.Cos(rot);
t.X.Y = t.Y.X = Mathf.Sin(rot);
t.Y.X *= -1;
// Scale
t.X *= 3;
t.Y *= 3;
Transform = t; // Change the node's transform to what we calculated.
剪切轉換矩陣(進階)
備註
如果你只想知道怎麼*用*轉換矩陣,可以跳過這一節。本節會討論轉換矩陣較少用到的一個特性,幫助你更深入理解其原理。
Node2D 內建支援剪切屬性。
你可能發現,轉換的自由度比上述操作加總還多。2D 轉換矩陣的基底(兩個 Vector2)總共有四個數值,但只用旋轉和縮放(用一個角度和一個 Vector2)僅有三個數值。這個「多出來」的自由度就稱為*剪切*(shearing)。
通常,基底向量都是互相垂直的。不過在某些時候,剪切也會有用,而且理解剪切有助於更深入掌握轉換矩陣的本質。
為了更直觀,讓我們把一個格線疊加在 Godot 標誌上:
這個格線上的每個點都是基底向量加總得來。右下角是 X+Y,右上角是 X-Y。只要你改變基底向量,整個格線也會跟著變形,因為格線就是由基底向量組成。格線上現在彼此平行的線,無論怎麼改變基底,都還是會保持平行。
例如,現在將 Y 設為 (1, 1):
var t = Transform2D()
# Shear by setting Y to (1, 1)
t.y = Vector2.ONE
transform = t # Change the node's transform to what we calculated.
Transform2D t = Transform2D.Identity;
// Shear by setting Y to (1, 1)
t.Y = Vector2.One;
Transform = t; // Change the node's transform to what we calculated.
備註
你不能在編輯器直接設定 Transform2D 的原始值,因此如果想要剪切物件,一定 要用程式碼來達成。
由於基底向量不再垂直,物件就產生了剪切。格線下方中間(對自身為 (0, 1))現在在世界座標的 (1, 1)。
物件內部的座標在紋理中稱為 UV 座標,這裡我們也借用這個術語。若要從相對座標算出世界位置,用 U * X + V * Y 這個公式。U、V 為數值,X、Y 為基底向量。
格線右下角(UV 座標為 (1, 1))在世界座標是 (2, 1),計算方式是 X*1 + Y*1,也就是 (1, 0) + (1, 1),結果為 (2, 1)。這和我們觀察到的圖像右下角位置一致。
同樣地,格線右上角(UV 座標是 (1, -1))在世界座標是 (0, -1),計算方式是 X*1 + Y*(-1),也就是 (1, 0) - (1, 1) = (0, -1)。這和我們觀察到的圖像右上角相符。
希望你現在已經完全理解轉換矩陣如何影響物件,以及基底向量和物件「UV」或「內部座標」之間的關係,以及它們如何被映射到世界座標。
備註
在 Godot 裡,所有轉換運算都是相對於父節點進行。當我們提到「世界座標」時,其實是相對於節點的父節點(如果有的話)。
如果想要進一步解釋,可以參考 3Blue1Brown 的線性轉換精采影片:https://www.youtube.com/watch?v=kYB8IZa5AuE
轉換的實用應用
在實際專案中,你經常會在多層轉換下運作,也就是多個 Node2D 或 Node3D 互為父子節點。
不過,理解如何手動計算所需的轉換值非常有用。我們會介紹如何用 Transform2D 或 Transform3D 來手動計算節點的轉換。
在轉換之間轉換座標
有很多情境會需要在不同轉換座標系之間進行座標轉換。例如,當你有一個相對於玩家的座標,想求出對應的世界座標(父節點座標),或是已知世界座標,想知道它在玩家座標系下的位置。
我們可以用 * 運算子來取得相對於玩家的向量在世界座標下的位置:
# World space vector 100 units below the player.
print(transform * Vector2(0, 100))
// World space vector 100 units below the player.
GD.Print(Transform * new Vector2(0, 100));
同理,也能用 * 運算子的相反運算來查出一個世界座標對應到玩家座標系的相對位置:
# Where is (0, 100) relative to the player?
print(Vector2(0, 100) * transform)
// Where is (0, 100) relative to the player?
GD.Print(new Vector2(0, 100) * Transform);
備註
如果你已經知道轉換本身位於 (0, 0),可以直接用 basis_xform 或 basis_xform_inv 這類方法,省略掉平移的處理。
相對於自身移動物件
一個常見操作(尤其在 3D 遊戲中)是讓物件相對於自己移動。例如在第一人稱射擊遊戲裡,按下 W 時,角色會沿 -Z 軸向前移動。
由於基底向量表示物件相對於父節點的方向,而 origin 向量代表位置,所以只要將基底向量乘倍數後加到 origin,就能達到相對於自身移動的效果。
以下程式碼會讓物件往自己的右方移動 100 單位:
transform.origin += transform.x * 100
Transform2D t = Transform;
t.Origin += t.X * 100;
Transform = t;
若是在 3D 空間,請將 x 改成 basis.x。
備註
在實務上,3D 可以用 translate_object_local,2D 可以用 move_local_x 與 move_local_y 來達成同樣效果。
轉換疊加
轉換最重要的一點之一,就是你可以將多個轉換互相疊加。父節點的轉換會影響所有子節點。讓我們來分析一個例子。
在這張圖中,子節點的每個分量名稱後面都有個「2」以便和父節點區分。雖然數字很多看起來很雜,其實每個數字都出現在箭頭旁和矩陣裡,幾乎一半的數字其實都是零。
這裡父節點只做了縮放 (2, 1),子節點做了縮放 (0.5, 0.5),兩個節點都有各自的位置設定。
所有子節點的轉換都會受到父節點的影響。子節點縮放 (0.5, 0.5) 看起來是 1:1 的正方形,但這只是在父節點座標系下。在世界座標中,子節點的 X 向量會變成 (1, 0),因為會被父節點的基底向量縮放。同理,子節點 origin 設成 (1, 1),但實際會被移動到 (2, 1),這也是因為父節點的基底向量所致。
要手動算出子節點的世界座標轉換,可用如下程式碼:
# Set up transforms like in the image, except make positions be 100 times bigger.
var parent = Transform2D(Vector2(2, 0), Vector2(0, 1), Vector2(100, 200))
var child = Transform2D(Vector2(0.5, 0), Vector2(0, 0.5), Vector2(100, 100))
# Calculate the child's world space transform
# origin = (2, 0) * 100 + (0, 1) * 100 + (100, 200)
var origin = parent.x * child.origin.x + parent.y * child.origin.y + parent.origin
# basis_x = (2, 0) * 0.5 + (0, 1) * 0
var basis_x = parent.x * child.x.x + parent.y * child.x.y
# basis_y = (2, 0) * 0 + (0, 1) * 0.5
var basis_y = parent.x * child.y.x + parent.y * child.y.y
# Change the node's transform to what we calculated.
transform = Transform2D(basis_x, basis_y, origin)
// Set up transforms like in the image, except make positions be 100 times bigger.
Transform2D parent = new Transform2D(2, 0, 0, 1, 100, 200);
Transform2D child = new Transform2D(0.5f, 0, 0, 0.5f, 100, 100);
// Calculate the child's world space transform
// origin = (2, 0) * 100 + (0, 1) * 100 + (100, 200)
Vector2 origin = parent.X * child.Origin.X + parent.Y * child.Origin.Y + parent.Origin;
// basisX = (2, 0) * 0.5 + (0, 1) * 0 = (0.5, 0)
Vector2 basisX = parent.X * child.X.X + parent.Y * child.X.Y;
// basisY = (2, 0) * 0 + (0, 1) * 0.5 = (0.5, 0)
Vector2 basisY = parent.X * child.Y.X + parent.Y * child.Y.Y;
// Change the node's transform to what we calculated.
Transform = new Transform2D(basisX, basisY, origin);
實務上,可以直接用 * 運算子把一個轉換套用到另一個,求出子節點的世界轉換:
# Set up transforms like in the image, except make positions be 100 times bigger.
var parent = Transform2D(Vector2(2, 0), Vector2(0, 1), Vector2(100, 200))
var child = Transform2D(Vector2(0.5, 0), Vector2(0, 0.5), Vector2(100, 100))
# Change the node's transform to what would be the child's world transform.
transform = parent * child
// Set up transforms like in the image, except make positions be 100 times bigger.
Transform2D parent = new Transform2D(2, 0, 0, 1, 100, 200);
Transform2D child = new Transform2D(0.5f, 0, 0, 0.5f, 100, 100);
// Change the node's transform to what would be the child's world transform.
Transform = parent * child;
備註
矩陣相乘時,順序很重要!不要搞混。
最後,套用單位轉換不會產生任何效果。
如果還想了解更多,可以參考 3Blue1Brown 關於矩陣組合的精彩影片:https://www.youtube.com/watch?v=XkY2DOUCWMU
反轉轉換矩陣
affine_inverse 函數會回傳一個能「反轉」前一個轉換的轉換。這在某些情況下很有用,以下有幾個例子。
將反轉後的轉換和原本的轉換相乘,可以消除所有轉換效果:
var ti = transform.affine_inverse()
var t = ti * transform
# The transform is the identity transform.
Transform2D ti = Transform.AffineInverse();
Transform2D t = ti * Transform;
// The transform is the identity transform.
對座標先做轉換,再做反轉換,結果會回到原本座標:
var ti = transform.affine_inverse()
position = transform * position
position = ti * position
# The position is the same as before.
Transform2D ti = Transform.AffineInverse();
Position = Transform * Position;
Position = ti * Position;
// The position is the same as before.
這些在 3D 空間下怎麼運作?
轉換矩陣最棒的特點之一,就是 2D 和 3D 的原理和用法都很像。上面 2D 所有程式碼與公式,在 3D 幾乎一樣,只有三個差異:多了一個軸(Z),每個軸用 Vector3 表示,而且 Godot 會把 Basis 和 Transform3D 分開存放,這樣設計是因為 3D 的數學運算較複雜。
3D 空間下,平移、旋轉、縮放、剪切等操作的概念和 2D 完全一樣。要縮放,直接將每個分量相乘;要旋轉,改變每個基底向量的指向;要平移,操作 origin;要剪切,讓基底向量不再垂直。
你可以多實驗幾次轉換,體會它們的效果。Godot 允許你在屬性面板中直接編輯 3D 轉換矩陣。你也可以下載這個專案,裡面用彩色線條和立方體幫助你在 2D/3D 下觀察 Basis 向量與原點:https://github.com/godotengine/godot-demo-projects/tree/master/misc/matrix_transform
備註
你無法在 Godot 4.0 的屬性檢視器直接編輯 Node2D 的轉換矩陣。未來 Godot 版本或許會改進這一點。
如果想要更深入了解,建議參考 3Blue1Brown 關於 3D 線性轉換的精彩影片:https://www.youtube.com/watch?v=rHLEWRxRGiM
3D 中的旋轉表示法(進階)
2D 和 3D 轉換矩陣最大的差異在於:如果不依靠基底向量,該如何單獨表達旋轉這個操作。
2D 可以用很簡單的方式(atan2 反三角函數)在矩陣和角度之間轉換。但在 3D,旋轉太複雜,無法用單一數字表示。有一種叫「歐拉角」的方式可以用三個數字來描述旋轉,不過這個方法有很多限制,大多數情況下並不好用,只有很簡單的場景才適合。
在 3D,我們通常不用角度來描述旋轉,而是直接用轉換基底(Godot 幾乎到處用這套)或用四元數(quaternion)。Godot 用 Quaternion 結構來表示四元數。建議你不用深究四元數的底層原理,因為它非常複雜且不直觀。
但如果你真的想深入了解,這裡有幾個不錯的參考資源,建議照順序閱讀:
https://www.youtube.com/watch?v=mvmuCPvRoWQ