几乎所有的3D游戏都离不开碰撞检测——无论是各物体之间的碰撞检测,还是物体与场景之间的碰撞检测。在真实世界中,你是很自然地不能穿墙而过的,所以很多人在玩各种3D游戏的时候自然而然的忽视了碰撞检测这一过程的存在。然而,碰撞检测的过程是重要的。如果没有它,你在CS中就会毫无遮拦的飞来飞去——如果考虑重力的话,则会一直不停地往下掉,直到符点数溢出(或你受不了这一漫长的过程而离开游戏)为止。碰撞检测是在编程时实现的。不要以为碰撞检测是在显示3D图像的同时由显卡完成的——这是幼稚的看法——现在没有什么硬件能支持碰撞检测。
看了这些后你可能对碰撞检测产生了兴趣,或者你作为一个3D编程爱好者正在寻找碰撞检测的原理和方法,或者已经找了很长时间还没找到(我是不是有点贫:P),那么,就让我们一起来看一种十分有效而又易于理解的算法吧。
这个算法是针对物体与场景之间的碰撞检测的。它要求你的场景是以很多的三角形组成的。目前几乎所有的3D程序的场景或者物体都是由许多三角形组成的,所以这一点不成问题。另外,你需要记录物体在上一帧的位置和当前帧的位置。这也很容易办到。除了这些,就每什么其他的要求了。那么我们来看一下它的原理。
一、原理
首先,根据物体的运动规律或用户的输入计算物体在即将渲染的一帧(当前帧)时的位置(这时还不用考虑碰撞检测的问题)然后,循环遍历场景中每一个三角形。在循环过程中,作如下操作:
1、找到当前三角形所在的平面,我们暂且称之为平面S。沿平面法线方向对它做一个平移d,表示物体和平面之间所能接近的距离。
2、判断物体在上一帧和当前帧时的位置OldPosition、NewPosition与平面S的关系:如果上一帧在平面前,而当前帧在平面后,如下图所示:
则进一步做3。 否则,跳过3、4,做5。
3、因为前后两帧物体在平面的异侧,说明物体穿过了平面S。但这时还不能说物体与三角形发生了碰撞,因为平面是无边界的,还需进一步判断物体是否在三角形三条边范围之内穿过平面S。过三角形的三边,做垂直于三角形的平面PS1,PS2,PS3。并且令它们的法线指向三角形的内部,如下图所示:
为了体现物体和三角形所能接近的最短距离,这三个平面也需要做一个平移L,只不过是沿着其法线负方向:
我们将依靠这三个平面来判断物体是否是在三角形三边所界定的范围之内穿过平面S的。但是,如果三角形有很尖利的锐角,就会使其界定的区域过大,如figure4中所示。所以我们需要另外三个与它们平行的平面PS4,PS5,PS6 来削去产生的锐角(figure5)。产生这三个平面很容易,只需将PS1、PS2、PS3分别向它们的法线正方向平移到与之相对的顶点,再加上L。
现在,判断物体位置是否在这六个平面之内,如果是,则做4,否则,做5。
4、我们已经能够确定物体与当前的三角形发生了碰撞,这时,修正当前帧的位置NewPosition,使物体的运动沿着与平面S平行的方向。
5、我们已经能够确定物体与当前三角形没有发生碰撞。使下一三角形成为当前三角形,回到1。
当所有的三角形都遍历了一次之后,物体的位置NewPosition就是经过碰撞检测并修正了之后的。以该位置渲染物体和场景,并在下一帧之前,更新旧的位置为当前帧的位置(OldPosition = NewPosition)。
总之,该算法的原理就是:给出一个三角形——判断物体是否穿过了三角形所在的面——再判断物体是否是在三角型内部穿过的。下面我们来看一看它的具体实现。
二、具体实现
在具体的实现中,我们可能会遇到下面这些问题。
首先,如何得到三角形所在的平面呢?一个平面可以由法向量和它与原点的距离来表示,假设我们有一个三角形ABC,其所在平面为 S ,如下图所示:
| 其顶点位置为三个三维向量a,b,c。我们首先得到三角形的法向量 N:(下面的vector 表示三维向量,float表示浮点型标量)
vector v1 = b - a '由 a 指向 b 的边向量 vector v2 = c - a '由 a 指向 c 的边向量。
vector N = Normalize(v1 X v2) ' X代表叉乘,注意叉乘是有顺序的,如果v1和v2互换,结果向量将会反向。Normalize表示将所得结果单位化。所得的N就是三角形的法向量
vector S.N = N '三角形的法向量也就是平面 S 的法向量
然后,我们计算该平面与原点的距离 S.D:
float S.D = N * a ' *代表点乘,计算结果为三角形顶点a到法线S.N上的投影的长度,即距离S.D,为一标量。
有了法向量和距离,就可以表示一个平面了。如果想要对平面进行平移,只需修改 S.D 的值。
那么,怎样判断物体在平面的那一边呢?只需要比较物体位置在平面法向量上的投影长度和平面的 D 值就行了。假定我们的物体位置用3维向量 p 表示
float dp = S.N * p '点乘,dp为物体位置在平面 S 法线 N 上的投影长度,为一标量。
if (dp - S.D)>0 ,在平面 S 正面。 if (dp - S.D)<0 ,在平面 S 背面。
还有一个问题,就是怎样得到上文中提到的平面PS1,PS2和PS3。这个其实也不难,可以由下面的方法得到(以PS1为例):
vector PS1.N = Normalize(S.N X v1) '平面 S 法向 N 与 三角形边向量v1叉积并单位化,结果为PS1.N代表PS1的法向量。
float PS1.D = PS1.N * a - L'三角形顶点 a 在PS1的法向量上的投影长度,再减去 L
与之对应的平面PS4可以随之求得:
vector PS4.N = -PS1.N '两个平面的法向相对
float PS4.D = PS1.N * c - L '用顶点c点乘PS4的法向量,再减去 L
(#_# 这些公式真是令人头痛,我已经尽力想把它们说得清楚一些了。)
下面让我们进入实质性的阶段——代码的编写。
三、编写代码
还记得前不久在本站出现的一篇有关DirectX Mesh优化的文章吗?那里用到了一个非常简陋的所谓的“第一人称射击”游戏——“没有碰撞检测,没有光照,没有LightMap,没有BSP树,没有敌人(也就没有AI),没有武器:天那,什么也没有!”好吧,现在就让我们来给它加上碰撞检测。
下面是我后来向该程序中加入的三个自定义函数:Collision()、PreCheckCollision()和CheckCollision()。其中,Collision()函数完成整个的碰撞检测过程。在其内部调用了另外两个函数。PreCheckCollision()主要判断物体位置(在该程序中就是玩家的位置)是否穿过了某个平面。CheckCollision()函数进一步判断穿过位置是否在三角形内部。
//===================================================== // 函数名称:Collision() // 函数功能: 完成碰撞检测 // 备注:使用优化后的场景网格模型 // 参数说明: // Player_State * lpPs:Player_State是代表玩家状态的 // 自定义结构。其中的vnewPos、 // vOldPos成员变量将被用于碰撞检测。 // 函数接受该结构的一个指针lpPs。 // LevelMap * lpMap :LevelMap是代表场景的自定义结构。 // 其中的m_pMesh成员变量是一个 // ID3DXMESH接口指针,保存地图的网 // 格信息。 //----------------------------------------------------- HRESULT Collision(Player_State * lpPs,LevelMap * lpMap) { WORD * pIndexData; CustomVertex * pVertexData; D3DXVECTOR3 vNormal,vP1,vP2,vP3; FLOAT fdistance; if(lpMap == NULL) return E_FAIL;
if(lpMap->m_pMesh == NULL) return E_FAIL; if(lpPs == NULL) return E_FAIL; if(lpMap->m_dwNumIndices == 0) return E_FAIL; if(lpMap->m_dwNumVertices == 0) return E_FAIL; | 在DirectX 8 Graphics中,场景的网格模型顶点缓冲和索引缓冲组成。顶点缓冲保存了所有的顶点的信息,而索引缓冲中的每三个元素代表一个三角形,这些元素的值是该三角形的三个顶点在顶点缓冲中的索引号。我们可以由此获得三角形的三个顶点的位置。下面的代码锁定网格模型中的顶点和索引缓冲,并得到指向它们的指针。
lpMap->m_pMesh->LockIndexBuffer(D3DLOCK_READONLY,(BYTE**)&pIndexData); lpMap->m_pMesh->LockVertexBuffer(D3DLOCK_READONLY,(BYTE**)&pVertexData); | 然后,遍历网格的每一个三角形:
for(WORD i = 0;i < lpMap->m_dwNumIndices; i+=3) { WORD a = pIndexData[i+0]; //a:三角形的第一个顶点的索引 WORD b = pIndexData[i+1]; //b:三角形的第二个顶点的索引 WORD c = pIndexData[i+2]; //c:三角形的第三个顶点的索引 D3DXVECTOR3 v1 = pVertexData[b].p - pVertexData[a].p; //v1:边ab的向量 D3DXVECTOR3 v2 = pVertexData[c].p - pVertexData[a].p; //v2:边ac的向量
D3DXVec3Cross(&vNormal,&v1,&v2); //叉积得到三角形法向量。 D3DXVec3Normalize(&vNormal,&vNormal);
fdistance = D3DXVec3Dot(&vNormal,&pVertexData[a].p)+5.0f; //顶点a 在法向量上的投影长度,即平面与原点的距离。
if(PreCheckCollision(lpPs,&vNormal,fdistance) == 1) {//如果玩家穿过了该三角形的平面
if(CheckCollision(lpPs,a,b,c,&vNormal,pVertexData) == 1) {//如果是在三角形三边范围之内穿过的
//修改玩家的位置,使之不能穿墙。 lpPs->vnewPos += vNormal*(fdistance - D3DXVec3Dot(&vNormal,&lpPs->vnewPos)+0.2);
} } //否则,继续循环,判断下一三角形。 }
//循环结束,在退出前必须解锁顶点和索引缓冲。 lpMap->m_pMesh->UnlockVertexBuffer(); lpMap->m_pMesh->UnlockIndexBuffer(); return S_OK; } | 下面是函数PreCheckCollision()和CheckCollision()的代码。
//================================================= // 函数名:PreCheckCollision() // 功能: 判断玩家是否穿过了当前平面。 // 参数说明: // _Player_State* lpps: 玩家的状态,包含位置信息。 // vNromal: 当前平面的法向量(已单位化) // fdistance: 当前平面与原点的距离 // 返回值: 如果需要进一步检测,返回1 // 否则返回0。 //------------------------------------------------- INT PreCheckCollision(Player_State* lpps,D3DXVECTOR3* pvNormal,FLOAT fDist) { float StartSide,EndSide; //计算玩家的旧位置与平面的距离 StartSide = D3DXVec3Dot(pvNormal,&lpps->vOldPos) - fDist; //计算玩家的新位置与平面的距离 EndSide = D3DXVec3Dot(pvNormal,&lpps->vnewPos) - fDist;
//如果玩家的旧位置在平面之前而新位置在平面之后, //说明穿过了平面。 if(StartSide>0 && EndSide <=0) return 1; else return 0; }
//============================================== // 函数名称:CheckCollision // 功能: 判断物体是否在三角形内穿过。 // 参数说明: // _Player_State* lpps :玩家的状态,包含位置信息。 // a,b,c :三角形的顶点在顶点 // 缓冲中的索引。 // D3DXVECTOR3* vNormal:三角形的法向量 // CustomVertex* pVertices:指向顶点缓冲的指针 // 返回值: 如果玩家在三角形内部穿过,返回1 // 否则返回0 //---------------------------------------------- INT CheckCollision(Player_State* lpps,WORD a,WORD b,WORD c,D3DXVECTOR3 * pvNormal,CustomVertex * pVertices) { D3DXVECTOR3 PerPlaneNormal; //垂直平面(perpendicular plane)的法向量 float fPerPlaneDist; //垂直平面与原点的距离。
D3DXVECTOR3 v1; //用于记录三角形当前边的向量。
//============================================================ // 检查物体是否在过三角形第一条边AB的垂直平面 // 和与它相对的平面之内。
// 首先计算三角形的AB边向量。 v1 = pVertices[b].p-pVertices[a].p; // 计算过该边的垂直平面的法向量。 D3DXVec3Cross(&PerPlaneNormal,pvNormal,&v1); D3DXVec3Normalize(&PerPlaneNormal,&PerPlaneNormal); // 保证垂直边的法向量指向三角形内部。 if(D3DXVec3Dot(&(pVertices[c].p-pVertices[a].p),&PerPlaneNormal)<0) PerPlaneNormal = -PerPlaneNormal;
// 计算该垂直平面与原点的距离,并减去物体与三角形能够接近的最短距离。 fPerPlaneDist = D3DXVec3Dot(&PerPlaneNormal,&pVertices[a].p) - 5.0f;
// 如果物体在此垂直平面之外,返回0。 if(D3DXVec3Dot(&PerPlaneNormal,&lpps->vOldPos)-fPerPlaneDist<0) return 0;
// 由于此垂直平面与其相对垂直平面平行, // 所以直接计算相对平面与原点的距离。 fPerPlaneDist = D3DXVec3Dot(&PerPlaneNormal,&pVertices[c].p) + 5.0f;
// 如果物体也在此垂直平面的相对平面外,返回0。 if(D3DXVec3Dot(&PerPlaneNormal,&lpps->vOldPos)-fPerPlaneDist>0) return 0;
//============================================================= // 下面检查物体是否在过边BC的垂直平面以及它的相对平面之内。 // 步骤与上面基本相似。 v1 = pVertices[c].p-pVertices[b].p; D3DXVec3Cross(&PerPlaneNormal,pvNormal,&v1); D3DXVec3Normalize(&PerPlaneNormal,&PerPlaneNormal); if(D3DXVec3Dot(&(pVertices[a].p-pVertices[b].p),&PerPlaneNormal)<0) PerPlaneNormal = -PerPlaneNormal;
fPerPlaneDist = D3DXVec3Dot(&PerPlaneNormal,&pVertices[b].p) - 5.0f;
if(D3DXVec3Dot(&PerPlaneNormal,&lpps->vOldPos)-fPerPlaneDist<0) return 0;
fPerPlaneDist = D3DXVec3Dot(&PerPlaneNormal,&pVertices[a].p) + 5.0f;
if(D3DXVec3Dot(&PerPlaneNormal,&lpps->vOldPos)-fPerPlaneDist>0) return 0;
//============================================================= // 然后是过边CA的垂直平面和它的相对平面。
v1 = pVertices[a].p-pVertices[c].p; D3DXVec3Cross(&PerPlaneNormal,pvNormal,&v1); D3DXVec3Normalize(&PerPlaneNormal,&PerPlaneNormal); if(D3DXVec3Dot(&(pVertices[b].p-pVertices[c].p),&PerPlaneNormal)<0) PerPlaneNormal = -PerPlaneNormal;
fPerPlaneDist = D3DXVec3Dot(&PerPlaneNormal,&pVertices[c].p) - 5.0f;
if(D3DXVec3Dot(&PerPlaneNormal,&lpps->vOldPos)-fPerPlaneDist<0) return 0;
fPerPlaneDist = D3DXVec3Dot(&PerPlaneNormal,&pVertices[b].p) + 5.0f;
if(D3DXVec3Dot(&PerPlaneNormal,&lpps->vOldPos)-fPerPlaneDist>0) return 0;
//如果物体在上面任意六个平面之外,则函数早已返回0。 // 如果到这里还没有返回,说明物体在六面之内, // 应返回1。 return 1; } | 注:上面的三个函数是我按照该算法的原理自己写的,尽管在我的程序中运行正常,但是由于水平有限,难免会有疏漏之处,而且由于是针对例程而写,所以并没有太好的通用性。但是在函数中这种碰撞检测的算法还是比较忠实地体现出来了的。总之是为了更进一步的说明该算法。
一些改进
在上面,我们一直把物体和平面所能接近的最小距离用一个常量表示。但是,这样做等于是把物体看成一个球体,不能体现出物体形状上的一些特点。例如,人体的长和宽是大于高的,在碰撞检测时,最好能够看成是一个高的椭球体。解决这一问题的一个简单的方法是根据所要判断的三角形的法线来计算物体和平面的最小距离,当三角形越陡,就令该距离越小。具体的算法在参考文献中会给出。
关于例程
本文所用的例程及源代码可以点击这里下载。由于时间和水平关系,该例程远远没有完善,所以并不具有较好的通用性,而且可能包含一些错误。要运行该程序,需要DirectX 8或以上版本。要编译源程序,需要DirectX 8或以上版本的SDK。(在本站下载区有下载)。
程序中的控制:
W | 前进 | S | 后退 | A | 向左平移 | D | 向右平移 | 上箭头 | 上升 | 下箭头 | 下降 | ESC | 退出 | 鼠标 | 控制方向 | 鼠标右键 | 跳跃 | 关于参考文献
本文的程序是参考来自Samuel Ranta-Eskola的 《Binary Space Partioning Trees and Polygon Removal in Real Time 3D Rendering》一文中 “Physics In BSP-Trees”章节的 “Collision Detection and Collision Handling”部分的算法而针对Direct3D编制的,原文只给出了伪码表示的算法。本文作者对其中的某些部分做了一点修改。
该参考文献为Adobe PDF格式,请点击这里下载。 |