一句话说清楚:如果两个AABB都在同一个世界坐标系下,那判断起来其实很简单——逐轴比较投影区间是否重叠就行。具体来说,就是x、y、z三个轴方向,各做一次区间交集判断,总共6次操作。别被“三维”“坐标系变换”这些名词吓到,核心原理就这么直接。
很多新手容易犯的错误是:试图先把AABB的8个顶点全部变换到另一个坐标系,再重新求凸包,或者直接套用通用的OBB碰撞检测函数。这种做法不仅慢,还容易因为浮点误差导致漏判。在实际项目里,只要两个AABB已经处于同一坐标系(比如都经过从模型空间到世界空间的变换),那么调用AABB::intersects(const AABB& other)时,就应该是一个纯粹的、按轴对齐的6次区间判断逻辑。
- 假设A的包围盒范围是
[minA.x, maxA.x]、[minA.y, maxA.y]、[minA.z, maxA.z],B同理 - 相交的条件是:
minA.x <= maxB.x && maxA.x >= minB.x && minA.y <= maxB.y && maxA.y >= minB.y && minA.z <= maxB.z && maxA.z >= minB.z - 注意边界处理:用
<=和>=而不是<和>,否则像maxA.x == minB.x这种刚好贴边的情况会被误判为不相交
当两个AABB处于不同局部坐标系时,必须先统一到同一坐标系再判断
没有“跨坐标系直接判断AABB相交”的捷径。所谓“不同坐标系”,本质上是各自有独立的变换矩阵(4×4矩阵)。你不能拿A的 min/max 直接去和B的对比——它们的单位、方向、原点都不同,比较毫无意义。
正确的做法只有一种:把其中一个AABB变换到另一个的坐标系下(比如把B变换到A的世界系),然后再按上一节的方法判断。这里的关键不是“怎么变”,而是“变什么”:
- 不要只变换8个顶点再重新计算AABB——这会放大误差,而且变换后的AABB可能已经不再是轴对齐的了(实际上变成了OBB)
- 应该使用AABB-to-AABB的保守变换方法:对每个轴,分别计算变换后8个顶点在该轴上的投影极值,即
newMin.x = min(dot(transformedVertex[i], x_axis)),其中x_axis是目标坐标系的x方向基向量(通常是变换矩阵的第0列) - 更高效的做法是:利用变换矩阵的旋转部分(3×3子矩阵)和位移,直接推导出新AABB的
min/max值——可以参考transformAABB(const glm::mat4& m, const AABB& src)的实现逻辑
为什么不用GJK或通用OBB碰撞检测
因为真的没必要。GJK适用于任意凸体,OBB适用于旋转后的包围盒,但AABB是OBB的一个特例——旋转分量为零。一旦你强行套用这些算法,结果往往是得不偿失:
- 性能开销大:GJK需要迭代收敛,OBB需要计算15条分离轴(3+3+3×3),而AABB只需要6次标量比较,差距巨大
- 数值不稳定:GJK在面共面、边共线的情况下容易震荡;OBB的轴叉积计算会引入额外的浮点误差
- 接口复杂:需要传顶点数组、法向量、支持函数,而AABB本质上只有6个float值,完全是杀鸡用牛刀
除非你的“AABB”其实根本没有对齐(比如美术导出时带了非零旋转),那它本质上就不是AABB,应该用OBB检测。但这种情况,数据结构的名字就该叫 OBB 而不是 AABB。
容易被忽略的浮点陷阱和边界情况
在实际编码中,最让人头疼的往往不是逻辑本身,而是浮点表示和初始化问题:
- 未初始化的AABB是最常见的坑。比如
min = {1e30, 1e30, 1e30},max = {-1e30, -1e30, -1e30},如果忘记调用reset()或encapsulate(point),会导致min.x > max.x,后续所有相交判断恒返回false - 用
std::numeric_limits初始化时要注意,它是正无穷大,参与运算可能产生::max() nan。建议用1e30f这类有限大值来初始化 - 当物体缩放为负值(比如镜像)时,变换后的AABB仍然可以用上述方法处理,但必须确保从变换矩阵中提取的旋转部分已经归一化——否则基向量长度不为1,投影会失真
聊两句真正的经验:真正让人头疼的,从来不是“怎么写这个判断函数”,而是“谁负责保证坐标系一致”和“谁来兜底未初始化的包围盒”。这两个问题不解决,算法写得再漂亮也是白搭。