Oh!Coder

Coding Life

Box2D C++ 教程-碰撞剖析

| Comments

声明:本教程翻译自:Box 2D C++ turorials - Anatomy of a collision,仅供学习参考。

在Box2D中,经常会遇到物体之间的碰撞问题,当一个碰撞发生时,就是利用定制器(fixtures)用来做碰撞检测的。碰撞可以以很多方式进行,我们可以在碰撞过程中获取大量信息用在游戏逻辑中。

比如说,你可能想知道这几条信息:

-碰撞的开始和结束 -定制器中的哪个点有碰撞接触 -定制器之间法向量的关系 -碰撞过程中产生了多大的能量以及会产生有多大的响应

通常情况下碰撞发生的速度非常快,但是这篇文章我们会对一个碰撞进行讨论,把它的速度降到很慢,以此让我们来仔细看看碰撞过程中到底发生了什么,以及我们可以从中得到哪些相关信息。

故事发生在两个多边形定制器(fixtures)之间,为了能够更好的控制这两个多边形,我们选择在失重的世界(重力加速度为零)创建它们。其中一个是静止的四方盒子,另一个是面向四方盒子进行横向移动的三角形。

pic

场景中,三角形底部的角会和盒子上方的角发生碰撞。话说为什么要设置成这样的碰撞场景,原因不是本次讨论的重点,本次的重点是碰撞进行过程的不同阶段我们可以得到哪些信息,如果你想重现这里提到的碰撞场景,可以直接到源代码中查看。

  • 获取碰撞信息

b2Contact对象包含了碰撞的信息。从对象中可以得知哪两个定制器发生了碰撞,以及碰撞的位置和碰撞之后的反作用方向。在Box2D中有两个方法可以获取b2Contact对象,一个是遍历接触(contacts)链表每一个物体,另外一种方法是用接触监听器(contact listener)。下面先让我们快速浏览一下接下来要讨论的两种方法。

-查看接触链表

你可以在任何时候,通过查看世界接触链表来获取当前所有接触:

1
2
for (b2Contact* contact = world->GetContactList(); contact; contact = contact->GetNext())
    contact->... //do something with the contact

或者通过某个物体查看它的接触:

1
2
for (b2ContactEdge* edge = body->GetContactList(); edge; edge = edge->next)
    edge->contact->... //do something with the contact

如果你使用这种方法,有一点非常重要的地方就是,即便链表中存在接触(contact)也并不代表着两个定制器之间正在发生接触-这仅仅代表了两个物体的AABB框发生了接触而已。如果你想知道定制器自身是否真正发生了碰撞,需要通过判断IsTouching()方法来检测。

-接触监听器

使用接触链表进行碰撞检测,对于需要进行大量快速的碰撞信息获取来说,显得就很低效。设置接触监听器可以让Box2D告诉你你所感兴趣的事情什么时候会发生,比起你只是为了得知碰撞的开始和结束而兴师动众的完成所有重量级工作(例如不停的遍历接触链表)来说,显得非常高效。接触监听器是一个包括四个方法的类(b2ContactListener),这些方法可以根据你的需要进行重写。

1
2
3
4
void BeginContact(b2Contact* contact);
void EndContact(b2Contact* contact);
void PreSolve(b2Contact* contact, const b2Manifold* oldManifold);
void PostSolve(b2Contact* contact, const b2ContactImpulse* impulse);

具体的事件内容取决于当前正在发生什么,一些事件只是给我们传递了b2Contact对象。世界中的Step方法执行过程中,当Box2D检测到某个事件发生时,就会回调这些方法,你就会知道到底发生了什么事情。实际当中的应用会在“碰撞回调”中讨论,这里我们只是关注碰撞的什么时候发生。

通常我比较建议使用接触监听器这种方法来做碰撞检测。乍看起来它好像有点烦杂笨重,但是从长远来看,这种方法更加高效和有用。

上述两种方法都可以获取接触,它们都包含了相同的信息。其中最根本的信息就是获取哪两个定制器发生了碰撞,可以通过如下方法获取:

1
2
b2Fixture* a = contact->GetFixtureA();
b2Fixture* b = contact->GetFixtureB();

如果你正在使用遍历接触链表的方法来检测碰撞,那么或许你已经知道发生碰撞的定制器是哪连个。但是如果你使用的方法是接触监听器,你需要依靠两个方法来判断哪两个定制器发生了碰撞。发生碰撞的定制器A和定制器B之间是没有次序关系的,所以你需要通过对定制器(fixtures)或物体(bodies)设置用户数据来说明当前的定制器属于哪一个。通过定制器,你可以通过GetBody()方法来找到对应的物体。

  • 拆解碰撞

现在让我们深入的来了解上面提到的碰撞场景中,所发生的一系列碰撞事件。幸运的是,我们可以通过表格的形势进行呈现(译者注:为了编辑方便偷了个懒,这里用了列表的形式,:P),表格的左边可以展现每一步的场景。你还通过testbed教程源代码页面下载源码,边运行边看教程。在testbed中,你可以对模拟器进行暂停和重新开始操作,然后按“Single Step”按键可以详细观察发生的一切。

我们就从定制器的AABB框尚未重叠的那一刻开始,这样我们就可以了解全程。单击’AABBs’选择框,来查看围绕在每个定制器外面的紫色四边形(译者注:这就是AABB框)。

pic

-定制器AABB框开始重叠(b2ContactManager::AddPair)

pic

虽然定制器自身并没有发生重叠,但此时世界当中,相关物体的b2Contact对象被创建并添加到接触链表中。如果你按照上面提到的方法遍历接触链表的话,你将会发现有相应的对象出现,这也就预示着告诉你将有可能发生碰撞,但是实际上来说,一般你并不会关心AABB框的重叠与否,因为你在乎的是定制器自身是否真正有重叠。

结果:

-接触对象存在于接触链表中,但IsTouching()返回false

-定制器开始重叠(b2Contact::Update)

pic
                            Step n

pic
      Step n+1(non bullet bodies)

对盒子左上角的图像进行缩放,可以通过左侧(译者注:这里是上图)就可以看到两个图形平移的情况。这一刻发生在某个时间步长,这也就是说真实的碰撞点(如上图点线所示)已经被跳过。这是因为Box2D是先移动所有物体,然后再进行重叠检测,至少默认设置是这样的。如果你想获得真实的碰撞位置,需要设置物体的”bullet”属性,这样就可以得到下图中所示的图像。具体实现如下:

1
2
bodyDef.bullet = true; //before creating body, or      
body->SetBullet(true); //after creating body

pic
   Step n+1(triangle as bullet body)

Bullte物体会花费更多CPU时间来计算碰撞点,而且对于很多应用来说这是没有必要的。作为默认设置,你需要知道有时候会发生碰撞完全丢失的情况-比如本例中,如果三角形移动过快,完全有肯能跳过四方盒子的右上角!如果一个物体需要运动的足够快而且又不能丢失任何碰撞,例如呃~…子弹!:)那么你需要把物体设置成bullet物体。接下来,我们还是延续之前的非bullet物体属性进行讨论。

结果:

-IsTouching现在变为true
-BeginContact回调方法会被执行

  • 碰撞点和法向量

此时我们有了一个已经重叠的接触,这就意味着现在我们就可以回答文章一开始提出的几个问题。首先,让我们先获取位置和法向量,下面这段代码,我们假定你要么放到接触监听器的BeginContact方法中,要么是通过获取接触链表,放到手动检测物体接触链表方法中。

其中,接触以物体自身坐标系统为标准,存储了碰撞点的坐标信息,然而这对我们来说用处不大。但是我们可以利用接触获取更有用的’world manifold’,它以世界坐标系为标准存储了碰撞点的位置信息。’Manifold’只不过是凭空想出来,能更好的区分两个定制器的那条线的名字而已。

1
2
3
4
5
6
7
8
9
10
//normal manifold contains all info...
int numPoints = contact->GetManifold()->pointCount;
//...world manifold is helpful for getting locations
b2WorldManifold worldManifold;
contact->GetWorldManifold( &worldManifold );
//draw collision points
glBegin(GL_POINTS);
    for (int i = 0; i < numPoints; i++)
        glVertex2f(worldManifold.points[i].x,worldManifold.points[i].y);
glEnd();

pic

当作用一个冲量,让两个定制器分开时,这些点就是碰撞反作用点。虽然这些点不是定制器第一次接触时精准的坐标点(除非你使用bullet类型物体)。实践中,这些点足够在游戏逻辑中使用。

下面让我们展示一下从定制器A指向定制器B的法向量:

1
2
3
4
5
6
7
8
9
10
float normalLength = 0.1f;
b2Vec2 normalStart = worldManifold.points[0] - normalLength * worldManifold.normal;
b2Vec2 normalEnd = worldManifold.points[0] + normalLength * worldManifold.normal;

glBegin(GL_LINES);
    glColor3f(1,0,0);//red
    glVertex2f( normalStart.x, normalStart.y );
    glColor3f(0,1,0);//green
    glVertex2f( normalEnd.x, normalEnd.y );
glEnd();

具体看起来像是这样,以最快的方法解算出重叠产生的冲量,以此将三角形的一个角推向左上方,同时将四边形的这个角推向右下方。请注意法向量只不过是一个方向而已,它并没有坐标也没有连接其中任何一个点-我仅仅是图方便,画到了points[0]点而已。

pic

这里有一个很重要的一点是碰撞法向量并不能为你提供两个碰撞定制器之间的角度-还记得这里的三角形是水平移动的,对吧?它只能给出定制器不会重叠的短距离移动方向。比如,想象一下如果三角形移动的速度再快一点重叠的部分看起来像这样:

pic

…那么短距离内使两个定制器分离,会把三角形向右上方推开。这就可以明显看出,使用法向量来作为定制器的碰撞角度并不是一个好的办法。如果你想知道两个定制器实际所受影响的角度,可以这样:

1
2
3
b2Vec2 vel1 = triangleBody->GetLinearVelocityFromWorldPoint( worldManifold.points[0] );
b2Vec2 vel2 = squareBody->GetLinearVelocityFromWorldPoint( worldManifold.points[0] );
b2Vec2 impactVelocity = vel1 - vel2;

…以此来获取碰撞过程中每个物体上点的实际相对速度向量。作为一个简单的例子,我们看看是否也可以获取三角形的线速度,因为我们知道四边形盒子是静态的并且三角形是不旋转的,但是上面的代码仍然考虑到了两个物体同时旋转或者平移的情况。

另外需要注意的一点是,不是所有碰撞都会有两个碰撞点。本实例我是特地找了一个复杂点的例子,其中多边形的两个角发生重叠,但是现实中更常见的碰撞情景是只有一个碰撞点。这里展示了一些只有一个碰撞点的例子:

pic
pic
pic

ok,现在我们已经知道如何找到碰撞点以及法向量,并且我们也了解到这些点和法向量将会被Box2D用来正确响应碰撞重叠。下面让我们继续回到事件序列的正轨上来…

  • 碰撞响应(b2Contact::Update, b2Island::Report)

pic
                           Impact

pic
                      Impact + 1

pic
                      Impact + 2

当定制器重叠的时候,Box2D默认的行为是为每一个定制器施加冲量,并让它们分开,但是在单个步长中,并不是每次总能成功的。正如这里所示,对于这个特殊的例子,这两个定制器在被’弹力’完全弹开并再次分离之前会有三个时间步长。

在这个时间长度里,如果我们愿意我们可以对每一步进行自定义行为。如果你使用接触监听器方法进行检测,监听器中的PreSolve和PostSolve方法会在定制器重叠过程中的每个时间步长里被重复调用,这就给了你机会在处理碰撞响应(PreSolve方法)之前来改变接触以及发现处理碰撞响应之后(PostSolve方法)所产生的碰撞向量。

为了让思路更清晰,这里通过在例子的主Step方法以及每一个接触监听器方法中放入输入模块,来依次把事件顺序进行了打印:


Step
Step
BeginContact
PreSolve
PostSovle
Step
PreSolve
PostSolve
Step
PreSolve
PostSolve
Step
PreSovle
PostSovle
Step
EndContact
Step
Step

结果:

-PreSolve和PostSolve方法会被重复调用

  • PreSolve和PostSolve

PreSovle和PostSolve方法都为你提供了一个b2Contact指针,那么我们就可以访问同一个指针以及在BeginContact方法中进行查看的常用信息。PreSolve方法为我们提供了一个在碰撞响应计算之前改变接触特性的机会,或者甚至是同时取消响应,而且通过PostSolve方法我们可以找到碰撞响应的具体信息。

下面是可以在PreSolve方法中对于接触做出的一些改变:

1
2
3
4
void SetEnabled(bool flag);//non-persistent - need to set every time step
//these available from v2.2.1
void SetFriction(float32 friction);//persists for duration of contact
void SetRestitution(float32 restitution);//persists for duration of contact

调用SetEnabled(false)方法将会关闭接触,这也就意味着正常的碰撞响应将会被跳过。你可以利用这一点暂时允许物体之间彼此穿透。一个典型的例子是单面墙或平台,玩家可以从一面穿过,另一面却是实墙,这只能在运行时根据不同的条件进行界定,类似于玩家此时的位置以及面部朝向,等等。

需要注意的重要的一点是接触的状态会在下一个时间步长恢复,所以如果你希望像上面那样关闭接触,那么每个时间步长都应该调用SetEnabled(false)方法。

除了接触指针以外,PreSolve还有第二个参数,此参数为我们提供了先前时间步长中关于碰撞的相关信息。如果有人知道这个参数用来做什么,也让我了解一下 :D

PostSolve方法在碰撞响应计算以及应用之后进行调用。它也有第二个参数,我们可以获取应用于碰撞的冲量信息。对于这个信息更常用的地方是用来检查所产生的碰撞响应是否超过给定阀值,通过检查这个阀值可以判断某个物体是否会发生爆炸,等等。详见’黏性抛体(“sticky projectiles”)’话题中的例子,使用PostSolve方法来检测当一支箭进行射击时是否应该黏到目标上。

Ok,回到时间线上…

pic

pic
                          放大图

-定制器完成重叠(b2Contact::Update)

AABB框仍然重叠,所以接触仍然保留在物体/世界的接触链表中。

结果:

-EndContact回调方法将会被调用
-IsTouching()现在返回false

pic

定制器的AABB框结束重叠(b2ContactManager::Collide)

结果:

-Contact从物体/世界的接触链表中移除

当调用EndContact方法的时候,接触链表会传入一个b2Contact指针,此时定制器将不再有接触,所以也不会再获得有效的相关信息。即便如此EndContact事件仍然是接触链表当中不可或缺的一部分,因为它允许你检查定制器/物体/游戏对象中哪个结束了接触。详见下一个话题中的例子。

  • 总结

希望此次话题,通过对Box2D碰撞事件毫秒级别一步步的讲解,能够让你对此有一个清晰的大致了解。这个话题看起来也许不是那么有意思(可以肯定的是,到目前为止应该是令人兴奋的话题!)但是我从一些论坛的问题中感受到,这里所讨论的一些细节经常被忽略掉,并且大部分时间都花在盲目的解决问题上,而不是真正了解到底是怎么回事。我还注意到一种回避使用接触监听器的倾向,从长远来看以此会让监听器承担更少的工作。了解这些细节可以让你真正理解实际过程是什么样的,可以让你有更好的设计,节约实现的时间。

Comments