向量、坐标与变换
位置、速度、朝向都是向量;一个节点"在哪、朝哪、多大"合起来是一个 Transform2D。本章讲清 Vector2 的常用运算、本地坐标和全局坐标的换算,以及父子节点的变换是怎么一层层叠加的。
前几章一直在说"把节点放到某个位置""让它转起来"。这一章把背后的数学讲清楚。内容不难,只用到中学的向量和三角函数,但它决定了你以后写移动、瞄准、跟随、视野判断这些代码时能不能一次写对。
本章的实践示例是第 1 章出现过的太阳系:太阳 → 地球 → 月亮三层父子节点,每个节点只改自己的 rotation,地球却会绕着太阳公转,月亮还会画出一圈圈的花边轨迹。读完本章你会知道这是为什么。
1. Godot 的 2D 坐标系
先记住三条约定,后面所有公式都建立在它们上面:
- 原点在左上角,x 轴朝右,y 轴朝下。这和数学课上的坐标系(y 朝上)相反,和大多数屏幕、图片的像素坐标一致。
- 单位是像素。
position = Vector2(100, 50)就是往右 100 像素、往下 50 像素(相对于父节点)。 - 角度用弧度,正方向是顺时针。因为 y 轴朝下,数学里的"逆时针为正"在屏幕上看起来就成了顺时针。
rotation = PI / 2会让节点顺时针转 90°。
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 里实际打印出来的:
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 值比较之前,一定要先把两个向量归一化。
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 带符号的夹角,顺时针为正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))
PI / 2(90°)-PI / 2(-90°)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)间隔均匀。
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() 负责换算。下面是几个和旋转相关的常用写法:
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°。
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()也就算不出来。
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) 的结果是?
(110, 0)(100, -10)(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 的参数是"相对于调用者自己"的坐标。
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 是?
(100, 50)(150, 0)(50, 100)
父节点顺时针转了 90°,它的 x 轴指向屏幕下方 (0, 1)。子节点沿父节点的 x 轴走 50,所以在 (100, 0) + 50·(0, 1) = (100, 50)。
8. 实践:太阳系里的变换继承
现在回到太阳系示例。它的场景树是 Solar → Sun → Earth → Moon,三个天体用的是同一个脚本 celestial.gd。每个天体每帧只做一件事——改自己的 rotation:
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:
@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 轴,你可以看到它们跟着节点一起转。
- 把太阳自转拖到 0:地球停止公转,但还在自转,月亮照样绕着地球转。公转完全来自父节点的旋转。
- 把太阳自转恢复为 20,再把地球自转拖到 -20:地球自转刚好抵消了太阳的旋转,月亮相对屏幕的方向不再变化,月亮的轨迹变成和地球轨道一样大的一个圆,只是整体平移了 58 像素。
- 把地球自转拖到 360:月亮绕地球转得很快,轨迹出现一圈圈的小环。这类曲线叫外摆线,就是两个旋转叠加的结果。
- 把太阳缩放拖到 1.5:太阳变大,同时地球、月亮也变大,轨道也变宽。地球的
position数值仍然是(190, 0),但全局距离变成了 285。缩放和旋转一样,会传给所有子孙节点。
不继承变换:top_level
有时候你希望某个子节点不要跟着父节点动。比如角色发射的子弹:放在角色下面方便管理,但发射后不应该跟着角色转。CanvasItem 的 top_level 属性就是做这个的:设为 true 后,节点的变换直接相对于画布,不再乘父节点的全局变换。
## 打开后变成 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 只切断了地球和太阳之间的继承,地球和月亮之间的父子关系没变。取消勾选后,地球从当前位置接着公转。to_local 的一个实际用途
地球和月亮背对太阳的半边画成了夜晚。阴影要始终背对太阳,但 _draw() 里的坐标是本地坐标,而地球自己在转。解决办法是把太阳的全局位置用 to_local() 换算到地球的本地坐标系,再取反方向:
# 背对太阳的半边画成夜晚。太阳的方向要换算到本地坐标,因为本节点自己在转。
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可以切断继承。