GameTech/Godot 4.7 互动教程
目录示例游戏
第一部分 · 入门 · 第 5 章

向量、坐标与变换

位置、速度、朝向都是向量;一个节点"在哪、朝哪、多大"合起来是一个 Transform2D。本章讲清 Vector2 的常用运算、本地坐标和全局坐标的换算,以及父子节点的变换是怎么一层层叠加的。

前几章一直在说"把节点放到某个位置""让它转起来"。这一章把背后的数学讲清楚。内容不难,只用到中学的向量和三角函数,但它决定了你以后写移动、瞄准、跟随、视野判断这些代码时能不能一次写对。

本章的实践示例是第 1 章出现过的太阳系:太阳 → 地球 → 月亮三层父子节点,每个节点只改自己的 rotation,地球却会绕着太阳公转,月亮还会画出一圈圈的花边轨迹。读完本章你会知道这是为什么。

1. Godot 的 2D 坐标系

先记住三条约定,后面所有公式都建立在它们上面:

  • 原点在左上角,x 轴朝右,y 轴朝下。这和数学课上的坐标系(y 朝上)相反,和大多数屏幕、图片的像素坐标一致。
  • 单位是像素。position = Vector2(100, 50) 就是往右 100 像素、往下 50 像素(相对于父节点)。
  • 角度用弧度,正方向是顺时针。因为 y 轴朝下,数学里的"逆时针为正"在屏幕上看起来就成了顺时针。rotation = PI / 2 会让节点顺时针转 90°。
常见坑:y 轴朝下。从数学公式或者别的引擎(比如 Unity 2D 的 y 朝上)搬代码过来时,跳跃速度、重力、角度的符号都要反过来想:往上跳是 velocity.y 为负,重力让 velocity.y 增大;Vector2.UP 是 (0, -1),Vector2.DOWN 是 (0, 1)。

2. Vector2:既是位置,也是方向

一个 Vector2 就是两个浮点数 x、y。同一个类型有两种用法:

  • 当位置用:从原点出发,到某个点的位移。position、global_position、鼠标坐标都是这种。
  • 当方向/速度用:只关心"往哪边、有多长",不关心从哪里出发。velocity、"从 A 指向 B 的方向"都是这种。

两者可以混合运算:位置 + 速度 * 时间 = 新位置,这就是几乎所有移动代码的核心。下面是最常用的几个运算,注释里的数值是在 Godot 4.7 里实际打印出来的:

snippets/vectors_math.gd第 7–15 行加减、数乘、长度、归一化在工程中查看 ↗
func vector_basics() -> void:
	var a := Vector2(3, 4)
	var b := Vector2(2, -1)
	print(a + b)              # (5.0, 3.0)    逐个分量相加
	print(a * 2)              # (6.0, 8.0)    数乘:方向不变,长度翻倍
	print(a.length())         # 5.0           勾股定理 √(3² + 4²)
	print(a.normalized())     # (0.6, 0.8)    方向不变,长度变成 1
	print(a.distance_to(b))   # 5.099...      两点之间的距离 √26
	print(a.direction_to(b))  # 从 a 指向 b 的单位向量,等于 (b - a).normalized()
写法含义公式 / 说明
a + b、a - b逐个分量加减b - a 是"从 a 指向 b"的向量
a * k数乘方向不变,长度乘 k;k 为负时方向反过来
a.length()长度√(x² + y²)。只比较远近时用 length_squared(),省一次开方
a.normalized()单位向量a / |a|,长度变成 1、方向不变。零向量归一化后还是零向量
a.distance_to(b)两点距离等于 (b - a).length()
a.direction_to(b)指向 b 的方向等于 (b - a).normalized()
a.limit_length(m)限制最大长度常用来限制速度上限

"方向 × 速度大小"这个组合要特别熟悉:velocity = direction.normalized() * speed。如果不先归一化,斜着走时方向向量是 (1, 1),长度约 1.414,角色斜向移动会比横向快 41%。这是新手最常见的 bug 之一。

3. 点积和叉积:判断"朝向"和"左右"

为什么需要它们?游戏里经常要回答两类问题:

  • 敌人在我前面还是后面?在不在我的视野锥里?两个方向有多"一致"?——用点积。
  • 目标在我左边还是右边?我应该顺时针转还是逆时针转?——用叉积。

点积 dot

公式:a·b = a.x·b.x + a.y·b.y = |a|·|b|·cos θ,θ 是两个向量的夹角。

第二个等号是关键:如果 a、b 都是单位向量,点积就直接等于 cos θ。夹角 0° 时是 1,90° 时是 0,180° 时是 -1。所以"敌人在不在我前方 60° 视野内"只需要一次点积和一次比较,不用算任何角度。

点积还能算投影:a 在 b 方向上的"影子"长度是 a.dot(b.normalized()),影子向量是 a.project(b)。把一个速度分解成"沿着斜坡"和"垂直斜坡"两部分,用的就是投影。

叉积 cross

2D 叉积的结果是一个数:a.cross(b) = a.x·b.y - a.y·b.x = |a|·|b|·sin θ。

它的正负号告诉你 b 在 a 的哪一侧。在 Godot 的坐标系里(y 朝下),叉积大于 0 表示从 a 转到 b 是屏幕上的顺时针方向。它的绝对值等于 a、b 张成的平行四边形面积。a.angle_to(b) 内部就是用 atan2(叉积, 点积) 算出的带符号夹角。

在游乐场里试试这几件事:把 b 拖到和 a 垂直的位置,点积变成 0;把 b 拖到 a 的另一侧,叉积变号、平行四边形变色;把 b 拖得很长,点积和叉积都跟着变大——所以拿点积和 cos 值比较之前,一定要先把两个向量归一化。

snippets/vectors_math.gd第 20–28 行视野判断和左右判断在工程中查看 ↗
func dot_and_cross() -> void:
	var facing := Vector2.RIGHT            # 角色朝右
	var to_enemy := Vector2(1, 1).normalized()
	var d := facing.dot(to_enemy)          # 两个单位向量的点积 = cos(夹角)
	print(d)                               # 0.707...  夹角 45°
	print(d > cos(deg_to_rad(60)))         # true      敌人在 ±60° 的视野内
	var c := facing.cross(to_enemy)        # 2D 叉积是一个数:x1*y2 - y1*x2
	print(c > 0)                           # true      在 Godot 里(y 轴朝下)表示"在右手边/顺时针方向"
	print(rad_to_deg(facing.angle_to(to_enemy)))  # 45.0  带符号的夹角,顺时针为正
snippets/vectors_math.gd第 33–40 行投影:把一个向量分解成平行和垂直两部分在工程中查看 ↗
func projection() -> void:
	var v := Vector2(4, 3)
	var ground := Vector2(1, 0.5)
	var along := v.project(ground)         # v 在 ground 方向上的分量
	var rest := v - along                  # 剩下的部分和 ground 垂直
	print(along, rest)
	print(is_zero_approx(rest.dot(ground)))   # true
	print(v.slide(ground.orthogonal().normalized()))  # 沿着法线为 n 的平面"滑动",也就是去掉 n 方向的分量

Vector2.RIGHT.angle_to(Vector2.UP) 的结果是多少?(Vector2.UP 是 (0, -1))

  1. PI / 2(90°)
  2. -PI / 2(-90°)
  3. 3 * PI / 2(270°)

叉积 = 1×(-1) - 0×0 = -1,点积 = 0,atan2(-1, 0) = -PI/2。屏幕上从"右"转到"上"是逆时针,Godot 里逆时针为负。angle_to 的结果总在 -PI 到 PI 之间,不会给出 270°。

4. 插值:lerp 和 move_toward

"从 A 移动到 B"有两种完全不同的写法,效果差别很大:

  • a.lerp(b, t):按比例。结果是 a + (b - a) * t,t = 0 得到 a,t = 1 得到 b,t = 0.5 是正中间。
  • a.move_toward(b, delta):按距离。朝 b 走 delta 这么远,如果剩下的距离不到 delta,就直接停在 b,不会冲过头。

当它们每帧都被调用时:每帧 lerp(目标, 固定比例) 每次都走剩余距离的一部分,所以越接近越慢,形成"缓出"的平滑跟随,理论上永远差一点点才到;每帧 move_toward(目标, 速度 * delta) 是匀速运动,准时到达。前者适合摄像机跟随、UI 动画,后者适合角色移动、加减速(平台跳跃示例里角色的水平速度就是用 move_toward 加速和减速的)。

在上面游乐场切到"lerp 与 move_toward"模式,点"播放":上排的点(lerp)一开始间隔很大,后来越来越密;下排的点(move_toward)间隔均匀。

snippets/vectors_math.gd第 45–52 行两种插值,以及帧率无关的平滑跟随在工程中查看 ↗
func interpolate(delta: float) -> void:
	var from := Vector2(0, 0)
	var to := Vector2(100, 0)
	print(from.lerp(to, 0.25))             # (25.0, 0.0)   按比例插值:走 25%
	print(from.move_toward(to, 30))        # (30.0, 0.0)   按距离走:最多走 30
	print(from.move_toward(to, 500))       # (100.0, 0.0)  不会冲过头
	# 帧率无关的"平滑跟随":每帧都 lerp 一点,系数用指数算
	position = position.lerp(to, 1.0 - exp(-10.0 * delta))
每帧写 position = position.lerp(target, 0.1) 的效果跟帧率有关:60 FPS 时每秒执行 60 次,30 FPS 时只有 30 次,跟随速度会差一倍。改成 lerp(target, 1.0 - exp(-k * delta)) 就和帧率无关了,k 越大跟得越紧。

5. 弧度制和旋转

Godot 里所有角度相关的 API 默认都用弧度:一整圈是 TAU(2π ≈ 6.283),半圈是 PI,90° 是 PI / 2。用弧度的原因是三角函数 sin、cos 本来就接受弧度,省去换算。

如果你更习惯角度,Node2D 提供了 rotation_degrees 属性,全局函数 deg_to_rad() 和 rad_to_deg() 负责换算。下面是几个和旋转相关的常用写法:

snippets/vectors_math.gd第 57–64 行弧度、角度、旋转向量在工程中查看 ↗
func angles() -> void:
	rotation = deg_to_rad(90)              # rotation 用弧度
	rotation_degrees = 90                  # 或者直接用角度版的属性,两行效果一样
	print(rotation)                        # 1.5707...  即 PI / 2
	print(Vector2.RIGHT.rotated(PI / 2))   # (0.0, 1.0)  右转 90° 指向下方(y 轴朝下)
	print(Vector2.from_angle(PI))          # (-1.0, 0.0)
	print(rad_to_deg(Vector2(0, -1).angle()))   # -90.0  指向屏幕上方
	print(rad_to_deg(angle_difference(deg_to_rad(350), deg_to_rad(10))))  # 20.0  最短的转向角

其中 Vector2.from_angle(θ) 得到 (cos θ, sin θ),也就是"朝向 θ 的单位向量";反过来 v.angle() 得到向量相对 x 轴的角度。angle_difference(a, b) 返回从 a 转到 b 的最短角度(结果在 -PI 到 PI 之间),做"慢慢转向目标"时比直接相减可靠:从 350° 转到 10°,直接相减是 -340°,最短路径其实是 +20°。

从 Godot 3 迁移时的改名。下面这些在 Godot 4 里已经不存在了,搬旧教程代码时要换掉:deg2rad → deg_to_rad,rad2deg → rad_to_deg,Vector2.linear_interpolate → lerp,Vector2.clamped → limit_length,Vector2.tangent → orthogonal,Transform2D.xform(v) → transform * v,xform_inv(v) → transform.affine_inverse() * v。

6. Transform2D:两根基向量加一个原点

一个 Node2D 有 position、rotation、scale、skew 四个属性。它们合起来是一个 Transform2D,可以通过 transform 属性读写。为什么要有这个合起来的东西?因为"先缩放、再旋转、再平移"这一串操作,以及父子节点之间的层层叠加,用矩阵表示后都变成了简单的乘法。

Transform2D 内部只存三个 Vector2:

  • x:本地坐标系的 x 轴,在父坐标系里指向哪里、有多长;
  • y:本地坐标系的 y 轴;
  • origin:本地原点在父坐标系里的位置,就是 position。

把一个本地坐标为 P 的点变换到父坐标系,公式是:

T * P = origin + P.x · x + P.y · y

意思很直白:从 origin 出发,沿着 x 轴走 P.x 步,再沿着 y 轴走 P.y 步。旋转 θ、缩放 (sx, sy) 的节点,它的两根基向量是 x = (sx·cos θ, sx·sin θ)、y = (-sy·sin θ, sy·cos θ)。没有旋转缩放时,x = (1, 0)、y = (0, 1),公式退化成 origin + P,就是普通的平移。

在小部件里多试几次,可以直观地得到这些结论:

  • 两根基向量等长且互相垂直时,图形只是被旋转和整体缩放,不会变形。rotation 就是 x 基向量的角度:atan2(x.y, x.x)。
  • 两根基向量不垂直时,图形被斜切(skew)。点"斜切"预设看看。
  • 把 x 基向量拖到反方向("水平翻转"预设),行列式变成负数,图形被镜像了。Sprite2D 的 flip_h 不改变换,只改贴图;而把 scale.x 设成 -1 会翻转整个节点,连同它的子节点一起。
  • 行列式 x.x·y.y - y.x·x.y 是面积放大倍数。它为 0 时图形被压成一条线,矩阵没有逆,这时 to_local() 也就算不出来。
snippets/vectors_math.gd第 69–78 行构造 Transform2D、变换点、变换方向、求逆在工程中查看 ↗
func transform_basics() -> void:
	# Transform2D 就是两根基向量 x、y 加一个原点 origin
	var t := Transform2D(Vector2(2, 0), Vector2(0, 1), Vector2(100, 50))
	print(t * Vector2(10, 10))             # (120.0, 60.0)  = origin + 10*x + 10*y
	print(t.basis_xform(Vector2(10, 10)))  # (20.0, 10.0)   只用基向量,不加原点:变换"方向"用它
	print(t.affine_inverse() * Vector2(120, 60))   # (10.0, 10.0)  逆变换:从外面换算回来
	# 由旋转、缩放、平移组合出来的变换,和直接写基向量是同一个东西
	var r := Transform2D(PI / 2, Vector2(100, 50))
	print(r.x, r.y)                        # x 基向量 ≈ (0, 1),y 基向量 ≈ (-1, 0)
	print(r.get_rotation(), r.get_scale(), r.origin)

注意 basis_xform:它只乘基向量、不加原点,用来变换"方向"而不是"位置"。比如把一个本地的速度方向换算到父坐标系,就不应该加上 origin。

Transform2D(Vector2(0, 1), Vector2(-1, 0), Vector2(100, 0)) * Vector2(10, 0) 的结果是?

  1. (110, 0)
  2. (100, -10)
  3. (100, 10)

origin + 10·x + 0·y = (100, 0) + 10·(0, 1) = (100, 10)。这个变换的 x 基向量指向屏幕下方,相当于顺时针旋转了 90°。

7. 本地坐标、全局坐标和父子叠加

每个 Node2D 的 position、rotation、scale 都是相对于父节点的,统称本地变换(transform)。节点在整个画面里的真实位置是全局变换(global_transform),它是从根节点一路乘下来的:

child.global_transform = parent.global_transform * child.transform

矩阵乘法的顺序不能换。读法是从右往左:先用子节点自己的变换把点放进父坐标系,再用父节点的全局变换把它放进世界。反过来乘相当于先做父节点的旋转缩放、再做子节点的,结果完全不同。下面这个小部件可以对比两种顺序:

Godot 为这种换算准备了现成的方法,不用自己乘矩阵:

  • node.to_global(p):把以 node 为参照的本地坐标 p 换算成全局坐标;
  • node.to_local(g):把全局坐标 g 换算成以 node 为参照的本地坐标;
  • global_position、global_rotation、global_scale:直接读写全局值,赋值时 Godot 会反算出合适的本地值。

一个容易混淆的点:child.global_position 等于 parent.to_global(child.position),而不是 child.to_global(child.position)。to_global 的参数是"相对于调用者自己"的坐标。

snippets/vectors_math.gd第 83–97 行验证 global_transform = 父的全局变换 * 子的本地变换在工程中查看 ↗
func parent_child() -> void:
	var parent := Node2D.new()
	var child := Node2D.new()
	parent.add_child(child)
	add_child(parent)                     # 这里假设当前节点自己在原点、没有旋转和缩放
	parent.position = Vector2(200, 100)
	parent.rotation = PI / 2
	parent.scale = Vector2(2, 2)
	child.position = Vector2(30, 0)
	# 子节点的全局变换 = 父节点的全局变换 * 子节点的本地变换
	print(child.global_transform == parent.global_transform * child.transform)  # true
	print(child.global_position)                        # (200.0, 160.0):先缩放、再旋转、再平移
	print(parent.to_global(Vector2(30, 0)))             # 和上一行一样
	print(parent.to_local(Vector2(200, 160)))           # (30.0, 0.0):反过来换算
	parent.queue_free()

父节点在 (100, 0),rotation = PI / 2,没有缩放;子节点 position = (50, 0)。子节点的 global_position 是?

  1. (100, 50)
  2. (150, 0)
  3. (50, 100)

父节点顺时针转了 90°,它的 x 轴指向屏幕下方 (0, 1)。子节点沿父节点的 x 轴走 50,所以在 (100, 0) + 50·(0, 1) = (100, 50)。

8. 实践:太阳系里的变换继承

现在回到太阳系示例。它的场景树是 Solar → Sun → Earth → Moon,三个天体用的是同一个脚本 celestial.gd。每个天体每帧只做一件事——改自己的 rotation:

solar/celestial.gd第 56–58 行每个天体只转自己在工程中查看 ↗
func _process(delta: float) -> void:
	rotation += deg_to_rad(spin_speed) * delta   # 只改自己的 rotation,子节点自动跟着转
	queue_redraw()                               # 背光的一面要始终背对太阳,所以每帧重画

地球的 position 固定是 (190, 0),脚本从来不改它。但因为地球是太阳的子节点,太阳一转,太阳的 x 基向量跟着转,地球的全局位置 = 太阳的全局变换 * (190, 0) 就在一个半径 190 的圆上移动——这就是公转。同理,月亮的 position 固定是 (58, 0),它的全局变换要乘上太阳和地球两层旋转。

轨道半径和缩放都是普通的 @export 属性,setter 里直接改 position 和 scale:

solar/celestial.gd第 12–22 行轨道半径就是本地 position 的长度在工程中查看 ↗
@export var spin_speed := 20.0          ## 自转速度(度/秒)
@export var distance := 0.0:            ## 离父节点原点多远,也就是本地坐标 position 的长度
	set(value):
		distance = value
		if kind != Kind.SUN:
			var dir := position.normalized() if position != Vector2.ZERO else Vector2.RIGHT
			position = dir * distance
@export var body_scale := 1.0:          ## 缩放。子节点会一起变大变小
	set(value):
		body_scale = value
		scale = Vector2(value, value)

运行后注意右侧"实时数据":地球 position 始终是 (190, 0),地球 global_position 一直在变。这两行数据就是本章的核心——本地变换不变,全局变换由父节点决定。每个天体身上的红绿箭头是它自己的本地 x、y 轴,你可以看到它们跟着节点一起转。

动手试试
  1. 把太阳自转拖到 0:地球停止公转,但还在自转,月亮照样绕着地球转。公转完全来自父节点的旋转。
  2. 把太阳自转恢复为 20,再把地球自转拖到 -20:地球自转刚好抵消了太阳的旋转,月亮相对屏幕的方向不再变化,月亮的轨迹变成和地球轨道一样大的一个圆,只是整体平移了 58 像素。
  3. 把地球自转拖到 360:月亮绕地球转得很快,轨迹出现一圈圈的小环。这类曲线叫外摆线,就是两个旋转叠加的结果。
  4. 把太阳缩放拖到 1.5:太阳变大,同时地球、月亮也变大,轨道也变宽。地球的 position 数值仍然是 (190, 0),但全局距离变成了 285。缩放和旋转一样,会传给所有子孙节点。

不继承变换:top_level

有时候你希望某个子节点不要跟着父节点动。比如角色发射的子弹:放在角色下面方便管理,但发射后不应该跟着角色转。CanvasItem 的 top_level 属性就是做这个的:设为 true 后,节点的变换直接相对于画布,不再乘父节点的全局变换。

solar/celestial.gd第 31–39 行切换 top_level 时保持屏幕位置不跳变在工程中查看 ↗
## 打开后变成 top_level:不再继承父节点的变换,停在原地,但自己的子节点照样跟着它。
@export var detach := false:
	set(value):
		if value == top_level:
			return
		var keep := global_transform     # 先记住现在在屏幕上的样子
		top_level = value
		global_transform = keep          # 切换后把全局变换还原,避免"瞬移"
		detach = value

注意这里先把 global_transform 存下来,切换后再赋回去。如果直接改 top_level,原来的本地值 (190, 0) 会被当成全局坐标,地球会一下跳到屏幕左上角附近。

动手试试:勾选地球 top_level。地球停在原地不再公转,但自己还在转,月亮仍然绕着它——top_level 只切断了地球和太阳之间的继承,地球和月亮之间的父子关系没变。取消勾选后,地球从当前位置接着公转。

to_local 的一个实际用途

地球和月亮背对太阳的半边画成了夜晚。阴影要始终背对太阳,但 _draw() 里的坐标是本地坐标,而地球自己在转。解决办法是把太阳的全局位置用 to_local() 换算到地球的本地坐标系,再取反方向:

solar/celestial.gd第 90–99 行把太阳的方向换算到本地坐标在工程中查看 ↗
# 背对太阳的半边画成夜晚。太阳的方向要换算到本地坐标,因为本节点自己在转。
var sun := get_tree().get_first_node_in_group("sun") as Node2D
if sun != null:
	var away := -to_local(sun.global_position).normalized()
	var a := away.angle()
	var pts := PackedVector2Array()
	for i in 25:
		var t := a - PI * 0.5 + PI * i / 24.0
		pts.append(Vector2(cos(t), sin(t)) * (radius + 0.5))
	draw_colored_polygon(pts, Color(0.02, 0.03, 0.08, 0.62))

这是 to_local 最典型的用法:拿到一个世界里的目标点,问"它在我自己的坐标系里是哪个方向"。同样的写法可以用来做炮台瞄准、判断敌人在角色左边还是右边。如果只需要角度,Node2D 还有 get_angle_to(point) 和 look_at(point) 两个现成的方法。

常见坑:在错误的坐标系里比较位置。两个节点的 position 只有在它们是同一个父节点的子节点时才能直接相减。比较不同分支上的节点,要用 global_position,或者先用 to_local 换算到同一个坐标系。另一个相关的坑:在 _draw() 里画东西用的是本地坐标,传入 global_position 会画到奇怪的地方。

小结

  • Godot 2D:原点左上、y 朝下、单位像素、弧度、顺时针为正。
  • 点积看"同向程度"和投影,叉积看"在左还是在右";比较之前先归一化。
  • lerp 按比例、move_toward 按距离;每帧调用时前者减速逼近,后者匀速到达。
  • Transform2D = x 基向量 + y 基向量 + origin,T * P = origin + P.x·x + P.y·y。
  • 全局变换 = 父的全局变换 × 自己的本地变换,顺序不能反。to_global / to_local 负责换算,top_level 可以切断继承。