1. 项目概述:为什么我们需要自己动手处理碰撞分离?
在Unity里做2D游戏,物理引擎是个好东西,Rigidbody2D和Collider2D一挂,碰撞检测和响应基本就交给引擎了,省心。但不知道你有没有遇到过这种情况:两个物体高速运动,或者因为复杂的物理模拟,它们“卡”在了一起,或者穿透得很深,引擎自带的物理解算有时候会显得力不从心,物体可能会抖动、弹飞,或者干脆就粘住了。尤其是在一些对碰撞精度和响应有特殊要求的场景,比如高精度模拟的物理谜题、需要自定义碰撞反馈的动作游戏,或者你正在开发自己的简易物理引擎时,Unity内置的“黑盒”处理就可能不够用了。
这时候,我们就需要深入到碰撞检测与响应的“最后一公里”——精确计算穿透向量(Penetration Vector),并据此将物体准确地分离开。GJK(Gilbert–Johnson–Keerthi)算法和EPA(Expanding Polytope Algorithm)算法就是解决这个问题的黄金搭档。GJK负责快速判断两个凸形状是否相交,而EPA则在它们相交后,精确计算出最小的穿透深度和方向。网上关于GJK/EPA原理的文章不少,但大多停留在数学推导和伪代码,真正能直接抄作业、在Unity里跑起来的完整C#实现,尤其是针对2D的,并不多见。
所以,这个项目的目的很明确:不依赖Unity的物理引擎,纯手动实现一套基于GJK+EPA算法的2D凸多边形碰撞检测与分离系统,并提供可直接集成到项目中的完整C#源码。这不仅能让你彻底理解碰撞响应的核心,更能让你在遇到棘手物理问题时,手里多一把锋利的“手术刀”。
2. 核心算法原理快速扫盲
在动手写代码之前,我们得先搞清楚GJK和EPA到底在干什么。不用担心,我会尽量用最直白的方式解释。
2.1 GJK算法:用“单纯形”快速问“是否碰撞”
你可以把GJK想象成一个非常聪明的“盲人摸象”过程。它的目标不是知道大象具体长什么样,而是快速回答“我的面前有没有一头大象(两个形状是否相交)”。
它的核心是一个叫Minkowski差的数学概念。对于两个形状A和B,它们的Minkowski差定义为 A - B = {a - b | a ∈ A, b ∈ B}。这个差集有一个神奇的性质:如果A和B相交,那么Minkowski差集必然包含原点(0,0)。反之,如果它们不相交,原点就在Minkowski差集之外。
GJK算法的工作就是,它不需要计算出整个庞大的Minkowski差集,而是通过一种叫“支撑函数(Support Function)”的迭代采样,在Minkowski差集中构建一个越来越逼近原点的单纯形(Simplex)——在2D里,单纯形就是点、线段或者三角形。
- 支撑函数:这是算法的基石。给定一个方向向量d,形状A的支撑点是在这个方向上投影最远的点。对于Minkowski差 A-B,支撑点 s(d) = support_A(d) - support_B(-d)。简单说,就是分别在两个形状上找“最朝d方向”和“最朝-d方向”的点,然后相减。
- 迭代构建单纯形:算法从一个随机方向开始,用支撑函数得到一个Minkowski差集上的点。然后,它朝着原点的方向,不断寻找新的支撑点,并用这些点构建单纯形(点->线段->三角形)。每次迭代,它都检查当前单纯形是否包含原点。
- 终止条件:如果在某次迭代中,新找到的支撑点在与原点相反方向上的投影距离不再增加(即无法更靠近原点),说明原点不在Minkowski差集中,两个形状不相交。如果构建出的单纯形(三角形)包含了原点,则说明它们相交。
GJK的高明之处在于它的速度,它通常只需要几次迭代就能得出结论,复杂度与顶点数无关,只与迭代次数有关。
2.2 EPA算法:相交之后,问“穿多深,往哪推”
当GJK告诉我们“撞上了”之后,EPA就该上场了。EPA要解决的问题是:既然撞了,那它们重叠了多少?我应该把其中一个物体往哪个方向、移动多少距离,才能让它们刚好分开?
EPA在GJK停止的地方开始工作。GJK最后得到的那个包含了原点的单纯形(一个三角形),就是EPA的起点。EPA会把这个三角形看作是一个**凸包(Polytope)**的初始状态,这个凸包位于Minkowski差集的边界上,并且包裹着原点。
- 寻找最近边:在当前的凸包(一开始是三角形)上,找到离原点最近的那条边。
- 沿法线方向扩张:沿着这条最近边的法线方向(指向凸包外部),调用支撑函数,得到Minkowski差集边界上的一个新点。
- 重构凸包:将这个新点插入到凸包中,重构一个更大的、仍然包裹着原点的凸包。
- 迭代收敛:重复步骤1-3。每次迭代,凸包都会更贴合Minkowski差集真正的边界。最近边到原点的距离会逐渐收敛。
- 终止与输出:当新加入的支撑点到最近边的距离变化小于一个非常小的阈值(比如0.0001)时,我们认为已经足够精确了。此时,那个“最近边”的法线方向,就是穿透方向(Penetration Normal),而原点到该边的距离,就是穿透深度(Penetration Depth)。
这个穿透方向和深度,正是我们将两个相交物体分离开所需的关键信息。将物体A沿此方向移动深度距离,或者将物体B反向移动,它们就能恰好分开。
注意:GJK/EPA只适用于**凸(Convex)**形状。对于凹形状,你需要先将其分解为多个凸形状的组合(凸分解,Convex Decomposition)。Unity的PolygonCollider2D如果勾选了“Used by Composite”,其内部就是这样处理的。
3. 实战:C#核心代码实现与解析
理论说再多不如一行代码。下面我们分模块拆解实现。我会先给出关键代码片段,然后解释其作用和注意事项。
3.1 数据结构定义:支撑点与单纯形
首先,我们需要定义一些基础数据结构。
// 描述Minkowski差集上的一个支撑点 public struct SupportPoint { public Vector2 Point; // Minkowski差点 (a - b) public Vector2 PointA; // 来自形状A的顶点 public Vector2 PointB; // 来自形状B的顶点 public SupportPoint(Vector2 point, Vector2 pointA, Vector2 pointB) { Point = point; PointA = pointA; PointB = pointB; } } // 描述一个2D单纯形(顶点数<=3) public class Simplex { private List<SupportPoint> _points = new List<SupportPoint>(); public int Count => _points.Count; public void Insert(SupportPoint point, int index) { _points.Insert(index, point); } public SupportPoint this[int index] => _points[index]; public void RemoveAt(int index) { _points.RemoveAt(index); } }为什么需要记录PointA和PointB?这是关键!EPA算法最后输出的穿透向量,需要作用回原始物体。我们只知道Minkowski差点Point是不够的,必须知道这个点是由哪两个原始顶点PointA和PointB相减得来的,这样才能在分离物体时,知道力应该作用在哪个局部坐标上,或者用于计算碰撞点。
3.2 支撑函数的实现
支撑函数是算法的性能关键。对于不同的形状,实现方式不同。这里以最常见的凸多边形为例。
public static SupportPoint GetSupportPoint(List<Vector2> verticesA, List<Vector2> verticesB, Vector2 direction) { // 在形状A上找方向d上最远的点 Vector2 pointA = GetFarthestPointInDirection(verticesA, direction); // 在形状B上找反方向-d上最远的点(因为Minkowski差是A-B) Vector2 pointB = GetFarthestPointInDirection(verticesB, -direction); // 计算Minkowski差点 Vector2 minkowskiPoint = pointA - pointB; return new SupportPoint(minkowskiPoint, pointA, pointB); } private static Vector2 GetFarthestPointInDirection(List<Vector2> vertices, Vector2 direction) { float maxDot = float.NegativeInfinity; Vector2 farthestVertex = vertices[0]; foreach (var vertex in vertices) { // 点积可以反映顶点在给定方向上的投影长度 float dot = Vector2.Dot(vertex, direction); if (dot > maxDot) { maxDot = dot; farthestVertex = vertex; } } return farthestVertex; }实操心得:这里的顶点列表
vertices应该是物体在世界空间下的坐标。如果你的碰撞体数据是局部坐标,需要在调用前用物体的变换矩阵(位置、旋转、缩放)将其转换到世界空间。这是一个常见的错误来源——在局部坐标下计算支撑点,会导致整个算法失效。
3.3 GJK算法核心迭代
这是GJK的判断循环,我把它写成了一个返回布尔值的函数。
public static bool GJKCollisionCheck(List<Vector2> verticesA, List<Vector2> verticesB, out Simplex simplex) { simplex = new Simplex(); // 1. 选择初始方向(可以取A中心指向B中心的方向) Vector2 direction = (GetCenter(verticesB) - GetCenter(verticesA)).normalized; if (direction == Vector2.zero) direction = Vector2.right; // 防零向量 // 2. 获取第一个支撑点 SupportPoint sp = GetSupportPoint(verticesA, verticesB, direction); simplex.Insert(sp, 0); // 下一次搜索方向指向原点 direction = -sp.Point; // 3. 开始迭代(最多迭代次数防止死循环) int maxIterations = 20; for (int i = 0; i < maxIterations; i++) { // 获取新支撑点 sp = GetSupportPoint(verticesA, verticesB, direction); // 如果新点在与方向相反的方向上没有推进(点积<=0),则原点不在Minkowski差集中 if (Vector2.Dot(sp.Point, direction) <= 0) { return false; // 不相交 } simplex.Insert(sp, 0); // 插入到单纯形中 // 根据单纯形顶点数,处理并更新搜索方向 if (HandleSimplex(ref simplex, ref direction)) { // HandleSimplex返回true,说明单纯形包含了原点 return true; // 相交 } // 否则,direction已被更新为指向原点的方向,继续循环 } // 达到最大迭代次数,保守起见返回不相交,或者可以根据情况抛出异常 return false; }关键在HandleSimplex函数里,它根据单纯形是线段还是三角形,使用向量叉乘等几何运算来判断原点位置并更新搜索方向。这部分代码几何性较强,核心是判断原点相对于线段的位置(在线段左侧、右侧还是之间),以及是否在三角形内部。
private static bool HandleSimplex(ref Simplex simplex, ref Vector2 direction) { if (simplex.Count == 2) { // 单纯形是一条线段 AB // 使用向量运算判断原点相对于线段AB的位置,并更新direction使其指向原点 // 如果原点在线段AB的“之间”,则说明单纯形(线段)已经包含原点(在2D中不可能,线段是1维的), // 但实际上我们需要检查原点是否在线段AB的“前面”。这里逻辑是判断原点是否在AB的垂直区域。 // 具体实现涉及向量AO, AB, 以及叉乘perp(AB)等。 return UpdateDirectionForLine(simplex, ref direction); } else if (simplex.Count == 3) { // 单纯形是一个三角形 ABC // 检查原点是否在三角形内部。如果是,返回true(碰撞发生)。 // 否则,移除离原点最远的那个点,将单纯形退化为一条边,并更新direction指向原点。 return UpdateDirectionForTriangle(simplex, ref direction); } // simplex.Count == 1 的情况在循环中处理,就是继续找点 return false; }3.4 EPA算法:从相交到分离向量
当GJK返回true并给出一个包含原点的初始单纯形后,EPA开始工作。
public static bool EPAPenetration(Simplex simplex, List<Vector2> verticesA, List<Vector2> verticesB, out Vector2 penetrationNormal, out float penetrationDepth) { penetrationNormal = Vector2.zero; penetrationDepth = 0f; // 将GJK得到的单纯形作为EPA的初始凸包(多边形) List<Edge> polytope = new List<Edge>(); // 将单纯形的边添加到凸包中,注意顺序(保证凸包是逆时针的) // 假设simplex有3个点A,B,C // 添加边 AB, BC, CA // ... float tolerance = 0.0001f; int maxIterations = 30; Edge closestEdge = default; SupportPoint newSupportPoint; for (int i = 0; i < maxIterations; i++) { // 1. 找到凸包中离原点最近的边 FindClosestEdge(polytope, out closestEdge, out float distance); // 2. 沿着该边的外法线方向获取新的支撑点 Vector2 supportDirection = closestEdge.Normal; // 外法线方向 newSupportPoint = GetSupportPoint(verticesA, verticesB, supportDirection); // 3. 计算新支撑点到这条边的距离(在法线方向上的投影) float supportDistance = Vector2.Dot(newSupportPoint.Point, supportDirection); // 4. 判断是否收敛:如果新点带来的距离增量非常小,则认为找到最小穿透 if (Mathf.Abs(supportDistance - distance) < tolerance) { penetrationNormal = closestEdge.Normal; penetrationDepth = supportDistance + tolerance; // 加一点容差,确保分离 return true; } // 5. 未收敛,将新点插入凸包,重构凸包 // 插入逻辑:找到新点应该插入的两条边之间,移除旧的边,添加两条新边 InsertPointInPolytope(ref polytope, newSupportPoint, closestEdge.Index); } // 迭代次数用尽,返回最后一次找到的近似结果 penetrationNormal = closestEdge.Normal; penetrationDepth = Vector2.Dot(newSupportPoint.Point, closestEdge.Normal); return false; // 或者 true,但精度可能不够 }FindClosestEdge函数需要遍历凸包的所有边,计算原点到每条边的有符号距离(通过边法线与原点向量点乘),并记录距离最小的边及其外法线方向。
InsertPointInPolytope函数是EPA的另一个关键,它需要维护凸包的有序性(逆时针)。插入新点后,要确保凸包仍然是凸的,并且所有边都指向外侧。
注意事项:EPA的迭代次数和容差
tolerance需要根据你的游戏尺度来调整。如果你的游戏单位是米,那么tolerance=0.0001(0.1毫米)通常足够。迭代次数maxIterations防止无限循环,30-50次对于2D凸多边形通常绰绰有余。过高的精度要求会导致不必要的性能开销。
4. 集成到Unity:碰撞分离的完整流程
有了穿透法线和深度,我们就能分离物体了。但这里有一个非常重要的选择:分离哪个物体?通常有几种策略:
- 分离质量小的物体:符合物理直觉,轻的物体被推开。
- 分离运动中的物体:比如只分离动态物体,静态物体不动。
- 按比例分离:根据物体的质量或自定义权重,按比例分配穿透深度。
下面是一个简单的分离函数示例,假设我们选择分离物体A:
public static void SeparateBodies(Transform transformA, Transform transformB, Vector2 penetrationNormal, float penetrationDepth, List<Vector2> localVerticesA, List<Vector2> localVerticesB) { // 分离策略:将物体A沿穿透法线方向移动 Vector2 separationVector = penetrationNormal * penetrationDepth; transformA.position += new Vector3(separationVector.x, separationVector.y, 0); // (可选)更高级的处理:计算碰撞点并应用冲量 // 1. 使用EPA最后得到的最近边上的信息,或者用支撑点近似,找到世界空间下的碰撞点。 // 2. 根据碰撞点、法线、物体速度和质量,计算冲量(Impulse)。 // 3. 更新物体的速度(如果是Rigidbody2D,可以修改其velocity)。 }一个完整的每帧碰撞处理流程可以这样组织:
void FixedUpdate() { // 假设我们有两个碰撞体数据 MyCollider colliderA = ...; MyCollider colliderB = ...; // 1. 获取世界坐标下的顶点 List<Vector2> worldVertsA = colliderA.GetWorldVertices(); List<Vector2> worldVertsB = colliderB.GetWorldVertices(); Simplex simplex; // 2. GJK检测 if (GJKCollisionCheck(worldVertsA, worldVertsB, out simplex)) { Vector2 normal; float depth; // 3. EPA计算穿透信息 if (EPAPenetration(simplex, worldVertsA, worldVertsB, out normal, out depth)) { // 4. 根据策略分离物体 SeparateBodies(colliderA.transform, colliderB.transform, normal, depth, colliderA.localVertices, colliderB.localVertices); // 5. (可选)触发碰撞事件,传递法线、深度、碰撞点等信息 OnCollisionDetected(colliderA, colliderB, normal, depth); } } }5. 性能优化与常见陷阱
自己实现物理算法,性能是绕不开的话题。GJK/EPA本身是高效的,但不当的实现会成为瓶颈。
5.1 性能优化点
- 缓存支撑点计算:对于固定形状,在给定方向上的最远点通常是那几个“极端点”。可以考虑为每个形状预计算一个凸包,并缓存各个方向上的候选顶点,但实现复杂度较高。对于顶点数不多的多边形,直接线性搜索通常可以接受。
- 提前剔除(Broad Phase):这是最重要的优化!不要对所有物体两两进行GJK检测。先用AABB(轴对齐包围盒)或包围圆进行粗略的快速剔除,只有AABB相交的物体对才进入GJK/EPA(窄相位检测)。Unity的物理引擎内部就是这样做的。
- 限制迭代次数:如代码所示,为GJK和EPA设置合理的最大迭代次数。
- 使用值类型:
SupportPoint、Vector2使用struct(值类型),减少堆分配。 - 避免频繁的List分配:
Simplex和EPA的polytope列表可以在类级别缓存,每帧复用,而不是每次检测都new一个新的。
5.2 常见问题与排查
- 物体抖动或穿透:
- 原因:分离后,下一帧由于速度等原因又立刻碰撞,EPA计算出的分离向量可能略有不同,导致位置来回变化。
- 解决:引入“位置纠正(Positional Correction)”或“滑移(Slop)”。不要完全按照理论穿透深度分离,而是分离
(depth - slop),留出一个微小的允许穿透量(如0.01个单位)。这能增加稳定性。许多物理引擎都有这个参数。
- EPA不收敛或结果异常:
- 原因a:顶点数据不是凸多边形。必须确保输入给GJK/EPA的顶点列表构成一个凸多边形,且顶点顺序是逆时针的。你可以用
Vector2.Cross遍历所有边,检查叉积是否始终同号(都大于0或都小于0)。 - 原因b:浮点数精度问题。在判断点是否在边上、距离是否接近零时,使用一个小的容差值(
Mathf.Epsilon)。 - 原因c:支撑函数返回的点不在形状的边界上。检查你的顶点变换(局部到世界)是否正确,以及
GetFarthestPointInDirection函数逻辑。
- 原因a:顶点数据不是凸多边形。必须确保输入给GJK/EPA的顶点列表构成一个凸多边形,且顶点顺序是逆时针的。你可以用
- GJK循环无法终止:
- 原因:方向向量计算错误,导致搜索陷入循环。仔细检查
HandleSimplex中更新方向的几何逻辑,确保方向向量始终指向原点。 - 调试:在迭代中打印
direction和simplex的点,可视化观察算法的搜索路径。
- 原因:方向向量计算错误,导致搜索陷入循环。仔细检查
- 分离后物体旋转异常:
- 注意:本项目提供的分离只处理了平移(Translation)。如果你同时需要处理旋转引起的碰撞(比如一个长杆旋转着插进另一个物体),则需要更复杂的“连续碰撞检测(CCD)”和考虑转动惯量的冲量计算。本方案适用于大多数平移为主的碰撞响应。
6. 扩展与应用场景
掌握了基础的GJK+EPA分离,你可以在此基础上构建更丰富的物理交互:
- 碰撞响应(冲量计算):分离解决了穿透,但物体应该有反弹。结合碰撞法线、相对速度、恢复系数(弹性)和摩擦系数,可以计算碰撞冲量,并应用到物体的线速度和角速度上,模拟真实的碰撞反弹。这需要你管理物体的质量、转动惯量和速度状态。
- 射线投射(Ray Cast):GJK算法可以很容易地改造成射线与凸形状的相交检测,原理类似,将射线看作一个长度无限的“薄”形状。
- 最近点计算:当GJK判断为不相交时,其最终得到的单纯形(通常是线段)可以用于计算两个凸形状之间的最近点和最近距离,这在AI寻路、障碍规避时很有用。
- 自定义碰撞体:你可以为任何凸形状实现支撑函数,比如椭圆、胶囊体(可以用线段和圆的Minkowski和来构造)。这样就能让自定义的奇怪形状也接入你的碰撞系统。
实现自己的GJK/EPA碰撞分离,就像给游戏开发技能树点了一个高级专精。它让你从物理引擎的使用者,变成了理解其内在逻辑的掌控者。当再次遇到诡异的碰撞bug时,你不再只能盲目调整物理材质参数,而是可以深入到算法层面去分析和解决。提供的完整源码是一个坚实的起点,建议你亲手敲一遍,并在简单的几何体上调试、可视化每一步,这比读十篇文章理解得更深刻。