DYNAMIC EXPLAINER / ARTICLE FLOW

把章节变成可单步观察的过程

01 / 14
STEP 01 / CODE / LOGIC

先说结论

GJK 不直接枚举两个凸形状的所有边是否相交,而是在 Minkowski Difference 的支持点上构造 Simplex,并不断判断这个 Simplex 能否包围原点。原点在其中意味着两个原形状重叠。

形状 A 区间:[0, 3]
形状 B 区间:[2, 5]
A - B 的范围:[-5, 1]
0 位于 [-5, 1] 内 -> 两区间重叠

分类:算法 / 物理碰撞

先说结论

GJK 不直接枚举两个凸形状的所有边是否相交,而是在 Minkowski Difference 的支持点上构造 Simplex,并不断判断这个 Simplex 能否包围原点。原点在其中意味着两个原形状重叠。

一维数字先建立直觉

形状 A 区间:[0, 3]
形状 B 区间:[2, 5]
A - B 的范围:[-5, 1]
0 位于 [-5, 1] 内 -> 两区间重叠

若 B 改成 [4, 6],差集范围变为 [-6, -1],不包含 0,因此分离。二维和三维的 Support、Simplex 只是把这个判断扩展到线段、三角形和四面体。

状态:理解笔记,后续可继续深入 Simplex 更新、EPA 和具体代码实现。

GJK 解决什么问题

GJK 全名:

Gilbert-Johnson-Keerthi

它主要用于判断:

两个凸形状是否相交。

常见对象:

凸多边形
凸多面体
盒子
胶囊体
凸包

它和 SAT 一样,主要处理凸形状。

区别可以先这样记:

SAT:找分离轴。
GJK:看闵可夫斯基差 A - B 是否包含原点。

跟着代码算出第一个 Support 点

先实现最基础的“沿某方向找最远顶点”:

using System;
using System.Collections.Generic;
using System.Numerics;

static Vector2 Furthest(Vector2[] vertices, Vector2 direction)
{
    Vector2 best = vertices[0];
    float bestDot = Vector2.Dot(best, direction);

    for (int i = 1; i < vertices.Length; i++)
    {
        float dot = Vector2.Dot(vertices[i], direction);
        if (dot > bestDot)
        {
            best = vertices[i];
            bestDot = dot;
        }
    }

    return best;
}

static Vector2 MinkowskiSupport(
    Vector2[] shapeA,
    Vector2[] shapeB,
    Vector2 direction)
{
    return Furthest(shapeA, direction)
         - Furthest(shapeB, -direction);
}

两个正方形左右分开:

Vector2[] a =
{
    new(-1, -1), new(1, -1),
    new( 1,  1), new(-1, 1)
};

Vector2[] b =
{
    new(2, -1), new(4, -1),
    new(4,  1), new(2, 1)
};

Vector2 direction = Vector2.UnitX;
Vector2 support = MinkowskiSupport(a, b, direction);

Console.WriteLine(support);                    // <-1, 0>
Console.WriteLine(Vector2.Dot(support, direction)); // -1

沿右方向计算时:

A 在 +X 最远的点 x = 1
B 在 -X 最远的点 x = 2
A - B 的 support.x = 1 - 2 = -1

Support 点在原点左侧,且沿搜索方向的点积仍小于 0。这说明闵可夫斯基差在这个方向到不了原点,可以提前判定两个形状不相交。

第二步:让两个方块发生重叠

把 B 左移:

b = new[]
{
    new Vector2(0.5f, -1), new Vector2(2.5f, -1),
    new Vector2(2.5f,  1), new Vector2(0.5f,  1)
};

support = MinkowskiSupport(a, b, Vector2.UnitX);
Console.WriteLine(support); // <0.5, 0>

现在点积为正,只能说明沿这个方向没有立即找到分离证据,不能只凭一个点判定相交。GJK 接下来会把 Support 点加入 Simplex,并把搜索方向转向原点:

List<Vector2> simplex = new() { support };
direction = -support;

后续迭代反复执行:

1. 沿 direction 取新的 Minkowski Support
2. 新点沿 direction 仍越不过原点 -> 不相交
3. 否则把点加入 Simplex
4. 根据点、线段或三角形中离原点最近的区域更新 Simplex
5. Simplex 包含原点 -> 相交

用日志观察一次迭代

for (int iteration = 0; iteration < maxIterations; iteration++)
{
    Vector2 point = MinkowskiSupport(a, b, direction);
    float progress = Vector2.Dot(point, direction);

    Console.WriteLine(
        $"iter={iteration}, dir={direction}, " +
        $"support={point}, dot={progress}");

    if (progress < 0f)
        return false;

    simplex.Add(point);

    if (UpdateSimplexAndDirection(simplex, ref direction))
        return true;
}

UpdateSimplexAndDirection 是 GJK 最容易写错的部分:它要根据 Simplex 的 Voronoi 区域删点、换方向,并判断原点是否被包围。教学时可以继续沿后文的一维、二维图理解;项目里应使用经过验证的物理库实现,并为重合点、零方向和迭代上限保留测试,而不是只复制这段循环骨架。

原点不是选出来的

GJK 里的原点不是算法随便选的点。

2D 里原点是:

(0, 0)

3D 里原点是:

(0, 0, 0)

它不是:

A 的中心
B 的中心
两个物体中点
碰撞点
某个顶点

GJK 之所以看原点,是因为:

如果 A 和 B 有相同位置的点 p,
那么 p - p = 0。

也就是说:

A 和 B 相交
= 存在 a == b
= 存在 a - b == 0
= A - B 包含原点

一维例子

A 是线段:

A = [2, 5]

B 是线段:

B = [4, 7]

它们重叠:

重叠区间是 [4, 5]

A - B 的范围:

最小:2 - 7 = -5
最大:5 - 4 = 1

所以:

A - B = [-5, 1]

里面包含 0。

因为可以取:

a = 4.5
b = 4.5

那么:

a - b = 0

所以 A 和 B 相交。

二维例子

A 是正方形:

x: 0 ~ 2
y: 0 ~ 2

B 是正方形:

x: 1 ~ 3
y: 1 ~ 3

它们有重叠区域:

x: 1 ~ 2
y: 1 ~ 2

可以取重叠点:

a = (1.5, 1.5)
b = (1.5, 1.5)

那么:

a - b = (0, 0)

所以 A - B 包含原点。

如果换成不相交的 B:

B:
x: 4 ~ 6
y: 1 ~ 3

A - B 的 x 范围:

0 - 6 = -6
2 - 4 = -2

也就是:

x: -6 ~ -2

这个范围不包含 0,所以 A - B 不包含原点,A 和 B 不相交。

三维里不是只看 x/y/z

容易误解的一点:

三维里是不是只要 x、y、z 三个方向的差值集合都包含 0,就相交?

不是。

这对 AABB 这种轴对齐盒子可以成立,因为 AABB 只需要检查 x/y/z 三个轴的范围。

但对一般 3D 凸形状,只看 x/y/z 不够。

正确理解是:

GJK 看的是三维空间里的 A - B 整个差集体,是否包含原点 (0,0,0)。

不是分别看:

x 轴包含 0
y 轴包含 0
z 轴包含 0

因为三个轴投影都有重叠,只能说明:

从 x/y/z 三个方向看,它们范围重叠。

但从某个斜方向看,两个物体仍然可能分开。

Support 点是什么

GJK 不会真的生成完整的 A - B。

如果 A 有 100 个顶点,B 有 100 个顶点,完整差集需要:

100 * 100 = 10000 个差值点

GJK 每次只问:

A - B 在当前方向上最远的点在哪里?

这个点叫 Support 点。

计算方式:

Support(A, B, dir)
= A 在 dir 方向上的最远点
- B 在 -dir 方向上的最远点

伪代码:

Vector3 Support(Shape a, Shape b, Vector3 dir)
{
    Vector3 pointA = a.FarthestPoint(dir);
    Vector3 pointB = b.FarthestPoint(-dir);

    return pointA - pointB;
}

当前方向上最远点怎么找

最朴素的做法是:

对所有顶点做点积。
谁的 dot(point, dir) 最大,谁就是这个方向上的最远点。

例如 2D 三角形:

P1 = (0, 0)
P2 = (2, 0)
P3 = (1, 2)

方向:

dir = (1, 1)

点积:

dot(P1, dir) = 0
dot(P2, dir) = 2
dot(P3, dir) = 3

所以当前方向最远点是:

P3 = (1, 2)

3D 也是一样:

dot = x * dx + y * dy + z * dz

谁的 dot 最大,谁就是当前方向最远点。

每次都要跑所有点吗

朴素实现里,是的。

一次 Support 查询:

A:遍历 n 个顶点,找 dir 方向最远点。
B:遍历 m 个顶点,找 -dir 方向最远点。

所以一次 Support 是:

O(n + m)

如果 GJK 迭代 k 次,大概是:

O(k * (n + m))

但它不是:

O(n * m)

因为它没有把所有 a - b 都列出来。

实际引擎会优化 Support:

球体:center + normalize(dir) * radius
盒子:根据 dir 的正负快速选角点
凸包:用邻接信息从上次最远点开始爬山
缓存:复用上一帧或上次查询的极点
Broad Phase:先过滤掉大量不可能碰撞的对象

方向是怎么选的

GJK 的方向不是随便选一条边。

方向来自:

当前 Simplex 和原点的位置关系。

GJK 的目标是:

判断 A - B 是否包含原点。

所以每一步都在问:

当前 Simplex 离包住原点还差哪个方向的信息?

2D 里:

点:朝点到原点的方向继续找。
线段:朝线段到原点的最近方向继续找。
三角形:如果包住原点,碰撞;否则保留靠近原点的边继续找。

3D 里:

点:朝原点方向。
线段:朝线段到原点的最近方向。
三角形:看原点在三角形哪一侧,朝那一侧继续找。
四面体:如果原点在内部,碰撞;否则保留原点所在外侧相关面继续找。

所以:

SAT 是枚举轴。
GJK 是动态追原点。

Simplex 是什么

Simplex 是 GJK 当前收集到的少量 Support 点组成的小形状。

2D 里 Simplex 可能是:

点
线段
三角形

如果三角形包住原点:

碰撞

3D 里 Simplex 可能是:

点
线段
三角形
四面体

如果四面体包住原点:

碰撞

注意:

不是原物体必须是四面体。

而是 GJK 在闵可夫斯基差边界上找到的几个 Support 点,在 3D 里组成四面体。

只要这个四面体包住原点,就说明 A - B 包含原点。

最重要的收获

GJK 用于判断两个凸形状是否相交。
它把问题变成:A - B 是否包含原点。
原点不是选出来的,而是坐标系里的 0。
因为重叠点 p - p = 0。
GJK 不生成完整 A - B,而是用 Support 函数逐步找边界点。
朴素 Support 每次是 O(n + m),不是 O(n * m)。
2D 中三角形 Simplex 包住原点即可判定碰撞。
3D 中四面体 Simplex 包住原点即可判定碰撞。