一、前言

本文是“2D物理引擎基础”的第三篇文章,主要介绍物体刚体力学的相关知识以及碰撞发生后的处理办法。后者背后的数学原理较为复杂,因此这里大多情况只是罗列公式并做简要说明。

二、刚体力学

连续的刚体力学

我们考虑二维的情况。假设有一刚体,质量为 $M$,转动惯量为 $I$,在距质心 $\vec{r}$ 处受到一力 $\vec{F}$ 的作用。

那么刚体所受相对于质心的力矩 $\vec{M}$ 为:

$$ \vec{M} = \vec{r} \times \vec{F} $$

注意 $\vec{M}$ 的方向垂直于平面,后面的角速度同理。

在 $\Delta{t}$ 时间内速度变化 $\Delta{\vec{v}}$ 有

$$ \Delta{\vec{v}} = \int_{t_0}^{t0+\Delta{t}} \frac{\vec{F}(t)}{M} dt $$

同样的,角速度变化 $\Delta{\vec{w}}$ 有

$$ \Delta{\vec{w}} = \int_{t_0}^{t0+\Delta{t}} \frac{\vec{M(t)}}{I} dt $$

类似的,也有位置和角度的变化

$$ \Delta{\vec{p}} = \int_{t_0}^{t0+\Delta{t}} \vec{v}(t) dt $$ $$ \Delta{\vec{\theta}} = \int_{t_0}^{t0+\Delta{t}} \vec{w}(t) dt $$

还有冲量

$$ \vec{I} = \int_{t_0}^{t0+\Delta{t}} \vec{F}(t) dt $$

以及角冲量(或称冲量矩)

$$ \vec{H} = \int_{t_0}^{t0+\Delta{t}} \vec{M}(t) dt $$

并有等式

$$ \vec{I} = M \Delta{\vec{v}} $$ $$ \vec{H} = I \Delta{\vec{w}} $$

离散的刚体模拟

正如第一篇文章中说过的,一般情况下计算机无法计算连续,只能通过离散的方式进行近似。物理引擎中进行的模拟会导致误差,但这对于视觉效果来说已经足够了。

力的视角

同样的,我们假设有一刚体,质量为 $M$,转动惯量为 $I$,在距质心 $\vec{r}$ 处受到一力 $\vec{F}$ 的作用。在 $\Delta{t}$ 的时间间隔下,有力矩

$$ \vec{M} = \vec{r} \times \vec{F} $$

速度和角速度的变化为

$$ \Delta{\vec{v}} = \frac{\vec{F}}{M} \Delta{t} $$ $$ \Delta{\vec{w}} = \frac{\vec{M}}{I} \Delta{t} $$

位置和角度的变化为

$$ \Delta{\vec{p}} = \vec{v} \Delta{t} $$ $$ \Delta{\vec{\theta}} = \vec{w} \Delta{t} $$

冲量的视角

假设物体在距质心 $\vec{r}$ 处极短的时间内受到一冲量 $\vec{I}$ 的作用,这个冲量将直接引起速度和角速度的变化。

首先,这个冲量相对于质心的冲量矩为

$$ \vec{H} = \vec{r} \times \vec{I} $$

冲量更新速度

$$ \Delta{\vec{v}} = \frac{\vec{I}}{M} $$

冲量矩更新角速度

$$ \Delta{\vec{w}} = \frac{\vec{H}}{I} $$

最后需要提一句,距离质点 $\vec{r}$ 处在全局坐标系的速度为

$$ \vec{v}_p = \vec{v} + \vec{w} \times \vec{r} $$

这些概念和公式并不复杂,只是有些时候难以准确记忆。罗列公式的意义也就在于此

三、碰撞约束

约束是物理引擎中的重要概念,指的是在一些情况下,通过某些手段,改变物体的位置、速度等数据,实现物体满足某种限制效果的方法。

本部分专门介绍碰撞约束,研究物体与物体发生碰撞后,限制物体不相互穿透的方法。我们的输入是碰撞的物体的物理属性、接触点、碰撞法线和穿透深度;输出是对碰撞物体的速度、角速度进行的修改。一般来说,物理引擎使用冲量对物体速度进行修改。

一个例子

我们首先考虑一个简单的问题,两个小球在平面上发生正碰,他们的初始速度分别为 $\vec{v}_a, \vec{v}_b$,由 A 到 B 的碰撞法线为 $\vec{n}$。恢复系数为 $C_r$。他们碰撞后速度改变,这等效于对两者施加怎样的冲量?

首先,我们计算两物体的相对速度

$$ \begin{equation} \vec{v}_{ab} = \vec{v}_b - \vec{v}_a \end{equation} $$

那么反弹后的相对速度应为

$$ \begin{equation} \vec{u}_{ab} = -C_r \vec{v}_{ab} \end{equation} $$

另一方面我们要对两物体施加冲量,因为牛顿第三定理,两个作用力等大反向,因此冲量的大小也相等,设为 $\lambda_n$

则冲量的作用效果为

$$ \begin{equation} \vec{u}_a = \vec{v}_a - \frac{\lambda_n \vec{n}}{m_a} \end{equation} $$ $$ \begin{equation} \vec{u}_b = \vec{v}_b + \frac{\lambda_n \vec{n}}{m_b} \end{equation} $$

$(4)-(3)$ 得

$$ \begin{equation} \vec{u}_b - \vec{u}_a = \vec{v}_b - \vec{v}_a + (\frac{1}{m_a} + \frac{1}{m_b}) \lambda_n \vec{n} \end{equation} $$

带入 $(1), (2)$ 式得

$$ \begin{equation} \lambda_n = \frac{-(1+C_r)\vec{v}_{ab}}{(\frac{1}{m_a} + \frac{1}{m_b})\vec{n}} \end{equation} $$

等式右侧上下同点乘 $\vec{n}$ 得

$$ \begin{equation} \lambda_n = \frac{-(1+C_r)(\vec{v}_b - \vec{v}_a) \cdot \vec{n}}{(\frac{1}{m_a} + \frac{1}{m_b})} \end{equation} $$

因此我们只需对物体 A 施加 $-\lambda_n \vec{n}$,对 B 施加 $\lambda_n \vec{n}$ 大小的冲量就足以实现碰撞后分开的约束效果。

刚体力学的碰撞约束

上一小节的例子只考虑了一个很简化的情况。只有正碰,而没有考虑斜碰时的摩擦力和旋转等问题。更一般的公式是如下所示的:

首先我们定义切向方向,它是与碰撞法线垂直,且位于速度同侧的单位向量或零向量,即

$$ \vec{v}_R = \vec{v}_b - \vec{v}_a $$ $$ \vec{t} = normalize(\vec{v}_R - (\vec{v}_R \cdot \vec{n}) \vec{n}) $$

那么法向的冲量大小为

$$ \lambda_n = \frac{-(1+C_r)(\vec{v}_b - \vec{v}_a) \cdot \vec{n}}{\frac{1}{m_a} + \frac{1}{m_b} + \frac{(\vec{r}_a \times \vec{n})^2}{I_a} + \frac{(\vec{r}_b \times \vec{n})^2}{I_b}} $$

切向的冲量大小为

$$ \lambda_t = \frac{-(\vec{v}_b - \vec{v}_a) \cdot \vec{t}}{\frac{1}{m_a} + \frac{1}{m_b} + \frac{(\vec{r}_a \times \vec{t})^2}{I_a} + \frac{(\vec{r}_b \times \vec{t})^2}{I_b}} $$

其中 $\vec{r}_a, \vec{r}_b$ 表示从物体质心到接触点的位置向量。

注意两个公式中 $\vec{n}$ 和 $\vec{t}$ 的不同。并且注意这里的速度 $\vec{v}_a, \vec{v}_b$ 考虑了角速度,即

$$ > \vec{v} = \vec{v}_{\text{linear}} + \vec{w}_{\text{rotate}} \times \vec{r} > $$

这样只要对物体 A 施加 $-(\lambda_n \vec{n} + \lambda_t \vec{t})$,对物体 B 施加 $\lambda_n \vec{n} + \lambda_t \vec{t}$ 大小的冲量就可以实现物体的碰撞的约束。

值得注意的是,我们在上面的公式中多次看到质量的倒数和转动惯量的倒数,这一数值在物理引擎中经常使用,我们可以将其在一开始便计算并储存起来。另外,冲量等式中分母部分也较为常见,我们同样可以将其提出作为单独的参数。此参数被称为(法向/切向)有效质量。

$$ M_n^{-1} = \frac{1}{\frac{1}{m_a} + \frac{1}{m_b} + \frac{(\vec{r}_a \times \vec{n})^2}{I_a} + \frac{(\vec{r}_b \times \vec{n})^2}{I_b}} $$ $$ M_t^{-1} = \frac{1}{\frac{1}{m_a} + \frac{1}{m_b} + \frac{(\vec{r}_a \times \vec{t})^2}{I_a} + \frac{(\vec{r}_b \times \vec{t})^2}{I_b}} $$

我们修改原有的 Body 类

        self.mass = density * get_area(self.points)
        self.inv_mass = 1 / self.mass # add inv_mass
        self.inertia = get_moments_of_inertia(self.points, self.mass)
        self.inv_inertia = 1 / self.inertia # add inv_inertia

并编写计算有效质量的函数

def get_effective_mass(body_a, ra, body_b, rb, unit_vect):
    ma = body_a.inv_mass
    mb = body_b.inv_mass

    ia = (np.cross(ra, unit_vect))**2 * body_a.inv_inertia
    ib = (np.cross(rb, unit_vect))**2 * body_b.inv_inertia

    return 1 / (ma + mb + ia + ib)

其中 unit_vect 表示切向量或者法向量。

摩擦力的模拟

很多时候我们还需要考虑摩擦力的影响,这样我们就需要对切向的冲量进行调整。具体来说,我们假设最大静摩擦系数等于动摩擦系数,那么就需要满足

$$ F_f \le \mu F_n $$

注意 $F_f, F_n$ 是力的大小,不考虑方向

不等式两边同乘 $\Delta{t}$ 就变成冲量不等式,即

$$ \lambda_t \le \mu \lambda_n $$

因此对摩擦力的限制即为

$$ \lambda'_t = clamp_{[-\mu\lambda_n, \mu\lambda_n]}(\lambda_t) $$

clamp 函数将 $\lambda_t$ 限制在区间 $[-\mu\lambda_n, \mu\lambda_n]$ 间。

速度约束

据此我们可以编写实施约束的代码。首先,我们将碰撞相关的信息打包成一个类。在物理引擎理论中,这似乎被称作 Manifold。

class Manifold:
    def __init__(self, body_a, body_b, collide_result):
        self.body_a = body_a
        self.body_b = body_b
        self.normal, self.contact_points = collide_result

        self.ra = []
        self.rb = []
        for point, depth in self.contact_points:
            self.ra.append(point - body_a.position)
            self.rb.append(point - body_b.position)

其中传入的 collide_result 参数即 sat3 函数的输出。

接着,我们编写碰撞中约束速度的函数:

def velocity_constrain(manifold):
    body_a = manifold.body_a
    body_b = manifold.body_b
    contact_points = manifold.contact_points
    n = manifold.normal

    for i in range(len(contact_points)):
        p, depth = contact_points[i]

        ra = manifold.ra[i]
        rb = manifold.rb[i]

        va = body_a.v + cross_product(body_a.w, ra)
        vb = body_b.v + cross_product(body_b.w, rb)

        rv = vb - va

        if np.dot(rv, n) > 0: # 如果速度方向背离了碰撞方向,则说明物体已经分离
            continue
        
        t = normalize(rv - np.dot(rv, n) * n)

        effective_mass_n = get_effective_mass(body_a, ra, body_b, rb, n)
        effective_mass_t = get_effective_mass(body_a, ra, body_b, rb, t)

        cr = min(body_a.restitution, body_b.restitution)

        lambda_n = -(1 + cr) * np.dot(rv, n) * effective_mass_n

        body_a.apply_impulse(-lambda_n * n, p)
        body_b.apply_impulse(lambda_n * n, p)

        lambda_t = - np.dot(rv, t) * effective_mass_t

        mu = np.sqrt(body_a.friction * body_b.friction)

        max_friction = abs(mu * lambda_n)

        lambda_t = clamp(lambda_t, -max_friction, max_friction)

        body_a.apply_impulse(-lambda_t * t, p)
        body_b.apply_impulse(lambda_t * t, p)


def cross_product(s, a):
    return np.array([-s * a[1], s * a[0]])


def clamp(value, smallest, largest):
    if value < smallest:
        return smallest
    elif largest < value:
        return largest
    else:
        return value

十分需要注意的是,碰撞约束的求解是一个迭代的过程,这是因为每次碰撞检测只会考虑某两个物体;对于多对物体,我们需要先求出所有发生碰撞的物体对,将碰撞信息存入 Manifold 类中。接着多次迭代,每一次迭代对所有的 Manifold 求解速度约束。只有这样才能尽量求得全局的约束结果。

穿透问题与位置约束

容易发现,我们上面的代码并没有使用穿透深度。在一些情况下,因为在碰撞后的第一帧就进行了速度处理,此时穿透深度较小,很快就能修正,所以视觉上并无大碍。但很多时候,如施加了重力的情况下,单纯的修改速度不足以抵消重力的影响,此时穿透深度会越来越大,最终会使物体陷入地面。

因此,我们除了进行速度约束以外,还需要根据穿透深度进行位置的修正。这就是位置约束。

我们设当前穿透深度为 $x$,最大允许穿透深度为 $x_{max}$,则有

$$ \lambda_{pos} = \beta M^{-1}_{n} max(x - x_{max}, 0) \quad (0 \lt \beta \lt 1) $$

其中 $\beta$ 是人为穿透的解决程度(虽然不太理解这有什么意义)。

类似于冲量,最终的位置的变化量为

$$ \Delta \vec{p}_a = -\frac{\lambda_{pos}\vec{n}}{m_a} $$ $$ \Delta \vec{p}_b = \frac{\lambda_{pos}\vec{n}}{m_b} $$

本方法的数学原理并不清楚,但似乎 box2d 中是这样做的?

据此我们能写出代码。需要注意一点,位置变化会引起接触点的变化,我们希望得知接触点分离的时刻,这通过保留物体质心到接触点的向量实现。这也是我们在 Manifold 类中加入 ra, rb 的原因。

def position_constrain(manifold):
    body_a = manifold.body_a
    body_b = manifold.body_b
    contact_points = manifold.contact_points
    n = manifold.normal

    bias_factor = 0.8
    max_depth = 0.005
    for i in range(len(contact_points)):
        p, depth = contact_points[i]

        ra = manifold.ra[i]
        rb = manifold.rb[i]

        pa = ra + body_a.position
        pb = rb + body_b.position

        c = pa - pb

        if np.dot(c, n) < 0: # 判断接触点是否分离
            continue

        bias = bias_factor * max((depth - max_depth), 0)
        lambda_p = get_effective_mass(body_a, ra, body_b, rb, n) * bias

        correction = lambda_p * n

        body_a.position = body_a.position - body_a.inv_mass * correction

        body_b.position = body_b.position + body_b.inv_mass * correction

位置约束的求解也是一个迭代过程。发生在速度约束的迭代完成后。

四、总结

这一篇文章内容较多,简单介绍了一下刚体力学的知识,接着主要是碰撞约束的求解方法。在一些应详细解释的地方因能力不足,未能详述,实在抱歉。但还是希望这篇文章能对自己和他人有所帮助吧。