概述
在Godot中,乃至一切游戏编程中,你应该都躲不开向量。这是每一个初学者都应该知道和掌握的内容,否则你将很难理解和实现某些其实原理非常简单的东西。
估计很多刚入坑Godot的小伙伴和我一样,不一定是计算机专业或编程相关专业从业人员。英语、数学、算法、设计模式以及Shader方面都是拦路虎。尤其数学,当初稀里糊涂,现在也早还给老师了。我本人就是个数学学渣,所以也是一路学引擎,一路补课数学、英语和编程知识。
本篇尽量由浅入深,让新手们不再像我当初初学时那样迷茫。
说明:本文写于2022年11月26日,基于的Godot版本是3.5,与目前Godot4.2在某些语法和API上可能会有差异,后续会基于4.2进行改写和拓展。
概念
二维向量,是指有两个分量的向量。在Godot的内置脚本语言GDScript中,用Vector2D类型表示二维向量。
万能的二维向量
二维向量可以表示屏幕二维坐标系上的点的位置。可以表示方向,还可以存储矩形的尺寸。
也可以用一堆二维向量表示平面上的一条折线路径。
Node2D的朝向
每一个2D物体其实有两个方向:
- 第一个是它自身的朝向,也就是由它的
rotation_degree
属性所定义的方向 - 第二个是在移动过程中,从自己的位置到目标位置的方向,也就是移动方向
不能只知道移动的方向,却忽略物体自身的朝向。在实际的设计中两者配合,才能做出更好的效果。
在Godot中确实没有“朝向”这个概念,只有rotation_degree
属性,但是却的的确确向我们展示了“朝向”的存在。look_at()
方法和经典的“Sprite随鼠标定位旋转”示例就表明了这一点。
Sprite随鼠标定位旋转
创建如下的场景,将icon.png拖进来,放到视口矩形范围的中间位置。
为Icon
节点添加如下代码:
extends Sprite
func _process(delta):
look_at(get_global_mouse_position())
运行后就可以看到一个始终朝向鼠标位置的效果。而这里可以看到,始终是右边朝向鼠标位置。
如果不够直观,我们可以给Sprite节点添加一个箭头,与其“朝向”保持一致。
你就可以看到更清晰的效果。
如果看到上面的示例,你能够想到经典游戏《祖玛》,说明你很聪明。
这里我们不做《祖玛》的示例,换为两张坦克素材图片,组成一个坦克。
我们将之前的代码放到坦克的“炮身”也就是cannon
节点上。
你立马就得到了一个可以随鼠标位置瞄准的坦克。
向量与位置
屏幕坐标系
在二维平面上表示一个点,最简单的方式就是定义一个平面坐标系,然后用一对(x,y)坐标来表示。Godot采用的坐标系与大多数计算机可视化编程所采用的是一致的,也就是以左上角为原点(0,0),X轴正方向向右,Y轴正方向向下的“屏幕坐标系”。
在Godot的2D工作区视口中,你可以看到Y轴用绿色,X轴用紫色,左上角原点也会有特殊的标记。
视口矩形
游戏窗口的尺寸是有限的,但是游戏地图或者游戏的世界可以比游戏窗口大得多。我们运行场景或项目时,窗口首先展示的屏幕范围,我们可以将其称为“第一屏”,这和网页设计中的“第一屏”概念是类似的。
“第一屏”是一个矩形区域,你可以用get_tree().root.get_visible_rect()
获得包含其信息的Rect2
数据。它与我们在项目设置中设置的窗口大小可能一致,也可能不一致,因为游戏窗口可以在运行过程中改变大小,并且还受缩放模式等设置的影响。
游戏中,我们通常不会始终待在“第一屏”,除非你的设计就是每个关卡地图就是一屏的大小。通过摄像机我们可以看到关卡地图的其他地方。
位置
好了,讲了必须讲的前置知识后,回到正题——“向量与位置”。在平面坐标系中,表示一个点的位置,就是用它在X轴和Y轴上的投影处的值组成的坐标。
那这又怎么跟向量扯上关系了呢?
向量其实可以理解为“相对移动”或“相对位置”,这种“相对性”其实很让人迷糊。但是举个例子你就明白了:
已知我们在地球上,已经通过地磁场确定了东西南北(废话),假设你的家乡在某个小镇A,但是你大学毕业后到了某个省的省会城市B工作,那么描述你的家乡小镇A和你工作的城市B之间的相对位置关系,你会怎么描述呢?
你会说你的家乡小镇A在你工作所在城市B的西北方向1200公里(数据胡诌),而你工作的城市B在你老家小镇A的东南方向1200公里公里。
你会发现两个地点的相对位置,可以用一个“方向+距离”的形式轻松的描述清楚。而“方向+距离”就是向量。
在相对中引入绝对
世上本没有东西南北,人类用某种科学规律和约定俗成规定了东西南北的方向,地球也本没有经纬,同样是人类用某种科学规律和科学家之间的约定俗成创建了一个可以定位地球某个点的方法。
同样的在二维平面上,本没有坐标系,为了方便描述位置,才引入了平面坐标系。类比《圣经·创世纪》中:世上本没有光,“神说“要有光”,就有了光”,科学家们何尝不是分开混沌,理清世界的“神”。
在屏幕坐标系中,左上角是坐标系原点(0,0)
,那么屏幕上的任何一点都可以被理解为“基于坐标系原点(0,0)
在某个方向上偏移多少距离”,或者“相对坐标系原点的某个方向和距离的点”。
这也就与我们上面所说的两个地点的相对位置描述一致了,只不过其中的一个点固定成了坐标系原点(0,0)
。
那么以下图为例,点(120,80)
的向量含义就是“相对坐标系原点(0,0)
的XX方向上移动YY距离的点”。而这就是屏幕坐标点与向量关系的由来。
那么这里的方向和距离到底是什么呢,如何计算?
我们先说距离,平面坐标系上的任何一个点,它在X轴、Y轴投影与向量之间围成一个直角三角形(如上图右)。那么根据勾股定律,向量的长度 d = x 2 + y 2 d=\sqrt{x^2 + y^2} d=x2+y2。
在GDScript中我们可以直接使用Vector2
的length()
方法获取向量的长度,如果是屏幕点的位置的话,它所求的就是从屏幕坐标系原点到这个点的距离。
get_global_mouse_position().length()
除此之外,你可以用Vector2
的distance_to()
求屏幕上任意两个点之间的距离。
Vector2(100,100).distance_to(Vector2(200,200))
那么方向呢?方向我们单位向量来表示,单位向量是指长度为1的向量,计算的话就是**单位向量 = **向量/向量长度
。在Godot中你同样可以省下这种计算,只用一个方法搞定,Vector2
的normalized()
就是求一个向量的单位向量。
Vector2(100,100).normalized()
单位向量的好处是,它的长度是1,1乘以任何数就是这个数本身,同时它又保存了向量的“方向”。那么我们就可以用单位向量乘以任何标量来“缩放向量”,同时也可以用**单位向量×长度(或叫距离)**来表示一个向量。也就回到了“在某个方向上偏移多少距离”的含义。
进一步的考虑在一个二维平面上,所有可能的方向都包含在一个中心点为坐标原点,半径为1的圆里。想想游戏手柄的摇杆和手机游戏的虚拟摇杆,你就应该明白,为什么它们可以控制你的游戏角色或视角移动了吧。
当然手柄的摇杆和手机游戏的虚拟摇杆还进一步检测了你拖动摇杆的力度,所以它不再是只包含圆周上的那些点,而是包含了圆周和圆内所有的点。
向量与移动
有了上面的基础,那么就应该研究物体在二维平面上的移动了。从A点到B点的移动可以理解为单纯的距离缩短。也可以描述为“A不停的向B移动,直到A和B重合(距离=0)”。
那么体现在代码上就是需要知道A到B的方向和距离,然后定义单位时间内移动的速度,然后就可以移动了。
A到B的方向direction
可以用A.direction_to(B)
求得,A到B的距离distance
可以用A.distance_to(B)
。
定义单位时间的移动距离,也就是速度speed
,那么速度向量velocity = direction * speed
,也就是方向*移动距离
。
不要懵了,direction
、distance
和velocity
是初学者学习基础移动必须学会的三个单词:
direction
:方向,申明变量时可以简写为dir
distance
:距离,申明变量时可以简写为dis
或d
velocity
:(沿某一方向的)速度,申明变量时可以简写为vec
或v
无论是申明变量还是使用Vector2
的方法你都会遇到这三个单词。
整个原理就是先判断起始点到目标点的距离是否大于0,然后将A的位置加上速度向量velocity
,移动一段距离,然后循环,直到距离=0。
具体可以参阅相关的示例。
这是用纯向量方法移动物体的形式,Godot中移动物体和实现碰撞需要用物理体那套,但是基础的基于向量的移动是必学的基础,它在某些时候会有用处。
局部坐标系
就像人类曾经经历了地心说和日心说,再到现在的宏大宇宙观,中心与原点有相似性,它也可以因为不同的认识和参照定义而发生变化。
你可以将二维平面的任意一点作为原点构造一个平面坐标系。但是你或者别人完全可以选择除了这一个点之外的任何一个点的位置重新建立一个坐标系。同一个点会因为你设立的坐标系不同而有不同的坐标值表示。
同时在二维平面的某个局部,你又可以创建一个局部坐标系。局部坐标系的好处是,它可以只描述局部范围内的内容,而忽略其他的东西。
相对的你可以将它的上一级坐标系称为“全局坐标系”,当然这很违心,因为“全局坐标系”本质上也是一个“局部坐标系”,因为它的外面可以有更大的坐标系。
就像地球的经纬度系统就可以看做是地球的全局坐标系,但是在太阳系乃至更大的银河系和宇宙来说,坐标系还可以随着讨论范围逐渐扩大。
但是在Godot里,一般情况下你就可以认为屏幕坐标系就是“最大”的坐标系,是“全局坐标系”,一个场景的根节点其“局部坐标系”默认与“全局坐标系”重合,除非你不移动它。而每一层的子节点,都有一个自己的“局部坐标系”。
在Godot的API中也体现了全局位置、缩放、旋转和局部位置、缩放、旋转的概念。
极坐标系
大致说完平面坐标系和二维向量,再加入一点极坐标和三角函数的内容。在一个平面上表示一个点的位置,不止有平面坐标系法和向量法,还有极坐标系法。
极坐标系(polar coordinates)是指在平面内由极点、极轴和极径组成的坐标系。在平面上取定一点O,称为极点。从O出发引一条射线Ox,称为极轴。再取定一个单位长度,通常规定角度取逆时针方向为正。这样,平面上任一点P的位置就可以用线段OP的长度ρ以及从Ox到OP的角度θ来确定,有序数对(ρ,θ)就称为P点的极坐标,记为P(ρ,θ);ρ称为P点的极径,θ称为P点的极角。
它的本质是规定一个正方向,表示0度,然后平面内的某个点就可以表示为方向为与这个正方向的夹角,距离为原点到这个点的距离,两个参数确定一个点。依然是方向和距离,但是用了角度和长度。
在Godot中可以理解为,2D节点的朝向,也就是rotation_degree
属性,遵循了这种坐标系,但是依然是反的,它的角度顺时针方向取正,逆时针方向取负。并且rotation_degree
属性的单位是度,而不是弧度。
向量的夹角
在GDScript中,你可以用Vector2
的angle()
求某个向量与X轴正方向的夹角。
print(Vector2(100,100).angle()) # 0.785398,单位:弧度
因此你完全可以将一个二维坐标系点的位置,通过封装(到原点的距离,与X轴正方向夹角(弧度))
来获得其极坐标表示。
你也可以用A.angle_to(B)
求两个向量之间的夹角。
用A.angle_to_point(B)
求由B到A的向量与X轴正方向的夹角。
B.angle_to_point(A)
求由A到B的向量与X轴正方向的夹角。
在三者中,angle_to_point()
应该是最让人费解的,但是平常也用不到,初学时不必深究。
向量的旋转
说完夹角再说旋转。向量除了加减乘除运算之外,还可以旋转。通过将一个向量旋转一定角度,可以获得一个新的向量。
向量的旋转有时候也很有用,比如在用Line2D
节点绘制多边形或圆时,就会用到。
Line2D和PoolVector2Array
Line2D
节点用于绘制路径,它的points
属性存储构成路径的所有顶点,是PoolVector2Array
类型。
PoolVector2Array
你可以简单理解为只存储Vector2
类型的特殊数组。
你可以直接在编辑器中手动绘制路径的顶点。注意它记录的是全局坐标,也就是基于屏幕左上角的位置,与自身的层级和局部坐标无关。
绘制后你可以在“检视器”面板展开points
属性,看到其中记录的顶点坐标。
同样的你可以用代码生成这些顶点坐标,而通过旋转向量的方法,我们可以用Line2D
绘制圆、圆弧等等。
绘制多边形和圆
策略很简单:
- 指定细分数,或者多边形的边数,然后我们用
360/边数
获得单次要旋转的角度 - 指定旋转半径,我们将X轴正方向的单位向量
Vector2.RIGHT
乘以旋转半径就获得了我们初始要旋转的向量 - 然后我们将它旋转指定的角度,并将旋转得到的新的点的位置加入到一个
PoolVector2Array
中,通过多次旋转就得到了多个点 - 最后我们赋值
Line2D
的points
属性为这个PoolVector2Array
,搞定!
extends Line2D
var subdivision = 6 # 细分数
var r = 100 # 旋转半径
var center = Vector2(400,200) # 旋转中心
func _ready():
width = 2
var pots:PoolVector2Array = []
var uint = Vector2.RIGHT # 向右的单位向量
var per_angle = (2 * PI) / subdivision # 单次旋转角度
var basic_vec = uint * r # 要旋转基础向量
pots.append(basic_vec + center)
for i in range(1,subdivision+1):
pots.append(basic_vec.rotated(per_angle * i) + center)
pots.append(basic_vec + center) # 回到第一个点,闭合
points = pots
pass
上面的代码运行后绘制的就是一个中心点在(400,200)
,中心点到每个顶点的距离是100
像素的正六边形。
圆和正多边形的唯一区别就是细分数,只要细分数达到一定的数量,就会“以直求曲”,从折线变成近似曲线的效果,这也是很多计算机软件里绘制圆形的奥秘。
绘制圆弧和扇形
学会了画圆,那么圆弧和扇形也就没有什么困难了。
extends Line2D
var subdivision = 5 # 细分数
var start_angle = deg2rad(45) # 起始角度
var end_angle = deg2rad(90) # 起始角度
var r = 100 # 旋转半径
var center = Vector2(400,200) # 选中中心
func _ready():
width = 2
var pots:PoolVector2Array = []
var uint = Vector2.RIGHT # 向右的单位向量
var d_angle = end_angle - start_angle if end_angle - start_angle >=0 else start_angle - end_angle
var per_angle = d_angle / subdivision # 单次旋转角度
var basic_vec = uint * r # 要旋转基础向量
print(basic_vec.rotated(start_angle))
pots.append(center) # 添加中心点
var start_point = basic_vec.rotated(start_angle) + center # 起始角度点
pots.append(start_point) # 添加起始点
for i in range(1,subdivision+1):
pots.append(basic_vec.rotated(start_angle + per_angle * i) + center)
pots.append(center) # 回到中心点,闭合
points = pots
pass
上面的代码如果首尾都不加中心点,就变成了圆弧。
螺旋线
上面举例的都是只旋转,但是旋转半径不变的情况,但你完全可以尝试一下按参数规律变化半径的螺旋线之类的。
三角函数
涉及平面坐标系、位置和角度,那么三角函数也是躲不过去的一个话题,这里我只简单的说一下,知道半径和角度,如何计算坐标。其实一张图就够了,你可以自己思考一下为什么。
关于向量其实想说的还很多,但是限于篇幅和经历,这次只说这么多,希望对新手有所帮助。