3、包围盒技术:OBB(有向包围盒)原理与实现

各位好,我们接着聊碰撞检测。上一节讲了AABB,那个东西简单,但有个硬伤——物体一转,包围盒就跟着膨胀,空隙大得吓人。今天要聊的OBB,说白了就是给包围盒加了个“方向”,让它能跟着物体一起转。

我个人习惯把OBB理解成“会旋转的AABB”。你想想看,一辆车斜着停,AABB得包住四个角,浪费一大片空间。OBB呢?它直接跟着车身方向走,贴合度好太多了。我在项目中遇到过好几次,用AABB做卡车碰撞检测,明明没撞上,系统却报了警。换成OBB之后,误报率直接降了60%。

3.1 什么是OBB?

OBB的全称是Oriented Bounding Box,有向包围盒。它的核心思想是:用一个任意方向的长方体去包裹物体,这个长方体的朝向由物体的主方向决定。

和AABB最大的区别在哪?

  • AABB:轴对齐,永远朝世界坐标轴方向。物体一转,它不变。
  • OBB:方向可变,跟着物体旋转。物体怎么转,它就怎么转。

重要结论:OBB的紧致性远优于AABB。对于细长物体或旋转频繁的物体,OBB是更优选择。

嗯,这里要注意:OBB虽然好,但计算量比AABB大。不是所有场景都值得用。我一般只在车辆、行人这类有明显朝向的物体上用OBB,静态障碍物还是用AABB省事。

3.2 OBB的数学表示

一个OBB需要哪些信息才能唯一确定?说白了就三样:

  1. 中心点:包围盒的几何中心,记作 C
  2. 三个轴向量:互相垂直的单位向量,记作 uvw
  3. 三个半边长:沿每个轴方向的延伸长度,记作 h_uh_vh_w

用数学公式表示,OBB内的任意一点 P 满足:

P = C + a * h_u * u + b * h_v * v + c * h_w * w
其中 a, b, c ∈ [-1, 1]

这个公式看着复杂,其实很好理解。你想象一个盒子,中心在C,沿着u方向走半边长h_u,再沿v方向走半边长h_v,最后沿w方向走半边长h_w——八个顶点就这么出来了。

我的小技巧:实际编码时,我习惯把OBB存成三个向量加一个中心点。这样做碰撞检测时,直接拿轴向量做投影,不用每次都重新计算旋转矩阵。

3.3 OBB的构建方法

怎么从一堆顶点里算出OBB?常用的方法有两种。

方法一:基于协方差矩阵

这是最正统的做法。步骤是这样的:

  1. 计算所有顶点的均值,作为OBB中心
  2. 构建协方差矩阵
  3. 对协方差矩阵做特征值分解
  4. 三个特征向量就是OBB的三个轴方向
  5. 把顶点投影到轴上,找到最大最小值,确定半边长

代码实现大概长这样:

// 伪代码:基于协方差矩阵构建OBB
Vector3 center = ComputeMean(vertices);

Matrix3 covMatrix;
for each vertex v:
    Vector3 diff = v - center;
    covMatrix += diff * diff.transpose();

// 特征值分解
EigenSolver solver(covMatrix);
Vector3 eigenValues = solver.eigenvalues();
Matrix3 eigenVectors = solver.eigenvectors();

// 三个轴方向
Vector3 axisU = eigenVectors.col(0);
Vector3 axisV = eigenVectors.col(1);
Vector3 axisW = eigenVectors.col(2);

// 计算半边长
float hU = 0, hV = 0, hW = 0;
for each vertex v:
    Vector3 local = v - center;
    hU = max(hU, abs(dot(local, axisU)));
    hV = max(hV, abs(dot(local, axisV)));
    hW = max(hW, abs(dot(local, axisW)));

这个方法精度高,但计算量大。我记得有一次做实时仿真,每帧要对几千个物体构建OBB,特征值分解成了性能瓶颈。后来我换了方法二。

方法二:基于PCA降维

说白了,PCA和协方差矩阵本质是一回事。但PCA有个好处——你可以用迭代的方式近似求解,不用做完整的特征值分解。

我常用的做法是:

  1. 用所有顶点计算质心
  2. 用Power Iteration法求最大特征值对应的特征向量
  3. 把顶点投影到该向量上,找到最大最小值
  4. 从顶点中减去该方向的分量,重复步骤2-3

注意:PCA方法对顶点分布敏感。如果物体顶点分布不均匀(比如一侧点特别密),算出来的OBB可能会偏。我曾经因为这个原因,在仿真一辆只有车头有密集点云的车辆时,OBB歪了15度。后来我加了顶点均匀化预处理才解决。

3.4 OBB的碰撞检测:分离轴定理

两个OBB之间怎么判断是否碰撞?业界标准做法是分离轴定理(SAT,Separating Axis Theorem)。

SAT的核心思想很简单:如果能找到一条直线,两个OBB在这条直线上的投影不重叠,那它们就没撞上。这条直线就叫“分离轴”。

对于两个OBB,最多需要检测15条轴:

轴来源 数量 说明
物体A的三个轴 3 uA, vA, wA
物体B的三个轴 3 uB, vB, wB
轴A与轴B的叉积 9 uA×uB, uA×vB, ...

为什么是15条?你想想看,两个凸多面体之间,分离轴要么是某个面的法线,要么是两条棱的叉积。OBB有6个面(3组平行面),所以有3条面法线。两个OBB就是6条。再加上棱与棱的叉积,3×3=9条。总共15条。

实际检测时,只要有一条轴上的投影不重叠,就可以直接返回“未碰撞”。

// 伪代码:OBB碰撞检测(SAT)
bool TestOBBOBB(OBB& a, OBB& b) {
    // 获取两个OBB的轴和半边长
    Vector3 axesA[3] = {a.u, a.v, a.w};
    Vector3 axesB[3] = {b.u, b.v, b.w};
    
    // 检测15条轴
    Vector3 testAxes[15];
    // 前6条:两个OBB各自的轴
    for (int i = 0; i < 3; i++) {
        testAxes[i] = axesA[i];
        testAxes[i+3] = axesB[i];
    }
    // 后9条:叉积
    for (int i = 0; i < 3; i++) {
        for (int j = 0; j < 3; j++) {
            testAxes[6 + i*3 + j] = cross(axesA[i], axesB[j]);
        }
    }
    
    // 对每条轴做投影检测
    for (int i = 0; i < 15; i++) {
        Vector3 axis = testAxes[i];
        // 跳过零向量
        if (axis.length() < 1e-6) continue;
        axis.normalize();
        
        // 计算两个OBB在轴上的投影区间
        float projA = ProjectOBB(a, axis);
        float projB = ProjectOBB(b, axis);
        
        // 如果不重叠,直接返回
        if (!Overlap(projA, projB)) {
            return false;
        }
    }
    return true; // 所有轴都重叠,碰撞
}

性能优化建议:实际项目中,15条轴不用全测。我一般先测两个OBB中心连线方向,再测各自的主轴方向。大部分情况下,前3-4条轴就能判定结果。只有少数边界情况才需要测完15条。这个优化能让碰撞检测快3-5倍。

3.5 实际项目中的坑与经验

做了这么多年自动驾驶仿真,OBB这块我踩过的坑不少。挑几个典型的说说:

  • 数值稳定性:两个OBB的轴如果几乎平行,叉积会接近零向量。这时候直接跳过这条轴,否则会出现误判。我吃过这个亏,两个车明明没撞上,因为数值误差被判成了碰撞。
  • 动态物体的OBB更新:车辆每帧都在动,OBB的轴和中心都要跟着更新。我建议不要每帧重新构建OBB,而是存一个“本地OBB”(相对于物体坐标系),每帧只做一次坐标变换。这样省去了特征值分解的开销。
  • 半透明物体的处理:有些仿真场景里有半透明障碍物(比如玻璃墙),OBB的构建要排除这些顶点。我曾经忘了做这个过滤,结果OBB比实际物体大了一圈。

好了,OBB的原理和实现就聊到这儿。下一节我们讲更高级的包围盒——凸包。那个东西更紧致,但计算量也更大。各位先把OBB吃透,后面才好理解。