4、动态碰撞检测:连续碰撞检测(CCD)概念、扫掠体法、保守推进法

4.1 为什么需要连续碰撞检测?

先聊个实际问题。你玩过那些高速运动的游戏吧?子弹、赛车、或者角色快速穿过薄墙。有没有遇到过「穿模」?明明看着撞上了,结果物体直接穿过去了。

这就是离散碰撞检测的硬伤。传统做法是每帧采样一次位置,然后判断是否重叠。但物体速度太快,一帧之内可能从墙的左边飞到右边。采样点完美错过了碰撞。

我早年做一款赛车游戏时就栽过这个坑。轮胎高速旋转,每次检测时轮胎已经「瞬移」到了地面另一侧。物理表现就像在冰面上滑行,毫无抓地力。后来我才意识到——不是物理参数错了,是检测方式错了。

连续碰撞检测(CCD)就是来解决这个问题的。它的核心思想很简单:不只看「当前是否重叠」,而是看「从上一帧到这一帧之间,是否发生过碰撞」

核心区别一句话总结:

  • 离散检测:问「现在撞了吗?」
  • 连续检测:问「刚才那段时间里撞过吗?」

4.2 CCD 的基本原理

说白了,CCD 就是把时间轴也纳入检测范围。我们不再把物体当成一个静态的快照,而是把它在一帧内的运动轨迹看作一条连续的路径。

数学上,我们引入一个时间参数 t,范围是 [0, 1]。t=0 代表上一帧的位置,t=1 代表当前帧的位置。然后我们求解:在 t 取什么值时,两个物体刚好接触?

这个「刚好接触」的时刻,我们称为 TOI(Time of Impact)。找到 TOI 之后,物理引擎就把物体回退到那个时刻,然后处理碰撞响应。

嗯,这里要注意:TOI 的精度直接影响物理表现。精度太高,计算量爆炸;精度太低,又会出现微小的穿透。我个人习惯是允许一个极小的容差(比如 1e-5),只要穿透量在这个范围内,就认为没有发生碰撞。

4.3 扫掠体法(Swept Volume)

扫掠体法是最直观的 CCD 实现方式。你想想看,一个物体从 A 点运动到 B 点,它扫过的空间是什么形状?

对于球体来说,扫过的体积是一个「胶囊体」——两个半球加一个圆柱。对于 AABB(轴对齐包围盒),扫过的是一个拉伸后的六面体。对于凸多面体,扫过的是一个更复杂的凸体。

然后我们检测这个扫掠体是否与其他物体相交。如果相交,就说明运动过程中发生了碰撞。

实战经验: 我曾经用扫掠体法做过一个弹幕游戏的碰撞系统。子弹速度极快,用离散检测时经常出现「子弹穿过敌人但没判定」的 bug。换成扫掠体后,问题立刻解决。但代价是——每颗子弹都要生成一个胶囊体,CPU 开销涨了 3 倍。

扫掠体法的核心步骤:

  1. 计算物体在 [0, 1] 时间内的运动轨迹
  2. 根据物体形状和轨迹生成扫掠体
  3. 用扫掠体与场景中的静态/动态物体做相交测试
  4. 如果相交,计算 TOI 并回退位置

代码实现上,最简单的球体扫掠体检测大概长这样:

// 球体扫掠体 vs 平面的碰撞检测
bool SweptSphereVsPlane(Vector3 start, Vector3 end, float radius, 
                         Plane plane, float& outTOI) {
    Vector3 velocity = end - start;
    float distToPlane = Dot(plane.normal, start) - plane.distance;
    
    // 球心到平面的有符号距离
    float signedDist = distToPlane - radius;
    
    // 如果已经穿透,直接返回
    if (signedDist < 0.0f) {
        outTOI = 0.0f;
        return true;
    }
    
    // 计算速度在法线方向的分量
    float vDotN = Dot(velocity, plane.normal);
    
    // 如果远离平面,不会碰撞
    if (vDotN >= 0.0f) return false;
    
    // 计算 TOI
    outTOI = signedDist / (-vDotN);
    return (outTOI >= 0.0f && outTOI <= 1.0f);
}

这个代码我实际项目里改过很多版。最初版本没考虑「已经穿透」的情况,结果物体卡在墙里时,TOI 算出来是负数,物理直接崩了。后来加上了 signedDist < 0 的判断,才算稳定。

4.4 保守推进法(Conservative Advancement)

扫掠体法虽然直观,但有个大问题——生成扫掠体的计算量不小,尤其是对于复杂形状。而且如果物体旋转了,扫掠体就更难算了。

保守推进法(简称 CA)是另一种思路。它不生成扫掠体,而是用迭代的方式「一步步往前推」。

具体做法是这样的:

  • 从 t=0 开始
  • 计算当前时刻两个物体之间的最近距离
  • 根据这个距离,计算一个「安全步长」——在这个步长内移动,保证不会发生碰撞
  • 把时间推进这个步长
  • 重复,直到 t 到达 1,或者距离小于阈值(发生碰撞)

为什么叫「保守」?因为每一步都走得非常谨慎,确保不会「跳过」碰撞。说白了,就是用多次迭代换取安全性。

注意: 保守推进法有可能陷入死循环!如果两个物体几乎平行地贴在一起滑动,每次推进的步长会越来越小,最终趋近于零。我遇到过这种情况,debug 了一整天才发现是浮点精度问题导致的步长退化。解决方案是设置一个最小步长阈值,如果步长小于这个值,就强制认为发生了碰撞。

保守推进法的伪代码:

float ConservativeAdvancement(Body a, Body b, float maxTime) {
    float t = 0.0f;
    float minStep = 1e-6f;
    
    while (t < maxTime) {
        // 计算当前时刻两个物体的最近距离
        float dist = ComputeDistance(a, b, t);
        
        // 如果距离小于阈值,认为发生碰撞
        if (dist < COLLISION_EPSILON) {
            return t;  // 返回 TOI
        }
        
        // 计算安全步长
        // 这里需要用到物体的速度、角速度等信息
        float step = ComputeSafeStep(a, b, dist);
        
        // 防止步长退化
        if (step < minStep) {
            return t;  // 强制返回
        }
        
        // 限制步长不超过剩余时间
        step = Min(step, maxTime - t);
        t += step;
    }
    
    return maxTime;  // 没有碰撞
}

我个人更偏爱保守推进法。虽然它比扫掠体法慢一些(迭代次数多),但胜在通用性强。不管物体是什么形状,只要你能算出两个形状之间的最近距离,就能用 CA。我在做 Ragdoll 物理时,骨骼之间的碰撞检测就是用 CA 实现的,效果很稳。

4.5 两种方法的对比

维度 扫掠体法 保守推进法
核心思路 生成运动轨迹的包络体 迭代推进,逐步逼近碰撞点
计算开销 一次扫掠体生成 + 一次相交测试 多次距离计算(迭代)
适用形状 球体、AABB、凸多面体 任意凸形状(只要能算距离)
旋转支持 困难(扫掠体形状复杂) 容易(只需更新变换矩阵)
精度 精确(数学上无误差) 迭代精度可控
主要风险 扫掠体生成失败 步长退化、死循环

4.6 我的选择建议

如果你问我实际项目中怎么选,我的建议是:

  • 子弹、小体积高速物体:用扫掠体法。这类物体形状简单(通常是球体或胶囊体),扫掠体生成快,一次检测搞定,性能好。
  • 角色、复杂形状物体:用保守推进法。角色可能涉及多个碰撞体,而且有旋转,CA 更灵活。
  • 静态场景 vs 动态物体:可以考虑混合方案。场景用 BVH 加速结构做粗检测,动态物体之间用 CA 做细检测。

我曾经在一个项目中两种方法都实现了,然后根据物体的「速度阈值」自动切换。速度低于某个值就用离散检测(省性能),高于阈值才启用 CCD。这样既保证了高速时的准确性,又不会让低速物体白白浪费计算资源。

一个小技巧: 不是所有物体都需要 CCD。静态物体、低速物体、或者体积很大的物体,用离散检测完全够用。我一般只对「速度 × 半径 > 某个阈值」的物体开启 CCD。这个阈值需要根据你的游戏世界尺度来调,没有通用值。

4.7 知识体系总览

下面这张图总结了本章的核心逻辑,我画成了流程图,方便你理解 CCD 的整体脉络:

连续碰撞检测 (CCD) 问题:高速物体穿透(穿模) 扫掠体法 生成运动轨迹包络体 保守推进法 迭代推进,逐步逼近 1. 计算运动轨迹 2. 生成扫掠体(胶囊体等) 3. 与场景物体做相交测试 1. 计算当前最近距离 2. 计算安全步长 3. 迭代推进,直到 t=1 或碰撞 输出:TOI(碰撞时间) 回退位置 → 处理碰撞响应 最终目标:消除穿模,物理表现真实

这张图把 CCD 的两种主流方法放在一起对比。你可以看到,无论选哪条路,最终目标都是算出 TOI,然后回退位置、处理响应。区别只在于「怎么算」。

好了,关于 CCD 的概念和两种核心方法,我们就聊到这里。下一节我们会深入具体的碰撞响应机制,包括冲量法、位置修正和摩擦处理。到时候见。


专注资料整理