GJK 实现细节
本文档基于
GJKCollisionDetector.cs、Simplex.cs、MyCollider.cs及子类源码,参考 PhysX 4.1GuGJK.h实现,按算法执行流程逐步梳理每一步的细节。
目录
- 整体调用链路
- [Margin 机制(表皮机制)](#Margin 机制(表皮机制))
- [Support 支撑函数调用链](#Support 支撑函数调用链)
- [GJK 主循环详解](#GJK 主循环详解)
- 单纯形更新详解(Simplex.DoSimplex)
- 单纯形收敛后还原最近点(GetClosestPoint)
- [GJK 的四种退出路径总结](#GJK 的四种退出路径总结)
1. 整体调用链路
1.1 场景入口
CollisionTest.Awake() 自动发现场景中所有 MyCollider,注册到 CollisionManager 并构建八叉树:
csharp
// CollisionTest.cs 第 9-11 行
private void Awake()
{
MyCollider[] colliders = FindObjectsOfType<MyCollider>();
CollisionManager.Instance.InsertCollider(new List<MyCollider>(colliders));
}
1.2 每帧更新顺序
CollisionManager.FixedUpdate()
├── 1. ColliderPosUpdate() // 遍历所有 MyCollider → 更新 AABB
├── 2. OctreeUpdate() // 检查碰撞体是否需要换八叉树节点
└── 3. CollisionDetectorUpdate() // 碰撞检测调度
├── 八叉树 Query → 获取同节点内的潜在碰撞对
├── AABB 粗筛 → GJKCollisionDetector.AABBCollisionCheck()
├── GJK 精确检测 → GJKCollisionDetector.GJKEPA() / GJKCollisionCheck()
└── 对比 _prevState ↔ _currState → 派发 Enter/Stay/Exit 事件
csharp
// CollisionManager.cs 第 151-156 行
private void FixedUpdate()
{
ColliderPosUpdate();
OctreeUpdate();
CollisionDetectorUpdate();
}
1.3 碰撞检测核心调度
从八叉树拿到潜在碰撞对后,先 AABB 粗筛,再 GJK 精确检测:
csharp
// CollisionManager.cs 第 220-255 行(CollisionDetectorUpdate 第三部分节选)
foreach (var colA in _colliders)
{
_octree.Query(colA, _queryTemp); // 八叉树粗筛:只查同节点的碰撞体
foreach (var colB in _queryTemp)
{
if (colA == colB) continue;
var pair = MakePair(colA, colB);
if (_checkedPairs.Contains(pair)) continue; // 去重
_checkedPairs.Add(pair);
// 第一层:AABB 粗筛
if (GJKCollisionDetector.AABBCollisionCheck(colA.AABB, colB.AABB))
{
// 第二层:GJK 精确检测
GjkStatus status = GJKCollisionDetector.GJKEPA(colA, colB,
out GjkOutput output, contactDistance);
// ... 根据 status 处理 Enter/Stay/Exit 事件
}
}
}
筛选层级:八叉树空间划分 → AABB 重叠检测 → GJK 精确检测
1.4 GJK 对外接口
GJKCollisionDetector 是静态工具类,提供三层接口:
| 接口 | 用途 | 返回值 |
|---|---|---|
GJKCollisionCheck(a, b) |
快速判断是否碰撞 | bool(true=重叠) |
GJKCheckCollision(a, b, out distance) |
判断碰撞 + 分离距离 | bool + float distance |
GJKClosest(a, b, out ...) |
完整查询:最近点/法线/距离 | GjkStatus |
三个接口最终都调用同一个 Gjk() 私有方法,区别在于参数和输出的取舍。
csharp
// GJKCollisionDetector.cs 第 62-73 行 --- 快速碰撞检测
public static bool GJKCollisionCheck(MyCollider colliderA, MyCollider colliderB)
{
GjkStatus status = Gjk(colliderA, colliderB, InitialDir(colliderA, colliderB), 0f,
out _, out _, out _, out _);
return status == GjkStatus.Contact;
}
// GJKCollisionDetector.cs 第 78-92 行 --- 带距离的碰撞检测
public static bool GJKCheckCollision(MyCollider colliderA, MyCollider colliderB,
out float distance)
{
// 用极大 contactDist 强制 GJK 收敛,从而总能拿到最近距离
GjkStatus status = Gjk(colliderA, colliderB, InitialDir(colliderA, colliderB),
LARGE_CONTACT_DIST, out _, out _, out _, out distance);
return status == GjkStatus.Contact;
}
// GJKCollisionDetector.cs 第 99-113 行 --- 完整查询
public static GjkStatus GJKClosest(MyCollider colliderA, MyCollider colliderB,
out Vector3 closestA, out Vector3 closestB, out Vector3 normal,
out float distance, float contactDist = LARGE_CONTACT_DIST)
{
return Gjk(colliderA, colliderB, InitialDir(colliderA, colliderB), contactDist,
out closestA, out closestB, out normal, out distance);
}
1.5 GjkStatus 状态枚举
csharp
// GJKCollisionDetector.cs 第 7-22 行
public enum GjkStatus
{
NonIntersect, // 两形状分离,且间隙大于 contactDist
Close, // 两形状分离但在 contactDist 内,返回最近点/距离信息
Contact, // 两形状重叠
// 以下为 EPA 使用,本文不展开
EpaContact, EpaDegenerate, EpaFail
}
2. Margin 机制(表皮机制)
参考 PhysX,所有形状有一层"表皮"(margin)。GJK 对不同形状采取不同策略:
| 类型 | Margin | IsMarginEqRadius | GJK 做法 |
|---|---|---|---|
| Box | MinHalfExtent × 0.15 |
❌ false | core 即完整角点,margin 仅影响终止 eps 和退化容差 |
| Sphere | Radius |
✅ true | core 收缩为点,GJK 收敛后最近点沿法线加回 margin |
| Capsule | Radius |
✅ true | core 收缩为线段,GJK 收敛后最近点沿法线加回 margin |
csharp
// MyBoxCollider.cs 第 71-76 行
private float MinHalfExtent => Mathf.Min(Size.x, Mathf.Min(Size.y, Size.z)) * 0.5f;
public override float Margin => MinHalfExtent * 0.15f;
public override float MinMargin => MinHalfExtent * 0.05f;
public override bool MarginIsRadius => false;
// MySphereCollider.cs 第 47-49 行
public override float Margin => Radius;
public override float MinMargin => Radius;
public override bool MarginIsRadius => true;
// MyCapsuleCollider.cs 第 74-76 行
public override float Margin => Radius;
public override float MinMargin => Radius;
public override bool MarginIsRadius => true;
2.1 为什么区分 quadratic 与非 quadratic
-
Quadratic 形状(球、胶囊) :GJK 对凸多面体才能正确收敛。球/胶囊是曲面体,所以将它们的 core 收缩为点/线段,radius 作为 margin。GJK 在 core 形状(点/线段)上迭代,收敛后再把最近点沿法线"推回"真实表面,距离减去
sumMargin。 -
非 quadratic 形状(Box):core 就是完整角点,margin 不收缩形状,仅用于终止 eps 和退化容差。
csharp
// GJKCollisionDetector.cs 第 194-200 行(Gjk 方法内的初始化)
bool aQuadratic = a.MarginIsRadius;
bool bQuadratic = b.MarginIsRadius;
// sumMargin 只累加 quadratic 形状的 margin
float sumMargin = (aQuadratic ? a.Margin : zero) + (bQuadratic ? b.Margin : zero);
float separatingDist = sumMargin + contactDist;
2.2 Margin 如何影响各退出路径
| 退出路径 | Margin 的作用 |
|---|---|
| Case 1 (NonIntersect) | 分离判据 separatingDist = sumMargin + contactDist 包含 margin |
| Case 2 (Close) | 最近点从 core 沿法线推回 margin 距离;表面距离 dist - sumMargin |
| Case 3 (Contact) | 不需要 margin 修正(已确认重叠) |
| Case 4 (退化) | acceptanceDist 取 sumMargin 或 0.2 × minMargin |
3. Support 支撑函数调用链
GJK 每轮迭代需要获取闵可夫斯基差(Minkowski Difference)上的支撑点:
support = A.SupportCore(-closest) - B.SupportCore(closest)
其中 closest 是原点到当前单纯形的最近点,-closest 是从最近点指向原点的方向。
3.1 调用链
GJK 传入世界方向 worldDir
→ MyCollider.SupportCore(worldDir) // 模板方法
→ InverseRotateYXZ(worldDir, RotEuler) // 世界方向 → 本地方向(旋转矩阵转置)
→ 本地几何计算 // 按形状类型取极值点
→ RotateYXZ(localPt, RotEuler) // 本地支撑点 → 世界支撑点
→ Center + worldSupport // 返回世界空间支撑点
3.2 旋转约定
项目使用旋转用欧拉角 YXZ 顺序 + 手动旋转矩阵实现:
csharp
// MyCollider.cs 第 114-130 行 --- 正向旋转(本地 → 世界)
protected Vector3 RotateYXZ(Vector3 v, Vector3 rotEuler)
{
float rx = rotEuler.x * Mathf.Deg2Rad;
float ry = rotEuler.y * Mathf.Deg2Rad;
float rz = rotEuler.z * Mathf.Deg2Rad;
float cx = Mathf.Cos(rx); float sx = Mathf.Sin(rx);
float cy = Mathf.Cos(ry); float sy = Mathf.Sin(ry);
float cz = Mathf.Cos(rz); float sz = Mathf.Sin(rz);
// YXZ 顺序旋转矩阵 R
float x = v.x * (cy * cz + sx * sy * sz) + v.y * (cz * sx * sy - cy * sz) + v.z * (cx * sy);
float y = v.x * (cx * sz) + v.y * (cx * cz) + v.z * (-sx);
float z = v.x * (cy * sx * sz - cz * sy) + v.y * (sy * sz + cy * cz * sx) + v.z * (cx * cy);
return new Vector3(x, y, z);
}
// MyCollider.cs 第 139-155 行 --- 逆向旋转(世界 → 本地),R 的转置 Rᵀ
protected Vector3 InverseRotateYXZ(Vector3 v, Vector3 rotEuler)
{
// 同样的 sin/cos 预计算 ...
// Rᵀ 矩阵
float x = v.x * (cy * cz + sx * sy * sz) + v.y * (cx * sz) + v.z * (cy * sx * sz - cz * sy);
float y = v.x * (cz * sx * sy - cy * sz) + v.y * (cx * cz) + v.z * (sy * sz + cy * cz * sx);
float z = v.x * (cx * sy) + v.y * (-sx) + v.z * (cx * cy);
return new Vector3(x, y, z);
}
设计要点 :三角函数只在
UpdatePos中调用;SupportCore热路径只需点积和数乘,零分配。
3.3 Box 的 SupportCore
Box 是非 quadratic 形状,core 即完整角点。按方向各分量的符号取对应顶点:
csharp
// MyBoxCollider.cs 第 51-68 行
public override Vector3 SupportCore(Vector3 dir)
{
// 世界方向 → 本地方向
Vector3 localDir = InverseRotateYXZ(dir, RotEuler);
// 按方向分量符号取 ±halfSize 顶点
Vector3 halfSize = Size * 0.5f;
Vector3 localSupport = new Vector3(
Mathf.Sign(localDir.x) * halfSize.x,
Mathf.Sign(localDir.y) * halfSize.y,
Mathf.Sign(localDir.z) * halfSize.z
);
// 本地角点 → 世界角点
Vector3 worldSupport = RotateYXZ(localSupport, RotEuler);
return Center + worldSupport;
}
3.4 Sphere 的 SupportCore
球是 quadratic 形状,core 收缩为球心(点):
csharp
// MySphereCollider.cs 第 40-44 行
public override Vector3 SupportCore(Vector3 dir)
{
// 球的 core 是球心,半径作为 margin 在 GJK 中处理
return Center;
}
3.5 Capsule 的 SupportCore
胶囊是 quadratic 形状,core 收缩为线段。取沿方向投影更远的端点:
csharp
// MyCapsuleCollider.cs 第 65-71 行
public override Vector3 SupportCore(Vector3 dir)
{
// 胶囊的 core 是线段 p0-p1,取沿 dir 投影更远的端点
float dotA = Vector3.Dot(_pointA, dir);
float dotB = Vector3.Dot(_pointB, dir);
return dotA > dotB ? _pointA : _pointB;
}
4. GJK 主循环
4.1 核心常量
csharp
// GJKCollisionDetector.cs 第 33-43 行
private const int MAX_ITERATIONS = 32; // 最大迭代次数
private const float EPS_REL = 0.000225f; // 精度阈值 1.5% 的平方
private const float LARGE_CONTACT_DIST = 1e18f; // 距离查询用的极大 contactDist
// 静态复用,热路径零分配
private static readonly Simplex _simplex = new Simplex();
4.2 初始搜索方向
取两 AABB 中心连线作为初始方向,这是让 GJK 收敛最快的经典选择。若两点重合(零向量),退化为 X 轴:
csharp
// GJKCollisionDetector.cs 第 165-168 行
private static Vector3 InitialDir(MyCollider a, MyCollider b)
{
return b.AABB.Center - a.AABB.Center;
}
4.3 Gjk() 方法逐步分解
完整方法签名:
csharp
// GJKCollisionDetector.cs 第 173-175 行
private static GjkStatus Gjk(MyCollider a, MyCollider b, Vector3 initialSearchDir,
float contactDist, out Vector3 closestA, out Vector3 closestB,
out Vector3 normal, out float distance)
步骤 0:初始化
csharp
// GJKCollisionDetector.cs 第 176-208 行
closestA = closestB = normal = Vector3.zero;
distance = 0f;
Simplex s = _simplex;
s.Reset(); // Size = 0,清空单纯形
const float zero = 0f;
// 初始搜索方向:退化则取 X 轴
Vector3 closest = Vector3.Dot(initialSearchDir, initialSearchDir) > zero
? initialSearchDir : Vector3.right;
Vector3 v = closest.normalized; // 搜索方向的单位向量
// eps:两形状重叠的终止判据,取最小 margin 的 10%,下限 1e-6
float minMargin = Mathf.Min(a.MinMargin, b.MinMargin);
float eps = Mathf.Max(1e-6f, minMargin * 0.1f);
float relDif = 1f - EPS_REL; // ≈ 0.999775
// 识别 quadratic 形状
bool aQuadratic = a.MarginIsRadius;
bool bQuadratic = b.MarginIsRadius;
float sumMargin = (aQuadratic ? a.Margin : zero) + (bQuadratic ? b.Margin : zero);
float separatingDist = sumMargin + contactDist; // 分离判据阈值
float dist = float.MaxValue; // 原点到单纯形的最近距离
float prevDist = dist;
Vector3 prevDir = v;
bool notDegenerated = true; // 迭代是否没有退化
bool reachedOrigin = false; // 单纯形是否包住了原点
int iter = 0;
关键变量含义:
| 变量 | 含义 |
|---|---|
closest |
原点到单纯形最近点的向量(闵可夫斯基差空间) |
v |
搜索方向单位向量,与 closest 同向 |
dist |
` |
eps |
距离终止阈值,dist ≤ eps 认为是重叠 |
separatingDist |
分离判据阈值 = sumMargin + contactDist |
relDif |
收敛相对判据 1 - EPS_REL ≈ 0.999775 |
notDegenerated |
跟踪距离是否在缩小(prevDist > dist) |
reachedOrigin |
退出循环后判断是否因到达原点而终止 |
步骤 1:取闵可夫斯基支撑点
csharp
// GJKCollisionDetector.cs 第 214-218 行(循环体内)
// -closest 是最近点指向原点的向量
// 在 core 形状上取支撑点
Vector3 supportA = a.SupportCore(-closest);
Vector3 supportB = b.SupportCore(closest);
Vector3 support = supportA - supportB; // 闵可夫斯基差点
步骤 2:分离轴判定(Case 1 → NonIntersect)
csharp
// GJKCollisionDetector.cs 第 220-228 行
float signDist = Vector3.Dot(v, support);
// 找到分离轴,且间隙超过 separatingDist → 两形状确定不相交
if (signDist > separatingDist)
{
Debug.Log($"[GJKCase1] {a.name} vs {b.name} → NonIntersect, 迭代 {iter + 1} 次");
return GjkStatus.NonIntersect;
}
判定原理:
signDist = v · support 是新支撑点在搜索方向 v 上的投影。
v与closest同向(v = closest / dist),都指向原点- 若
signDist > separatingDist,说明支撑点已越过原点足够远(超出 margin + contactDist) - 这意味着两凸体沿方向
v存在足够大的分离间隙 - 找到了一个分离轴 → 两形状确定不相交,立即返回
步骤 3:收敛判定(Case 2 → Close)
csharp
// GJKCollisionDetector.cs 第 230-240 行
// 支撑点投影已逼近当前最近距离,无法再前进
if (signDist > sumMargin && signDist > relDif * dist)
{
Vector3 n = -v; // 法线 = 由 A 指向 B
s.GetClosestPoint(closest, out Vector3 closA, out Vector3 closB);
// 球/胶囊:把见证点从 core 推回真实表面
closestA = aQuadratic ? closA + n * a.Margin : closA;
closestB = bQuadratic ? closB - n * b.Margin : closB;
normal = n;
distance = Mathf.Max(zero, dist - sumMargin);
Debug.Log($"[GJKCase2] {a.name} vs {b.name} → Close(dist={distance:F4}), 迭代 {iter + 1} 次");
return GjkStatus.Close;
}
判定原理:
条件 signDist > relDif × dist(即 signDist > 0.999775 × dist):
- 新支撑点到原点的投影 ≈ 当前原点到单纯形的距离
- 说明新支撑点几乎就在当前最近距离的平面上,无法再显著缩小距离
- GJK 已收敛到稳定的最近距离
收敛后的处理:
- 法线
n = -v:v与closest同向指向原点 →-v由 A 指向 B - 从单纯形取 core 上的最近点
(closA, closB) - 对于 quadratic 形状,把 core 最近点沿法线推回真实表面
- 表面距离 =
max(0, dist - sumMargin)
步骤 4:扩展单纯形 + 计算最近点
csharp
// GJKCollisionDetector.cs 第 242-248 行
s.Add(support, supportA, supportB);
// 计算原点到单纯形的最近点,同时缩减单纯形(移除无用顶点)
closest = s.DoSimplex(support);
dist = closest.magnitude;
// dist > eps 时更新方向;否则保持上一轮方向继续
v = dist > eps ? closest / dist : v;
关键操作:
s.Add(support, supportA, supportB):将新闵可夫斯基差点 + 对应的 A/B 支撑点加入单纯形s.DoSimplex(support):求原点到当前单纯形的最近点,同时剔除不再贡献的顶点(详见第 5 章)- 方向更新:
dist > eps→v = closest/dist(单位化);dist ≤ eps→v保持上一轮方向不变
步骤 5:终止检查
csharp
// GJKCollisionDetector.cs 第 250-257 行
notDegenerated = prevDist > dist; // 本迭代是否仍在缩小距离
bool notTerminated = dist > eps && notDegenerated;
if (!notTerminated)
{
// notDegenerated==true 表示因 dist<=eps 退出 → 原点已到达
reachedOrigin = notDegenerated;
break;
}
终止条件分析:
| 情况 | dist ≤ eps |
notDegenerated |
reachedOrigin |
含义 |
|---|---|---|---|---|
| 到达原点 | ✅ | ✅ | ✅ true |
距离收缩到 eps 以内 → 重叠 |
| 退化 | ❌ | ❌ | ❌ false |
距离不再缩小 → 迭代终止 |
| 继续 | ❌ | ✅ | --- | 循环继续 |
步骤 6:循环后处理
循环结束有两种可能:到达原点(重叠)或退化退出。
Case 3:单纯形包住原点 → Contact
csharp
// GJKCollisionDetector.cs 第 261-267 行
if (reachedOrigin)
{
distance = zero;
normal = -prevDir;
Debug.Log($"[GJKCase3] {a.name} vs {b.name} → Contact, 迭代 {iter} 次(单纯形包住原点)");
return GjkStatus.Contact;
}
reachedOrigin = true 意味着循环因 dist ≤ eps 退出(且未退化)→ 单纯形已逼近原点 → 两形状重叠。
Case 4:退化 → 近似判定
csharp
// GJKCollisionDetector.cs 第 269-283 行
float acceptanceMargin = 0.2f * Mathf.Min(a.Margin, b.Margin);
float acceptanceDist = sumMargin > zero ? sumMargin : acceptanceMargin;
Vector3 nn = -v;
s.GetClosestPoint(closest, out Vector3 pClosA, out Vector3 pClosB);
closestA = aQuadratic ? pClosA + nn * a.Margin : pClosA;
closestB = bQuadratic ? pClosB - nn * b.Margin : pClosB;
normal = nn;
float finalDist = Mathf.Max(zero, dist - sumMargin);
distance = finalDist;
GjkStatus state = finalDist > acceptanceDist ? GjkStatus.Close : GjkStatus.Contact;
Debug.Log($"[GJKCase4] {a.name} vs {b.name} → {state}, 迭代 {iter} 次(退化/超限)");
return state;
退化时的判定逻辑:
- 有 quadratic 形状时:
acceptanceDist = sumMargin(更宽松) - 无 quadratic 形状时:
acceptanceDist = 0.2 × minMargin - 最终表面距离
finalDist > acceptanceDist→Close(分离);否则 →Contact(视作重叠)
5. 单纯形更新(Simplex.DoSimplex)
单纯形(Simplex)是 GJK 维护的核心数据结构,表示闵可夫斯基差空间中一组点构成的凸包(最多 4 个点)。每轮迭代加入新支撑点后,需要计算原点到当前单纯形的最近点,并剔除不再需要的顶点。
5.1 数据结构
csharp
// Simplex.cs 第 14-17 行
public readonly Vector3[] Q = new Vector3[4]; // 闵可夫斯基差点
public readonly Vector3[] A = new Vector3[4]; // 凸体 A 的 core 支撑点
public readonly Vector3[] B = new Vector3[4]; // 凸体 B 的 core 支撑点
public int Size; // 当前顶点数 (0~4)
热路径零分配 :固定长度数组,不使用
List<T>。
5.2 添加顶点
csharp
// Simplex.cs 第 26-32 行
public void Add(Vector3 q, Vector3 sa, Vector3 sb)
{
Q[Size] = q;
A[Size] = sa;
B[Size] = sb;
Size++;
}
5.3 DoSimplex 按顶点数分发
csharp
// Simplex.cs 第 37-47 行
public Vector3 DoSimplex(Vector3 support)
{
switch (Size)
{
case 1: return support; // 单点:最近点就是该点
case 2: return ClosestPtPointSegment(); // 线段
case 3: return ClosestPtPointTriangle(); // 三角形
case 4: return ClosestPtPointTetrahedron(); // 四面体
default: return support;
}
}
5.4 线段:原点到 Q0-Q1 的最近点
csharp
// Simplex.cs 第 84-102 行
private Vector3 ClosestPtPointSegment()
{
Vector3 a = Q[0];
Vector3 b = Q[1];
Vector3 ab = b - a;
float denom = Vector3.Dot(ab, ab); // |ab|²
float nom = Vector3.Dot(-a, ab); // (-a)·ab = 投影系数分子
// 两点几乎重合,退化为单点
if (denom <= DEGEN_EPS) // DEGEN_EPS = 1e-9
{
Size = 1;
return Q[0];
}
// 投影系数夹到 [0,1],保证最近点在线段上而非延长线
float t = Mathf.Clamp01(nom / denom);
return a + ab * t;
}
原理:
原点 O 到线段 ab 的最近点 P = a + t·ab
求投影系数:t = (O→a · ab) / |ab|²
由于 O 是原点,O→a = a,而 (-a)·ab = 使 t 取 a→b 方向的投影
实际:t = (-a · ab) / |ab|²
clamp t 到 [0,1]:
t ≤ 0 → 最近点是 a
t ≥ 1 → 最近点是 b
0<t<1 → 最近点在线段内部
退化处理:若 |ab|² ≤ 1e-9,两点几乎重合 → Size=1,返回 a
5.5 三角形:原点到 Q0-Q1-Q2 的最近点
csharp
// Simplex.cs 第 108-147 行
private Vector3 ClosestPtPointTriangle()
{
Size = 3;
Vector3 a = Q[0], b = Q[1], c = Q[2];
Vector3 ab = b - a;
Vector3 ac = c - a;
Vector3 signArea = Vector3.Cross(ab, ac); // 2×面积向量
float area = Vector3.Dot(signArea, signArea); // 4×面积²
// 三点共线,退化成线段
if (area <= DEGEN_EPS)
{
Size = 2;
return ClosestPtPointSegment();
}
_indices[0] = 0; _indices[1] = 1; _indices[2] = 2;
ClosestPtPointTriangleBaryCentric(a, b, c, _indices, out int featSize,
out Vector3 closestPt);
// 最近特征是边或顶点时,按 _indices 缩减重排 Q/A/B
if (featSize != 3)
{
int i0 = _indices[0], i1 = _indices[1];
Q[0] = Q[i0]; Q[1] = Q[i1];
A[0] = A[i0]; A[1] = A[i1];
B[0] = B[i0]; B[1] = B[i1];
Size = featSize;
}
return closestPt;
}
5.5.1 三角形重心坐标 Voronoi 区域判定
这是核心算法------通过重心坐标 + 边投影,判断原点的投影落在三角形哪个 Voronoi 区域:
csharp
// Simplex.cs 第 156-175 行
private float ClosestPtPointTriangleBaryCentric(Vector3 a, Vector3 b, Vector3 c,
int[] indices, out int size, out Vector3 closestPt)
{
size = 3;
Vector3 ab = b - a;
Vector3 ac = c - a;
Vector3 n = Vector3.Cross(ab, ac); // 三角形法线(2×面积向量)
float nn = Vector3.Dot(n, n); // 4×面积²
if (nn < DEGEN_EPS) { /* 退化返回 */ }
// 三个重心权重,符号表示原点投影在三角形的哪一侧
float va = Vector3.Dot(n, Vector3.Cross(b, c)); // 顶点 A 的权重
float vb = Vector3.Dot(n, Vector3.Cross(c, a)); // 顶点 B 的权重
float vc = Vector3.Dot(n, Vector3.Cross(a, b)); // 顶点 C 的权重
重心坐标 (va, vb, vc) 是原点 O 在三角形 ABC 上的未归一化重心坐标。va ≥ 0 && vb ≥ 0 && vc ≥ 0 表示原点在三角形面内的投影落在三角形内部。
然后计算一系列点积用于判断落在哪条边/顶点的 Voronoi 区域:
csharp
// Simplex.cs 第 188-198 行
Vector3 ap = -a, bp = -b, cp = -c; // 各顶点指向原点的向量
float d1 = Vector3.Dot(ab, ap); // A 处 ab 边投影
float d2 = Vector3.Dot(ac, ap); // A 处 ac 边投影
float d3 = Vector3.Dot(ab, bp); // B 处 ab 边投影
float d4 = Vector3.Dot(ac, bp); // B 处 ac 边投影
float d5 = Vector3.Dot(ab, cp); // C 处 ab 边投影
float d6 = Vector3.Dot(ac, cp); // C 处 ac 边投影
float unom = d4 - d3; // BC 边投影参数分子
float udenom = d5 - d6; // BC 边投影参数分母
5.5.2 七种 Voronoi 区域判定
| 区域 | 条件 | 最近特征 | 最近点计算 |
|---|---|---|---|
| ❶ 面内 | va≥0 && vb≥0 && vc≥0 |
三角形面 | 原点在法线 n 上的投影(垂足) |
| ❷ AB 边 | vc≤0 && d1≥0 && d3≤0 |
AB 边 | a + ab × d1/(d1-d3) |
| ❸ BC 边 | va≤0 && d4≥d3 && d5≥d6 |
BC 边 | b + bc × unom/(unom+udenom) |
| ❹ AC 边 | vb≤0 && d2≥0 && d6≤0 |
AC 边 | a + ac × d2/(d2-d6) |
| ❺ 顶点 A | d1≤0 && d2≤0 |
顶点 A | a |
| ❻ 顶点 B | d3≥0 && d3≥d4 |
顶点 B | b |
| ❼ 顶点 C | 其余 | 顶点 C | c |
区域 ❶ 面内的实现:
csharp
// Simplex.cs 第 178-185 行
if (va >= 0f && vb >= 0f && vc >= 0f)
{
float t = Vector3.Dot(n, a) / nn; // 原点到三角形平面的投影参数
Vector3 q = n * t; // 最近点 = 法线方向的投影(垂足)
closestPt = q;
size = 3; // 三角形完整保留
return Vector3.Dot(q, q); // 返回距离平方
}
区域 ❷ AB 边的实现:
csharp
// Simplex.cs 第 201-209 行
if (vc <= 0f && d1 >= 0f && d3 <= 0f)
{
float t = SafeDiv(d1, d1 - d3); // 投影系数
Vector3 q = a + ab * t;
closestPt = q;
return Vector3.Dot(q, q);
}
区域 ❸ BC 边的实现 (注意 _indices 重排,把 B 和 C 的索引放到前两位):
csharp
// Simplex.cs 第 212-224 行
if (va <= 0f && d4 >= d3 && d5 >= d6)
{
Vector3 bc = c - b;
float t = SafeDiv(unom, unom + udenom);
indices[0] = indices[1]; // 前移:B 的索引 → [0]
indices[1] = indices[2]; // 前移:C 的索引 → [1]
Vector3 q = b + bc * t;
closestPt = q;
return Vector3.Dot(q, q);
}
区域 ❹ AC 边的实现:
csharp
// Simplex.cs 第 227-234 行
if (vb <= 0f && d2 >= 0f && d6 <= 0f)
{
float t = SafeDiv(d2, d2 - d6);
indices[1] = indices[2]; // C 的索引 → [1](A 的索引保持在 [0])
Vector3 q = a + ac * t;
closestPt = q;
return Vector3.Dot(q, q);
}
区域 ❺❻❼ 顶点的实现:
csharp
// Simplex.cs 第 237-258 行
size = 1;
// 顶点 A
if (d1 <= 0f && d2 <= 0f)
{
closestPt = a;
return Vector3.Dot(a, a);
}
// 顶点 B
if (d3 >= 0f && d3 >= d4)
{
indices[0] = indices[1]; // B 的索引 → [0]
closestPt = b;
return Vector3.Dot(b, b);
}
// 顶点 C(兜底)
indices[0] = indices[2]; // C 的索引 → [0]
closestPt = c;
return Vector3.Dot(c, c);
当特征为边或顶点时 ,调用方重排 Q/A/B 数组并缩减 Size:
csharp
// 回到 ClosestPtPointTriangle 中:
if (featSize != 3)
{
// 按 _indices 把选中的顶点重排到数组前 featSize 个位置
int i0 = _indices[0], i1 = _indices[1];
Q[0] = Q[i0]; Q[1] = Q[i1];
A[0] = A[i0]; A[1] = A[i1];
B[0] = B[i0]; B[1] = B[i1];
Size = featSize; // 收缩单纯形
}
5.6 四面体:原点到 Q0-Q1-Q2-Q3 的最近点
csharp
// Simplex.cs 第 264-313 行
private Vector3 ClosestPtPointTetrahedron()
{
Vector3 a = Q[0], b = Q[1], c = Q[2], d = Q[3];
// 退化检测 1: 前三个顶点共面
Vector3 nRaw = Vector3.Cross(b - a, c - a);
float nLen = nRaw.magnitude;
if (nLen <= DEGEN_EPS)
{
Size = 3;
return ClosestPtPointTriangle();
}
// 退化检测 2: 第四个顶点在前三个顶点的平面上
Vector3 n = nRaw / nLen;
float signDist = Vector3.Dot(n, d - a);
if (Mathf.Abs(signDist) < TETRA_PLANE_EPS) // TETRA_PLANE_EPS = 1e-4
{
Size = 3;
return ClosestPtPointTriangle();
}
// 判断原点在四面体四个面的内/外侧
PointOutsideOfPlane4(a, b, c, d, out bool o0, out bool o1, out bool o2, out bool o3);
// ★ 核心判定:原点在所有面内侧 → 包住原点 → 两形状重叠!
if (!o0 && !o1 && !o2 && !o3)
{
return Vector3.zero; // 返回零向量,最近距离 = 0
}
// 否则:在所有"外侧"的面里取离原点最近的那个三角形
_indices[0] = 0; _indices[1] = 1; _indices[2] = 2;
Vector3 closest = GetClosestPtPointTriangle(o0, o1, o2, o3, _indices, out int featSize);
Size = featSize;
// 按选中的三角形索引重排 Q/A/B
int i0 = _indices[0], i1 = _indices[1], i2 = _indices[2];
Q[0] = Q[i0]; Q[1] = Q[i1]; Q[2] = Q[i2];
A[0] = A[i0]; A[1] = A[i1]; A[2] = A[i2];
B[0] = B[i0]; B[1] = B[i1]; B[2] = B[i2];
return closest;
}
5.6.1 PointOutsideOfPlane4:判断原点在四面体各面的内外
csharp
// Simplex.cs 第 371-401 行
private void PointOutsideOfPlane4(Vector3 a, Vector3 b, Vector3 c, Vector3 d,
out bool o0, out bool o1, out bool o2, out bool o3)
{
Vector3 ab = b - a, ac = c - a, ad = d - a;
Vector3 bd = d - b, bc = c - b;
// 四个面的法线(未归一化,方向朝外,由右手定则确定)
Vector3 v0 = Vector3.Cross(ab, ac); // 面 ABC 的法线
Vector3 v1 = Vector3.Cross(ac, ad); // 面 ACD 的法线
Vector3 v2 = Vector3.Cross(ad, ab); // 面 ABD 的法线
Vector3 v3 = Vector3.Cross(bd, bc); // 面 BCD 的法线
// 计算每个面上"原点"与"对面顶点"相对法线的投影符号
float signa0 = Vector3.Dot(v0, a); // 原点在面 ABC 的投影符号
float signd0 = Vector3.Dot(v0, d); // 对顶点 D 在面 ABC 的投影符号
float signa1 = Vector3.Dot(v1, a); // 原点在面 ACD
float signd1 = Vector3.Dot(v1, b); // 对顶点 B
float signa2 = Vector3.Dot(v2, a); // 原点在面 ABD
float signd2 = Vector3.Dot(v2, c); // 对顶点 C
float signa3 = Vector3.Dot(v3, b); // 原点在面 BCD
float signd3 = Vector3.Dot(v3, a); // 对顶点 A
// 核心逻辑:若原点与对顶点在同侧 → 原点在该面外侧
// 即 signaᵢ × signdᵢ ≥ -PLANE4_EPS
const float th = -PLANE4_EPS; // PLANE4_EPS = 1e-6
o0 = signa0 * signd0 >= th; // 原点在面 ABC 外侧?
o1 = signa1 * signd1 >= th; // 原点在面 ACD 外侧?
o2 = signa2 * signd2 >= th; // 原点在面 ABD 外侧?
o3 = signa3 * signd3 >= th; // 原点在面 BCD 外侧?
}
判定逻辑图解:
对四面体每个面 F:
取法线 N_F(朝外)
取该面的对顶点 V_opposite(不在该面上的那个顶点)
计算:
sign_origin = N_F · O(原点投影)
sign_opposite = N_F · V_opposite(对顶点投影)
若 sign_origin × sign_opposite ≥ -ε:
→ 原点与对顶点同侧
→ 原点在该面外侧
若原点在全部 4 个面内侧:
→ 原点被四面体包围
→ O ∈ 四面体
→ 两形状重叠!
5.6.2 GetClosestPtPointTriangle:遍历外侧面的最近点
当原点不在四面体内部时,对每个"外侧"面调用 ClosestPtPointTriangleBaryCentric,取最近的那个:
csharp
// Simplex.cs 第 318-365 行
private Vector3 GetClosestPtPointTriangle(bool o0, bool o1, bool o2, bool o3,
int[] indices, out int size)
{
float bestSq = float.MaxValue;
Vector3 closestPt = Vector3.zero;
size = 3;
if (o0) // 面 ABC:顶点 0,1,2
{
indices[0] = 0; indices[1] = 1; indices[2] = 2;
bestSq = ClosestPtPointTriangleBaryCentric(Q[0], Q[1], Q[2], indices,
out size, out closestPt);
}
if (o1) // 面 ACD:顶点 0,2,3
{
_idx[0] = 0; _idx[1] = 2; _idx[2] = 3;
float sq = ClosestPtPointTriangleBaryCentric(Q[0], Q[2], Q[3], _idx,
out int sz, out Vector3 cp);
if (bestSq > sq)
{
bestSq = sq; closestPt = cp; size = sz;
indices[0] = _idx[0]; indices[1] = _idx[1]; indices[2] = _idx[2];
}
}
// o2、o3 同理...
return closestPt;
}
6. 单纯形收敛后还原最近点(GetClosestPoint)
GJK 收敛后,需要从单纯形上的最近点还原出两个凸体上对应的最近点。原理:用最近点在当前单纯形上的重心坐标 ,对 A[](凸体 A 的支撑点)和 B[](凸体 B 的支撑点)做相同插值。
csharp
// Simplex.cs 第 53-78 行
public void GetClosestPoint(Vector3 closest, out Vector3 closA, out Vector3 closB)
{
switch (Size)
{
case 2: // 线段:一维重心坐标
{
BarycentricSegment(closest, Q[0], Q[1], out float v);
closA = A[0] + (A[1] - A[0]) * v;
closB = B[0] + (B[1] - B[0]) * v;
break;
}
case 3: // 三角形:二维重心坐标
{
BarycentricTriangle(closest, Q[0], Q[1], Q[2], out float v, out float w);
closA = A[0] + (A[1] - A[0]) * v + (A[2] - A[0]) * w;
closB = B[0] + (B[1] - B[0]) * v + (B[2] - B[0]) * w;
break;
}
default: // 单点或兜底
{
closA = A[0];
closB = B[0];
break;
}
}
}
6.1 线段重心坐标
已知线段端点 a, b 和线上一点 p,求参数 v 使 p ≈ a + v·(b-a):
csharp
// Simplex.cs 第 406-414 行
private void BarycentricSegment(Vector3 p, Vector3 a, Vector3 b, out float v)
{
Vector3 v0 = a - p;
Vector3 v1 = b - p;
Vector3 d = v1 - v0; // d = (b-p) - (a-p) = b-a = ab
float denom = Vector3.Dot(d, d); // |ab|²
float num = Vector3.Dot(-v0, d); // (p-a)·(b-a)
v = denom > 0f ? num / denom : 0f;
}
6.2 三角形重心坐标
已知三角形顶点 a, b, c 和面内一点 p,求参数 (v, w) 使 p ≈ a + v·(b-a) + w·(c-a):
csharp
// Simplex.cs 第 419-434 行
private void BarycentricTriangle(Vector3 p, Vector3 a, Vector3 b, Vector3 c,
out float v, out float w)
{
Vector3 v0 = b - a;
Vector3 v1 = c - a;
Vector3 v2 = p - a;
float d00 = Vector3.Dot(v0, v0); // |b-a|²
float d01 = Vector3.Dot(v0, v1); // (b-a)·(c-a)
float d11 = Vector3.Dot(v1, v1); // |c-a|²
float d20 = Vector3.Dot(v2, v0); // (p-a)·(b-a)
float d21 = Vector3.Dot(v2, v1); // (p-a)·(c-a)
// 解 2×2 线性方程组
float denom = d00 * d11 - d01 * d01; // 三角形面积平方
float inv = Mathf.Abs(denom) > FEPS ? 1f / denom : 0f;
v = (d11 * d20 - d01 * d21) * inv; // (b-a) 的系数
w = (d00 * d21 - d01 * d20) * inv; // (c-a) 的系数
// u = 1 - v - w 是顶点 a 的系数
}
7. GJK 的四种退出路径总结
| Case | 条件 | 返回值 | 含义 |
|---|---|---|---|
| Case 1 | signDist > separatingDist |
NonIntersect |
找到分离轴且间隙 > margin+contactDist,确定两形状不相交 |
| Case 2 | signDist > sumMargin && signDist > relDif × dist |
Close |
GJK 收敛,返回两形状表面最近点/法线/距离 |
| Case 3 | dist ≤ eps 且未退化(reachedOrigin = true) |
Contact |
单纯形逼近原点,两形状重叠 |
| Case 4 | 退化(prevDist ≤ dist)或 32 次迭代耗尽 |
Close 或 Contact |
无法继续收敛,用 acceptanceDist 阈值判定 |
Case 4 的接受阈值:
- 有 quadratic 形状时:
acceptanceDist = sumMargin - 无 quadratic 形状时:
acceptanceDist = 0.2 × min(a.Margin, b.Margin) finalDist > acceptanceDist→Close;否则 →Contact
关键文件索引
| 文件 | 职责 |
|---|---|
Assets/Collision/GJK/GJKCollisionDetector.cs |
GJK 主循环 + AABB 粗筛 + 对外接口 |
Assets/Collision/GJK/Simplex.cs |
单纯形数据结构 + 更新逻辑(点/线/三角形/四面体) |
Assets/Collision/Collider/MyCollider.cs |
碰撞体基类:AABB + SupportCore 模板方法 + 欧拉角旋转 |
Assets/Collision/Collider/MyBoxCollider.cs |
Box SupportCore:按方向分量符号取角点 |
Assets/Collision/Collider/MySphereCollider.cs |
Sphere SupportCore:core 收缩为点,返回球心 |
Assets/Collision/Collider/MyCapsuleCollider.cs |
Capsule SupportCore:core 收缩为线段,返回较远端点 |
Assets/Test/CollisionManager.cs |
碰撞检测调度:八叉树 → AABB → GJK → 事件派发 |
Assets/CollisionTest.cs |
场景入口:自动发现碰撞体 + 注册到管理器 |