本文解释了GJK算法及其常见实现。
GJK算法来源于两份论文:
- GJK原始论文[1]:由Gilbert, Johnson, Keerth三人编写。对GJK算法的正确性进行了证明,并给出了算法。论文偏理论,对数值问题没有一个性能较好的解决方法。
- GinoGJK[2]:由Gino van den Bergen编写。在原有GJK基础上解决了很多痛点,让算法性能更高以便于更好的用于实时环境。论文偏工程。此文章中的算法和其实现[3]是现代游戏物理引擎中用的最多的。
本文会将上述三个文章中的知识做总结,述说现在成熟的GJK方案并阅读Gino的GJK源码。
现在大多数文章介绍的都是Ericson的GJK算法,他首次显式地提出在GJK中使用重心坐标并推广到游戏编程圈内。但从论文本身来说,和Ericson的方法(至少形式上)并不一样。
多篇文章的符号不一样,但由于Gino的实现和其文章一致,所以我们用Gino文章中的符号。
GJK算法
预备知识
GJK算法能做很多事情:
- 判断两个凸体是否相交
- 如果相交,获得穿透深度(GinoGJK)
- 如果不相交,算得两个物体之间的距离已经最近点(原始GJK)
首先是一些符号定义。
距离
定义两个物体$A$和$B$间的距离为
$$ d(A, B) = \min \{ |x - y|: x \in A, y \in B \} $$GJK中使用欧几里得距离。
半空间
半空间为使用平面分割的一半的空间。定义平面为
$$ H(\vec{n}, d) : \vec{n_x}x +\vec{n_y}y + \vec{n_z}z + d = 0 $$其法向量$\vec{n}$指定了平面正向。平面正向的空间记为$H^+$,而反向的记为$H^-$。
仿射集和凸集
定义仿射集为:
$$ \operatorname{aff} X = \{ \sum_{i = 1}^l \lambda_i x_i :x_i \in X, \lambda_1 + \cdots + \lambda_l = 1 \} $$定义凸集为:
$$ \operatorname{co} X = \{ \sum_{i = 1}^l \lambda_i x_i :x_i \in X, \lambda_1 + \cdots + \lambda_l = 1, \lambda_i \ge 0 \} $$凸集是比较重要也是常用的概念。显然,凸集是仿射集的扩展,也是所有凸体的描述(满足任意集合内任意两点连线在集合内)。
一般用$\operatorname{co}X$表示$X$的凸包。显然,这也是GJK首要研究的对象。
重心坐标是$|X| = 3$的集合的特例,所以也符合凸集的公式。
再定义仿射无关(Affinely Independent):
对于集合$X = \{x_1, x_2, \cdots, x_n\}$,如果集合
$$ \{x_2 - x_1, x_3 - x_1, \cdots , x_n - x_1\} $$线性无关,那么就叫$X$仿射无关。
仿射无关的几何意义是,集合中没有多余的点落在此集合构成的凸包内(面上可以)。也就是说,集合内所有点共同支棱起一个凸包。
支撑映射(Support Mapping)
对于集合$X$,其某个方向$d$的支撑距离为
$$ h_X(d) = \max \{x \cdot d: x \in X\} $$即沿着这个方向在$X$上的最远距离。而满足$h_X(d)$中$x$的这个点就叫支撑点,记为$s_X(d)$。
显然有:
$$ \begin{aligned} & h_X(d) = s_X(d) \cdot d \\ & h_X(-d) = \min \{x \cdot d : x \in X\} \end{aligned} $$而在GJK算法中,一般研究离原点最近的点:
$$ v(X) \in X, |v(X)| = \min \{|x| : x \in X\} $$而由凸集的定义,显然有:
$$ v(\operatorname{co}X) = \sum_{i = 1}^l \lambda_i x_i, x_i \in X, \lambda_i \gt 0, \sum \lambda_i = 1 $$接下来是一些常见几何体的支撑映射
常见几何体的支撑映射
Box
可以利用其三个互相垂直的轴加速计算。
假设此Box由三个轴上的半长$\eta_x, \eta_y, \eta_z$表示。那么支撑点函数就是:
$$ S_{Box}((x, y, z)) = (sgn(x)\eta_x, sgn(y)\eta_y, sgn(z)\eta_z) $$Sphere
显然为:
$$ S_{sphere}(\vec{v}) = \begin{cases} \frac{r}{|\vec{v}|}\vec{v} & \text{if } ||\vec{v}|| \ne 0 \\ 0 & \text{otherwise} \end{cases} $$Capped Cone
意指有界圆锥,即只有一个顶点和一个底面的圆锥。
假设此圆锥轴心位于$y$轴,朝上(顶点在上底面在下),底面半径为$\rho$,顶点在$y = \eta$处,底面中心在$y = -\eta$处。顶点处的半角为$\alpha$满足$\sin(\alpha) = \frac{\rho}{\sqrt{\rho^2 + (2\eta)^2}}$。
令$\sigma = \sqrt{x^2 + z^2}$为任意点到$y$轴的距离,那么支撑点函数为:
$$ S_{cone}((x, y, z)) = \begin{cases} (0, \eta, 0) & \text{if } y \gt ||(x, y, z)||\sin(\alpha) \\ (\frac{\rho}{\sigma}x, -\eta, \frac{\rho}{\sigma}z) & \text{else } \sigma \gt 0 \\ (0, -\eta, 0) & \text{otherwise} \end{cases} $$Capped Cylinder
有界圆柱。轴为$y$轴,中心在原点。半高为$\eta$,半径为$\rho$。
令$\sigma = \sqrt{x^2 + z^2}$为任意点到$y$轴的距离,那么支撑点函数为:
其支撑点函数为:
$$ S_{cylinder}((x, y, z)) = \begin{cases} (\frac{\rho}{\sigma}x, \operatorname{sgn}(y)\eta, \frac{\rho}{\sigma}z) & \text{if } \sigma \gt 0 \\ (0, \operatorname{sgn}(y)\eta, 0) & \text{otherwise} \end{cases} $$仿射变换后的几何体
定义仿射变换为$T(\vec{x}) = B\vec{x} + \vec{c}$。那么对于一个物体$A$经过仿射变换之后的物体$T(A)$,其支撑点函数为:
$$ S_{T(A)}(\vec{v}) = T(S_A(B^T\vec{v})) $$很易证。原论文有证明这里不写了。
尖锐边角的处理
圆柱和圆锥的底边边角即带有弧又很尖锐,圆锥的顶角也同理。在我的实践中发现这很容易导致数值精度误差,会导致GJK算法在还未真正收敛的情况下退出。
JoltPhysics给出了一个解决方法我觉得很好用:将这些地方变圆。比如对于圆柱体,其SupportMapping的代码为:
| |
Minkowski差和CSO
在Gino的论文2中,将两个物体$A$和$B$的Minkowski差称为CSO(Configuration Space Obstacle):
$$ CSO_{A-B} = A - B = \{a - b : a \in A, b \in B\} $$由于会反复出现$A$和$B$的Minkowsi差,我们用$K = A - B$来表示。
现在可以证明:
- 两物体相交等价于原点在CSO内:显然,两物体相交区域内必有两个位置一样的点$a \in A, b \in B$,那么显然$a - b = \vec{0}$,那么原点在CSO内。
- 计算两物体的距离等价于计算原点到Minkowski差的距离:显然,原点到Minkowski差的距离为$d(\vec{0}, K) = \min \{|k - \vec{0}|: k \in K\}$,而 $x = a - b, a \in A, b \in B $正是两物体之间距离。
接下来介绍GJK原始论文中的算法。而这个算法着重于求两物体间的距离。那么这个问题就转换为求$v(K)$。
算法
由于GJK是对任意维度$R^m$都成立的算法,那么对其的证明自然围绕$R^m$。
定理1
令$K = A - B, K \in R^m$是紧致和凸的。那么定义$g_K :R^m \rightarrow R$为:
$$ g_K(x) = |x|^2 + h_K(-x) $$
所谓“紧致(compact)”是拓扑学中的概念,意为有界且闭的。这里紧致+凸就是指一般的凸体或者说凸的刚体。
如果假设$x \in K$,那么定理1有四条性质:
$g_K(x) \ge 0$恒成立。这是显然的,因为$x\cdot s_K(-x) = \min\{a: a \cdot x, a \in K \} \cdot x \le x \cdot x = |x| ^ 2$,而$g_K(x) = |x|^2 -x\cdot s_K(-x) \ge 0$显然就成立了。
如果$g_K(x) \gt 0$,那么在线段$\operatorname{co} \{x, s_K(-x)\}$上一定有一点$z$满足$|z| \lt |x|$(即$z$到原点的距离比$x$小)
证明:
先证$z$的存在性:
显然在$|s_K(-x)| \lt |x|$时,取$z = s_K(-x)$即可。
那么考虑$|s_K(-x)| \ge |x|$的情况:
由于$z$是线段上一点,那么可以用凸集表示为:
$$ \begin{aligned} & z = (1 - \lambda) x + \lambda s_K(-x) = x + \lambda(s_K(-x) - x) \\ & |z|^2 = (x + \lambda(s_K(-x) - x))^2 = |x|^2 + 2\lambda x(s_K(-x) - x) + \lambda^2|s_K(-x) -x|^2 \end{aligned} $$我们要求得最小的$|z|$,所以对$|z|^2$求导数,并令导数为0:
$$ \begin{aligned} & \frac{d|z|^2}{d|z|} = 2x(s_K(-x) - x) + 2\lambda|s_K(-x) -x|^2 = 0 \\ & \Rightarrow \\ & \lambda = \frac{x(x - s_K(-x))}{|s_K(-x) - x|^2} & (1) \end{aligned} $$那么问题就变成找到一个合格的$\lambda$。
而我们可以对$g_K(x)$变形得到:
$$ g_K(x) = |x|^2 + h_K(-x) = |x|^2 +(-x)s_K(-x) = |x|^2 - xs_K(-x) = x(x - s_K(-x)) $$正是上面$(1)$式的分子。所以有:
$$ \lambda = \frac{g_K(x)}{|s_K(-x) - x|^2} $$而:
$$ \begin{aligned} & |x - s_K(-x)|^2 = |x|^2 + |s_K(-x)|^2 - 2x\cdot s_K(-x) \\ & 2g_K(x) = 2(|x|^2 - x \cdot s_K(-x)) = 2|x|^2 - 2x\cdot s_K(-x) \\ & \text{上述两式相减得:} \\ & |x - s_K(-x)|^2 - 2g_K(x) = |s_K(-x)|^2 - |x|^2 \ge 0 &(*) \\ & \Rightarrow \\ & |x - s_K(-x)|^2 \ge 2g_K(x) \Rightarrow \frac{g_K(x)}{|x - s_K(-x)|^2} \le \frac{1}{2} \Rightarrow \lambda \le \frac{1}{2} \end{aligned} $$$(*)$式是因为,$s_K(-x) = \{y : \max\{y \cdot (-x)\}\} \ge x \cdot (-x)$。那么左右两边平方就有$|s_K(-x)|^2 \ge |x|^2$。
而又根据$\lambda$的定义有$\lambda \gt 0$。所以综上,总能找到一个$\lambda \in [0, \frac{1}{2}]$。那么也就总存在一个$z$了。
综上,$z$的存在性证明完毕。
然后证明$|z| \lt |x|$,只需将$g_K(x) = \lambda|x - s_K(-x)|^2$带入$|z|^2$式子中即可:
$$ \begin{aligned} |z|^2 & = |x|^2 + 2\lambda x(s_K(-x) - x) + \lambda^2|s_K(-x) - x|^2 \\ & = |x|^2 + 2\lambda(-g_K(x)) + \lambda(\lambda|x - s_K(-x)|^2) \\ & = |x|^2 - 2\lambda g_K(x) + \lambda g_K(x) \\ & = |x|^2 - \lambda g_K(x) \end{aligned} $$因为$\lambda \gt 0$, $g_K(x) \gt 0$(根据题设),所以有$|z|^2 \lt |x|^2$成立。
$x = v(K)$当且仅当$g_K(x) = 0$。也就是说$x$为整个Minkowski差中距离原点最近的点,当且仅当$g_K(x) = 0$。
证明:
先证必要性:
$$ g_K(x) = 0 \Rightarrow |x|^2 +h_K(-x) = 0 \Rightarrow |x|^2 = -h_K(-x) = \min \{a \cdot x: a \in K\} $$那么有
$$ |x|^2 \le |x|^2 + |a - x|^2 = |a|^2 + 2(|x|^2 - a \cdot x) \le |a|^2, a \in K $$这样就证明了$|x|^2 \le |a|^2$小于$K$中任意数的模的平方。那么$x$就是距离原点的最近点。
而充分性可以用反证法:
假设$g_K(x) \gt 0$。那么根据性质2,一定有一个距离原点更小的$z$,违背条件$x = v(K)$。所以矛盾。
$|x - v(K)|^2 \le g_K(x)$
证明:
将左式展开:
$$ \begin{aligned} |x - v(K)|^2 & = |x|^2 - 2x\cdot v(K) + v(K)^2 \\ & \le |x|^2 - 2x \cdot v(K) + x \cdot v(K) \\ & = |x|^2 - x \cdot v(K) \\ & \le |x|^2 - x \cdot s_K(-x) \\ & = g_K(x) \end{aligned} $$证毕。
距离算法
通过定理1可知,当$g_K(x) = 0$时,$x = v(K)$就能找到要求的距离原点最近点。这个是GJK算法的终止条件。
先给出通过GJK求距离的算法:
给出紧致的凸集$K \in R^m, K = \{y_1, y_2, \cdots, y_v \}, 1 \le v \le m+1$,算法步骤如下:
- 令$V_0 = \{y_1, \cdots, y_v\}$,令$k = 0$
- 找到$v_k = v(\operatorname{co} V_k)$
- 如果$g_K(v_k) = 0$,那么显然$v(K) = v_k$,算法终止返回$|v_k|$作为距离
- 令$V_{k+1} = \hat{V_k} \cup \{s_K(-v_k)\}$,这里 $\hat{V_k} \subset V_k$,并且$|\hat{V_k}| \le m, v_k \in \operatorname{co}\hat{V_k}$。然后$k = k+1$自增$k$,然后回到步骤2
翻译成人话:
初始集合$V_0$是整个凸体的点集
$k$其实是当前迭代次数,而$v_k$是第$k$步下距离原点最近点
每一次迭代都算$V_k$中距离原点的最小值
如果算法没终止,这里$\hat{V_k}$的要求有三个:
- 包含$v_k$
- 元素个数不大于$m$
- $\hat{V_k} \subset V_k$
意思是每次都要在$V_k$中找到一个子集,这子集得包含$v_k$且元素个数不大于$m$。
那么根据$V_{k+1}$的构造有$|V_{k+1}| \le m$。并且$V_{k+1}$一定包含$s_K(-v_k)$。也就是说$V_{k+1}$一定包含两个点:
- $s_K(-v_k)$
- $v_k$
我们知道大部分GJK文章中说的做法都是:
- 每步找Simplex距离原点最近的点$v_k$
- 如果点在Simplex内,停止算法。否则,如果Simplex点数已满,删除$v_k$对面的点,然后将$v_k$加入Simplex
这里的Simplex其实就类似于$V_k$。而删点其实就类似于找到$\hat{V_k}$。所以算法每一步都在构造新Simplex。
我们后面可以看到,这就是对$R^3$下的算法进行的特化。数学上来说,此方法和论文中的方式是等价的。只是GJK本身能够处理$R^m$空间,所以论文中写了个更通用的算法。
这里有几点要注意:
- 第二步的$v_k = v(\operatorname{co}V_k)$说明$v_k$可以在$\operatorname{co}V_k$的面上或体内
- 那么第三步的意思其实是:原点在$\operatorname{co}V_k$上,也就是原点在单纯形内,那么算法立刻退出
接下来要证明此方法是正确的(能够找到$v(K)$的):
首先证明此算法每一步都是可达的。主要是步骤4:
在步骤4中,显然$g_K(x) \gt 0$,那么根据定理1的性质3,$v_k \ne \vec{0}$(说明原点不在单纯形上或内)。那么$s_K(-v_k)$就一定能找到点,那么$\hat{V_k}$就一定可构造了。
然后证明$|v_{k+1}| \lt |v_k|$,即每次找到的一定是离原点更近的点:
根据定理1的性质2有:
$$ |v_{k+1}| = |v(\operatorname{co}V_{k+1})| \le |v(\operatorname{co}\{v_k, s_K(-v_k)\})| \lt |v_k| $$
然后证明此算法是可以在有限步骤内完成的。这被称为定理2:
定理2
假设$Z \subset R^m$是有限集。$K = \operatorname{co}Z$。假设$\forall \eta \in R^m, s_K(\eta) \in Z$,那么距离算法可以在有限步内找到$v(K)$
证明:
简单来说,因为每一步的$V_k$和之前的任何$V_k$都是不相同的(因为上面的证明2,$|v_k|$的大小是严格递减的,所以每个$v_k$肯定不一样,而$V_k$又一定要包含$v_{k-1}$,所以显然每个$V_k$也是不一样的)。而$K$又是有限集,也就是说他的子集个数是有限的。那么算法最坏情况下会遍历完其所有子集。而由之前Minkowski差转为寻找最近点问题的证明可知,一定存在$v(K)$。所以$v(K)$一定会在某个子集进入算法循环时被找到。
这就证明了算法可在有限步(最坏情况下是$K$子集个数)中完成。
距离子算法(Distance Subalgorithm)
距离算法中的第二步和第四步中的$\hat{V_k}$的选取并没有明确说明要怎么做。距离子算法就是来做这个事情的。
距离子算法由于是Johnson发明的,所以又叫Johnson算法。
我们假定每一步的$V_k$都是仿射无关的(如果某一步仿射相关,那整个GJK就崩了,算法执行失败)。为了方便记忆,后用$Y$表示:$Y = V_k$。
令$Y$的非空集合个数为$\sigma$个,定义数列$s = 1, \cdots, \sigma$为其集合个数的序列。而$|Y| = v$,定义元素在$Y$中的下标序列为$I = \{1, \cdots, v\}$。对于$Y$的任意的子集$Y_s$,我们使用$I_s \subset I$表示$Y_s$中元素下标的集合,而$I_s^{'}$为$I_s$在$I$中的补集(即不在$Y_s$中的元素下标)。
首先,有$v(\operatorname{aff}Y_s) = \sum_{i = 2}^{r}\lambda_ix_i$,且$\lambda_1 = 1 - \sum_{i = 2}^r \lambda_i$。那么我们要找$v(\operatorname{co}Y_s)$就是找到一系列$\lambda_i$,使得$|v(\operatorname{aff}Y_s)|$最小。那么定义函数$f(\lambda_2, \cdots, \lambda_r)$为:
$$ f(\lambda_2, \cdots, \lambda_r) = |v(\operatorname{co}(Y_s))|^2 = |\sum_{i=1}^r \lambda_i x_i|^2 = |x_1 + \sum_{i = 2}^{r}\lambda_i(x_i - x_1)|^2 $$我们要求这个$r$元函数的最小值。根据分析学的理论:
- 函数导数为0的点可能是函数的最小值点(需要通过Hessian矩阵辅助判断)
但这里$f$是凸函数,这意味导数为0的点一定是最小值点。那么可以通过Jacobian矩阵表示其导数为:
通过Cramer法则[4]可以解出所有$\lambda_i = \frac{\Delta_i(Y_s)}{\Delta(Y_s)}$。其中$\Delta_i(Y_s)$是第$i$行替换$b$得到的矩阵,而$\Delta(Y_s) = |A_s|$。
当然这里可以硬算行列式。但论文中给出了一种更加快速的,利用旧行列式缓存的方式,以递增的方式去计算。
具体的公式是:
$$ \begin{aligned} & \Delta_i(\{y_i\}) = 1, & i \in I \\ & \Delta_j(Y_s \cup \{y_j\}) = \sum_{i \in I_s}\Delta_i(Y_s)(y_i \cdot y_k - y_i \cdot y_j), & i \in I_s, k \in I_s, j \in I_s^{'} \\ & \Delta(Y_s) = \sum_{i \in I}\Delta_i(Y_s) \end{aligned} $$即:
- 含有单个元素的Cache为1
- 在原有行列式基础上增加一个点,可以通过增量形式计算这个新行列式
- 分母由所有$\Delta_i(Y_s)$相加得到
我们可以举例说明这个算法的细节:
$\lambda_i$的增量计算过程
假设我们在$R^3$下运行此算法。并且此时$Y$里面没有点。所以我们先往里面填充点:
第一步:
加入$y_1$。此时$Y = Y_s = \{y_1\}$,由定义$\Delta_1(Y_s) = \Delta_i(\{y_1\}) = 1$,$\Delta(Y_s) = 1$。
第二步:
加入$y_2$,此时$Y = \{y_1, y_2\}$。注意$Y_s \subset Y$按照$I_s$中的下标选取元素。所以这里有三种$Y_s$:
$$ \begin{aligned} & Y_1 = \{y_1\}, & I_1 = \{1\} \\ & Y_2 = \{y_2\}, & I_2 = \{2\} \\ & Y_3 = \{y_1, y_2\}, & I_3 = \{1, 2\} \\ \end{aligned} $$这里,由于$y_2$是新增点,所以$I_s^{'} = \{2\}$。那么就是:
$$ \begin{aligned} & \Delta_j(Y_1 \cup \{y_2\}) = \sum_{i \in I_1}\Delta_i(Y_1)(y_k - y_j) \cdot y_i \\ & I = \{1, 2\} \\ & I_s = I_1 \\ & i = k \in I_1 \\ & j \in I_2^{'} \end{aligned} $$那么有
$$ \Delta_2(Y_3) = \Delta_2(Y_1 \cup \{y_2\}) = \Delta_1(Y_1)(y_1 - y_2) \cdot y_1 $$可这为何能成立?因为这就是$|A_i|$的Laplace展开。当有两个点时:
$$ \begin{aligned} & |A_s| = \begin{vmatrix} 1 & 1 \\ (y_2 - y_1) \cdot y_1 & (y_2 - y_1) \cdot y_2 \end{vmatrix} \\ &|A_2| = \begin{vmatrix} 1 & 1 \\ (y_2 - y_1) \cdot y_1 & 0 \end{vmatrix} \end{aligned} $$将$|A_2|$按第二列展开就得到了。
那么新问题来了。公式里有$\Delta(Y_s) = \sum_{i \in I} \Delta_i(Y_s)$。我们这里是$\Delta(Y_3) = \Delta_1(Y_3) + \Delta_2(Y_3)$。我们有$\Delta_2(Y_3)$,但是$\Delta_1(Y_3)$从何而来?其实一样的,只是组成的集合不一样。
对于$\Delta_1(Y_3)$,$j = 1$,也就是说此时$I_s^{'} = \{1\}$。那么为了凑齐$I = \{1, 2\}$显然$I_s = \{2\}$。
也就是说,$\Delta_1(Y_3)$是视为从$\{y_2\}$中增加$\{y_1\}$点得到的结果。那么也是一样计算,会得到:
$$ \Delta_1(Y_3) = |A_1| = (y_2 - y_1) \cdot y_2 $$那么为什么$\Delta(Y_s) = |A_s| = \sum_{i \in I} \Delta_i(Y_s)$成立呢?这其实是$|A_s|$按第一行展开的结果。
所以这里你就可以看到,使用旧的$\Delta_i(Y_s)$去加速计算新$\Delta_j(Y_s)$的过程了。
第三步:
为了更好地看到这种加速,我们再加一个点。将$y_3$加入$Y$中得$Y = \{y_1, y_2, y_3\}$。那么此时有6个$Y_s$:
$$ \begin{aligned} & 之前保留的Y_1 \sim Y_3: \\ & Y_1 = \{y_1\}, & I_1 = \{1\} \\ & Y_2 = \{y_2\}, & I_2 = \{2\} \\ & Y_3 = \{y_1, y_2\}, & I_3 = \{1, 2\} \\ & 新增的: \\ & Y_4 = {y_3}, & I_4 = \{3\} \\ & Y_5 = {y_2, y_3}, & I_5 = \{2, 3\} \\ & Y_6 = {y_1, y_2, y_3}, & I_6 = \{1, 2, 3\} \\ \end{aligned} $$那么新增的$Y_4 \sim Y_6$是否能用$Y_1 \sim Y_3$加速计算呢?显然是可以的。首先根据定义$\Delta(Y_4) = 1$。然后
$$ \begin{aligned} & |A_5| = \begin{vmatrix} 1 & 1 \\ (y_3 - y_2) \cdot y_2 & (y_3 - y_2) \cdot y_3 \end{vmatrix} \\ & I = \{2, 3\} \\ & I_s = \{2\}, I_s^{'} = \{3\} 时 \\ & \Delta_3(Y_5) = \Delta_3(Y_2 \cup \{y_3\}) = \Delta_2(Y_2)(y_2 - y_3) \cdot y_2 = (y_2 - y_3) \cdot y_2 \\ & I_s = \{3\}, I_s^{'} = \{2\} 时 \\ & \Delta_2(Y_5) = \Delta_2(Y_4 \cup \{y_2\}) = \Delta_2(Y_4)(y_3 - y_2) \cdot y_3 = (y_3 - y_2) \cdot y_3 \\ & \Delta(Y_5) = \Delta_2(Y_5) + \Delta_3(Y_5) \end{aligned} $$而$Y_6$则是:
$$ \begin{aligned} & |A_6| = \begin{vmatrix} 1 & 1 & 1 \\ (y_2 - y_1) \cdot y_1 & (y_2 - y_1) \cdot y_2 & (y_2 - y_1) \cdot y_3 \\ (y_3 - y_1) \cdot y_1 & (y_3 - y_1) \cdot y_2 & (y_3 - y_1) \cdot y_3 \\ \end{vmatrix} \\ & I = \{1, 2, 3\} \\ \\ & I_s = \{1, 2\}, I_s^{'} = \{3\} 时 \\ & \Delta_3(Y_6) = \Delta_3(Y_3 \cup \{y_3\}) = \Delta_1(Y_3)(y_1 - y_3) \cdot y_1 + \Delta_2(Y_3)(y_1 - y_3) \cdot y_2 \\ \\ & I_s = \{2, 3\}, I_s^{'} = \{1\} 时 \\ & \Delta_1(Y_6) = \Delta_1(Y_5 \cup \{y_1\}) = \Delta_2(Y_5)(y_2 - y_1) \cdot y_2 + \Delta_3(Y_5)(y_2 - y_1) \cdot y_3 \\ \\ & I_s = \{1, 3\}, I_s^{'} = \{2\} 时 \\ & \Delta_2(Y_6) = \Delta_2(\{y_1, y_3\} \cup \{y_2\}) = \Delta_1(\{y_1, y_3\})(y_1 - y_2) \cdot y_1 + \Delta_3(\{y_1, y_3\})(y_1 - y_2) \cdot y_3 \end{aligned} $$可以看到确实可以通过旧值加速计算新值。这里只有$\Delta_2(Y_6)$中的$\Delta_i(\{y_1, y_3\})$没有旧值,但仍旧可以使用递推公式算得旧值。
当$Y$内点到达上限$m$时,此时新增点前需要删除点。新增的点仍旧可以用旧值加速计算(不再举例赘述)。
这个递推公式的好处就是可以快速计算$\lambda$,只需要使用旧值计算$\Delta_i(Y_s)$,然后将各个$\Delta_i(Y_s)$相加即可得到$\Delta(Y_s)$,然后就可以用Cramer法则得到最后的$\lambda$。免去了每次都要从头展开行列式带来的性能问题。
定理3
$Y_s$有效,当且仅当
- $\Delta(Y_s) \gt 0$
- $\Delta_i(Y_s) \gt 0, i \in I_s$
- $\Delta_j(Y_s \cup \{y_j\}) \le 0, j \in I_s^{'}$
最后,$\lambda_i = \frac{\Delta_i(Y_s)}{\Delta(Y_s)}$
这三条分别说明:
$Y_s$是仿射无关的。
这是引理1:$Y_s$仿射无关当且仅当$\Delta(Y_s) \gt 0$
证明:
因为$\Delta(Y_s) = Q_s^TQ_s$,而$Q_s$则是:
$$ Q_s = \begin{bmatrix} (x_2 - x_1) \cdots (x_r - x_1) \end{bmatrix} $$而形如$Q_s^TQ_s$的矩阵叫做Gramian矩阵[5]。而只有当他正定时,$Q_s$中元素线性无关。而$Q_s$元素线性无关正是$Y_s$仿射无关的定义。
$v(\operatorname{co}Y_s)$是$\operatorname{co}Y_s$的相对内点(即在$\operatorname{co}Y_s$内部,不包含边但包含面。或者换句话说,$\lambda_i \ne 0$)。这意味着此$v(\operatorname{co}Y_s)$无需再在$Y_s$的子集内搜索(因为如果点落在边上,说明还存在更小的子集需要搜索)
这其实是个凸优化里面的结论:$Y_s$仿射无关时,$\operatorname{co}(Y_s) = \{ \sum\lambda_i y_i : \lambda_i \gt 0, \sum \lambda_i = 1 \}$。这里不再证明。
$v(\operatorname{co}Y_s) = v(\operatorname{co}Y)$,即局部最小值就是全局最小值。
距离子算法步骤
给出有限集$Y = \{y_1, \cdots, y_v \} \subset R^m$和其所有子集$Y_s, s = 1, \cdots, \sigma$。算法步骤如下:
- 令 $s = 1$
- 如果$\Delta(Y_s) \gt 0$且 $\Delta_i(Y_s) \gt 0, i \in I_s$ 且 $\Delta_j(Y_s \cup \{y_j\}) \le 0, j \in I_s^{'}$。那么我们就找到了$v(\operatorname{co}Y)$(使用$\lambda_i$可计算出来)。算法终止。
- 如果$s \lt \sigma$,$s = s+1$返回步骤1
- 如果算法停止,返回失败
这里简单来说就是遍历$Y_s$所有子集,试图找满足条件2的$v(\operatorname{co}Y)$。
现在我们可以看一下,论文中的距离子算法和重心坐标的联系。
以Simplex包含三个点为例(也就是三角形,这样简单一些)。假设现在Simplex中有三个点$Y = \{ y_1, y_2, y_3 \}$。那么其一共有7个子集。分别是:
$$ \begin{aligned} & \{y_1\} \Rightarrow \Delta(\{y_1\}) = 1 \\ & \{y_2\} \Rightarrow \Delta(\{y_2\}) = 1 \\ & \{y_3\} \Rightarrow \Delta(\{y_3\}) = 1 \\ \\ & \{y_1,y_2\} \Rightarrow \begin{vmatrix} 1 & 1 \\ (y_2 - y_1) \cdot y_1 & (y_2 - y_1) \cdot y_2 \\ \end{vmatrix} \\ & \{y_1, y_3 \} \Rightarrow \cdots \\ & \{y_2, y_3 \} \Rightarrow \cdots \\ \\ & \{y_1, y_2, y_3 \} = \begin{vmatrix} 1 & 1 & 1 \\ (y_2 - y_1) \cdot y_1 & (y_2 - y_1) \cdot y_2 & (y_2 - y_1) \cdot y_3 \\ (y_3 - y_1) \cdot y_1 & (y_3 - y_1) \cdot y_2 & (y_3 - y_1) \cdot y_3 \\ \end{vmatrix} \end{aligned} $$本质上,含有两个点的集合就是边(线段),含有三个点的集合就是三角面。
而对每个子集判断$\Delta_i(Y_s) \gt 0, \Delta(Y_s) \gt 0$其实就等价于看原点是否在当前边/面的Voronoi域内。
$\Delta(Y_s)$我们知道是Gramian行列式,这个行列式在数学上就是计算由$y_i$个点组成的仿射无关的体的体积。
$\Delta(Y_s) \gt 0$的判断,其实就是在说这$y_i$个点仿射无关,确定了Simplex的有效性。并且$\Delta(Y_s)$本质上是平方量,不会出现小于0的情况。
而$\Delta_i(Y_s)$其实就是计算Simplex中子区域(以原点在Simplex上的最近点为顶点)的体积。所以两者的比值才是重心坐标。其实和重心坐标计算公式是完全等价的。
然后$\Delta_i(Y) \gt 0$的判断,这里$\Delta_i(Y)$是带符号的。等于在说离原点的最近点必须在$\operatorname{co}(Y_s)$上。
而$\Delta_j(Y_s \cup \{y_j\}) \lt 0$的判断则是说明,原点与新点$y_j$必须在$\operatorname{co}Y_s$的异侧(等价于Ericson GJK中,说新点要“越过”原点)。
所以可以看到,原始GJK在数学上的表述和Ericson GJK是完全等价的。
距离子算法的工程实现
GJK原始论文1的理论已搞懂,但工程上到底要如何实现呢?Gino在他的论文2中给出了答案。
我们只考虑$R^3$下的情况(但理论上Gino的方法可以推广到$R^m$)。由于Simplex最多只有4个点(这里Simplex指$Y$),而含有4个点的集合最多只有16个,所以可以用一个16x4的矩阵存储$\Delta_i(Y_s)$。
那么我们可以用一个4位的位掩码来表示这16个集合。甚至可以根据此掩码的1的位置表示此集合中包含的顶点。比如mask = 0100就是第4个子集,此子集包含第三个顶点。而mask = 0101就是第5个子集,此子集包含两个顶点(第一个和第三个)。
有了这个表示法,$\Delta_i(Y_s)$的增量计算就好算了,直接套论文中的公式即可。
而在实际编码中3,Gino甚至对$\Delta_j(Y_s \cup \{y_j\}) = \sum_{i \in I_s}\Delta_i(Y_s)(y_k - y_j) \cdot y_i$中反复用到的$y_k - y_j$也做了缓存(将其正负值各存一份)用于加速计算。
从CSO中反向计算最近点
在整个GJK算法结束后,我们可以找到CSO上距离原点的最近点$v(K)$。此时,我们可以通过此点反向计算出在$A$和$B$上的最近点对$a, b$。只需要使用重心坐标$\lambda_i$计算即可。因为:
$$ v(K) = \sum \lambda_i k_i = \sum \lambda_i (a_i - b_i) = \sum \lambda_i a_i - \sum \lambda_i b_i $$那么可以用$\sum \lambda_i a_i$得到$A$中最近点,$\sum \lambda_i b_i$得到$B$中最近点。
所以算法中不仅要记录CSO上的点,还得记录这个点的原始两个点。
适用于二次曲面的GJK
原始的GJK论文只讨论了凸多面体。但后人发现可以应用于任何的凸体,包括含有二次曲线的(比如球,球扫略体等)。并且还证明了GJK算法只需要两个物体的支撑映射,而不需要全部的顶点信息。
那么有一个问题:对于含有二次曲面的GJK,其曲面上的顶点是无数个。这有可能导致GJK不停地逼近最近点,或者由于数值精度问题在最近点疯狂摇摆。此时必须增加容差。
记每次迭代中距离原点的最近点为$\vec{v_k}$,在此次迭代中新加入Simplex的点为$\vec{w_k}$(使用$\vec{v_k}$找到)。那么这两个点构成一个支撑平面:
$$ H(-\vec{v_k}, \vec{w_k}\cdot \vec{v_k}) $$
GJK算法本身只会给出$d(A, B)$的上界$|v(A - B)|$(因为GJK一直在尝试减少$|v(A - B)|$所以是上界)。但并没有给出下界。
而根据刚才的支撑平面可知,$|v(A - B)|$的下界就是点$\vec{w_i}$到原点的距离,即:
$$ \delta_k = \frac{\vec{w_k}\cdot \vec{v_k}}{|\vec{v_k}|} \le |\vec{v_k}| $$那么当上界界逐渐逼近的时候,显然GJK算法会越来越准确。那么我们认为,当
$$ |\vec{v_k}| - \delta_k \le \epsilon $$的时候,GJK算法就找到了正确的最近点。这里$\epsilon$是一个用户给定值。
但这里有个问题:此算法不一定能终止,虽然上界$|v_k|$是单调递减的,但下界$\delta_k$并不是一个单调递增的数值,所以可能导致算法一直无法满足。
解决方法是找到所有$\delta_k$中的最大的那个:
$$ \mu_k = \max \{0, \delta_0, \cdots, \delta_k\} $$那么$\mu_k$显然是递增的,那么总是有:
$$ |\vec{v_k}| - \mu_k \le \epsilon $$这就是最后的终止条件。
这里的夹逼其实就是原始条件$g_K(v(K)) = 0$的相对容差版本:
$$ \begin{aligned} & |v_k| - \mu_k \le \epsilon |v_k| \\ & \Rightarrow |v_k| - \max \{\frac{v_k \cdot w_k}{|v_v|} \} \le \epsilon |v_k| \\ & \Rightarrow |v_k|\max \{|v_k|\} - \max \{ v_k \cdot w_k \} \le \epsilon |v_k| \max \{ |v_k| \} \\ \end{aligned} $$其实左边是$g_K(v_k) = |v_k|^2 - s(-x)$的近似版本,所以所以这个式子其实就是$g_K(x)$的容差版本,只是容差不太一样而已($|v_k|^2$和$|v_k|\max \{ |v_k| \}$之间的差距可以通过选取适当的$\epsilon$抹去)。
Gino发现,在物体特别大的时候这个终止条件会有浮点数精度问题,所以需要一个相对容差而非绝对容差:
$$ |\vec{v_k}| - \mu_k \le \epsilon |\vec{v}| $$而当物体特别小的时候,会出现浮点数下溢导致$\vec{v}$变成0向量,导致算法死循环。所以他的建议是再给一个和0相关的容差$\omega$:
$$ |\vec{v}| \le \omega $$而在实际代码中,他使用的是相对容差:
$$ |\vec{v}|^2 \le \epsilon |\max \{ \vec{v} \}|^2 $$当满足这个容差时,算法也终止,同时认为当前$\vec{v}$就是最近点。
而Gino在论文里说过,原式有$|v_k|$,为了在工程上不进行昂贵的开方操作,不如将等式左右两边再乘上$\max \{|v_k|\}$变成:
$$ |\vec{v_k}|\max \{|v_k|\} - \max \{ \vec{v_k}\cdot {\vec{w_k}} \} \le \epsilon |\vec{v}| \max \{|v_k|\} $$这也和上面说到的公式一模一样,反向证明了GinoGJK的终止条件等价于原始GJK的终止条件。
数值精度问题以及Gino的鲁棒性优化
如果几何体偏离原点太远,可能由于大浮点数运算问题导致精度问题。原始论文的方法是利用其几何中心连线的中点$\rho$进行偏移:
$$ \begin{aligned} & z_1 = \frac{\sum_i^N{z_{1i}}}{N} \\ & z_2 = \frac{\sum_i^N{z_{2i}}}{N} \\ & \rho = \frac{z_1 + z_2}{2} \end{aligned} $$在判断$g_K(x) = 0$的时候,可以使用一个相对容差进行判断:
$$ \begin{aligned} & g_K(x) \le \epsilon(D(K)^2) \\ & D(K) = \max(|z| : z \in K) \end{aligned} $$其中$\epsilon$是一个用户给定的极小值。
而如果你使用Gino的终止条件,则无需做任何调整。
在做Johnson距离子算法时,可能会由于反复的浮点数计算导致所有子集中$\Delta(Y_s) \gt 0, \Delta_i(Y_s) \gt 0,\Delta_j(Y_s \cup \{y_j\}) < 0$的判断失效,从而导致算法异常退出。原始论文解决的方法是采用一个兜底程序(Backup Procedure):暴力遍历所有子集,找到满足$\Delta(Y_s) \gt 0, \Delta_i(Y_s) \gt 0$的距离原点的最近点作为返回值。这总是可以找到的(比如只含有一个点的集合)。这个方法叫Johnson Robust。
但Gino在他的论文中说,他发现每次触发兜底程序返回的$v(K)$,几乎就是上一帧找到的$v_k$。所以他建议直接就返回上一帧的$v_k$不要再跑这个笨重的兜底程序。
或者直接报错(但在我个人的实践中,这种情况在刚体不旋转的情况下还是较为常见的,所以我不建议直接报错)
病态情况(ill-condition):当大小相差几个数量级的多面体彼此非常接近的时候,这些对象的CSO可能产生及其狭长的面从而导致算法无限循环(详见Gino论文)。这时,新算出的$\vec{w_k}$其实是Simplex中已有的点。这样会导致算法死循环。
解决方法就是每次都判断$\vec{w_k} \in W_{k-1} \cup \{\vec{w_{k-1}}\}$是否成立。如果成立说明遇到了病态情况,那么算法直接结束走兜底程序。
利用GJK进行相交测试
上面说了如何利用GJK计算得到两物体之间的距离已经最近点对。接下来再上述算法的基础上稍加改动即可利用GJK进行相交测试。此方法记录在GinoGJK论文2中。
只要$|\vec{v_k}|$的下界$\delta_k \gt 0$,那么显然两物体就是相离的。否则就是相交/相贴的。
等价于:
$$ \vec{v_k} \cdot \vec{w_k} \gt 0 $$并且这里的$\vec{v_k}$就是分离轴。所以此时算法就变成:
$$ \begin{aligned} & \vec{v} = \text{"arbitary vector"} \\ & W = \emptyset \\ & \text{repeat} \\ & \text{ }\vec{w} = s_{A - B}(-\vec{v}) \\ & \text{ if } \vec{v} \cdot \vec{w} \gt 0 \text{ then return false} \\ & \text{ } \vec{v} = v(\operatorname{co}(W \cup \{\vec{w}\})) \\ & \text{ } W = \text{"smallest }X \subseteq W \cup \{\vec{w}\} \text{ such that } \vec{v} \in \operatorname{co}(X) \text{"} \\ & \text{until } \vec{v} = \vec{0} \\ & \text{return true} \\ \end{aligned} $$注意这里的$\vec{v}$不再是CSO上的任意顶点的方向,而是任意方向。这很有利于帧间一致性。
帧间一致性(Frame coherence)/热启动(Warm start)
指实时模拟中相邻两帧之间物体只移动/旋转了一点点,几何配置变化很小——所以上一帧的结果(分离轴、支撑点、最近特征等)在本帧大概率仍然有效或非常接近,可以作为本帧迭代的热启动起点。
在游戏物理引擎中也叫做热启动。
GinoGJK中就提出了帧间一致性:当Polytope使用爬山法构造时(爬山法详见“从0开始制作游戏物理引擎(二)”),可以利用上一帧最后找支撑点的方向作为下一帧的开始方向查找,一般会大大减少爬山法搜索的时长。
而在使用GJK进行相交测试时,由于$\vec{v}$的初值可以是任意值。那么我们可以利用上一帧找到的分离轴作为方向来加速查找。这样也能加快GJK的执行。
SOLID3中的GJK实现
接下来阅读Gino的GJK实现3。主要是intersect和closest_points两个函数。其他的函数需要EPA算法支持。
着眼于closest_points函数。intersect函数大体上差不多。
剔除多余的代码,intersect的大体流程如下:
| |
而GJK结构体中就是一些和论文一模一样的GJK算法。不再赘述。