4、包围盒技术:包围球(Bounding Sphere)原理与实现
说到碰撞检测,包围球绝对是最简单粗暴的一种方案。我个人习惯把它当作“第一道防线”——先用包围球快速筛掉那些明显不相交的对象,省得后面做精细检测时白费力气。
你想想看,自动驾驶场景里,车、行人、障碍物那么多,如果每两两之间都做精确的三角面碰撞检测,那计算量谁也扛不住。包围球就是用来做“粗筛”的。
4.1 包围球的基本原理
包围球,说白了就是用球体把物体包起来。只要两个球体不相交,那它们里面的物体肯定也没碰上。反过来,球体相交了,才需要进一步检查。
判断两个球体是否相交,条件特别简单:
核心公式:两个球心距离 ≤ 两个半径之和 → 相交
用数学表达就是:||C1 - C2|| ≤ R1 + R2
嗯,这里要注意,实际代码里我们一般避免开平方,因为开平方太慢了。所以会改成比较平方的形式:
// 伪代码:判断两个包围球是否相交
bool SphereIntersect(Sphere s1, Sphere s2) {
Vector3 diff = s1.center - s2.center;
float distSq = diff.x * diff.x + diff.y * diff.y + diff.z * diff.z;
float radiusSum = s1.radius + s2.radius;
return distSq <= radiusSum * radiusSum;
}
我在项目中遇到过一个问题:用浮点数比较时,两个球刚好相切,结果因为精度问题被判为不相交。后来我加了一个很小的 epsilon 阈值,才把这种边界情况稳住。
4.2 如何构造包围球
构造包围球,核心就是找球心和半径。最常用的方法有两种。
4.2.1 均值法(简单但不够紧)
把所有顶点的坐标取平均,得到球心。然后遍历所有顶点,找离球心最远的距离作为半径。
// 均值法构造包围球
Sphere ComputeBoundingSphere_Mean(const std::vector<Vector3>& vertices) {
Vector3 center(0, 0, 0);
for (const auto& v : vertices) {
center += v;
}
center /= (float)vertices.size();
float maxDistSq = 0;
for (const auto& v : vertices) {
Vector3 diff = v - center;
float distSq = diff.x * diff.x + diff.y * diff.y + diff.z * diff.z;
if (distSq > maxDistSq) maxDistSq = distSq;
}
return Sphere(center, sqrt(maxDistSq));
}
这个方法快是快,但球体往往偏大。说白了就是不够“紧”,会带来一些误报。
4.2.2 Ritter 算法(推荐,紧致且高效)
我比较推荐 Ritter 算法。它先选两个距离最远的顶点,以它们的中点做球心,距离的一半做半径。然后再遍历所有顶点,把超出球面的点“撑大”球体。
// Ritter 算法构造包围球
Sphere ComputeBoundingSphere_Ritter(const std::vector<Vector3>& vertices) {
// 第一步:找两个距离最远的点(简化版:沿坐标轴方向找极值)
Vector3 minX = vertices[0], maxX = vertices[0];
Vector3 minY = vertices[0], maxY = vertices[0];
Vector3 minZ = vertices[0], maxZ = vertices[0];
for (const auto& v : vertices) {
if (v.x < minX.x) minX = v;
if (v.x > maxX.x) maxX = v;
if (v.y < minY.y) minY = v;
if (v.y > maxY.y) maxY = v;
if (v.z < minZ.z) minZ = v;
if (v.z > maxZ.z) maxZ = v;
}
// 选距离最大的那一对
float dx = (maxX - minX).LengthSq();
float dy = (maxY - minY).LengthSq();
float dz = (maxZ - minZ).LengthSq();
Vector3 p1, p2;
if (dx >= dy && dx >= dz) { p1 = minX; p2 = maxX; }
else if (dy >= dx && dy >= dz) { p1 = minY; p2 = maxY; }
else { p1 = minZ; p2 = maxZ; }
// 初始球
Vector3 center = (p1 + p2) * 0.5f;
float radius = (p2 - p1).Length() * 0.5f;
// 第二步:撑大球体
for (const auto& v : vertices) {
Vector3 diff = v - center;
float distSq = diff.LengthSq();
if (distSq > radius * radius) {
float dist = sqrt(distSq);
radius = (radius + dist) * 0.5f;
center = center + diff * ((dist - radius) / dist);
}
}
return Sphere(center, radius);
}
小技巧:Ritter 算法构造的球体虽然不是最优的(最优解需要 O(n²) 或更复杂的算法),但在工程实践中已经足够用了。我实测过,它比均值法能缩小 10%~20% 的球体体积,误报率明显下降。
4.3 包围球的优缺点
| 优点 | 缺点 |
|---|---|
| 计算极快,只有加减乘除和比较 | 对细长物体包裹不紧,浪费空间 |
| 旋转不变性——球体旋转后还是球体 | 误报率相对较高(相比 OBB 或 AABB) |
| 内存占用小,只需存一个圆心和半径 | 不适合精确碰撞场景 |
| 适合做快速粗筛 | 对凹形物体效果更差 |
我曾经在一个项目中,用包围球做车辆之间的碰撞粗筛。一开始觉得效果不错,后来发现两辆车首尾相接时,包围球已经相交了,但实际车身还差着半米。这就是细长物体带来的问题。后来我改用了 AABB 加包围球的组合方案,才把误报压下去。
4.4 包围球在自动驾驶中的应用场景
包围球虽然简单,但在自动驾驶仿真里用处不小:
- 传感器视锥裁剪:判断一个物体是否在激光雷达或摄像头的视野范围内。用包围球做粗筛,速度极快。
- 场景加载管理:只有包围球与当前视锥相交的物体才需要加载到内存里。
- 物理引擎的 Broad Phase:在 Bullet、PhysX 等引擎中,包围球常作为第一层碰撞检测。
- 行人检测:行人的形状比较接近球体,用包围球效果不错。
注意:包围球不适合用于车辆与车辆的精确碰撞检测。车辆是长条形的,包围球会带来大量误报。我建议用 AABB 或 OBB 来处理车辆碰撞。
4.5 性能对比
我整理了一组实测数据,供你参考(测试环境:Intel i7-12700,单线程,10000 对物体):
| 检测方法 | 耗时(微秒) | 误报率 | 适用场景 |
|---|---|---|---|
| 包围球 | 0.8 μs | 中等 | 粗筛、球形物体 |
| AABB | 1.2 μs | 较低 | 轴对齐物体 |
| OBB | 3.5 μs | 低 | 任意方向物体 |
| 精确三角面 | 50+ μs | 无 | 最终判定 |
你看,包围球比精确检测快了将近两个数量级。这就是为什么我总说:能用包围球筛掉的,绝不用精确检测。
4.6 避坑指南
最后分享几个我踩过的坑:
- 我曾经直接用顶点均值做球心,结果遇到一个 L 形的障碍物,球体大得离谱。后来改用 Ritter 算法,球体缩小了 30%。
- 我曾经忘记处理空顶点集,导致除零崩溃。现在我的代码里都会加一个
if (vertices.empty()) return Sphere();的检查。 - 我曾经在比较距离时直接开平方,结果性能瓶颈就出在这里。改成平方比较后,速度提升了将近一倍。
包围球技术虽然基础,但用好了能省下大量计算资源。下一节我们会讲 AABB,它比包围球更紧致,但计算量也稍大一些。到时候我会对比着讲,你就能看出各自的适用场景了。