在做三维运动防碰撞项目中需要用到SAT算法,但是查阅资料发现很少有讲明白三维SAT应用的。于是打算自己记录一下实现过程。
一. 简介
分离轴理论,简称SAT(SeparatingAxisTheorem),通过判断任意两个凸多边形在任意角度下的投影是否均存在重叠,来判断是否发生碰撞。即两个不相交的多边形一定能找到一条轴,它们在这条轴上的投影不相交,而如果找不到这样的一条轴,那说明这两个物体相交。这就是分离轴定理的核心思想。
注意:分离轴定理只适合凸多边形(体),所以如果是凹多边形(体)的话需要转换成多个凸多边形(体)。可以参考:二维多边形分解为凸块的库
二. SAT在二维的应用
二维的SAT应用比较具象,理解起来更为容易,适合新手学习,建议新学者可以先看这篇文章 常见的2D碰撞检测 中的分离轴定理部分。
三. SAT在三维的应用
在理解了SAT在二维中的应用后,尝试将其扩展到三维空间中对物体进行碰撞检测。在这过程中也遇到了不少坑,分享一下思路与部分代码。
首先介绍一下模型的基本信息:模型中包含多个组件,每个组件有一个相对于绝对坐标系下的位姿矩阵。组件中又包含多个图元,每个图元有一个相对于组件坐标系下的位姿矩阵。
部分数据结构信息如下:
//位姿信息
typedef struct _Matrix4x3 {
union {
float poseMatrix[12]; //表示4*3的矩阵
struct {
Matrix3x3 m; //表示poseMatrix中的前9位,是一个3*3的旋转矩阵
Point center; //表示poseMatrix中的后3位,是体心坐标
};
};
}Matrix4x3;
//结构体:AABB包围盒
typedef struct _Aabb{
Point aabbMin; //Aabb包围盒的xyz方向上的最小值构成的坐标点
Point aabbMax; //Aabb包围盒的xyz方向上的最大值构成的坐标点
}Aabb;
//结构体:球
typedef struct _Sphere {
float radius; //球的半径
}Sphere;
//结构体:圆柱
typedef struct _Cylinder {
float radius; //底面圆的半径
float h; //圆柱的高
}Cylinder;
//结构体:长方体
typedef struct _Cube {
float length; //长
float width; //宽
float height; //高
}Cube;
1. 创建组件坐标系下的AABB包围盒
已知图元位姿矩阵与基本参数后,对于基本图元(长方体、圆柱、球等)创建AABB包围盒,分三种情况处理:
Bit32 CalculateAabb(Primitive* primitive, Aabb* aabb) {
//根据图元类型进行分流
if (primitive->type == SPHERE_TYPE){ //球
return CalculSphereAabb(&primitive->sphere, primitive->poseMatrix, aabb);
}
else if (primitive->type == CYLINDER_TYPE){ //圆柱
return CalculCylinderAabb(&primitive->cylinder, primitive->poseMatrix, aabb);
}
else if (primitive->type == CUBE_TYPE){//长方体
return CalculCubeAabb(&primitive->cube, primitive->poseMatrix, aabb);
}
return -1;
}
计算长方体与球的AABB盒都十分简单,只有在计算圆柱时稍微复杂一点,需要在图元坐标系中计算出包围盒顶点坐标,再将8个顶点转换到组件坐标系下,找出最大最小值,更新包围盒顶点坐标。已知圆柱的位姿矩阵为:R =
Bit32 CalculCylinderAabb(Cylinder* cylinder, float* poseMatrix,Aabb* aabb){
Point box[8] = {0}, vertices[8] = {0}; //圆柱在图元坐标系下的AABB盒8个顶点
int i = 0, j = 0, k = 0;
int index = 0;
//计算8个顶点坐标
for (i = 0; i < 2; i++) {
for (j = 0; j < 2; j++) {
for (k = 0; k < 2; k++) {
box[index].x = centerX + sign(i) * cylinder->radius;
box[index].y = centerY + sign(j) * cylinder->radius;
box[index].z = centerZ + sign(k) * cylinder->h;
index++;
}
}
}
//遍历box的8个顶点,对点坐标进行转换成组件坐标系下的坐标之后,找出其中所有转换点的最大、最小值
for (i = 0; i < 8; i++) {
vertices[i] = MultiMatrixVector(&poseMatrix, &box[i]);
vertices[i] = Add(&vertices[i], &poseMatrix);
aabb->aabbMax.x = fmax(vertices[i].x, xmax);
aabb->aabbMax.y = fmax(vertices[i].y, ymax);
aabb->aabbMax.z = fmax(vertices[i].z, zmax);
aabb->aabbMin.x = fmin(vertices[i].x, xmin);
aabb->aabbMin.y = fmin(vertices[i].y, ymin);
aabb->aabbMin.z = fmin(vertices[i].z, zmin);
}
return 0;
}
通过计算得到基本图元AABB盒信息,该步骤是为了在后续使用SAT检测时减少计算量。
2. 确定绝对坐标系下的分离轴
如果两个包围盒不相交,那么必定存在能将它们分离开来的平面,也即必定存在满足分离条件的分离轴。判断两个包围盒是否相交,就是看是否能找到一条分离轴,使两者在其上的投影不重叠。
而怎么去找分离轴很关键,与二维不同,找三维空间中的分离轴要复杂一些。对于两个相交的由多面体,它们之间的相交方式可以归结为以下几种:面-面、面-边、边-边相交。对于上述三种相交方式,我们就可以逐条去找所有可能的分离平面:
1. 包围盒A三个互不平行的平面;
2. 包围盒B三个互不平行的平面;
3. 包围盒A中的三条互不平行边和包围盒B中的三条互不平行边两两之间的公共垂面
而分离轴是垂直于分离平面的,这样我们就可以得到两个包围盒之间潜在的分离轴可能存在于以下位置:
首先是每个包围盒平面的法向量,共3条互不平行的法向量(对包围盒来说,法向量与边平行):
const Bit32 CUBE_POINT_NUM = 8;
const Bit32 CUBE_MAX_AXIS = 3;
Point Cube1Vertices[CUBE_POINT_NUM] = {0}; //储存包围盒的8个顶点
Point Cube2Vertices[CUBE_POINT_NUM] = {0};
Point Proj_Axis[15] = {0}; //储存3x3+3+3共15条分离轴
Bit32 vertexNum[CUBE_MAX_AXIS] = {1, 2, 4}; //顶点编号,用于求边向量
Bit32 axisCnt = 0;
//找到AABB1的3个边向量,并单位化
for (i = 0; i < CUBE_MAX_AXIS; i++) {
Proj_Axis[axisCnt++] = Subtract(&Cube1Vertices[0], &Cube1Vertices[vertexNum[i]]);
}
//找到AABB2的3个边向量,并单位化
for (i = 0; i < CUBE_MAX_AXIS; i++) {
Proj_Axis[axisCnt++] = Subtract(&Cube2Vertices[0], &Cube2Vertices[vertexNum[i]]);
}
//传入两个点计算向量
Point Subtract(Point* p1, Point* p2)
{
Point res = {0, 0, 0};
if (p1 == NULL || p2 == NULL) {
return res;
}
res.x = p1->x - p2->x;
res.y = p1->y - p2->y;
res.z = p1->z - p2->z;
// 向量单位化
NormalizePoint(&res);
return res;
}
然后更关键的是,两个包围盒任意两条边公共垂面的法向量(共3*3=9条):
//循环计算两包围盒每条边之间公垂面的法向量
for (i = 0; i < CUBE_MAX_AXIS; i++) {
for (j = 0; j < CUBE_MAX_AXIS; j++) {
Proj_Axis[axisCnt] = Cross(&Proj_Axis[i], &Proj_Axis[CUBE_MAX_AXIS + j]);
NormalizePoint (&Proj_Axis[axisCnt]);
axisCnt++;
}
}
//叉乘计算两向量公垂面的法向量
Point Cross(Point* p1, Point* p2) {
Point res = {0, 0, 0};
if (p1 == NULL || p2 == NULL)
{
return res;
}
res.x = p1->y * p2->z - p1->z * p2->y;
res.y = -(p1->x * p2->z - p1->z * p2->x);
res.z = p1->x * p2->y - p1->y * p2->x;
return res;
}
这里使用叉乘来计算两个向量公共垂面的法向量。
3. 计算包围盒在分离轴方向上的投影
上一步得到15条分离轴,然后再计算两个包围盒分别在每条分离轴上的投影最大最小值。
这里使用向量点乘来计算包围盒每个顶点在分离轴上的投影值:
//储存计算值
float min1 = 0.0, min2 = 0.0, max1 = 0.0, max2 = 0.0, dot1 = 0.0, dot2 = 0.0;
//在每个分离轴上进行投影计算
for (i = 0; i < CUBE_MAX_AXIS * CUBE_MAX_AXIS + CUBE_MAX_AXIS * 2; i++) {
min1 = Dot(&Cube1Vertices[0], &Proj_Axis[i]);
max1 = min1;
min2 = Dot (&Cube2Vertices[0], &Proj_Axis[i]);
max2 = min2;
//对两个包围盒中每个顶点进行投影
for (j = 1; j < CUBE_POINT_NUM; j++) {
dot1 = Dot(&Cube1Vertices[j], &Proj_Axis[i]);
dot2 = Dot(&Cube2Vertices[j], &Proj_Axis[i]);
if (dot1 < min1) {min1 = dot1;}
if (dot1 > max1) {max1 = dot1;}
if (dot2 < min2) {min2 = dot2;}
if (dot2 > max2) {max2 = dot2;}
}
//判断是否两包围盒在分离轴上投影是否相交
if (max1 < min2 || max2 < min1) {
return 0; //分离轴上投影不相交,未发生碰撞
}
return 1; //遍历所有分离轴都相交,则返回发生碰撞
}
//点乘计算投影
float Dot(Point* p1, Point* p2) {
if (p1 == NULL || p2 == NULL) {
return -1;
}
return p1->x*p2->x + p1->y*p2->y + p1->z*p2->z;
}
4. 判断两物体包围盒投影是否相交
最后一步就是判断两包围盒投影是否相交,根据投影最大最小值进行判断:
//判断是否两包围盒在分离轴上投影是否相交
if (max1 < min2 || max2 < min1) {
return 0; //存在分离轴,未发生碰撞
}
四. 总结
这样就完成了在绝对坐标系下对包围盒的碰撞检测,但由于包围盒相对图元来说存在冗余空间,包围盒发生并不代表图元一定发生碰撞,后续对三种基本图元之间进行碰撞检测需要用到GJK算法,有时间再继续完善。