4. 碰撞事件处理:OnCollisionEnter、OnCollisionStay、OnCollisionExit、碰撞检测模式

碰撞事件,说白了就是两个物体碰到一起时,Unity 告诉你的那一声「嘿,撞上了!」。我个人觉得,这是物理引擎里最性感的部分——没有事件回调,物理碰撞就只是一堆刚体在瞎蹦跶。

今天咱们就把 OnCollisionEnterOnCollisionStayOnCollisionExit 这三个老朋友聊透,再顺带把碰撞检测模式也捋清楚。

4.1 三个核心回调:什么时候触发?

这三个方法的名字已经说明了一切。但很多新手会搞混它们的触发时机,我见过有人把 Stay 当成 Enter 用,结果逻辑跑飞了。

回调方法 触发时机 典型用途
OnCollisionEnter 两个碰撞体第一次接触的那一帧 播放撞击音效、扣血、触发爆炸
OnCollisionStay 两个碰撞体持续接触的每一帧 持续伤害、摩擦力计算、压力感应
OnCollisionExit 两个碰撞体分离的那一帧 停止音效、移除状态、清理数据

嗯,这里要注意:Stay 是每帧都调用的。如果你在 Stay 里做 Instantiate,那画面太美我不敢看。我曾经在项目里看到有人这么干,结果一秒钟生成了几百个粒子特效,帧率直接崩到个位数。

4.2 代码怎么写?参数里有什么?

这三个方法的签名是一样的,都接收一个 Collision 参数。这个参数里藏着很多好东西。

void OnCollisionEnter(Collision collision)
{
    // 获取碰撞到的物体
    GameObject other = collision.gameObject;

    // 获取碰撞点信息
    ContactPoint contact = collision.contacts[0];
    Vector3 point = contact.point;
    Vector3 normal = contact.normal;

    // 获取相对速度
    float impactForce = collision.relativeVelocity.magnitude;

    Debug.Log($"撞到了 {other.name},冲击力:{impactForce}");
}

你想想看,有了 contact.point,你可以在撞击位置生成火花特效;有了 contact.normal,你可以让子弹反弹得更真实。这些细节,就是专业和业余的分水岭。

我的小习惯: 我一般会在 OnCollisionEnter 里先判断 collision.contacts.Length > 0,虽然大多数情况下它不会为空,但保一手总没错。毕竟线上出 bug 的时候,没人会同情你。

4.3 避坑指南:常见翻车现场

我做物理相关课程好几年了,总结出几个高频翻车点,你记一下。

  • 两个物体都没有刚体? 那碰撞事件永远不会触发。至少其中一个要有刚体,而且不能是 Kinematic(除非你用了触发器)。
  • 用了触发器(IsTrigger)?OnCollision 系列不会调用,你得用 OnTrigger 系列。这两个是两套系统,别混了。
  • 脚本挂错了对象? 碰撞事件是在挂载脚本的那个物体上触发的,不是在被撞物体上。我曾经排查了半天,结果发现脚本挂在了父物体上,子物体撞了但父物体没反应。
警告: 不要在 OnCollisionStay 里频繁修改刚体的位置或旋转。这会导致物理引擎重新计算,轻则性能下降,重则物理模拟崩溃。如果你需要持续施加力,用 AddForce 而不是直接改 transform.position

4.4 碰撞检测模式:性能与精度的取舍

Unity 提供了三种碰撞检测模式,你可以在 Rigidbody 组件的 Collision Detection 下拉菜单里找到。

模式 说明 性能开销 适用场景
Discrete(离散) 默认模式,每帧检测一次碰撞 大多数静态或低速物体
Continuous(连续) 对动态物体进行连续碰撞检测 高速运动的物体(如子弹)
Continuous Dynamic(动态连续) 对动态和静态物体都进行连续检测 极高速物体,且需要与静态物体碰撞

为什么会有这些模式?说白了就是「穿模」问题。如果一个子弹飞得太快,一帧内从墙的左边穿到了右边,离散模式根本检测不到碰撞。连续模式会在这帧内做插值计算,确保不会漏掉。

我记得有一次做射击游戏,子弹老是莫名其妙穿过敌人身体。查了半天,发现是碰撞检测模式没改,默认的 Discrete 根本抓不住高速子弹。改成 Continuous 后,问题立刻解决。

性能建议: 不要给所有物体都开 Continuous。只有那些真正高速运动的物体才需要。比如玩家角色、普通 NPC,用 Discrete 完全够用。全开 Continuous 的话,你的 CPU 会哭的。

4.5 实战组合:一个完整的碰撞处理示例

咱们来写一个完整的例子。假设你有一个「伤害区域」,玩家碰到它就会持续掉血,离开后停止。

public class DamageZone : MonoBehaviour
{
    public float damagePerSecond = 10f;
    private Dictionary<GameObject, float> victims = new Dictionary<GameObject, float>();

    void OnCollisionEnter(Collision collision)
    {
        if (collision.gameObject.CompareTag("Player"))
        {
            victims[collision.gameObject] = 0f;
            Debug.Log("玩家进入伤害区域");
        }
    }

    void OnCollisionStay(Collision collision)
    {
        if (victims.ContainsKey(collision.gameObject))
        {
            victims[collision.gameObject] += damagePerSecond * Time.deltaTime;
            // 这里可以触发持续伤害效果
        }
    }

    void OnCollisionExit(Collision collision)
    {
        if (victims.ContainsKey(collision.gameObject))
        {
            victims.Remove(collision.gameObject);
            Debug.Log("玩家离开伤害区域,总伤害:" + victims[collision.gameObject]);
        }
    }
}

这个例子虽然简单,但涵盖了三个回调的典型用法。你想想看,如果只用 EnterExit,你没法做持续伤害;如果只用 Stay,你没法知道玩家是什么时候进来的。三个配合使用,才能做出细腻的交互。

4.6 最后说两句

碰撞事件处理,说难不难,说简单也不简单。我见过太多项目因为碰撞事件没处理好,出现各种诡异 bug——比如角色卡在墙里、子弹穿模、伤害重复计算。这些问题的根源,往往就是对这三个回调的理解不够深。

嗯,今天的课就到这里。下一章咱们聊聊触发器和碰撞体的区别,以及什么时候该用触发器而不是碰撞体。那个话题更有意思,到时候见。