3、静态碰撞检测:分离轴定理(SAT)原理与实现、GJK算法原理与实现
各位同学,今天我们来啃一块硬骨头——静态碰撞检测。说白了,就是判断两个物体在某一时刻是否“挨着”或“穿模”。
我刚开始做物理引擎时,觉得碰撞检测不就是算算距离嘛。结果第一次做多边形碰撞,两个矩形明明叠在一起了,我的代码却说没碰到……嗯,从那以后我老老实实学了分离轴定理(SAT)。
3.1 分离轴定理(SAT)原理
SAT的核心思想其实很简单:如果两个凸多边形没有重叠,那么一定能找到一条直线(分离轴),使得两个多边形在这条轴上的投影不重叠。
你想想看,两个物体在空间里,如果它们之间有一条缝隙,那从某个方向看过去,它们的影子肯定也是分开的。反过来,如果所有方向上的影子都重叠了,那它们肯定撞上了。
关键点:SAT只适用于凸多边形。凹多边形需要先分解成凸多边形。
3.2 SAT的数学基础
我们需要做三件事:
- 找到所有可能的分离轴——其实就是每个边的法线
- 计算投影——把多边形顶点投影到这些轴上
- 判断重叠——检查投影区间是否相交
我记得第一次实现时,卡在了“如何计算边的法线”上。其实很简单:
// 计算边的法线(二维)
Vector2 edge = p2 - p1;
Vector2 normal = new Vector2(-edge.y, edge.x).normalized;
注意,这里要归一化。不归一化也能判断是否碰撞,但计算穿透深度时会出问题。我踩过这个坑。
3.3 SAT的实现步骤
直接上代码吧,这样更直观:
bool SATCollision(Polygon a, Polygon b) {
// 获取两个多边形的所有边法线
List<Vector2> axes = new List<Vector2>();
// 添加多边形a的边法线
for (int i = 0; i < a.vertices.Length; i++) {
Vector2 edge = a.vertices[(i+1) % a.vertices.Length] - a.vertices[i];
axes.Add(new Vector2(-edge.y, edge.x).normalized);
}
// 添加多边形b的边法线
for (int i = 0; i < b.vertices.Length; i++) {
Vector2 edge = b.vertices[(i+1) % b.vertices.Length] - b.vertices[i];
axes.Add(new Vector2(-edge.y, edge.x).normalized);
}
// 对每个轴进行投影测试
foreach (Vector2 axis in axes) {
float minA, maxA, minB, maxB;
ProjectPolygon(a, axis, out minA, out maxA);
ProjectPolygon(b, axis, out minB, out maxB);
// 如果任何一个轴上投影不重叠,则没有碰撞
if (maxA < minB || maxB < minA) {
return false;
}
}
return true;
}
void ProjectPolygon(Polygon poly, Vector2 axis, out float min, out float max) {
float dot = Vector2.Dot(poly.vertices[0], axis);
min = max = dot;
for (int i = 1; i < poly.vertices.Length; i++) {
dot = Vector2.Dot(poly.vertices[i], axis);
if (dot < min) min = dot;
if (dot > max) max = dot;
}
}
性能提示:如果两个多边形都是矩形,其实只需要检测2条轴(矩形的两条边法线),而不是4条。因为矩形的对边法线是平行的。
3.4 GJK算法原理
SAT虽然直观,但有个问题——当多边形顶点很多时,要检测的轴也很多。GJK算法就聪明多了,它用迭代的方式,效率更高。
GJK的核心思想是:两个凸多边形的闵可夫斯基差如果包含原点,则它们碰撞。
什么叫闵可夫斯基差?说白了就是:把多边形A的所有点,减去多边形B的所有点,得到一个新的点集。如果这个新点集包含原点(0,0),说明A和B有重叠。
我刚开始也觉得这个概念有点绕。但你可以这样理解:
- 如果A和B碰撞,那么A中至少有一个点a,B中有一个点b,使得a - b = 0
- 也就是说,原点在闵可夫斯基差里
3.5 GJK的实现步骤
GJK不直接计算整个闵可夫斯基差,而是通过迭代构建一个单纯形(simplex),逐步逼近原点:
bool GJKCollision(ConvexShape a, ConvexShape b) {
// 初始方向,随便选一个
Vector2 direction = new Vector2(1, 0);
// 获取第一个支撑点
Vector2 support = GetSupport(a, b, direction);
// 单纯形(这里用列表表示)
List<Vector2> simplex = new List<Vector2>();
simplex.Add(support);
// 反向搜索
direction = -support;
// 迭代
for (int i = 0; i < 20; i++) { // 防止死循环
support = GetSupport(a, b, direction);
// 如果支撑点没有越过原点,则无碰撞
if (Vector2.Dot(support, direction) < 0) {
return false;
}
simplex.Add(support);
// 检查单纯形是否包含原点
if (CheckSimplex(simplex, ref direction)) {
return true;
}
}
return false;
}
Vector2 GetSupport(ConvexShape a, ConvexShape b, Vector2 direction) {
// 获取a在direction方向上的最远点
Vector2 pointA = a.GetFarthestPointInDirection(direction);
// 获取b在-direction方向上的最远点
Vector2 pointB = b.GetFarthestPointInDirection(-direction);
// 闵可夫斯基差
return pointA - pointB;
}
注意:GJK的迭代次数上限要设合理。我见过有人设1000次,结果性能崩了。20次对于大多数情况足够了,如果20次还没收敛,基本可以认为没碰撞。
3.6 SAT vs GJK:怎么选?
| 特性 | SAT | GJK |
|---|---|---|
| 适用形状 | 凸多边形(2D/3D) | 凸形状(任意维度) |
| 实现难度 | 简单直观 | 中等,概念较抽象 |
| 性能 | O(n*m),n和m为边数 | O(log n),迭代收敛快 |
| 额外信息 | 可直接得到穿透深度和法线 | 需要额外算法(如EPA)获取穿透信息 |
| 适用场景 | 顶点数少的2D碰撞 | 顶点数多或3D碰撞 |
我个人习惯是:2D游戏用SAT,3D游戏用GJK。为什么?因为2D中SAT的轴数量可控,代码也容易调试。3D中SAT的轴会爆炸(每个面的法线+边的叉积),GJK反而更高效。
3.7 避坑指南
我曾经在项目中遇到一个诡异的bug:两个物体明明没碰到,SAT却说碰撞了。查了两天才发现,是顶点顺序搞反了——多边形顶点必须是顺时针或逆时针顺序,不能乱。
还有一次,GJK在边缘情况(刚好相切)时返回了错误结果。这是因为浮点数精度问题。解决方案是加一个很小的epsilon:
if (Mathf.Abs(dot) < 1e-6f) {
// 视为刚好接触,按碰撞处理
return true;
}
总结一下:
- SAT适合2D、顶点少的凸多边形碰撞检测
- GJK适合3D、顶点多的凸形状碰撞检测
- 两者都要求物体是凸的
- 浮点数精度问题要小心处理
好了,静态碰撞检测的核心就这些。下节课我们讲动态碰撞检测——物体在运动过程中如何检测碰撞,那又是另一番天地了。
公众号:蓝海资料掘金营,微信deep3321