高等向量数学

平面

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

../../_images/tutovec10.png

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

平面经过原点,它的表面垂直于这条单位向量(即法线)。这条向量指向的一侧为正半空间,而另一侧则为负半空间。以上概念在 3D 中依旧适用,只不过平面不再是直线,而是一个无限的表面(想象一张固定在原点,无限伸展的平坦纸张)。

到平面的距离

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

GDScriptC#

  1. var distance = normal.dot(point)
  1. var distance = normal.Dot(point);

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

../../_images/tutovec11.png

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

脱离原点

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

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

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

../../_images/tutovec12.png

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

Basically, N and D can represent any plane in space, be it for 2D or 3D (depending on the amount of dimensions of N) and the math is the same for both. It’s the same as before, but D is the distance from the origin to the plane, travelling in N direction. As an example, imagine you want to reach a point in the plane, you will just do:

GDScriptC#

  1. var point_in_plane = N*D
  1. var pointInPlane = N * D;

This will stretch (resize) the normal vector and make it touch the plane. This math might seem confusing, but it’s actually much simpler than it seems. If we want to tell, again, the distance from the point to the plane, we do the same but adjusting for distance:

GDScriptC#

  1. var distance = N.dot(point) - D
  1. var distance = N.Dot(point) - D;

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

GDScriptC#

  1. var distance = plane.distance_to(point)
  1. var distance = plane.DistanceTo(point);

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

Flipping the polarity of the plane can be done by negating both N and D. This will result in a plane in the same position, but with inverted negative and positive half spaces:

GDScriptC#

  1. N = -N
  2. D = -D
  1. N = -N;
  2. D = -D;

在Godot中,同样也可以使用 Plane 中实现这个操作,示范如下:

GDScriptC#

  1. var inverted_plane = -plane
  1. var invertedPlane = -plane;

因此,谨记,向量数学当中,平面的主要用途就是计算某个点到它的距离。那么,什么时候计算点到平面的距离有用呢?下文将提供应用实例。

在二维空间中构造平面

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

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

GDScriptC#

  1. var N = normal
  2. var D = normal.dot(point)
  1. var N = normal;
  2. var D = normal.Dot(point);

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

GDScriptC#

  1. # Calculate vector from `a` to `b`.
  2. var dvec = point_a.direction_to(point_b)
  3. # Rotate 90 degrees.
  4. var normal = Vector2(dvec.y, -dvec.x)
  5. # Alternatively (depending the desired side of the normal):
  6. # var normal = Vector2(-dvec.y, dvec.x)
  1. // Calculate vector from `a` to `b`.
  2. var dvec = pointA.DirectionTo(pointB);
  3. // Rotate 90 degrees.
  4. var normal = new Vector2(dvec.Y, -dvec.X);
  5. // Alternatively (depending the desired side of the normal):
  6. // var normal = new Vector2(-dvec.Y, dvec.X);

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

GDScriptC#

  1. var N = normal
  2. var D = normal.dot(point_a)
  3. # this works the same
  4. # var D = normal.dot(point_b)
  1. var N = normal;
  2. var D = normal.Dot(pointA);
  3. // this works the same
  4. // var D = normal.Dot(pointB);

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

平面的一些示例

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

对多边形的每一段,我们通过计算获取经过该段的平面。重复以上操作,直到获得多边形的平面列表后,我们就可以基于该列表,做些简单的事情,例如检查某点是否处于多边形内部。

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

../../_images/tutovec13.png

上述思路的代码实现如下:

GDScriptC#

  1. var inside = true
  2. for p in planes:
  3. # check if distance to plane is positive
  4. if (p.distance_to(point) > 0):
  5. inside = false
  6. break # with one that fails, it's enough
  1. var inside = true;
  2. foreach (var p in planes)
  3. {
  4. // check if distance to plane is positive
  5. if (p.DistanceTo(point) > 0)
  6. {
  7. inside = false;
  8. break; // with one that fails, it's enough
  9. }
  10. }

很酷吧?不过它还能做到更多!只要再花点心思,类似的逻辑也能让我们知道两个凸多边形是否重叠。这就是所谓的分离轴定理 ( Separating Axis Theorem, SAT ),多数物理引擎基于该定理检测碰撞。

对于点的检测,只需检查平面是否返回正距离,就足以判断该点是否在外部。而对于多边形之间的检测,我们必须找到一个平面,使得另一个多边形上的所有点到该平面的距离都为正。在代码实现方面,可以先用多边形 A 的平面对多边形 B 的点进行检查,再用多边形 B 的平面对多边形 A 的点进行检查:

../../_images/tutovec14.png

上述思路的代码实现如下:

GDScriptC#

  1. var overlapping = true
  2. for p in planes_of_A:
  3. var all_out = true
  4. for v in points_of_B:
  5. if (p.distance_to(v) < 0):
  6. all_out = false
  7. break
  8. if (all_out):
  9. # a separating plane was found
  10. # do not continue testing
  11. overlapping = false
  12. break
  13. if (overlapping):
  14. # only do this check if no separating plane
  15. # was found in planes of A
  16. for p in planes_of_B:
  17. var all_out = true
  18. for v in points_of_A:
  19. if (p.distance_to(v) < 0):
  20. all_out = false
  21. break
  22. if (all_out):
  23. overlapping = false
  24. break
  25. if (overlapping):
  26. print("Polygons Collided!")
  1. var overlapping = true;
  2. foreach (Plane plane in planesOfA)
  3. {
  4. var allOut = true;
  5. foreach (Vector3 point in pointsOfB)
  6. {
  7. if (plane.DistanceTo(point) < 0)
  8. {
  9. allOut = false;
  10. break;
  11. }
  12. }
  13. if (allOut)
  14. {
  15. // a separating plane was found
  16. // do not continue testing
  17. overlapping = false;
  18. break;
  19. }
  20. }
  21. if (overlapping)
  22. {
  23. // only do this check if no separating plane
  24. // was found in planes of A
  25. foreach (Plane plane in planesOfB)
  26. {
  27. var allOut = true;
  28. foreach (Vector3 point in pointsOfA)
  29. {
  30. if (plane.DistanceTo(point) < 0)
  31. {
  32. allOut = false;
  33. break;
  34. }
  35. }
  36. if (allOut)
  37. {
  38. overlapping = false;
  39. break;
  40. }
  41. }
  42. }
  43. if (overlapping)
  44. {
  45. GD.Print("Polygons Collided!");
  46. }

As you can see, planes are quite useful, and this is the tip of the iceberg. You might be wondering what happens with non convex polygons. This is usually just handled by splitting the concave polygon into smaller convex polygons, or using a technique such as BSP (which is not used much nowadays).

三维环境下的碰撞检测

这是本章的另一个奖励内容,是对你耐心看完这本长篇教程的奖励:以下内容是又一个前人的智慧结晶,虽然它不算是个直接能拿来用的实例( Godot 的碰撞检测功能已经足够好了),但是接下来的内容是几乎所有物理引擎和碰撞检测库都在使用的一个原理 : )

还记得把 2D 中的凸形转换成 2D 平面阵列可用于碰撞检测吗?你可以检测一个点是否在任何凸面形内,或两个 2D 凸面形是否重叠。

其实,这在 3D 中也同样适用:如果两个 3D 多面体相撞,你将无法找到这两个多面体的分离平面。反之,如果能找到分离平面,那么这两个图形肯定不会发生碰撞。

To refresh a bit a separating plane means that all vertices of polygon A are in one side of the plane, and all vertices of polygon B are in the other side. This plane is always one of the face-planes of either polygon A or polygon B.

不过,在 3D 空间中,这种思路存在一个问题:某些情况下可能找不到分离平面。下文就是这种情况的一个示例:

../../_images/tutovec22.png

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

../../_images/tutovec23.png

所以,最后的算法代码差不多像这样:

GDScriptC#

  1. var overlapping = true
  2. for p in planes_of_A:
  3. var all_out = true
  4. for v in points_of_B:
  5. if (p.distance_to(v) < 0):
  6. all_out = false
  7. break
  8. if (all_out):
  9. # a separating plane was found
  10. # do not continue testing
  11. overlapping = false
  12. break
  13. if (overlapping):
  14. # only do this check if no separating plane
  15. # was found in planes of A
  16. for p in planes_of_B:
  17. var all_out = true
  18. for v in points_of_A:
  19. if (p.distance_to(v) < 0):
  20. all_out = false
  21. break
  22. if (all_out):
  23. overlapping = false
  24. break
  25. if (overlapping):
  26. for ea in edges_of_A:
  27. for eb in edges_of_B:
  28. var n = ea.cross(eb)
  29. if (n.length() == 0):
  30. continue
  31. var max_A = -1e20 # tiny number
  32. var min_A = 1e20 # huge number
  33. # we are using the dot product directly
  34. # so we can map a maximum and minimum range
  35. # for each polygon, then check if they
  36. # overlap.
  37. for v in points_of_A:
  38. var d = n.dot(v)
  39. max_A = max(max_A, d)
  40. min_A = min(min_A, d)
  41. var max_B = -1e20 # tiny number
  42. var min_B = 1e20 # huge number
  43. for v in points_of_B:
  44. var d = n.dot(v)
  45. max_B = max(max_B, d)
  46. min_B = min(min_B, d)
  47. if (min_A > max_B or min_B > max_A):
  48. # not overlapping!
  49. overlapping = false
  50. break
  51. if (not overlapping):
  52. break
  53. if (overlapping):
  54. print("Polygons collided!")
  1. var overlapping = true;
  2. foreach (Plane plane in planesOfA)
  3. {
  4. var allOut = true;
  5. foreach (Vector3 point in pointsOfB)
  6. {
  7. if (plane.DistanceTo(point) < 0)
  8. {
  9. allOut = false;
  10. break;
  11. }
  12. }
  13. if (allOut)
  14. {
  15. // a separating plane was found
  16. // do not continue testing
  17. overlapping = false;
  18. break;
  19. }
  20. }
  21. if (overlapping)
  22. {
  23. // only do this check if no separating plane
  24. // was found in planes of A
  25. foreach (Plane plane in planesOfB)
  26. {
  27. var allOut = true;
  28. foreach (Vector3 point in pointsOfA)
  29. {
  30. if (plane.DistanceTo(point) < 0)
  31. {
  32. allOut = false;
  33. break;
  34. }
  35. }
  36. if (allOut)
  37. {
  38. overlapping = false;
  39. break;
  40. }
  41. }
  42. }
  43. if (overlapping)
  44. {
  45. foreach (Vector3 edgeA in edgesOfA)
  46. {
  47. foreach (Vector3 edgeB in edgesOfB)
  48. {
  49. var normal = edgeA.Cross(edgeB);
  50. if (normal.Length() == 0)
  51. {
  52. continue;
  53. }
  54. var maxA = float.MinValue; // tiny number
  55. var minA = float.MaxValue; // huge number
  56. // we are using the dot product directly
  57. // so we can map a maximum and minimum range
  58. // for each polygon, then check if they
  59. // overlap.
  60. foreach (Vector3 point in pointsOfA)
  61. {
  62. var distance = normal.Dot(point);
  63. maxA = Mathf.Max(maxA, distance);
  64. minA = Mathf.Min(minA, distance);
  65. }
  66. var maxB = float.MinValue; // tiny number
  67. var minB = float.MaxValue; // huge number
  68. foreach (Vector3 point in pointsOfB)
  69. {
  70. var distance = normal.Dot(point);
  71. maxB = Mathf.Max(maxB, distance);
  72. minB = Mathf.Min(minB, distance);
  73. }
  74. if (minA > maxB || minB > maxA)
  75. {
  76. // not overlapping!
  77. overlapping = false;
  78. break;
  79. }
  80. }
  81. if (!overlapping)
  82. {
  83. break;
  84. }
  85. }
  86. }
  87. if (overlapping)
  88. {
  89. GD.Print("Polygons Collided!");
  90. }

更多信息

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

如果你需要进一步的解释,你可以看看 3Blue1Brown 的绝佳的系列视频《线性代数的本质》:http://www.bilibili.com/video/BV1ys411472E?p=2