http://bbs.9ria.com/thread-143677-1-1.html
本帖最后由 十旋转45度 于 2012-8-13 13:11 编辑
教程详情
难度:中级
使用平台: AS3,Flash
预计完成时间:一小时
分离轴定理在检测两个简单的多边形之间的碰撞或多边形与圆形之间的碰撞时经常用到。与其他所有算法一样,分离轴定理也并非所向披靡的完美存在。本教程中,我们就深入数学角度来研究本定理,同时也向读者展示了游戏开发中如何应用此定理的示例代码和demo。
注:虽然本教程demo和源码使用的是Flash和AS3,但同理也可以将这个思想移植到其他游戏开发环境中。
定理的表述
分离轴定理(简称SAT)提出,若你能找到一条直线,使得两个多边形分离,那么这两个多边形之间就没有发生碰撞。就这么简单。
在上图中,明显第二排的图形是存在碰撞的。无论你怎么尝试在两图形之间塞一条线进去,结果总是失败的。而第一行是另一种情况。你能够轻松地画一条直线(或是更多线)使得这些图形分离:
好的,孩纸们现在应该已经都掌握了这个要点了。这个关键点就是,若你找到这样一条直线,那就图形之间就必然分离。接下来,我们就探讨该如何检测是否存在这样的直线。
在任意轴向上投影
假设现在要进行碰检的两个多边形为四边形:位于左侧的box1和右侧的box2。显而易见,这两个四边形在水平方向上是分离的。用代码检测的方法,是先计算得到两四边形之间的水平距离,然后减去box1和box2各自的半宽:
- //Pseudo code to evaluate the separation of box1 and box2
- var length:Number = box2.x - box1.x;
- var half_width_box1:Number = box1.width*0.5;
- var half_width_box2:Number = box2.width*0.5;
- var gap_between_boxes:Number = length - half_width_box1 - half_width_box2;
- if(gap_between_boxes > 0) trace("It's a big gap between boxes")
- else if(gap_between_boxes == 0) trace("Boxes are touching each other")
- else if(gap_between_boxes < 0) trace("Boxes are penetrating each other")
复制代码
当四边形朝向改变,情况会是怎么样呢?
虽然它们之间的距离保持不变,但我们不得不想另一种方法来计算图形中心之间的距离长以及和半宽值-此时是沿着P轴的。于是矢量代数就闪亮登场了。我们将向量A和B沿P投影,以得到半宽。
那接下来让我们小小复习下代数知识。
矢量代数复习
首先,复习下两个向量A和B之间的点积的概念:
点积的定义可以由向量的两个分量实现:
从另外一个角度理解点积,涉及向量大小以及夹角:
现在,我们要求向量A在P上的投影。
参考上图,我们求得投影值为 (theta为A和P之间的夹角),虽然计算夹角值进而得到投影也能达到目的,但这样太麻烦了,我们其实有更直接的计算方式:
注, 其实就是P的单位向量。
现在,不必直接使用方程右侧的公式,取而代之使用左侧也可以得到相同的结果。
应用到场景
在继续前进之前,我们来明确下四边形的角点命名。这在后续代码将会用到:
场景如下:
现有两个四边形偏离水平轴向45°,我们必须按顺序计算下面的长度,以求得四边形之间的距离。
投影得到正值,但由于向量指向的方向相反,B到P的投影值是负的。AS3实现如下,第25行就涉及了这个问题:
- var dot10:Point = box1.getDot(0);
- var dot11:Point = box1.getDot(1);
- var dot20:Point = box2.getDot(0);
- var dot24:Point = box2.getDot(4);
- //Actual calculations
- var axis:Vector2d = new Vector2d(1, -1).unitVector;
- var C:Vector2d = new Vector2d(
- dot20.x - dot10.x,
- dot20.y - dot10.y
- )
- var A:Vector2d = new Vector2d(
- dot11.x - dot10.x,
- dot11.y - dot10.y
- )
- var B:Vector2d = new Vector2d(
- dot24.x - dot20.x,
- dot24.y - dot20.y
- )
- var projC:Number = C.dotProduct(axis)
- var projA:Number = A.dotProduct(axis);
- var projB:Number = B.dotProduct(axis);
- var gap:Number = projC - projA + projB; //projB is expected to be a negative value
- if (gap > 0) t.text = "There's a gap between both boxes"
- else if (gap > 0) t.text = "Boxes are touching each other"
- else t.text = "Penetration had happened."
复制代码
以下demo对应上述代码。单击拖拽四边形内部的红色圆点,观察交互反应。
完整源代码请在源码下载中查看 DemoSAT1.as。
缺陷
以上确实顺利实现,但存在不少问题:
首先,向量A和B是固定的。所以当你交换box2和box1的位置,碰撞检测就会失败。
第二,由于只沿一根轴进行距离值的计算,所以像下面所示的情况将得不到正确碰检结果:
尽管前面的demo是有缺陷的,但不妨碍我们从中学习投影概念。接下来,我们就要对其进行改进。
解决第一个缺陷
首先,我们需要得到四边形各个角点到P的投影最小值和最大值(从原点到四边形顶点的向量)到P。
上图展示了当四边形朝向刚好吻合向量P的情况下,角点到P的投影最小值和最大值。
但如果box1和box2的朝向与P不一致呢?
上图展示了四边形并非刚好沿着P方向的情况下对应的最小最大投影。在这种情况下,我们就得遍历四边形的各个角点,以作出正确选择。
现在,我们已经得到最小-最大投影,那么接下来就得对四边形之间是否发生碰撞进行判断。要如何实现呢?
通过上图的观察,我们可以清楚地看到box1.max以及box2.min到轴P上投影的几何表示。
如你所见,当两个四边形之间存在间隔, box2.min-box1.max将大于零,换句话说, box2.min > box1.max。当四边形位置交换,则变成box1.min > box2.max,这就表示它们不存在重叠。
将这一结论转化为代码,我们就会得到:
- //SAT: Pseudocode to evaluate the separation of box1 and box2
- if(box2.min>box1.max || box1.min>box2.max){
- trace("collision along axis P happened")
- }
- else{
- trace("no collision along axis P")
- }
复制代码
初始化代码
接下来呢,我们深入代码细节。注意,以下AS3代码未经优化。尽管下列代码有点冗长,还是能清楚了解到数学原理的应用。
首先,需要提供向量:
- //preparing the vectors from origin to points
- //since origin is (0,0), we can conveniently take the coordinates
- //to form vectors
- var axis:Vector2d = new Vector2d(1, -1).unitVector;
- var vecs_box1:Vector.<Vector2d> = new Vector.<Vector2d>;
- var vecs_box2:Vector.<Vector2d> = new Vector.<Vector2d>;
- for (var i:int = 0; i < 5; i++) {
- var corner_box1:Point = box1.getDot(i)
- var corner_box2:Point = box2.getDot(i)
-
- vecs_box1.push(new Vector2d(corner_box1.x, corner_box1.y));
- vecs_box2.push(new Vector2d(corner_box2.x, corner_box2.y));
- }
复制代码
接下来,要得到box1投影的最大值和最小值。box2中也会有类似的方法:
- //setting min max for box1
- var min_proj_box1:Number = vecs_box1[1].dotProduct(axis);
- var min_dot_box1:int = 1;
- var max_proj_box1:Number = vecs_box1[1].dotProduct(axis);
- var max_dot_box1:int = 1;
- for (var j:int = 2; j < vecs_box1.length; j++)
- {
- var curr_proj1:Number = vecs_box1[j].dotProduct(axis)
- //select the maximum projection on axis to corresponding box corners
- if (min_proj_box1 > curr_proj1) {
- min_proj_box1 = curr_proj1
- min_dot_box1 = j
- }
- //select the minimum projection on axis to corresponding box corners
- if (curr_proj1> max_proj_box1) {
- max_proj_box1 = curr_proj1
- max_dot_box1 = j
- }
- }
复制代码
最后,对特定轴P上是否存在碰撞进行判定:
- var isSeparated:Boolean = max_proj_box2 < min_proj_box1 || max_proj_box1 < min_proj_box2
- if (isSeparated) t.text = "There's a gap between both boxes"
- else t.text = "No gap calculated."
复制代码
以下为上述实现的demo:
可以拖拽四边形内部的点来移动,R和T键控制旋转。红色圆点表示该四边形的最大角点,黄色代表最小的。当四边形有两条边与P平行时,你拖拽四边形时会发现角点在闪烁,这是因为这两个角点有相同的特征。
完整代码可在源码下载中的DemoSAT2.as查看。
优化
进一步优化加速,p的单位向量其实是不需要计算的。因此,涉及Math.sqrt()的开销巨大的勾股定理计算有相当大部分可以跳过:
推导如下(参考上图中变量的辅助标记):
- /*
- Let:
- P_unit be the unit vector for P,
- P_mag be P's magnitude,
- v1_mag be v1's magnitude,
- v2_mag be v2's magnitude,
- theta_1 be the angle between v1 and P,
- theta_2 be the angle between v2 and P,
- Then:
- box1.max < box2.min
- => v1.dotProduct(P_unit) < v2.dotProduct(P_unit)
- => v1_mag*cos(theta_1) < v2_mag*cos(theta_2)
- */
复制代码
现在,如果不等式两边同时乘以一个相同的数字A(A>0),不等号方向不变:
- /*
- So:
- A*v1_mag*cos(theta_1) < A*v2_mag*cos(theta_2)
- If A is P_mag, then:
- P_mag*v1_mag*cos(theta_1) < P_mag*v2_mag*cos(theta_2)
- ...which is equivalent to saying:
- v1.dotProduct(P) < v2.dotProduct(P)
- */
复制代码
因而,这个是单位向量与否实际上并没多大关系——结果并不影响。
若只是检查是否重叠,那么这方法无疑非常好用。但如果需要得到box1与box2之间确切的距离值(大多数游戏可能会有要求),那你还是得对P的单位向量进行计算。
解决第二个缺陷
一个轴向的问题解决了,但还没结束。我们仍需要解决其他轴——问题是,选择哪个轴向呢?
对四边形的探究相当直观:我们需要在两个轴P和q上进行比较。为确认碰撞的确存在,所有轴向上的重叠检测必须都通过——若存在某个轴向上轴没有重叠,那么可以得出没有碰撞的结论。
那么四边形朝向不同的情况呢?
单击绿色按钮可以跳转到另一个页面。在P,Q,R和S轴之中,有一个轴显示四边形没有重叠,于是我们得出结论,四边形之间不存在碰撞。
但现在的问题是,我们该如何确定哪些轴向可以用来检查重叠?嗯,先让我们来观察下多边形的法线。
一般情况下,如果有两个四边形,那我们就必须沿着八个轴向检查: box1和box2各自的n0, n1, n2和n3轴向。然而,以下同一对其实是在同一轴向上的:
- box1的n0和n2
- box1的n1和n3
- box2的n0和n2
- box2的n1和n3
因此不需要对所有8轴向都进行检查,只要对4个进行就行了。若box1和box2朝向一致,那我们又可以进一步减少到只检测两个轴。
那对于其他多边形呢?
很不幸的是,对于上述的三角形以及五角形无法走这样的捷径,得对所有法线方向进行检查。
法线计算
每个面都有两条法线:
上图显示了p的左右法线。注意,向量两个分量的置换以及符号的不同。
我的实现约定了顺时针,所以我使用左法线。以下提取了SimpleSquare.as的重要部分来展示。
- public function getNorm():Vector.<Vector2d> {
- var normals:Vector.<Vector2d> = new Vector.<Vector2d>
- for (var i:int = 1; i < dots.length-1; i++)
- {
- var currentNormal:Vector2d = new Vector2d(
- dots[i + 1].x - dots[i].x,
- dots[i + 1].y - dots[i].y
- ).normL //left normals
- normals.push(currentNormal);
- }
- normals.push(
- new Vector2d(
- dots[1].x - dots[dots.length-1].x,
- dots[1].y - dots[dots.length-1].y
- ).normL
- )
- return normals;
- }
复制代码
全新实现
- //results of P, Q
- var result_P1:Object = getMinMax(vecs_box1, normals_box1[1]);
- var result_P2:Object = getMinMax(vecs_box2, normals_box1[1]);
- var result_Q1:Object = getMinMax(vecs_box1, normals_box1[0]);
- var result_Q2:Object = getMinMax(vecs_box2, normals_box1[0]);
- //results of R, S
- var result_R1:Object = getMinMax(vecs_box1, normals_box2[1]);
- var result_R2:Object = getMinMax(vecs_box2, normals_box2[1]);
- var result_S1:Object = getMinMax(vecs_box1, normals_box2[0]);
- var result_S2:Object = getMinMax(vecs_box2, normals_box2[0]);
- var separate_P:Boolean = result_P1.max_proj < result_P2.min_proj ||
- result_P2.max_proj < result_P1.min_proj
- var separate_Q:Boolean = result_Q1.max_proj < result_Q2.min_proj ||
- result_Q2.max_proj < result_Q1.min_proj
- var separate_R:Boolean = result_R1.max_proj < result_R2.min_proj ||
- result_R2.max_proj < result_R1.min_proj
- var separate_S:Boolean = result_S1.max_proj < result_S2.min_proj ||
- result_S2.max_proj < result_S1.min_proj
- //var isSeparated:Boolean = separate_p || separate_Q || separate_R || separate_S
- if (isSeparated) t.text = "Separated boxes"
- else t.text = "Collided boxes."
复制代码
其实不对某些变量进行计算也可以得到正确结果。若separate_P,separate_Q, separate_R和separate_S之中任意值为true,那表明多边形之间是分离的,后面步骤都可以跳过。
每次计算结束立即进行布尔值检查,符合条件的话就可以节省大量检测开销。这样就需要对代码进行部分修改,相信孩纸们能够自己完成的(或查看DemoSAT3.as的注释块)。
以下为上述实现的demo。单击拖拽四边形内部的圆点,R和T键用于旋转四边形:
后记
本算法运行时,它会检查各个轴向上是否存在重叠。在这里我有两点需要指出:
- SAT属于乐天派。若多边形在某个轴向上没有重叠,那么这个算法就可以退出并愉快地得出“没有碰撞”的结论。如果一直检测出碰撞,那SAT就不得不遍历全部全部轴向直至结束。因此,碰撞检测不通过的情况越多,算法的实际性能越差。
- SAT使用到多边形每条边上的法线。因此,多边形越是复杂,SAT开销越大。
六角形-三角形的碰撞检测
以下为另一个检测六角形和三角形之间是否存在碰撞的示例代码片段:
- private function refresh():void {
- //prepare the normals
- var normals_hex:Vector.<Vector2d> = hex.getNorm();
- var normals_tri:Vector.<Vector2d> = tri.getNorm();
- var vecs_hex:Vector.<Vector2d> = prepareVector(hex);
- var vecs_tri:Vector.<Vector2d> = prepareVector(tri);
- var isSeparated:Boolean = false;
- //use hexagon's normals to evaluate
- for (var i:int = 0; i < normals_hex.length; i++)
- {
- var result_box1:Object = getMinMax(vecs_hex, normals_hex[i]);
- var result_box2:Object = getMinMax(vecs_tri, normals_hex[i]);
-
- isSeparated = result_box1.max_proj < result_box2.min_proj || result_box2.max_proj < result_box1.min_proj
- if (isSeparated) break;
- }
- //use triangle's normals to evaluate
- if (!isSeparated) {
- for (var j:int = 1; j < normals_tri.length; j++)
- {
- var result_P1:Object = getMinMax(vecs_hex, normals_tri[j]);
- var result_P2:Object = getMinMax(vecs_tri, normals_tri[j]);
-
- isSeparated = result_P1.max_proj < result_P2.min_proj || result_P2.max_proj < result_P1.min_proj
- if (isSeparated) break;
- }
- }
- if (isSeparated) t.text = "Separated boxes"
- else t.text = "Collided boxes."
- }
复制代码
完整代码可在源码下载中的DemoSAT4.as查看。
以下为demo。交互操作与之前的demo一样:拖拽中间的圆点,并使用R和T进行旋转。
四边形-圆形的碰撞检测
与圆形之间的碰检相对简单。因为它的投影在所有方向上均相同(就只是圆的半径而已),可以就这么实现:
- private function refresh():void {
- //prepare the vectors
- var v:Vector2d;
- var current_box_corner:Point;
- var center_box:Point = box1.getDot(0);
-
- var max:Number = Number.NEGATIVE_INFINITY;
- var box2circle:Vector2d = new Vector2d(c.x - center_box.x, c.y - center_box.y)
- var box2circle_normalised:Vector2d = box2circle.unitVector
-
- //get the maximum
- for (var i:int = 1; i < 5; i++)
- {
- current_box_corner = box1.getDot(i)
- v = new Vector2d(
- current_box_corner.x - center_box.x ,
- current_box_corner.y - center_box.y);
- var current_proj:Number = v.dotProduct(box2circle_normalised)
-
- if (max < current_proj) max = current_proj;
- }
- if (box2circle.magnitude - max - c.radius > 0) t.text = "No Collision"
- else t.text = "Collision"
- }
复制代码
完整源码请查看DemoSAT5.as。随便拖拽圆形或四边形来查看其碰撞。
小结
嗯,就是这样。感谢阅读,欢迎留言反馈问题。下个教程再见