拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Unity开发必备:AABB与OBB包围盒的数学原理、检测算法与实战踩坑指南

做Unity开发这几年包围盒这个东西平时不太显眼但只要你碰到过碰撞误判、角色选择点不准、渲染批次裁剪异常这类问题就绕不开它。AABB和OBB正是其中最常见的两种包围盒说白了就是一种“先粗后精”的碰撞和裁剪手段先拿一个足够简单的几何体把复杂模型套住用这个简单几何体去快速排除掉大部分不可能的情况剩下少部分再交给精细计算。这篇文章就是围绕这两个概念展开的我会把数学原理、Unity里的实际入口、检测算法、踩坑经验都摊开来讲适合正在做交互原型、物理碰撞调试、或者想优化渲染裁剪的开发者参考。1. 为什么要聊包围盒从碰撞误判到性能优化先说一个实际场景。有一次我在做一个人物交互系统玩家点了屏幕上一个很细的剑柄结果怎么点都点不中后来换了根手指粗的触控区域立刻就好了。本质原因就是那把剑的碰撞体太贴合模型轮廓像素级的点击判定在距离稍远时就会漏掉。这种情况如果直接在模型外面包一层盒子用盒子去参与粗检测点击命中的体验会稳很多。包围盒的另一大用途是性能优化。一个场景里如果丢了几百个角色模型每个模型几万面碰撞检测和渲染裁剪如果全部跑精细逻辑CPU和GPU都会很难受。正确的做法是先用包围盒做一次粗筛选剔除掉完全在视野外、完全不相交的对象剩下真正可能相交、可能可见的对象才去算细节。这个思路在Unity的视锥剔除Frustum Culling、物理引擎的Broad Phase、以及各种空间加速结构如八叉树、BVH里都是基础。AABB全称是Axis-Aligned Bounding Box轴对齐包围盒OBB全称是Oriented Bounding Box有向包围盒。两者都是把模型包在一个六面体里区别在于盒子的朝向。AABB的三条轴始终与世界坐标系的X/Y/Z轴平行所以它算法简单、内存占用小、相交测试极快但代价是当物体旋转时AABB会跟着物体在世界空间里“撑大”一圈包裹得很不紧密。OBB则允许盒子的轴跟随物体的旋转方向贴合得更紧密但存储和计算开销都会高一些。用生活中的例子来理解AABB像是一个永远只能横平竖直摆放的快递箱东西斜着放进去就会有很多空隙OBB像是一个可以跟着物品朝向任意旋转的包装盒虽然包装时花的心思多但装得紧凑。游戏引擎里大部分粗检测用AABB需要更精确贴合的场景——比如车辆碰撞、武器判定、物理包围体积——就会考虑OBB。2. 动手之前先把底子打牢AABB与OBB的数学定义2.1 AABB的两种存储方式和Unity中的BoundsAABB在数学上有两种主流存储方式Min-Max形式和Center-Extents形式。Min-Max就是直接记录盒子在三个轴上的最小值和最大值共6个浮点数Center-Extents记录中心点和三个方向上的半边长也是6个浮点数。两种形式各有优点。Min-Max在做AABB求交时非常直观只要两个盒子的最小点都小于对方的最大点且最大点大于对方的最小点就说明有重叠Center-Extents在做物体变换、合包围盒、以及和射线判交时更方便因为中心点可以直接参与位置运算。Unity的Bounds结构体用的就是第二种源码里暴露了center和extents两个核心字段size等于extents * 2。Unity里拿到Bounds的方式很多最常用的三个入口是Renderer.bounds返回世界空间下的AABB包含了该渲染器所有子网格的包围范围日常调试和视锥剔除基本都用它。Collider.bounds返回碰撞体在世界空间下的AABB这个值是引擎实时根据碰撞体形状和Transform算出来的。Mesh.bounds返回模型空间局部空间下的AABB是一个只跟模型顶点数据相关的静态值不会随Transform变化。这三个入口的区别很关键。Mesh.bounds是“出生证”记录的是模型在美术制作时相对原点的范围Renderer.bounds和Collider.bounds是“现场状态”会跟着物体移动、旋转、缩放实时变化。我在项目里见过不少人搞混把Mesh.bounds直接拿去当世界坐标范围用结果位置永远对不上。2.2 OBB的数学结构中心点、旋转轴与半边OBB的定义比AABB多了一个关键部分朝向。一个完整的OBB通常由一个中心点center、三个正交的单位轴向axis[3]、以及三个方向上的半边长halfSize[3]组成。在Unity里OBB并没有内置的公开结构体但我们可以非常方便地用Transform来表示一个OBB中心点就是transform.position三个正交轴向就是transform.right、transform.up、transform.forward三个半边长则是物体局部空间AABB的extents乘以transform.lossyScale。换句话说一个带旋转的立方体BoxCollider本质上就是一个OBB。这种表示方式有一个好处你不需要额外写一套OBB数据结构直接用现有的Transform和Bounds就能表达。做检测时把两个物体的Transform信息提取出来配合各自的局部Bounds就能完成OBB层面的相交判断。2.3 变换后的命运为什么AABB会膨胀、OBB不会这是初学者最容易困惑的点为什么一个立方体旋转45度后它的AABB变大了原因在于AABB的轴是固定贴着世界坐标系的。物体旋转后原本贴着物体表面的各条边不再与世界轴平行为了仍然用一个“轴对齐”的盒子包住整个物体盒子只能在三个世界轴上尽量往内收最终必然会出现多余空间。举个直观数据一个边长为1的正方体旋转45度后它在X轴上的投影长度变成根号2Y轴也一样于是新的AABB会比原始立方体大出约41%的空白体积。OBB不会出现这种膨胀因为它的轴跟着物体一起旋转。物体转多少度盒子的轴就转多少度始终能紧贴模型表面。代价是相交检测不能再简单地比较三个轴上的Min-Max了得引入分离轴定理SAT去算后面会展开讲。这里也顺便解释一个Unity里的坑Renderer.bounds虽然名字叫Bounds但拿到手的是世界空间AABB即使物体旋转了它返回的也是轴对齐盒子。所以如果你在Update里每帧读取旋转物体的bounds做精确碰撞会得到一个“膨胀”的范围。要精确贴合只能自己按Transform去算OBB或者直接用物理引擎的Collider。3. 相交检测是怎么做的分离轴定理SAT从原理到实现3.1 二维世界先看懂SAT分离轴定理Separating Axis Theorem是OBB相交检测的地基。它的核心结论是两个凸多边形如果不相交那么一定存在一条直线能把它们隔开。这条直线的法线方向就叫“分离轴”。怎么找分离轴在二维情况下只需要检查两个多边形各自所有边的法线方向即可。每选一个方向就把两个多边形分别投影到这条轴上得到两段一维区间。如果存在某个方向的投影区间不重叠就可以立刻断定两个多边形不相交如果所有方向的投影区间都重叠那么它们一定相交。这个思路可以直观地理解为两个盒子在阳光下的影子如果从某个角度照射时两个影子完全错开那这两个盒子一定没有碰在一起如果所有角度下影子都有重叠那就是真碰到了。在三维情况下OBB与OBB的分离轴候选就来自三个部分A的三个面法线、B的三个面法线、以及A的一条边和B的一条边的叉积。总共是339也就是15条轴逐一做投影测试只要有一条轴上两个投影区间分离就判定不相交。3.2 OBB与OBB的15条轴检测我来解释为什么需要边叉积产生的9条轴。三维物体之间除了“面对脸”的碰撞还有一种更常见的“边碰边”的情况比如两个盒子棱角相撞。两个盒子如果刚好卡在边和边交错的状态面法线方向上可能看不出分离只有沿着两条边的叉积方向投影才能暴露出缝隙。所以边叉积轴是必不可少的。每条轴的检测过程都一样把两个OBB的中心分别投影到这条轴上再结合各自的旋转半径去求半径范围。一个OBB在任意方向轴上的投影半径等于它的三个半边长分别乘以对应轴向与该方向轴的点积绝对值再求和。这个公式非常重要也是SAT实现里最高频的计算。为了降低计算量实际工程里经常会用一些前置判断先看两个OBB中心距离在某个轴上的分量是否已经大于投影半径之和如果大于直接返回不相交省掉后面十几条轴的计算。这就是所谓的“短路退出”性能优化的大头都在这。3.3 代码实现一个可直接复用的检测函数下面给一个轻量级的OBB相交检测实现。思路是把OBB用中心点、三轴、三半边长来抽象然后按SAT检查前三条轴和叉积轴。public struct OBB { public Vector3 center; public Vector3 axisX; public Vector3 axisY; public Vector3 axisZ; public Vector3 halfSize; // 三个方向的半边长 public OBB(Transform t) { center t.position; axisX t.right.normalized; axisY t.up.normalized; axisZ t.forward.normalized; // 如果物体有缩放需要乘上 lossyScale var localBounds t.GetComponentMeshFilter().sharedMesh.bounds; Vector3 scale t.lossyScale; halfSize Vector3.Scale(localBounds.extents, scale); } } public static bool OBBAABBIntersect(OBB a, OBB b) { // 15条轴的候选列表a的三个面方向、b的三个面方向、a的每条边叉积b的每条边 Vector3[] axes new Vector3[15]; axes[0] a.axisX; axes[1] a.axisY; axes[2] a.axisZ; axes[3] b.axisX; axes[4] b.axisY; axes[5] b.axisZ; int idx 6; for (int i 0; i 3; i) { Vector3 ai i 0 ? a.axisX : (i 1 ? a.axisY : a.axisZ); for (int j 0; j 3; j) { Vector3 bj j 0 ? b.axisX : (j 1 ? b.axisY : b.axisZ); axes[idx] Vector3.Cross(ai, bj).normalized; } } for (int i 0; i axes.Length; i) { Vector3 axis axes[i]; if (axis.sqrMagnitude 0.0001f) continue; // 平行边叉积为0跳过 float rA ProjectionRadius(a, axis); float rB ProjectionRadius(b, axis); float centerDist Mathf.Abs(Vector3.Dot(a.center - b.center, axis)); if (centerDist rA rB) return false; // 找到分离轴 } return true; } static float ProjectionRadius(OBB box, Vector3 axis) { return Mathf.Abs(Vector3.Dot(box.axisX, axis)) * box.halfSize.x Mathf.Abs(Vector3.Dot(box.axisY, axis)) * box.halfSize.y Mathf.Abs(Vector3.Dot(box.axisZ, axis)) * box.halfSize.z; }上面这段代码能在绝大多数场景下工作但它没有处理一个特殊情况当一个OBB完全在另一个OBB内部时15条轴全部投影重叠SAT返回相交这符合物理直觉没有问题但如果想进一步求出穿透深度和分离向量就得额外找最小穿透轴这里不展开。提示检测两个OBB时务必先把两个盒子的坐标统一到同一空间下。一般建议都在世界空间比较因为Transform本身就是世界空间的直接使用最省事。如果需要频繁检测可以尝试把其中一个转换到另一个的局部空间再做SAT投影轴数量虽然不变但有些轴计算能提前缓存。3.4 AABB vs AABB最简单的判定AABB之间相交检测远比OBB简单三条轴各做一次区间判断就行。代码写出来就是bool AABBIntersect(Bounds a, Bounds b) { return a.min.x b.max.x a.max.x b.min.x a.min.y b.max.y a.max.y b.min.y a.min.z b.max.z a.max.z b.min.z; }这个判断在物理引擎的Broad Phase中会执行成千上万次所以CPU缓存友好性极其重要。Unity的Bounds.Intersects(Bounds)内部也是类似的AABB测试日常开发直接调用即可。4. Unity中获取包围盒的几个真实入口4.1 Renderer.bounds一切可视对象的AABB做视锥剔除、场景资源统计、动态合批范围判断时Renderer.bounds是首选。它会自动计算所有子网格和蒙皮网格的包围范围是一个世界空间的AABB。这个字段的代价是每次访问都可能触发引擎内部的变换计算。如果你在一个几千个对象的循环里反复读Renderer.boundsProfile一看会发现Transform相关耗时很高。所以要么在对象静止时缓存bounds要么只在需要精确判断的帧去读取。蒙皮网格SkinnedMeshRenderer的bounds有一个特殊之处它其实是美术或引擎预定义的包围盒并不严格等于当前骨骼姿态下的顶点范围。当角色做出极端动作比如手臂伸得很长时原始的bound可能包不住就会出现模型明明在屏幕边缘、却被视锥剔除的错误。这种情况需要手动调整SkinnedMeshRenderer.localBounds或者动态扩大包围范围在角色动画项目里是个经典坑。4.2 Collider.bounds与Physics.OverlapBox物理碰撞层面Collider.bounds是Unity自动维护的AABB用于物理引擎内部Broad Phase。它返回世界坐标但同样不保证贴合旋转后的碰撞体因为它是轴对齐的。如果你想要一个“带旋转的盒体检测区域”也就是OBB形式的物理查询Unity提供了一个非常对口的APIPhysics.OverlapBox(Vector3 center, Vector3 halfExtents, Quaternion orientation)。这个接口里的orientation参数会把检测盒转成任意角度效果等价于一个OBB区域里做Overlap查询。举个例子我想判断玩家前方偏右45度、半径2米范围内有没有敌人可以这样写Vector3 center player.position player.forward * 2f; Vector3 halfExtents new Vector3(2f, 1f, 2f); Quaternion orientation player.rotation; Collider[] hits Physics.OverlapBox(center, halfExtents, orientation);这个接口比你手动把范围内所有Collider拿回来再SAT检测要快得多因为Unity的物理引擎在Broad Phase阶段已经帮你筛掉了一大批对象。4.3 Mesh.bounds模型空间下的静态数据Mesh.bounds返回的是模型空间下的AABB它是模型导入时根据顶点数据自动生成的范围。这个值在运行时是不变的不管物体怎么移动旋转缩放mesh.bounds始终还是那一组Min-Max。它最常见的用途是计算模型的中心偏移比如把Pivot自动修正到模型中心。做程序化合批时用bounds估算合并后的整体范围。做LOD切换或粒子发射范围时参考模型尺寸。需要注意如果一个模型由多个子网格组合而成Mesh.bounds并不一定包含所有子网格Renderer.bounds则会把它们全算进去。所以“总包围”请用Renderer“单网格包围”才看Mesh。4.4 自己动手算一个OBB既然Unity没有内置OBB结构体有些场景就得自己算。最常见的需求是把一个模型的顶点集转换到世界空间然后求一个方向可控的OBB。做法很简单先用Transform.TransformPoint把模型顶点从模型空间转到世界空间然后以当前物体的三个轴向为基准把顶点投影到三个轴上记录每个轴上的最小值和最大值这样就得到了一组“贴着物体旋转方向”的OBB。public static Bounds CalculateWorldSpaceOBB(Transform t, Mesh mesh) { Vector3[] verts mesh.vertices; Vector3 axisX t.right.normalized; Vector3 axisY t.up.normalized; Vector3 axisZ t.forward.normalized; float minX float.MaxValue, maxX float.MinValue; float minY float.MaxValue, maxY float.MinValue; float minZ float.MaxValue, maxZ float.MinValue; for (int i 0; i verts.Length; i) { Vector3 world t.TransformPoint(verts[i]); float px Vector3.Dot(world, axisX); float py Vector3.Dot(world, axisY); float pz Vector3.Dot(world, axisZ); minX Mathf.Min(minX, px); maxX Mathf.Max(maxX, px); minY Mathf.Min(minY, py); maxY Mathf.Max(maxY, py); minZ Mathf.Min(minZ, pz); maxZ Mathf.Max(maxZ, pz); } Vector3 center (axisX * (minX maxX) axisY * (minY maxY) axisZ * (minZ maxZ)) * 0.5f; Vector3 size new Vector3(maxX - minX, maxY - minY, maxZ - minZ); return new Bounds(center, size); }这段代码返回的Bounds并不是真正的AABB而是一个“局部坐标系下的AABB”配合物体的旋转矩阵才能正确映射回世界空间。使用时要把Bounds.center重新放到世界坐标或者直接把它当OBB的三个半边长来用。5. 实战技巧与常见问题速查5.1 bounds在旋转物体上膨胀怎么缓存如果你有一个不停旋转的风扇或者机械臂想用Renderer.bounds去判断它是否进入某个区域会发现范围时大时小极其不稳定。这就是AABB随旋转膨胀造成的。针对性方案有两个。一是把查询从“世界空间AABB”换成“OBB区域检测”直接用Physics.OverlapBox并把旋转角度传进去。二是给旋转物体设置一个足够大的静态缓冲范围用缓存值做粗判断只在进入候选范围后再做精确OBB计算。我在实际项目里更推荐第一种。因为物理引擎已经帮你做了大量优化自己写的OBB遍历很容易变成性能瓶颈。只有当你的对象不是物理体、或者需要和自定义数据结构交互时才考虑第二种。5.2 用OBB做精确区域判定比如做一个扇形攻击判定Unity里常用Physics.OverlapSphere先拿到半径范围内的所有Collider再用角度过滤。这种方案在目标和玩家的距离很近时没问题但如果你要判定一个长方体形状的区域——比如走廊里的激光陷阱、车头撞击区域——OverlapSphere的预处理就不够精确大量与区域不相交的物体会进入后续计算。这时候直接上Physics.OverlapBox最舒服它既支持任意旋转角度内部又做了Broad Phase剔除。我做一个车辆撞击特效时就是用OverlapBox以车头朝向为轴、长宽高设为车头尺寸瞬间就能筛出真正被撞到的行人性能和精度都比Sphere方案好一个档次。5.3 虚拟摇杆和UI点击区域扩大怎么做看热搜词里有一条“Unity如何扩大按钮的点击范围”其实这也算包围盒思维的延伸。UI的点击判定是基于RectTransform的矩形区域要扩大点击范围最简单的是在目标按钮上挂一个透明Image并把它扩展到想要的尺寸。但多加一层Image会影响布局和事件冒泡更干净的做法是用IPointerClickHandler配合自定义矩形判定本质上也是把一个不规则的点击目标改成一个更大的AABB区域。如果点击目标本身是一个旋转过的2D图片想让它点击区域也跟着旋转就需要用OBB类似思路把鼠标点转换到图片的局部坐标系下再判断是否落在局部AABB里。这个过程和3D里把世界点转到物体局部空间再检测是同一个套路。5.4 阴影裁剪和相机视锥剔除中的Bounds阴影距离、动态合批、相机剔除Unity底层大量依赖包围盒。动态阴影的裁剪范围如果过小远处的物体阴影会突然消失过大则阴影贴图分辨率被浪费。很多阴影问题不一定是Light参数不对而是Shader里使用世界空间AABB进行裁剪时旋转物体的bound在极端姿态下发生了膨胀。蒙皮网格的阴影尤其明显。角色长期处于T-Pose时localBounds是最大的换成武器握持姿势后手臂位置变了包的就没那么紧。如果你在动画里发现阴影偶尔闪断多半是继承了原始T-Pose的bound在极端动作下顶点瞬移出去导致剔除错误。手动调整localBounds或者在Animator更新后重新RecalculateBounds能解决大部分这类问题。5.5 常见问题速查表现象可能原因解决方案旋转物体碰撞检测不准确用了世界空间AABB做精确检测改用Physics.OverlapBox或SAT检测OBB模型被视锥裁剪掉但实际可见SkinnedMeshRenderer的bound过小手动调大localBounds或动态RecalculateBounds点击很细的小物体总是点不中命中区域过小用包围盒扩大点击范围或增加透明点击层大量读取Renderer.bounds导致卡顿每帧在循环里访问触发变换计算缓存静态物体Bounds非必要不每帧读阴影在角色极端动作下闪烁蒙皮网格的bounds包不住顶点调整localBounds或每帧修正结尾经验和选型建议做项目这么多年我的体会是**能预判的性能问题永远比事后优化省心。**包围盒选型也是一样尽早想清楚当前场景到底需要AABB的“快”还是OBB的“准”比埋在代码里Debug半天要划算得多。如果只是场景剔除、粗碰撞、点击范围扩大这类需求直接用Unity内置的Bounds和Overlap系列接口别自己造轮子。如果是车辆碰撞、精确武器判定、或者自定义物理逻辑OBB这套数学你必须自己掌握但实现时也优先考虑能否用Unity的物理查询接口去模拟。真到了非手工计算不可的地步再把我上面那段SAT的代码改造成适合自己项目的版本。最后分享一个小技巧调试包围盒时可以用Gizmos.DrawWireCube配合Collider.bounds和一个自定义的OBB绘制函数把两种包围盒同时画出来。这样能非常直观地看出AABB的膨胀量有多大、OBB贴合得多紧密比读一百篇理论都管用。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门