Attention: Here be dragons

This is the latest (unstable) version of this documentation, which may document features not available in or compatible with released stable versions of Godot.

高等向量数学

平面

单位向量的点积还有一个有趣的性质。请想象一个垂直于这个向量(且经过原点)的平面。平面会将整个空间划分为正(在平面上方)和负(在平面下方)两部分,并且(与普遍的看法相反)你也可以在 2D 中进行这样的数学运算:

../../_images/tutovec10.png

垂直于表面的单位向量称为单位法向量(因此描述的是表面的朝向),不过通常会简称为法线。平面、3D 几何体等场合中都会用到法线(用来确定面或顶点的属于哪一侧)。法线一种单位向量,因为用途才被称为法线。(就像我们说坐标 (0,0) 是“原点”一样!)。

平面经过原点,其表面与单位向量(或法线)垂直。向量所指的一侧为正半空间,另一侧为负半空间。在 3D 中原理完全相同,不同之处在于:此时的平面不再是一条直线,而是一个无限的平面(可以想象为一张无限大的、可任意定向并固定在原点的平整纸张)。

到平面的距离

现在平面是什么就很清楚了,让我们再回到点积上。单位向量和任何空间点之间的点积(是的,这次我们在向量和位置之间进行点乘),将返回从该点到平面的距离

var distance = normal.dot(point)

但返回的不止是距离的绝对值,如果点位于负半空间,那么这个距离也是负的:

../../_images/tutovec11.png

这样我们就能够知道点位于平面的哪一侧。

脱离原点

我知道你在想什么!到目前为止还算不错,但真正的平面在空间中无处不在,并不一定要经过原点。你想要的是真正平面,你现在就想行动起来。

请记住,平面不仅仅是将空间一分为二,这两个空间还有极性。也就是说,如果两个平面完全重合,它们的正负半空间可以相反。

明确了这一点,我们就可以将完整的平面描述为法线 N与原点的距离标量 D。这样用 N 和 D 就可以表示我们的平面了。例如:

../../_images/tutovec12.png

对于 3D 空间中的平面,Godot 提供了 Plane 内置类型来处理这些计算。

基本上,N 和 D 可以表示空间中的任何平面,无论是 2D 还是 3D(取决于 N 的维数),两者的数学运算相同。它与之前相同,但 D 是从原点到平面的距离,沿 N 方向行进。例如,假设你想要到达平面上的某个点,只需执行以下操作:

var point_in_plane = N*D

这将拉伸(调整大小)法线向量并使其接触平面。这个数学运算可能看起来很混乱,但实际上比看起来要简单得多。如果我们想再次知道从点到平面的距离,可以以相同方法,但要调整距离:

var distance = N.dot(point) - D

也可以用内置函数执行同样的计算:

var distance = plane.distance_to(point)

这同样会返回一个正或负的距离。

还可以通过同时对 N 和 D 取负来反转平面的极性。这样,平面的位置不变,但正负半空间倒置:

N = -N
D = -D

Godot 还在 Plane 中实现了该运算。因此,使用以下格式将按预期工作:

var inverted_plane = -plane

所以,请记住,平面的主要实际用途是我们可以计算到平面的距离。那么,什么时候计算从点到平面的距离有用呢?让我们看一些例子。

在 2D 中构造平面

平面不会凭空出现,必须先进行构造。在 2D 空间中构造平面很简单:只需要法线(单位向量)和某一个点,或者空间中任意两点都可以完成。

在法线和点的情况下,由于法线已经被计算出来,大部分计算工作都已完成。因此,只需根据法线和点的点积计算 D 即可。

var N = normal
var D = normal.dot(point)

而在空间中任意两点的情况下,空间内会有两个平面同时经过两点:这两个平面共享同一个空间,但其法线方向相反。因此,为计算这两点的法线,必须先获得方向向量,然后将其向任意一侧旋转 90° :

# Calculate vector from `a` to `b`.
var dvec = point_a.direction_to(point_b)
# Rotate 90 degrees.
var normal = Vector2(dvec.y, -dvec.x)
# Alternatively (depending the desired side of the normal):
# var normal = Vector2(-dvec.y, dvec.x)

剩余步骤与前例相同。point_a 和 point_b 都可以用于计算,毕竟两者位于同一个平面内:

var N = normal
var D = normal.dot(point_a)
# this works the same
# var D = normal.dot(point_b)

在 3D 空间中构造平面更加复杂,下文会进一步解释。

平面的一些示例

以下是平面的用途示例。假设有一个多边形。比如矩形、梯形、三角形或任何没有面向内弯曲的多边形。

对于多边形的每段,我们计算经过该段的平面。一旦我们有了平面列表,我们就可以做一些有趣的事情,例如检查某个点是否在多边形内。

我们遍历所有平面,如果我们能找到一个到该点的距离为正的平面,那么该点就在多边形外部。如果我们找不到,那么该点就在多边形内部。

../../_images/tutovec13.png

代码应该是这样的:

var inside = true
for p in planes:
    # check if distance to plane is positive
    if (p.distance_to(point) > 0):
        inside = false
        break # with one that fails, it's enough

很酷吧?好戏还在后面!再多花点功夫,类似的逻辑也能让我们知道两个凸多边形是否重叠。这被称为分离轴定理(或 SAT),大多数物理引擎都使用它来检测碰撞。

对于一个点,只要检查是否有一个平面返回正距离,就足以判断该点是否在外部。对于另一个多边形,我们必须找到一个平面,使另一个多边形的所有点到它的距离都为正。先使用 A 的平面对 B 的点进行检查,然后使用 B 的平面对 A 的点进行检查:

../../_images/tutovec14.png

代码应该是这样的:

var overlapping = true

for p in planes_of_A:
    var all_out = true
    for v in points_of_B:
        if (p.distance_to(v) < 0):
            all_out = false
            break

    if (all_out):
        # a separating plane was found
        # do not continue testing
        overlapping = false
        break

if (overlapping):
    # only do this check if no separating plane
    # was found in planes of A
    for p in planes_of_B:
        var all_out = true
        for v in points_of_A:
            if (p.distance_to(v) < 0):
                all_out = false
                break

        if (all_out):
            overlapping = false
            break

if (overlapping):
    print("Polygons Collided!")

如你所见,平面非常有用,而这只是冰山一角。你可能好奇非凸多边形该怎么办。通常的处理方式只是将凹多边形分割成更小的凸多边形,或使用诸如 BSP(现在很少使用)之类的技术。

3D 碰撞检测

这是另一点额外内容,是对耐心坚持看完这篇长篇教程的奖励。这是另一条经验之谈。这可能不是能直接用上的东西(Godot 已经能很好地检测碰撞),但几乎所有物理引擎和碰撞检测库都在使用它 :)

还记得将 2D 凸形转换为 2D 平面数组对于碰撞检测很有用吗?你可以检测某个点是否位于任何凸形内,或者两个 2D 凸形是否重叠。

嗯,这在 3D 中也适用,如果两个 3D 多面体发生碰撞,你将无法找到分离平面。如果找到了分离平面,那么这两个形状肯定没有发生碰撞。

稍微回顾一下,分离平面意味着多边形 A 的所有顶点都在平面的一侧,而多边形 B 的所有顶点都在另一侧。该平面总是多边形 A 或多边形 B 的面平面之一。

不过,在 3D 中,这种方法存在问题,因为在某些情况下可能找不到分离平面。以下是这种情况的一个示例:

../../_images/tutovec22.png

为了避免这种情况,需要将一些额外的平面作为分隔器测试,这些平面是多边形 A 的边和多边形 B 的边的叉积

../../_images/tutovec23.png

所以,最终的算法是这样的:

var overlapping = true

for p in planes_of_A:
    var all_out = true
    for v in points_of_B:
        if (p.distance_to(v) < 0):
            all_out = false
            break

    if (all_out):
        # a separating plane was found
        # do not continue testing
        overlapping = false
        break

if (overlapping):
    # only do this check if no separating plane
    # was found in planes of A
    for p in planes_of_B:
        var all_out = true
        for v in points_of_A:
            if (p.distance_to(v) < 0):
                all_out = false
                break

        if (all_out):
            overlapping = false
            break

if (overlapping):
    for ea in edges_of_A:
        for eb in edges_of_B:
            var n = ea.cross(eb)
            if (n.length() == 0):
                continue

            var max_A = -1e20 # tiny number
            var min_A = 1e20 # huge number

            # we are using the dot product directly
            # so we can map a maximum and minimum range
            # for each polygon, then check if they
            # overlap.

            for v in points_of_A:
                var d = n.dot(v)
                max_A = max(max_A, d)
                min_A = min(min_A, d)

            var max_B = -1e20 # tiny number
            var min_B = 1e20 # huge number

            for v in points_of_B:
                var d = n.dot(v)
                max_B = max(max_B, d)
                min_B = min(min_B, d)

            if (min_A > max_B or min_B > max_A):
                # not overlapping!
                overlapping = false
                break

        if (not overlapping):
            break

if (overlapping):
   print("Polygons collided!")

更多信息

有关在 Godot 中使用向量数学的更多信息,请参阅以下文章:

如果你需要进一步的解释,你可以看看 3Blue1Brown 的绝佳系列视频 《线性代数的本质》