碰撞检测
现在,我们先前的文章中讲述的方案已经能够模拟大部分的刚体了,那么接下来我解决另外一个问题--碰撞检测。在本篇文章中,我将要使用一个名为“分离轴理论”的算法来进行碰撞检测。如果你已经知道了这个理论,那么你就可以直接跳过这个部分,去看碰撞反应的内容。那么,分离轴理论是怎么样工作的了?正如这个理论的名称所讲述的那样,如果我们能够在两个刚体之间找到一条直线,这条直线不与这两个刚体中的任何一个发生交叉,那么我们就认为这两个物体是没有碰撞的。下图演示了这个理论:
这个算法的唯一限制的地方就是,它只能够作用与凸边形。如果我们对两个凹边形进行这样的测试,那么这个理论就会失败,也就是说,可能会错误的检测出两个形状之间发生了碰撞,而实际上是没有发生碰撞的。通过下图,你就能够很清晰的明白这个理论:
我们能够将上面的凹多边形拆分称为多个凸多边形组合而成,然后对这些形状分别进行测试。本篇文章为了简便,将只对凸多边形进行处理。如果你觉得有必要,那么就增加对凹多边形的处理。
那么,我们怎么去发现我们是否能够在两个刚体之间插入一条直线了?我们当然能够测试所有的直线,看看是否存在这样的一条。但是这样做是非常没有效率的。为了做到类似的效果,我们可以采用投影的方式进行。如果我们在图1中的分离轴上,绘制一条垂直的线,那么,我们就能够发现这两个刚体在这个垂直线上的投影实际上是没有重合的部分。那么,如果这两个刚体在这条垂直线上的投影发生了交叉,那么也就是说他们的投影有重叠的部分。
无论我们将这条直线放在什么地方,投影的结果都是一维空间里面的,也就是说只有这条线的方向对我们有意义。所以,实际上我们并不是要找到那条确切的将两个刚体分离开来的直线,而是要找到一个方向,在这个方向上,两个刚体的投影不会发生交叉重叠。而找这样的一个方向实际上是十分简单的。我们来考虑下只有一条可能的线将两个刚体分离的情况。
很明显,我们能够看到这条线实际上是和下面的四边形的边界线是平行的。因此,我们只要遍历这个四边形的四条边,来检测两个刚体的投影是否在这些边上发生了重合。如果存在一条边,没有发生重合,那么我们就能够认为这两个刚体实际上是没有发生碰撞的。如果没有这样的边存在,那么我们就认为这两个刚体发生了碰撞,接下来就需要对它们进行碰撞反应的处理。
我们将这些内容编写成代码的形式。但是在实现我们的碰撞检测之前,我们首先写一个刚体的结构,它包含了它的顶点和边界:
struct PhysicsBody {
int VertexCount;
int EdgeCount;
Vertex* Vertices[ MAX_BODY_VERTICES ];
Edge* Edges [ MAX_BODY_EDGES ];
void ProjectToAxis( Vec2& Axis, float& Min, float& Max );
//Again, constructors etc. omitted
};
这个结构体里面的ProjectToAxis,将会将这个刚体投影到指定的轴线上去,并且通过Min和max来返回在这条轴线上的坐标。由于投影是值将一个2D形状变换为1D的操作,所以这个投影的结果能够通过两个float型的数据来保存。投影的方法十分的简单:
void PhysicsBody::ProjectToAxis( Vec2& Axis, float& Min, float& Max ) {
float DotP = Axis*Vertices[ 0 ]->Position;
//Set the minimum and maximum values to the projection of the first vertex
Min = Max = DotP;
for( int I = 1; I < VertexCount; I++ ) {
//Project the rest of the vertices onto the axis and extend
//the interval to the left/right if necessary
DotP = Axis*Vertices[ I ]->Position;
Min = MIN( DotP, Min );
Max = MAX( DotP, Max );
}
}
正如你所看到的,将2D投影到1D上的操作,仅仅是一个点积操作而已。那么,碰撞检测的代码就像下面这样:
bool Physics::DetectCollision( PhysicsBody* B1, PhysicsBody* B2 ) {
//Just a fancy way of iterating through all of the edges of both bodies at once
for( int I = 0; I < B1->EdgeCount + B2->EdgeCount; I++ ) {
Edge* E;
if( I < B1->EdgeCount )
E = B1->Edges[ I ];
else
E = B2->Edges[ I - B1->EdgeCount ];
//Calculate the axis perpendicular to this edge and normalize it
Vec2 Axis( E->V1->Position.Y - E->V2->Position.Y, E->V2->Position.X - E->V1->Position.X );
Axis.Normalize();
float MinA, MinB, MaxA, MaxB; //Project both bodies onto the perpendicular axis
B1->ProjectToAxis( Axis, MinA, MaxA );
B2->ProjectToAxis( Axis, MinB, MaxB );
//Calculate the distance between the two intervals - see below
float Distance = IntervalDistance( MinA, MaxA, MinB, MaxB );
if( Distance > 0.0f ) //If the intervals don't overlap, return, since there is no collision
return false;
}
return true; //There is no separating axis. Report a collision!
}
上面的算法就是我们前面所描述的那样。如果你对这段代码存在疑惑,那么我建议你一步一步的看下前面的解释。IntervalDistance也是十分简单的:
float Physics::IntervalDistance( float MinA, float MaxA, float MinB, float MaxB ) {
if( MinA < MinB )
return MinB - MaxA;
else
return MinA - MaxB;
}
由于我们不知道刚体A是否落在刚体B的左边或者右边,所以我们先进行检测,判断下两个端点的位置关系,从而判断是否发生了重叠。
上面的代码就是碰撞检测的所有内容,除了我们还需要在碰撞检测系统中获取一个碰撞向量,这个向量将要用来将两个刚体分离,使他们不再发生碰撞而仅仅是相互接触。实际上这样的向量有很多,但是为了让我们的物理显的更加真实,我们需要找到其中最小的一个向量。这个最小的向量有一个特殊的特性,那就是它总是垂直于我们将要投影到的那条直线上去,也就是说我们需要对每一条边计算一次碰撞向量,从中找到最小的一个向量即可。这个碰撞向量的长度是容易计算出来的,大家只要看下下图就能够明白:
上面的代码我们将每一个刚体都投影到了一个归一化的向量上去,然后调用IntervalDistance来检测他们是否发生了重叠。而碰撞向量的长度刚好就等于这个哈苏计算出来的两个投影之间重叠的部分长度。为了让我们的碰撞检测系统和接下来的碰撞反应系统能够很好的进行交互,我们将这个碰撞向量的信息创建了一个单独的结构体:
class Physics {
struct {
float Depth;
Vec2 Normal;
} CollisionInfo;
//Everything else omitted
}
Depth成员表示的就是碰撞向量的长度,Normal就是碰撞向量的方向。
我们新的碰撞检测函数将进行修改,如下所示:
bool Physics::DetectCollision( PhysicsBody* B1, PhysicsBody* B2 ) {
float MinLength = 10000.0f; //Initialize the length of the collision vector to a relatively large value
for( int I = 0; I < B1->EdgeCount + B2->EdgeCount; I++ ) {
Edge* E;
if( I < B1->EdgeCount )
E = B1->Edges[ I ];
else
E = B2->Edges[ I - B1->EdgeCount ];
Vec2 Axis( E->V1->Position.Y - E->V2->Position.Y, E->V2->Position.X - E->V1->Position.X );
Axis.Normalize();
float MinA, MinB, MaxA, MaxB;
B1->ProjectToAxis( Axis, MinA, MaxA );
B2->ProjectToAxis( Axis, MinB, MaxB );
float Distance = IntervalDistance( MinA, MaxA, MinB, MaxB );
if( Distance > 0.0f )
return false;
//If the intervals overlap, check, whether the vector length on this
//edge is smaller than the smallest length that has been reported so far
else if( abs( Distance ) < MinDistance ) {
MinDistance = abs( Distance );
CollisionInfo.Normal = Axis; //Save collision information for later
}
}
CollisionInfo.Depth = MinDistance;
return true; //There is no separating axis. Report a collision!
}
一旦我们有了这个函数,我们就能够实现一些非常简单的碰撞反应 了。由于我们计算出来的碰撞向量,能够将两个刚体相互分离,并且不再发生碰撞,所以我们就可以简单的使用这个碰撞向量对刚体的所有顶点进行移动,以此来进行碰撞反应的处理。这个能够解决问题,但是结果看上去并不是非常的好。两个刚体之间将会发生相对的滑动,他们的表现和现实世界的效果并不一致,不会有相对旋转的效果出现。
使用上面的方法,当刚体的顶点速度不相同的时候,刚体就会发生旋转。同样的,一个刚体只要当它的顶点具有不同的加速度的时候,这个刚体才会存在旋转速度。加速度就是速度的改变,而在Verlet积分器中速度的改变就是位置的改变。因此,如果我们将两个刚体通过碰撞向量移动,那么这两个刚体中所有顶点的速度都会发生同样的改变,也就是说,顶点速度一致,就不存在旋转速度了。正是因为这个原因,我们需要编写出更好的碰撞反应出来。
这里就是为什么我们要使用Position Verlet的原因所在。在一个刚体系统中,我们需要通过非常复杂的公式去计算一个刚体的动量,然后在分别计算它的线性动量和角动量。在我们的系统中,整个事情变得十分简单,我们只需要移动边界和发生碰撞的一些参与点,直到两个刚体不再发生碰撞而仅仅是相互接触的状态。由于边界和顶点都和其他的刚体中的属性相关联,所以当我们改变了这些边界和顶点的时候,由于边界约束的存在,其他的边界和顶点也会随之发生改变,从而适应这个新的情况。最终就导致两个相互碰撞的刚体会发生相互旋转的效果。所以,整个碰撞反应的处理,就是将发生碰撞的一些边界和顶点的位置进行改变,使他们相互分离,那么剩下来的顶点就会在后面的约束中被自动的进行修正完善了。
在一次碰撞中,标示那些参与的边界和顶点并没有什么困难的。碰撞的顶点实际上就是距离另外一个刚体最近的顶点。因此,我们只要简单的计算一个刚体中所有的顶点到碰撞向量的法向量那条直线的距离,得出最小距离的那个点即可,使用如下的几何公式,就能够得到我们想要的距离:
上面公式中的N表示的是碰撞向量,R0是坐标系的原点,R是刚体上的点,d就是R点到碰撞向量法向量那条直线的距离。一旦我们计算出了刚体中每一个顶点到这条直线的距离,我们选择具有最小距离的那个顶点作为碰撞顶点。注意,上面公式中的d可能为负值。一条直线将一个空间分为两个不同的半空间,如果点R坐落在直线的法向向量所指向的空间中,那么这个d就是正值,如果坐落在另外一个方向,那么这个d就是负值。所以,碰撞向量所指向的方向十分重要。在我们的实现中,我们总是认为碰撞向量的方向是包含碰撞顶点的那个刚体。
碰撞边界就更加容易寻找到。还记得我们前面通过将一个刚体投影到边界的法线上去寻找最小的碰撞向量吗?碰撞边界实际上就是产生这个最小碰撞向量的边界。
是时候将这些内容编写成代码实现了。首先,我们需要扩展前面设计的类,以此来容纳我们这里需要的碰撞边界和碰撞顶点的信息:
struct {
float Depth;
Vec2 Normal;
Edge* E;
Vertex* V;
} CollisionInfo;
然后,我们就能够重新编写我们的DetectCollision函数,以此来获取额外的信息:
bool Physics::DetectCollision( PhysicsBody* B1, PhysicsBody* B2 ) {
float MinDistance = 10000.0f;
for( int I = 0; I < B1->EdgeCount + B2->EdgeCount; I++ ) { //Same old
Edge* E;
if( I < B1->EdgeCount )
E = B1->Edges[ I ];
else
E = B2->Edges[ I - B1->EdgeCount ];
Vec2 Axis( E->V1->Position.Y - E->V2->Position.Y, E->V2->Position.X - E->V1->Position.X );
Axis.Normalize();
float MinA, MinB, MaxA, MaxB;
B1->ProjectToAxis( Axis, MinA, MaxA );
B2->ProjectToAxis( Axis, MinB, MaxB );
float Distance = IntervalDistance( MinA, MaxA, MinB, MaxB );
if( Distance > 0.0f )
return false;
else if( abs( Distance ) < MinDistance ) {
MinDistance = abs( Distance );
CollisionInfo.Normal = Axis;
CollisionInfo.E = E; //Store the edge, as it is the collision edge
}
}
CollisionInfo.Depth = MinDistance;
//Ensure that the body containing the collision edge lies in
//B2 and the one containing the collision vertex in B1
if( CollisionInfo.E->Parent != B2 ) {
PhysicsBody* Temp = B2;
B2 = B1;
B1 = Temp;
}
//This is needed to make sure that the collision normal is pointing at B1
int Sign = SGN( CollisionInfo.Normal*( B1->Center - B2->Center ) );
//Remember that the line equation is N*( R - R0 ). We choose B2->Center
//as R0; the normal N is given by the collision normal
if( Sign != 1 )
CollisionInfo.Normal = -CollisionInfo.Normal; //Revert the collision normal if it points away from B1
float SmallestD = 10000.0f; //Initialize the smallest distance to a high value
for( int I = 0; I < B1->VertexCount; I++ ) {
//Measure the distance of the vertex from the line using the line equation
float Distance = CollisionInfo.Normal*( B1->Vertices[ I ]->Position - B2->Center );
//If the measured distance is smaller than the smallest distance reported
//so far, set the smallest distance and the collision vertex
if( Distance < SmallestD ) {
SmallestD = Distance;
CollisionInfo.V = B1->Vertices[ I ];
}
}
return true;
}
在上面的代码中,我们为PhysicsBody添加了另外一个新的成员属性center。这个center会在碰撞步骤之前进行计算更新,我们只要对所有的顶点求平均值即可得到。