碰撞分组与误伤排查

2.1 分组和掩码,到底是谁在跟谁撞

Skyline Courier 的世界里同时存在好几类东西:楼宇墙体、包裹、障碍物、风区、送达点。它们两两之间该不该碰撞,规则各不相同。

我最初的实现是"所有东西都在 DEFAULT 组里,全撞"。结果无人机撞到包裹,包裹和包裹之间也互相撞,一堆包裹堆在角落里挤成一团乱麻。

3.x 里管这件事的机制叫分组和掩码。官方文档给的碰撞判定公式只有一行:

(GroupA & MaskB) && (GroupB & MaskA)

拆开看:一个物体 A 和物体 B 要碰撞,A 的分组必须落在 B 的掩码里,同时 B 的分组也必须落在 A 的掩码里。两个条件都满足才算。

这里的关键是"两个方向都要满足",不是单向判断。这一点跟很多人以为的不一样。

分组是一个位标志。官方文档给的例子:DEFAULT 分组的 index 是 0,实际值是 1 << 0 = 1,二进制 0000 0001;SELF_PLANE 的 index 是 1,实际值是 1 << 1 = 2,二进制 0000 0010。

掩码也是位标志,一个掩码可以同时包含多个分组。文档里 SELF_PLANE 的掩码值是 (1<<3)+(1<<4)=24,二进制 0001 1000,意思是"我同时想跟第 3 组和第 4 组碰撞"。

所以掩码的二进制位就是一张"我要跟谁撞"的名单。位是 0 表示不撞,是 1 表示撞。

2.2 分组在哪里配,代码里怎么写

分组矩阵的配置入口在 项目设置 → 物理 → 碰撞矩阵。这是个表格,行列都是分组名,勾选格子表示"这两组会碰撞"。新项目只有一个 DEFAULT 分组,新建分组默认不与其它组碰撞。

这个表格是 2D 和 3D 共用的。官方文档在 2D 物理系统那节明确说了这一点。

我在项目里定义了这么几个分组:

分组名 撞什么 不撞什么 用途
DEFAULT 墙体 玩家层、包裹层 楼宇、地面这类静态障碍
PLAYER 墙体、障碍 包裹、其他玩家层 无人机本体
PACKAGE 墙体、障碍 玩家层、其他包裹层 飞行的包裹
TRIGGER 所有 所有 送达点、风区触发器、检测区域

包裹之间不碰撞这一条,是我特意在碰撞矩阵里取消勾选的。取消之后,一堆包裹能互相穿过、叠在一起,视觉上不会打架。

代码里怎么设置分组?3.x 的 2D 刚体和碰撞器上都有一个 group 属性,直接赋值就行:

import { _decorator, Component, RigidBody2D, Collider2D, PhysicsSystem2D, ERigidBody2DType } from 'cc';
const { ccclass } = _decorator;

// 分组值用左移写,跟碰撞矩阵里的 index 对得上
const GROUP = {
    DEFAULT: 1 << 0,
    PLAYER:  1 << 1,
    PACKAGE: 1 << 2,
    TRIGGER: 1 << 3,
};

@ccclass('Package')
export class Package extends Component {
    onLoad () {
        const body = this.getComponent(RigidBody2D)!;
        body.type = ERigidBody2DType.Dynamic;
        body.group = GROUP.PACKAGE;

        const collider = this.getComponent(Collider2D)!;
        collider.group = GROUP.PACKAGE;
        collider.friction = 0.4;
        collider.restitution = 0.1;
    }
}

这里有个容易混的点,我得说清楚:RigidBody2D 和 Collider2D 都有 group 属性,但它们不是一回事的生效对象。

看引擎源码里 2D 碰撞器的过滤条件构造逻辑,规则是这样的:如果碰撞器自己的 group 是 DEFAULT,就用刚体的 group;否则用碰撞器自己的 group。掩码则是从 PhysicsSystem2D.instance.collisionMatrix 里按这个分组值查出来的。

换句话说,碰撞矩阵是唯一的真相来源,mask 不是你在碰撞器上手动填的。你只管在碰撞矩阵里勾好格子,然后给组件指派分组,引擎自己去查掩码。这一点跟 3.x 的 3D 物理不一样,3D 那边可以在刚体上手动设掩码,2D 这边不行。

我在这个问题上浪费了大半天。有一段时间我怀疑是 2D 物理的 bug,去看官方文档的"动态设置分组和掩码"那节,那节给的示例用的是 rigid.setGroup() 和 rigid.setMask(),我照着写,TypeScript 直接报方法不存在,那是 3D 刚体的 API。2D 的 RigidBody2D 上只有 group 这个属性,没有 setGroup 和 setMask。

顺便说一句,我不确定官方文档这一节是不是该标注"仅 3D",但我当时确实是被它坑了。

2.3 传感器:做"进入区域"检测的正确姿势

有些东西我需要知道"玩家碰到了没有",但不需要它产生任何物理效果。送达点、风区触发器、包裹的拾取范围都属于这一类。

3.x 的 Collider2D 上有个 sensor 属性,勾上之后这个碰撞体会产生碰撞回调,但不会发生物理碰撞效果。官方文档的原话。

import { _decorator, Component, Collider2D, Contact2DType, IPhysics2DContact } from 'cc';
const { ccclass } = _decorator;

@ccclass('DeliveryZone')
export class DeliveryZone extends Component {
    onEnable () {
        const collider = this.getComponent(Collider2D)!;
        collider.sensor = true;
        // 只监听自己这个碰撞体的接触事件
        collider.on(Contact2DType.BEGIN_CONTACT, this.onBeginContact, this);
        collider.on(Contact2DType.END_CONTACT, this.onEndContact, this);
    }

    onDisable () {
        const collider = this.getComponent(Collider2D)!;
        collider.off(Contact2DType.BEGIN_CONTACT, this.onBeginContact, this);
        collider.off(Contact2DType.END_CONTACT, this.onEndContact, this);
    }
    onBeginContact (self: Collider2D, other: Collider2D, contact: IPhysics2DContact | null) {
        console.log('进入区域', other.node.name);
    }
    onEndContact (self: Collider2D, other: Collider2D, contact: IPhysics2DContact | null) {
        console.log('离开区域', other.node.name);
    }
}

这段代码里有一个我踩了很久才明白的点:3.x 里不是在你组件上写 onBeginContact 方法然后引擎自动调你。引擎不认识你的方法名。

注册碰撞回调有两条路:

  1. 在碰撞器上注册。用 collider.on(Contact2DType.BEGIN_CONTACT, 回调, this)。
  2. 在物理系统上注册全局回调。用 PhysicsSystem2D.instance.on(Contact2DType.BEGIN_CONTACT, 回调, this)。

Contact2DType 是个常量对象,四个成员:BEGIN_CONTACT、END_CONTACT、PRE_SOLVE、POST_SOLVE,另外还有一个 None。字符串值分别是 'begin-contact'、'end-contact'、'pre-solve'、'post-solve'。

回调方法的名字你可以随便起,叫 onBeginContact 只是习惯。我一开始写了个 onBeginContact 方法就以为完事了,跑起来什么都不触发,换成显式 on 注册之后立刻就好了。

还有一件事:官方文档特别强调,回调里的 contact 信息是缓存的,只在回调执行期间有效。我在里面存了一份 contact 的引用,之后再取数据就是脏数据。要用就把需要的东西取出来复制一份。

2.4 通过 contact 怎么拿到对方节点

上面那个例子里,我用 other.node.name 拿到了对方节点名。这是最直接的路径。

但官方文档里给的参数说明有点绕,我第一次读的时候绕了半天才明白:selfCollider 是"回调脚本所在节点上的碰撞体",otherCollider 是"发生碰撞的另一个碰撞体"。也就是说,参数里已经直接给了双方两个碰撞体,你不需要再从 contact 里反查。

contact 是 IPhysics2DContact 类型,它提供的是碰撞的几何信息:碰撞点、法向量、摩擦系数、恢复系数这些。

我想知道 用什么
对方是哪个节点 otherCollider.node
对方碰撞体是哪个组件 otherCollider 本身,或 otherCollider.tag
碰撞点在哪 contact.getWorldManifold() 拿到 points 和 normal
双方相对速度 otherCollider.body.getLinearVelocityFromWorldPoint(点)
这次接触要不要忽略 contact.disabled = true 或 contact.disabledOnce = true

getWorldManifold() 返回的对象里有 points 数组和 normal 法向量。官方文档专门提醒了一句:法向量不是碰撞角度,它只指明解决重叠的最短方向。想知道实际撞击方向,得用两个碰撞点在同一点上的速度相减。

contact.disabled 和 contact.disabledOnce 也很好用。前者让这次接触在结束前都被忽略,后者只在当前时间步里忽略。做"无敌帧"或者"穿过某扇门"就用得上。

还有一个坑:官方文档说 Builtin 物理模块下 contact 参数是空的。Builtin 2D 物理只提供碰撞检测功能,而且只发 BEGIN_CONTACT 和 END_CONTACT 两种消息。我在项目设置里用的是 Box2D,需要完整功能的话就别切到 Builtin。

2.5 排查手册:现象、原因、修法

这一节是我自己整理的。上面所有的坑都在这里出现过一次。

现象 原因 修法
完全检测不到碰撞 刚体的 EnabledContactListener 没开 勾上 RigidBody2D 的 EnabledContactListener,或代码里设 body.enabledContactListener = true
完全检测不到碰撞 回调没注册 用 collider.on(Contact2DType.BEGIN_CONTACT, 回调, this) 显式注册,光写方法名没用
完全检测不到碰撞 两边分组在碰撞矩阵里没勾 项目设置 → 物理 → 碰撞矩阵,勾上对应的格子
完全检测不到碰撞 碰撞体所在节点没激活,或者碰撞器组件被禁用 检查 activeInHierarchy 和组件的 enabled
什么都撞到了 所有东西都在 DEFAULT 组 给不同类型的物件分配不同分组
什么都撞到了 传感器没开 只想检测不想碰撞的话,勾 Collider2D 的 Sensor
传感器有回调但物体会被撞飞 碰撞器没勾 Sensor,或者挂错组件 Sensor 是碰撞器上的属性,不是刚体上的
包裹卡在墙里 物理步长太大或迭代次数太低 fixedTimeStep 保持 1/60,velocityIterations 和 positionIterations 别往下调
高速物体穿过墙 刚体没开 Bullet RigidBody2D 上勾选 Bullet
回调里数据是脏的 contact 是缓存,只在回调期间有效 在回调里把需要的数据复制出来
碰撞事件触发了但事件被吞 触点落在按钮这类会拦截事件的节点上 这是 UI 事件的问题,不是物理问题,分开处理

关于 Bullet 这个选项,官方文档的注释有点绕,我翻译一下我的理解:勾上之后这个刚体不会被其他高速移动的刚体穿过。文档特别强调所有刚体本来就不会穿过运动刚体和静态刚体,这个选项只对动态刚体之间的穿透有效,而且会明显增加处理时间,所以要省着用。

我的场景里包裹飞行速度不慢,偶尔会穿过很薄的墙体。给包裹的刚体勾上 Bullet 就好了。

2.6 相关阅读