朝向不等於位置:遊戲裡的向量、內積、叉積與座標系
飛船要決定開不開火、往哪邊轉,得先知道目標在不在前方、在左還是右、有沒有超過射程。這幾件事各對應一個向量運算,內積給角度,2D 叉積給左右,長度給距離。再加上座標轉換,砲口才會跟著船身一起轉;撞牆時把速度拆成兩個方向,船才會沿著牆滑過去。
公式本身不難,容易弄混的是每個量代表什麼。位置、位移、方向和速度在程式裡都是兩個浮點數,意思卻完全不同。「用數學描述遊戲世界」系列共用一個虛構的太空採礦遊戲,第一篇就從這艘船的空間運算開始。
四種長得一樣的量
在 2D 遊戲裡,下面四個東西都是 (x, y):
- 位置:某個點在世界裡的座標,例如船在
(10, 5)。 - 位移:從一點到另一點的差,
目標位置 - 船的位置,帶方向也帶距離。 - 方向:長度為 1 的向量,只說「往哪邊」,不說多遠。
- 速度:每秒的位移,長度是速率。
這個區分在程式裡沒有型別幫你擋。位置加位置沒有意義,位置減位置得到位移,位移除以自己的長度得到方向,混著用不會報錯,船卻可能往奇怪的地方飛。後面做座標轉換時,位置和方向也得分開處理,轉成世界座標用的式子不同。
內積:目標在不在視野內
兩個向量的內積 a·b = ax*bx + ay*by,等於兩者長度相乘再乘上夾角的 cos。當兩邊都是長度為 1 的方向,內積就是夾角的 cos:1 是正前方,0 是正側面,-1 是正後方。
所以視野判定不需要算角度。船的朝向 forward 和「船指向目標的方向」做內積,跟視野半角的 cos 比較就好。Godot 的向量教學用殭屍看玩家當例子,判斷式就是內積大於 0(夾角小於 90°)。cos 可以先算好存著,迴圈裡只剩乘法和加法。
內積的另一個用法是投影。把一個向量和某個方向做內積,得到它在那個方向上的分量。接近速度就是這樣算的:兩船的相對速度投影到連線方向上,正值代表距離在縮短,負值代表在拉開。
2D 叉積:往左轉還是往右轉
3D 的叉積會得到一個垂直於兩者的向量;在 2D 只取它的 z 分量,a×b = ax*by - ay*bx。正負號告訴你 b 在 a 的哪一邊:在 y 軸朝上的座標系裡,正值是 b 在 a 的左邊(逆時針),負值是右邊。
決定轉向時,目標在左就逆時針轉,在右就順時針轉;叉積剛好是 0,代表目標在正前方或正後方,要另外處理,不然浮點誤差會讓船在兩個方向之間來回抖。螢幕座標常常 y 軸朝下,左右也會跟著顛倒,同一個專案裡要先講好用哪一種。
局部座標與世界座標
砲口裝在船頭右前方,在船自己的座標系裡是 (4, 1),砲管朝向是 (1, 0)。船轉了 90° 又移到 (10, 5) 之後,要知道砲口在世界裡的哪裡、子彈往哪飛。
轉換點的座標,要先旋轉再加上船的位置;方向只旋轉,不加位置,因為它沒有「在哪裡」。Unity 把這兩件事分成兩個 API:TransformPoint 處理位置,TransformDirection 處理方向,文件寫明後者不受 transform 的位置與縮放影響,回傳的向量長度不變。把方向也當成點來轉,算出的朝向就會有錯。
撞牆後沿牆滑動
船以 (20, 0) 撞上一面 45° 的牆,牆的法向量是 n(長度 1,指向牆外)。如果撞到就把速度歸零,玩家會覺得船被黏住。比較好的做法是只拿掉往牆裡面的那一份:
v_into = (v·n) n
v_slide = v - v_into
v·n 是負的代表正在往牆裡衝,減掉這一份之後剩下沿著牆面的分量。反彈則是減兩次,v - 2(v·n)n,Godot 的 bounce() 做的就是這件事。
零向量不能正規化
正規化是除以長度,長度是 0 就沒有方向可言。目標和船在同一點、搖桿沒推、兩個物件剛好重疊,都會得到零向量。Godot 文件特別寫了 GDScript 的 normalized() 遇到零向量會原樣回傳,不會報錯;其他引擎或自己寫的版本可能回傳 NaN,之後所有跟它運算的值都會變成 NaN。比較穩的做法是讓呼叫端決定「沒有方向」時該怎麼辦,例如保持原本的朝向。
範例
把這些運算放進同一個小程式,船在原點,朝向 +x,視野 60°,射程 100。下方程式與輸出使用 Python 3.13.16:
import math
import sys
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class V2:
x: float
y: float
def __add__(self, o): return V2(self.x + o.x, self.y + o.y)
def __sub__(self, o): return V2(self.x - o.x, self.y - o.y)
def __mul__(self, k): return V2(self.x * k, self.y * k)
def dot(self, o): return self.x * o.x + self.y * o.y
def cross(self, o): return self.x * o.y - self.y * o.x # 2D: z component only
def length(self): return math.hypot(self.x, self.y)
def normalized(self, eps=1e-9):
n = self.length()
if n < eps:
return None # caller decides what "no direction" means
return V2(self.x / n, self.y / n)
def __repr__(self): return f"({self.x:.2f}, {self.y:.2f})"
def heading(deg):
r = math.radians(deg)
return V2(math.cos(r), math.sin(r))
# The ship sits at the origin, nose pointing along +x (heading 0 deg).
ship_pos, ship_fwd = V2(0, 0), heading(0)
FOV_HALF = 30 # 60 deg cone
RANGE = 100
cos_half = math.cos(math.radians(FOV_HALF))
print("== 1. in front, to the side, or out of range ==")
for name, target in [("A", V2(80, 20)), ("B", V2(40, 60)),
("C", V2(-50, 5)), ("D", V2(150, 0))]:
to_t = target - ship_pos
dist = to_t.length()
d = to_t.normalized()
forward = ship_fwd.dot(d) # cos of the angle
c = ship_fwd.cross(d)
side = "left" if c > 1e-6 else "right" if c < -1e-6 else "none"
in_cone = forward >= cos_half and dist <= RANGE
print(f"{name} {target}: dist={dist:6.1f} cos={forward:+.3f} "
f"angle={math.degrees(math.acos(forward)):5.1f} turn={side:5} in_cone={in_cone}")
print("\n== 2. closing speed: project relative velocity onto line of sight ==")
ship_vel = V2(30, 0)
for name, pos, vel in [("rock", V2(100, 0), V2(-10, 0)),
("drone", V2(100, 0), V2(0, 40)),
("probe", V2(100, 0), V2(35, 0))]:
los = (pos - ship_pos).normalized()
closing = (ship_vel - vel).dot(los) # >0 means the gap is shrinking
print(f"{name:5}: closing speed {closing:+6.1f} m/s")
print("\n== 3. local to world: a point and a direction transform differently ==")
def to_world_point(local, pos, angle_deg):
c, s = math.cos(math.radians(angle_deg)), math.sin(math.radians(angle_deg))
return V2(local.x * c - local.y * s, local.x * s + local.y * c) + pos
def to_world_dir(local, angle_deg):
c, s = math.cos(math.radians(angle_deg)), math.sin(math.radians(angle_deg))
return V2(local.x * c - local.y * s, local.x * s + local.y * c) # no translation
muzzle_local = V2(4, 1) # gun barrel, in ship coordinates
muzzle_dir_local = V2(1, 0)
for pos, ang in [(V2(0, 0), 0), (V2(10, 5), 90), (V2(10, 5), 135)]:
print(f"ship at {pos} heading {ang:3d}: muzzle point {to_world_point(muzzle_local, pos, ang)}"
f" muzzle dir {to_world_dir(muzzle_dir_local, ang)}"
f" wrong dir (translated) {to_world_point(muzzle_dir_local, pos, ang)}")
print("\n== 4. hitting a wall: keep the tangential part ==")
wall_normal = V2(-1, 1).normalized() # a 45 deg wall
v = V2(20, 0)
into = v.dot(wall_normal)
slide = v - wall_normal * into if into < 0 else v
print(f"v={v} n={wall_normal} v.n={into:.2f} -> slide={slide} |slide|={slide.length():.2f}")
print("\n== 5. zero vector ==")
print("normalized((0,0)) ->", V2(0, 0).normalized())
print(f"Python {sys.version.split()[0]}")
== 1. in front, to the side, or out of range ==
A (80.00, 20.00): dist= 82.5 cos=+0.970 angle= 14.0 turn=left in_cone=True
B (40.00, 60.00): dist= 72.1 cos=+0.555 angle= 56.3 turn=left in_cone=False
C (-50.00, 5.00): dist= 50.2 cos=-0.995 angle=174.3 turn=left in_cone=False
D (150.00, 0.00): dist= 150.0 cos=+1.000 angle= 0.0 turn=none in_cone=False
== 2. closing speed: project relative velocity onto line of sight ==
rock : closing speed +40.0 m/s
drone: closing speed +30.0 m/s
probe: closing speed -5.0 m/s
== 3. local to world: a point and a direction transform differently ==
ship at (0.00, 0.00) heading 0: muzzle point (4.00, 1.00) muzzle dir (1.00, 0.00) wrong dir (translated) (1.00, 0.00)
ship at (10.00, 5.00) heading 90: muzzle point (9.00, 9.00) muzzle dir (0.00, 1.00) wrong dir (translated) (10.00, 6.00)
ship at (10.00, 5.00) heading 135: muzzle point (6.46, 7.12) muzzle dir (-0.71, 0.71) wrong dir (translated) (9.29, 5.71)
== 4. hitting a wall: keep the tangential part ==
v=(20.00, 0.00) n=(-0.71, 0.71) v.n=-14.14 -> slide=(10.00, 10.00) |slide|=14.14
== 5. zero vector ==
normalized((0,0)) -> None
Python 3.13.16
B 在射程內,但夾角 56.3° 超過 30° 的半角,不在視野裡;D 在正前方,距離 150 卻超過射程。視野判定要同時看角度和距離,兩個條件各自來自不同的運算。D 也剛好示範了轉向判斷的邊界:叉積是 0,結果是 none,如果只寫 > 0 判斷左右,正前方的目標就會算到右邊,讓船多轉一下。
接近速度那一段,drone 的速度完全垂直於連線,對接近速度沒有貢獻,算出來的 30 m/s 全部來自自己的船速。probe 跑得比船快,接近速度是負的,距離在拉開。
座標轉換則要留意第一組結果:船在原點、朝向 0° 時,把方向當成點來轉也會得到正確答案,因為加上的位置剛好是 0。只在原點測試會漏掉這個錯誤,把船移開才看得到。
撞牆後速率從 20 剩 14.14,方向沿著牆面。拿掉的是法向分量,長度也是 14.14;兩個分量互相垂直,要用畢氏定理算:√(20² - 14.14²) ≈ 14.14。至於這些位置與速度如何隨時間更新,下一篇〈同一艘船在 30 FPS 和 144 FPS 開起來不一樣:積分方法與固定步長〉會接著處理。
補充筆記
- 位置減位置得到位移,位移除以長度得到方向,速度則表示每秒的位移。這些量在程式裡長得一樣,用途仍要分清楚;轉換時,位置要旋轉再平移,方向只旋轉,只在原點測試會漏掉錯誤。
- 視野判定可以直接比較兩個單位向量的內積與預先算好的 cos,因為內積就是夾角的 cos,不必再算角度。投影到某個方向也用內積。
- 2D 叉積的正負號給左右,等於 0 時要另外處理,螢幕座標 y 軸朝下時左右會顛倒。
- 撞牆後要沿牆滑動,就減掉速度在法向量上的分量;要反彈則減兩次。
- 正規化前先檢查零向量,它沒有方向,後續怎麼處理由呼叫端決定。
延伸閱讀
- Godot Engine〈Vector math〉:內積做視野判定、零向量不能正規化、
bounce()的反射公式,官方教學文件。 - Unity〈Transform.TransformDirection〉:方向轉換不受位置與縮放影響,與
TransformPoint的分工,官方 API 文件。 - Wikipedia〈Dot product〉與〈Cross product〉:定義與幾何意義,百科條目,適合查公式,不作為引擎行為的依據。
- Adams、Dormans《Game Mechanics: Advanced Game Design》第 1 章:物理機制與經濟機制、連續與離散機制的區分,遊戲設計教科書,這個系列後面幾篇也會用到。
想法與技術判斷出自 Sheng,和 Claude 一起起草 · 範例在 Python 3.13.16 實測。