介绍:SAT为何物
简介
在游戏开发的过程中,我们往往会进行碰撞检测。 SAT(Separating Axis Theorem,分离轴定理)是一种常用的碰撞检测算法。运用它,我们可以对任意凸多边形、圆之间进行碰撞检测。
原理
介绍其原理前,我们先来看在数学上的凸集分离定理,它是SAT算法的基础。
注:上述定理的证明我也不会,这需要用到大学的数学知识,然而我连大专学历都没有(
引申
根据上述原理,我们可以得到在二维平面上的分离轴定理
对于两个凸多边形,若存在一条直线将两者分开,则这两个多边形不相交。
接下来,我们将会用程序去寻找这条直线,找到了,两个多边形就会分离,反之为碰撞。
图中给出了一个实例,两个分开的凸多边形被一条直线分开
讨论:SAT算法的实现逻辑
我们的检验方法可以概括为:
对于两个凸多边形,检测是否存在一条这样的直线t:
- 作垂直于t的直线l,将两个多边形的所有顶点分别投影到l上;
- 分别做出两个多边形的最远的两个投影点的连接线;
- 如果两条连接线不存在相交部分,则此时两个多边形在直线t的方向上存在一条直线分割两个多边形
只要有一条这种直线t,那么两个凸多边形就算是分离了。
这是投影的示意图,可以看到,两个多边形分离。
不过,程序的算力不允许我们遍历所有方向,那么在这里,我们将该方法做一种简化,我们只需要将所有的多边形的边的方向作为直线t的方向,其垂线作为投影轴(直线l)即可。
- 将两个多边形所有的边所在的直线作为直线t进行依次测试,
- 如果都不行,则再不存在其它的直线满足条件。
- 其实就是,只需要依次在每条边的垂直线做投影即可。
此外,圆和多边形之间的碰撞检测大体相同,只不过我们还需要加一条投影轴(直线l),是圆心到多边形上距离圆心最近一点的连线。
注:我们的数学直觉告诉我们确实是这样的,但是要证明这样做可行,还是比较困难的。(就是我不会的意思。)
于是,检测碰撞的过程就是:
- 依次确定多边形的各个投影轴;
- 将多边形投射到某条投影轴上;
- 检测两段投影是否发生重叠。
实现:SAT算法的C#代码
定义
首先,我们需要定义几个类来帮助我们表示多边形、圆。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109
| using System; using Microsoft.Xna.Framework;
public class Polygon {
private Vector2[] points; private Line[] verticleLines; public Vector2[] Points { get => points; } public Line[] VerticleLines { get => verticleLines; }
public Polygon(Vector2[] points) { if (points.Length <= 1) { throw new Exception("多边形的顶点数应当大于1"); } this.points = new Vector2[points.Length]; for (int i = 0; i < points.Length; i++) { this.points[i] = new Vector2(points[i].X, points[i].Y); } FillVerticleLines(); }
private void FillVerticleLines() { verticleLines = new Line[Points.Length]; for (int i = 0; i < Points.Length - 1; i++) { var edge = new Line(Points[i], Points[i + 1]); verticleLines[i] = ShapeManager.ReturnVerticleLine(edge, Vector2.Zero); } verticleLines[Points.Length - 1] = ShapeManager.ReturnVerticleLine (new Line(Points[Points.Length - 1], Points[0]), Vector2.Zero); }
public Line[] GetAllEdges() { Line[] edges = new Line[Points.Length]; for (int i = 0; i < Points.Length - 1; i++) { var edge = new Line(Points[i], Points[i + 1]); edges[i] = edge; } edges[Points.Length - 1] = new Line(Points[Points.Length - 1], Points[0]); return edges; } }
public class Circle {
public Vector2 center; public float radius;
public Circle(Vector2 point, float radius) { center = point; this.radius = radius; } }
public class Line {
public Vector2 point1; public Vector2 point2;
public Line(Vector2 point_1, Vector2 point_2) { point1 = point_1; point2 = point_2; }
}
|
如上方所示,我们用Polygon
和Circle
两个类表示多边形和圆,还添加了Line
表示线段。
Polygon
储存多边形的顶点points
,并且在创建之初就提供了所有边相应的投影轴verticleLines
,这是由FillVerticleLines()
方法给出的,多边形还有GetAllEdges
方法获取所有的边;
Circle
储存一个圆的对象,具有center
圆心坐标,radius
半径数据;
Line
储存一条线段的两个顶点point1``point2
的位置;
Vector2
表示一个向量,具有X
Y
表示其水平、竖直分量,它来自MonoGame Framework。(我平常游戏开发所使用的就是这个框架。)
判断
我们定义一个ShapeManager
类来对它们做判断,接下来我将展示其中的一些方法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
|
internal static Line ReturnVerticleLine(Line target, Vector2 point) { if (!target.IsZeroVector()) { Vector2 swiftPos = target.point2 - target.point1; Vector2 normalVector = new Vector2(-swiftPos.Y, swiftPos.X); normalVector.Normalize(); return new Line(point, point + normalVector); } return new Line(new Vector2(point.X, point.Y), new Vector2(target.point1.X, target.point1.Y)); }
|
前文提到了ReturnVerticleLine()
方法,现在在此给出,它返回一条过顶点的垂线,用于确定投影轴。
这里涉及到了Normalize()
方法,这会将normalVector
转化为单位向量(模长为1)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
|
public static bool IsCollision(Polygon polygon1, Polygon polygon2) { var allVerticles = new Line[polygon1.VerticleLines.Length + polygon2.VerticleLines.Length]; Array.Copy(polygon1.VerticleLines, 0, allVerticles, 0, polygon1.VerticleLines.Length); Array.Copy(polygon2.VerticleLines, 0, allVerticles, polygon1.VerticleLines.Length, polygon2.VerticleLines.Length); foreach (Line verticle in allVerticles) { float[] cast1 = GetProjectLengthRange(polygon1, verticle); float[] cast2 = GetProjectLengthRange(polygon2, verticle); if (!IsTwoProjectionIntersect(cast1, cast2)) { return false; } } return true; }
|
这是IsCollision()
方法,它用于判定两个多边形是否相交。
- 首先,获得所有可能的投影轴,将它们储存在
allVerticles
内;
- 接着,用
GetProjectLengthRange()
,将它们依次投影到轴上,每条轴都来一遍;
- 用
IsTwoProjectionIntersect()
判断,如果有一条轴出现分离,认为两个多边形分离。
接下来,我们来具体看一下后两个方法的实现。
关键方法
1 2 3 4 5 6 7 8 9 10 11
|
private static float[] GetProjectLengthRange(Circle circle, Line castOn) { float projectValue = circle.center.X * (castOn.point1.X - castOn.point2.X) + circle.center.Y * (castOn.point1.Y - castOn.point2.Y); float[] result = new float[2] { projectValue - circle.radius, projectValue + circle.radius }; return result; }
|
在这个方法中,我们对起始位置为原点,末位置为多边形顶点的向量和投影轴向量进行了相乘,获得了它们的数量积。由于ReturnVerticleLine()
获取的投影轴方向向量是单位向量,结合高中知识,这个数量积表示投影点到投影轴向量起始点(在这里是原点)的距离,那么所有投影点其距离的最大、最小值就可以表示线段的位置。
就是这样,我们获取了线段端点到原点的位置,进而确定线段。
1 2 3 4 5 6 7 8 9
|
private static bool IsTwoProjectionIntersect(float[] cast1, float[] cast2) { float minAll = MathF.Min(cast1[0], cast2[0]); float maxAll = MathF.Max(cast1[1], cast2[1]); return cast1[1] - cast1[0] + (cast2[1] - cast2[0]) >= maxAll - minAll; }
|
接下来根据线段的位置判断,就没有问题了。
圆的特殊投影轴
当然,根据前文所说,我们在判断圆和多边形相交时,要新加入一条投影轴,那么根据其性质,代码如下。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76
|
public static bool IsCollision(Polygon polygon, Circle circle) { var allVerticles = new Line[polygon.VerticleLines.Length + 1]; Array.Copy(polygon.VerticleLines, 0, allVerticles, 0, polygon.VerticleLines.Length); allVerticles[allVerticles.Length - 1] = GetNearestAxisFromPolygon(circle.center, polygon); foreach (Line verticle in allVerticles) { float[] cast1 = GetProjectLengthRange(polygon, verticle); float[] cast2 = GetProjectLengthRange(circle, verticle); if (!IsTwoProjectionIntersect(cast1, cast2)) { return false; } } return true; }
private static Line GetNearestAxisFromPolygon(Vector2 point, Polygon polygon) { float nowDistance = float.MaxValue; Line axis = new Line(Vector2.Zero, Vector2.Zero); Line[] edges = polygon.GetAllEdges(); foreach (Line edge in edges) { float distance = GetDistanceFromPointToLine(point, edge); if (distance < nowDistance) { nowDistance = distance; axis = ReturnVerticleLine(edge, Vector2.Zero); } } foreach (Vector2 polygonPoint in polygon.Points) { float distance = Vector2.Distance(polygonPoint, point); if (distance < nowDistance) { nowDistance = distance; var pointer = point - polygonPoint; pointer.Normalize(); axis = new Line(Vector2.Zero, pointer); } } return axis; }
private static float GetDistanceFromPointToLine(Vector2 point, Line line) { Polygon triangle = new Polygon(new Vector2[3] {point, line.point1, line.point2}); float area = triangle.GetTriangleArea(); float edgeLength = Vector2.Distance(line.point1, line.point2); return 2 * area / edgeLength; }
|
如上,我们就找到了圆心到多边形上最近一点的连线的方向向量,因而确定了那条特殊的投影轴。
完整代码查看
欢迎访问我的个人项目FullLeafFramework,一个以MonoGame为基础的游戏框架扩展,在dev
分支上,你可以下载到拥有以上逻辑判断的完整代码,这里只是截取关键部分帮助理解。
总结
SAT是一种非常有效的凸多边形、圆的碰撞检测算法,可以做到对凸多边形、圆是否相交的精密检测,其复杂度为O((m+n)^2),在多边形边数不多时效率不错,但一旦边数过大便效率有限,且它对于凹多边形不适用,使用时需注意。