前阵子帮朋友调一个 Cocos Creator 3.x 的战斗逻辑,子弹偶尔打不到敌人,敌人却会卡在墙边,队友之间还会互相掉血。排查到凌晨才定位到问题:分组(Group)和掩码(Mask)的配置没对齐。这个坑在项目初期很容易埋下,尤其是团队里几个人各自添加组、各自勾碰撞矩阵,后面一合功能就开始玄学。如果你也在做 Cocos 游戏开发,对分组和掩码的理解还停留在“编辑器里勾一勾”的程度,那这篇文章值得看完。我会从位运算原理、编辑器配置、代码动态控制、UI Mask 裁剪、相机可见性到常见排查技巧都过一遍,都是我实际踩过的坑和经验,希望能帮你省下几个通宵。
1. 分组和掩码:先分清三个容易混淆的概念
1.1 物理碰撞分组:决定“能不能发生碰撞”
物理碰撞里的分组,本质上是给碰撞体贴一个“身份标签”。你把这个物体规划到哪个分组,它就属于哪个阵营。掩码则是这个物体自己维护的一份“允许接触清单”,清单里包含哪些分组,它就能和这些分组的物体发生碰撞。
很多刚接触的人会把它和“碰撞矩阵”当成一回事,其实碰撞矩阵只是编辑器提供的一种可视化配置方式。矩阵里的每一格,最终都会被引擎转换成两个属性:你要去碰撞别人的“分组”,以及你自己愿意接受的“掩码”。理解这条链路,后面就不会被编辑器界面带偏。
1.2 渲染遮罩 Mask:决定“能不能看得到”
另一类 Mask 是 UI 上的遮罩组件,比如Mask组件。它用来裁剪子节点,让图片只显示在某个圆形、矩形或自定义图形范围里。头像裁剪成圆、弹窗里滚动内容不溢出、小地图圆盘效果,都是用这个组件实现的。
它和物理分组掩码没有任何关系,只是中文都叫“掩码”。它们的底层机制也完全不同:物理掩码是 CPU 上做位运算判断,UI Mask 是 GPU 用模板缓冲(stencil buffer)做逐像素裁剪。写代码时一定要区分开,搜索问题才能搜到对的资料。
1.3 相机可见性掩码:决定“哪个相机看见谁”
Cocos Creator 里还有个容易混淆的概念:节点的 Layer 和相机的 Visibility。相机的 Visibility 属性本质上也像一组掩码,只有节点 Layer 与相机可见性匹配时,节点才会被这个相机渲染。
所以你会发现,“分组 + 掩码”这个思想其实在 Cocos 里贯穿了物理、UI、渲染三套系统。只是每套系统的名词和配置入口不一样。下面我会分别展开,先把最核心的物理碰撞部分讲透。
| 概念 | 系统 | 决定的事情 | 配置入口 |
|---|---|---|---|
| Group + Mask | 物理碰撞 | 是否发生碰撞 | 碰撞矩阵 / Collider 属性 |
| Mask 组件 | UI 渲染 | 子节点显示裁剪 | 节点上的 Mask 组件 |
| Layer + Visibility | 相机渲染 | 相机是否渲染该节点 | 节点 Layer / Camera Visibility |
2. 物理碰撞分组和掩码的工作原理
2.1 分组就是一个 32 位的“座位号”
在 Cocos Creator 里,一个项目最多能定义 32 个分组,对应一个 32 位整数。第 0 组对应第 0 位,第 1 组对应第 1 位,以此类推。
分组索引和位值之间的换算关系是:
| 分组索引 | 位值(二进制) | 位值(十进制) |
|---|---|---|
| 0 | 0000...0001 | 1 |
| 1 | 0000...0010 | 2 |
| 2 | 0000...0100 | 4 |
| 3 | 0000...1000 | 8 |
| 4 | 0000...0001 0000 | 16 |
当你把某个节点或碰撞体的分组设置成索引 3 时,它对应的 Group 值其实是1 << 3,也就是二进制里的第 3 位为 1。这个设计特别像电影院座位号:一个人可以坐在固定编号的位置上,分组就是告诉你“这个物体属于哪位”。
2.2 掩码怎么算:从开关到二进制
掩码又是一个 32 位整数,每一位代表“我允许与哪个分组碰撞”。如果掩码的第 1 位是 1,就代表我接受第 1 组的碰撞请求;如果第 1 位是 0,就代表第 1 组的物体我不会理。
所以,如果你想允许一个物体同时与第 1 组和第 3 组碰撞,它的掩码值就是(1 << 1) | (1 << 3),也就是2 | 8 = 10。这里的|是按位或运算,它的作用是把多个分组位合并成一个掩码。
注意,这里很多人会写成加法,比如2 + 8 = 10,在刚好没有重叠位的时候结果碰巧一样,但不是好习惯。如果有一个分组位你重复添加了,用加法很容易把位搞坏,用按位或则永远是幂等的,不会错。
2.3 双向校验:为什么 A 看上了 B,B 还得看上 A
物理引擎判断两个物体是否产生碰撞时,不是只看一方掩码,而是要做双向校验。假设物体 A 的分组是 GroupA,掩码是 MaskA;物体 B 的分组是 GroupB,掩码是 MaskB。A 与 B 能碰撞的前提是:
(GroupA & MaskB) !== 0(GroupB & MaskA) !== 0
换句话说,你必须允许我,我也必须允许你。很多问题就出在这里:子弹的掩码里勾了敌人,但敌人碰撞体上的掩码没勾子弹,结果子弹飞过去毫无反应。
我在代码里排查时,经常写一个辅助函数来模拟这个判断:
function canCollide(groupA: number, maskA: number, groupB: number, maskB: number): boolean { return (groupA & maskB) !== 0 && (groupB & maskA) !== 0; }用这个函数一跑,谁和谁不能碰,立刻就能看出来。编辑器里碰撞矩阵的每一格,本质上也是在维护这两个关系。你勾了 A 行 B 列,等于同时满足了双向,所以很多人只在编辑器里操作时感觉不到问题,一旦代码里改了某个碰撞体的掩码,矩阵和代码就打架了。
3. 编辑器配置与代码动态控制
3.1 项目设置里的碰撞矩阵怎么填
在 Cocos Creator 中,物理碰撞矩阵一般在“项目设置”里的物理相关页签中。不同大版本位置略有不同,比如 2.x 和 3.x 会有差异,但基本逻辑都是:先在最上面定义分组名称,然后在矩阵中勾选哪些分组之间需要碰撞。
我建议项目一立项就把分组规划好,否则后面加一组就要改一批配置。常见的规划方式:
- 默认组
DEFAULT:地图、场景静态物体 PLAYER:玩家角色和玩家召唤物ENEMY:敌方角色PLAYER_BULLET:玩家子弹ENEMY_BULLET:敌方子弹TRIGGER:触发区域、掉落物
矩阵配置时,我习惯从行视角去理解:这一行代表“这个分组会与哪些分组产生碰撞”。比如PLAYER_BULLET这一行,只勾选ENEMY和TERRAIN,那玩家子弹就会打敌人、会被墙挡,但不会打到自己人。
3.2 代码设置分组和掩码的几个技巧
虽然碰撞矩阵能完成大部分配置,但动态生成的物体还是要在代码里控制。比如子弹是对象池里复用的,它的掩码可能因为上一发子弹的目标阵营不同而要临时修改。
以伪代码为例,动态设置碰撞体的分组和掩码大概长这样:
// 伪代码,具体接口名以你当前引擎版本为准 const collider = node.getComponent(Collider); collider.setGroup(PhysicsGroup.PLAYER_BULLET); collider.setMask(combine(PhysicsGroup.ENEMY, PhysicsGroup.TERRAIN));这里有两个容易踩的坑:
第一个是掩码覆盖问题。有些版本里,如果你只修改 Mask,不会影响 Group;但如果你重新 SetGroup,某些底层实现会把掩码重置为默认值。所以我在改完 Group 后一定会重新 SetMask,确保顺序正确。
第二个是时机问题。如果你在onLoad里设置完掩码,但后续又有其他脚本在start里重置了碰撞体属性,最终效果会被覆盖。排查时不要只看当前代码,要全局搜索谁还动了这个碰撞体。
3.3 用枚举和工具函数管理分组
几十个分组靠数字记忆是不现实的。我会维护一个枚举,把所有分组按位值定义好,再写几个工具函数,项目里所有碰撞体统一走这些封装。
export enum PhysicsGroup { DEFAULT = 1 << 0, PLAYER = 1 << 1, ENEMY = 1 << 2, PLAYER_BULLET = 1 << 3, ENEMY_BULLET = 1 << 4, TERRAIN = 1 << 5, TRIGGER = 1 << 6, } export function combineGroups(...groups: PhysicsGroup[]): number { return groups.reduce((mask, g) => mask | g, 0); } export function addGroup(mask: number, group: PhysicsGroup): number { return mask | group; } export function removeGroup(mask: number, group: PhysicsGroup): number { return mask & ~group; }这样写带来的好处是,代码里通过PhysicsGroup.PLAYER能直接看出含义,而不是看到一个数字2还要去查表。后续如果要把某个子弹改成“只打敌人,不打地形”,只要在生成子弹处修改combineGroups的参数,一行代码就改完了。
4. UI Mask 组件:视觉裁剪与性能取舍
4.1 Mask 组件能做什么
UI 里的 Mask 组件,功能是把子节点裁剪到一个形状范围内。常见使用场景:
- 圆形头像:图片放在圆形 Mask 下,得到圆形裁剪效果
- 滚动列表:内容会超出边界,用矩形 Mask 把视觉范围限制在弹窗内部
- 小地图:用一个圆环或矩形区域显示地图视野
- 技能图标遮罩:用自定义图形做冷却效果
在 Cocos Creator 3.x 里,Mask 组件一般提供矩形、椭圆、自定义图形,以及基于 Sprite 的模板裁剪。如果你只是需要圆形头像,直接用椭圆类型的 Mask 就能搞定,性能上也比自定义图形要稳。
4.2 Mask 对渲染合批和 draw call 的影响
Mask 组件的底层依赖模板缓冲,它的工作机制是这样的:先绘制遮罩区域写入模板,再在绘制子节点时对模板值进行测试,只有模板值符合的像素才会输出。
听起来没问题,但它会破坏渲染合批。因为被 Mask 裁剪的节点和普通节点无法再合并到同一个批次里,draw call 会明显上升。如果你的界面里有十几个圆形头像,每个头像都单独挂一个 Mask,性能压力会非常明显。
我做过一个优化案例:好友列表里有 30 个头像,最初全部用 Mask 做圆形裁剪,draw call 涨了 30 多个。后来让美术直接出一张圆形头像图,或者用带透明通道的纹理做圆形,把这些绘制都放在同一张图集里,draw call 一下子降回个位数。
4.3 常见裁剪效果的正确实现姿势
如果确实需要用 Mask,有几个经验分享:
- 尽量把 Mask 节点下的内容做简单,避免在 Mask 内部再套一层 Mask,嵌套会进一步打断批次
- 如果 Mask 内部有大量动态文本或图片,考虑把滚动区域单独做成一个渲染根节点,减少合批范围
- 如果 Mask 只裁剪静态内容,比如一张纹理,可以用 Sprite 的九宫格 + 外框代替,而不要挂 Mask
- 小心 Mask 和 Label 的层级关系,有些版本里 Label 在 Mask 下的渲染顺序容易出现遮挡异常
我通常会在项目里做一个公共的“裁剪容器”预制体:一个 Mask 节点,默认类型为矩形,内部所有子节点放在一个普通节点下。这样既方便调整裁剪区域,又不会被错误地到处挂 Mask 导致后续性能失控。
5. 从“分组+掩码”延伸出去:渲染分层与逻辑筛选
5.1 相机 visibility 和节点 Layer 配合
相机上的 Visibility 属性,就是一个渲染层的掩码。你可以通过它控制哪个相机渲染哪些节点,实现小地图、UI 相机、主世界相机分离。
比如有一个主相机和小地图相机。主相机需要看到地形、角色、特效,不想看到小地图背景;小地图相机只想看到代表玩家和敌人的简单色块节点。这时你就可以给这些节点设置不同 Layer,然后在相机 Visibility 里勾选对应层。
这个操作非常像物理碰撞矩阵,只不过判断的不是“是否碰撞”,而是“是否渲染”。我见过很多人把 UI 和世界节点混在一起,最后相机乱显示,其实就是没规划好 Layer 和 Visibility 的对应关系。
5.2 用 Group 代替字符串做逻辑判断
物理分组除了给碰撞引擎用,也适合用于业务逻辑分类。比如在一个节点上获取之后,你想判断它是不是敌人,第一反应可能是node.name === 'Enemy'。但字符串比较不可靠,一来改名就失效,二来性能不如整数位运算。
Cocos Creator 的节点本身带有分组属性,你可以在代码里用node.group(或对应分组接口)判断。如果同一个节点需要同时属于多个分类,还可以用位掩码方式组合判断。比如node.group & PhysicsGroup.ENEMY不为 0,就认为它是敌人阵营。
这个思路很实用,尤其是在对象池复用时,物体类型经常变。比如从池里取一个特效节点,根据释放者阵营重新设置分组,逻辑上就能复用同一套碰撞和交互判断,不用同时维护多个布尔变量。
6. 实战中的常见问题与排查清单
6.1 子弹打不到人:掩码没算对
这是最高频的问题。如果你的子弹飞过敌人身体但毫无反应,第一件事就是打印子弹和敌人的 Group、Mask,用位运算判断一下双向结果。
我习惯写一个全局的调试快捷键,点击时输出当前点击目标的组别和掩码信息,用二进制形式打印:
function logGroupMask(obj: Node) { const collider = obj.getComponent(Collider); if (collider) { console.log(`group=${collider.group.toString(2)} mask=${collider.mask.toString(2)}`); } }看到二进制后,很多问题一眼就能看出来。比如敌人掩码是1010,而子弹分组是0100,按位与结果是 0,当然不会碰撞。
6.2 队友误伤:碰撞矩阵勾多了
队友之间互相掉血,多半是碰撞矩阵里队友组和子弹组勾到了同一对组合。比如玩家子弹的掩码里包含PLAYER,玩家角色又是PLAYER组,子弹就会打自己人。
这种问题在编辑器里很隐蔽,因为矩阵格子越多越难检查。建议把矩阵导出成一份文档,每次新增分组时同步更新,团队里所有人共用一份分组规划表,而不是各自凭感觉勾。
6.3 Mask 组件显示黑块或裁剪失效
UI Mask 出现黑块,常见原因有几个:
- Mask 下没有有效的渲染内容
- 图片本身带有图集外的空白像素,导致裁剪区域异常
- Mask 和子节点之间插入了不透明背景节点
- 多个 Mask 嵌套时模板状态冲突
我的排查顺序是:先关掉 Mask 看子节点是否正常显示,再单独移动 Mask 看变化,最后看是否涉及图集打包。很多时候把 Mask 内的图片独立出图集,问题就解决了。
6.4 修改分组掩码后仍不生效
代码改了 collision mask,但实际碰撞行为没变,这时候要怀疑是不是有别的脚本在后面又覆盖了。可以全局搜索对应节点的setGroup、setMask、group、mask关键字,把所有赋值点都看一遍。
另外要提醒一点,不要在update里频繁修改掩码。虽然它能改,但底层物理形状可能要重新同步,大量物体每帧改掩码会造成明显的性能波动。如果需要临时“无敌”或“隐身”,建议用开关碰撞体组件的方式,而不是每帧改掩码。
6.5 打包后表现和编辑器不一致
如果你在编辑器里调得好好的,打包成原生 APK 或小游戏后碰撞变奇怪,先检查是否在代码里通过脚本动态改过碰撞分组。编辑器执行的顺序和真机启动顺序可能不同,某些初始化代码在真机上晚执行,就会导致一帧内有错误碰撞状态。
我的做法是,把物理分组和掩码的初始化统一放到一个init方法里,在场景启动流程中显式调用,并且加日志确认执行完成。不要在onLoad、start、外部插件回调里分散设置。
6.6 分组数量不够用
如果一个项目真的用满了 32 个分组,说明你的分组规划过于细粒化了。比如把每个关卡特殊怪物都单独设一个组,这完全没有必要。更合理的方式是:按“行为类别”分组,而不是按“对象个体”分组。特殊属性可以用自定义组件或标记位处理,不要都塞进物理分组里。
比如你有一个敌人、一个中立 NPC、一个可破坏箱子,它们都只和玩家子弹碰撞,那就都放ENEMY组,箱子是否破坏交给脚本逻辑判断,而不是单独开一个BOX分组。
7. 我的一些后续建议
如果你刚开始一个新项目,我强烈建议先花半小时把分组规划表写出来,再开发逻辑。不要等代码写了一半才去补碰撞矩阵,那时候你不仅要改配置,还要面对一堆历史遗留的group和mask硬编码。封装好工具函数后,物理分组和掩码这套东西其实是很简单的位运算,排查效率会高很多。
UI Mask 则要形成团队规范:能用美术图解决的效果,就不要依赖 Mask;非用不可的时候,评估它对 draw call 的影响,并预留优化方案。这也是我做了几个项目后最大的体会。
最后再分享一个小技巧:Cocos Creator 里节点名和分组名尽量统一。比如分组叫ENEMY,节点前缀也用Enemy_。排查问题时,你从编辑器里一眼就能看出场景中的对象属于哪个组,不容易被名字误导。
分组和掩码不是小功能,它是整个碰撞体系的地基。把地基打牢,后面叠加战斗、敌人 AI、掉落物、镜头分层都会顺很多。希望这篇内容能帮你少踩一些坑,也欢迎分享你在项目里遇到过的诡异碰撞问题。