news 2026/9/15 2:21:41

Cocos分组与掩码深度解析:物理碰撞与UI裁剪实战指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Cocos分组与掩码深度解析:物理碰撞与UI裁剪实战指南

做游戏开发这么久,我发现“cocos 分组和掩码”这个问题,几乎每个从入门到进阶的开发者都会卡一次。面试的时候问,项目联调的时候也问,社区里天天有人截个图问“为什么我的两个节点组设置好了还是互相穿透”。其实分组和掩码在 Cocos 里不是一套东西,而是两套容易搞混的概念:一套是物理碰撞系统里的分组和掩码,负责“谁和谁能碰”;另一套是 UI 上的 Mask 组件,负责“显示区域裁剪成什么形状”。很多人把它们混在一起聊,越聊越乱。这篇文章我把两套东西完全拆开,从原理讲到实战,再把我这些年踩过的坑都摆出来,你可以直接照着用。

内容面向 Cocos Creator 2.x/3.x 的开发者,不管是做 2D 还是 3D,不管是刚接触物理还是被 UI 裁剪折磨过,都能在这篇文章里找到对应的答案。核心围绕一件事:分组怎么分,掩码怎么填,什么时候用矩阵,什么时候用 Mask 组件,以及出了问题怎么快速定位。

1. 先把概念拆清楚:分组是身份,掩码是权限

1.1 节点分组到底被谁读取

在 Cocos 编辑器里,任何一个节点从创建那一刻起,就带有一个“Group”属性。你用鼠标点开节点的属性面板,在最上面能看到一个下拉框,默认是 DEFAULT。这个属性在很多人眼里形同虚设,因为不设置它游戏也能跑。但一旦项目里接入了物理系统,它就是决定碰撞关系的第一道关卡。

这里的分组不是给人看的备注,而是给物理系统、事件系统等底层模块读取的“身份编号”。Cocos 里分组数量上限是 32 个,编号从 0 到 31。为什么偏偏是 32?因为引擎底层用的是一个 32 位整数来存分组标记,每一位对应一个分组。这种设计在游戏引擎里非常常见,比如 Unity 的 Layer 也是这么干的。你可以在项目设置里维护自定义分组名,编辑器会给每个分组分配一个从 0 开始的索引,你只要把组名和索引对应好,后面运行期代码就能用索引做掩码计算。

很多新手会把节点的 Group 当成类似“标签(Tag)”的东西来用,比如“我判断敌人是不是 BOSS 就查一下 node.group”。能用是能,但我不建议这么干。Group 在物理和渲染系统里有明确职责,你拿它当业务标签,后面一旦需要调整物理碰撞矩阵,就会发现牵一发动全身。

节点分组在代码里一般通过节点上的 group 相关接口读写,不同大版本 API 名称略有差异,但思路完全一致:你赋值的是组名,引擎内部拿到的是一串位标记。你在编辑器里“Edit Groups”里添加多少个名字,引擎就给多少个索引位。真正决定物理碰撞是否发生的,是这些索引位参与的掩码计算。

1.2 掩码的核心是一串二进制开关

你可以在素材包里选一张只有人物轮廓的透明贴图,用一个节点专门挂 Mask,然后让需要裁剪显示的节点成为它的子节点。这样画面里就只显示贴图轮廓内的部分,轮廓外一律不渲染。

很多新手第一次看到遮罩效果觉得很神奇,其实原理说白了就是模板缓冲区:一个 Mask 节点往模板缓冲区里写入一个图形,子节点在渲染前先比对模板值,模板值匹配的像素才通过,不匹配的直接丢弃。你可以把模板缓冲想象成剪纸艺术:先在一张白纸上裁出一个洞,再拿颜料往纸上刷,只有洞下面的纸面能碰到颜料。

Cocos 的 Mask 组件有几种类型:GRAPHICS_RECT、GRAPHICS_ELLIPSE、GRAPHICS_CUSTOM、SPRITE_STENCIL。前两种就是矩形和圆形裁剪,最省事;GRAPHICS_CUSTOM 允许你自己用 Graphics 组件画任意多边形;SPRITE_STENCIL 则是拿一张 SpriteFrame 当模板,比如做不规则头像框、地图上浮现的区域,都用得上。

这里的掩码和物理掩码完全是两回事。物理掩码是参与碰撞的位运算规则,UI Mask 是渲染层的裁剪算法。它们只是中文翻译上都叫“掩码”,实际底层没有一毛钱关系。面试题里如果出现“Cocos 的 Mask 和物理掩码有什么区别”,答案就是:物理掩码靠位运算滤碰撞对象,UI Mask 靠模板缓冲裁子节点像素。

3.2 嵌套 Mask 和合批问题

UI 上的 Mask 组件最烦人的一点是:它会打断渲染合批,导致 draw call 上涨。本来一个界面几百个节点全部合并成几个批次,结果中间插了一个 Mask,后面的 Sprite、Label 可能就合不进去了。

我之前做过一个背包页面,每个格子都用 Mask 裁剪图标,本地测试还好,一上低端安卓机就发热明显。后来用 profiler 一看,光背包界面就多了几十个 draw call,罪魁祸首就是那一排 Mask。解决方式很粗暴:图标的圆角效果直接用美术切好圆角图,或者用九宫格 Sprite 拉伸,而不是套 Mask。

嵌套 Mask 更要命。模板缓冲有固定层数限制,在小游戏平台和某些 Web 端上,模板缓冲深度只有 8 层。这意味着你 UI 里嵌套超过 8 层 Mask,后面的层级直接显示异常,可能整个元素消失,也可能出现不该显示的边缘溢出。这个坑遇到一次就能记一辈子。我在一个多级弹出菜单里做过三层 Mask 嵌套,iPhone 真机上一切正常,换到安卓的某款浏览器直接白屏,排查了半天才发现是模板缓冲溢出。

Mask 还有个隐蔽问题:被裁剪区域里的 Label 在部分字体和开启描边效果时,边缘会出锯齿或发虚。这是因为模板测试和字体渲染的 alpha 测试顺序在不同渲染管线里不完全一致。遇到这种问题,常规做法是把文字挪出 Mask,或者改用 Bitmap 字体。

3.3 比 Mask 更省的做法

能不用 Mask 就不用 Mask,这是我现在的原则。圆形头像这种需求,一张带透明通道的圆形图片就是最优解;圆角卡片用九宫格,四角拉伸不变形;不规则显示区域,优先考虑美术直接出透明贴图,运行时只要换 SpriteFrame 就行。

如果确实要做动态变化的遮罩,比如进度条那种部分显示效果,也不一定上 Mask。进度条用Sprite组件的 fillRange 就能实现,环形进度条用ProgressBar配合自定义 Shader,效率远高于每帧重绘 Mask。还有一个方案是直接用Graphics组件绘制显示区域的填充形状,只在节点可见性上做控制,不走模板缓冲,性能会稳很多。

真正非 Mask 不可的场景一般是有复杂轮廓的 UI 裁剪,比如“地图上只显示玩家视野范围”。这种需求用 GRAPHICS_CUSTOM 画视野多边形,或者用 SPRITE_STENCIL 配一张视野纹理,都是常见做法。记得在 Mask 组件上勾选正确的影响范围,并且不要把它放在频繁变形的节点下面。

4. 实战案例:2D 射击小游戏的分组掩码设计

4.1 先画一张碰撞矩阵表

我每次做带物理的项目,开工第一件事不是写代码,而是拿一张纸出来画碰撞矩阵。游戏里最基本的几个碰撞对象先列出:玩家、敌人、玩家子弹、敌人子弹、障碍物、道具。给它们分配分组索引:

分组索引分组名说明
0PLAYER玩家本体
1ENEMY敌人本体
2PLAYER_BULLET玩家发射的子弹
3ENEMY_BULLET敌人发射的子弹
4OBSTACLE地图障碍物
5ITEM可拾取道具

有了分组索引,再定碰撞规则。玩家的子弹要打敌人和障碍物,但自己不能被玩家子弹命中;敌人的子弹反过来;道具只和玩家发生接触;玩家和敌人本体碰到障碍物都要停下来。整理成碰撞矩阵就是:

碰撞矩阵0 玩家1 敌人2 玩家子弹3 敌人子弹4 障碍物5 道具
0 玩家忽略伤害忽略伤害阻挡拾取
1 敌人伤害忽略伤害忽略阻挡忽略
2 玩家子弹忽略伤害忽略忽略阻挡忽略
3 敌人子弹伤害忽略忽略忽略阻挡忽略
4 障碍物阻挡阻挡阻挡阻挡忽略忽略
5 道具拾取忽略忽略忽略忽略忽略

这个矩阵表我会直接截图放在项目文档里。你千万不要觉得这是多余工作,当项目跑起来后出现“子弹穿透了墙体”这种诡异问题,回头看一眼这张表,五分钟内就能定位是不是某两行没勾上。代码写得再漂亮,都不如一开始就把矩阵表定准。

4.2 代码落地:常量、分组、碰撞回调

设计好分组索引之后,代码里第一步是建一个常量文件,把组名和掩码集中管理,不要散落在各个脚本里。我这边的习惯是写一个LayerDefine.ts,类似这样:

export const GroupDef = { PLAYER: 0, ENEMY: 1, PLAYER_BULLET: 2, ENEMY_BULLET: 3, OBSTACLE: 4, ITEM: 5, }; export const MaskDef = { PLAYER: (1 << GroupDef.PLAYER) | (1 << GroupDef.ENEMY_BULLET) | (1 << GroupDef.ITEM), ENEMY: (1 << GroupDef.ENEMY) | (1 << GroupDef.PLAYER_BULLET), PLAYER_BULLET: (1 << GroupDef.ENEMY) | (1 << GroupDef.OBSTACLE), ENEMY_BULLET: (1 << GroupDef.PLAYER) | (1 << GroupDef.OBSTACLE), OBSTACLE: (1 << GroupDef.PLAYER) | (1 << GroupDef.ENEMY) | (1 << GroupDef.PLAYER_BULLET) | (1 << GroupDef.ENEMY_BULLET), ITEM: (1 << GroupDef.PLAYER), };

这里的掩码含义是“我这个物体能和哪些组产生碰撞”。比如玩家能碰撞的组包括玩家自己、敌人子弹和道具;玩家子弹能碰撞的组只有敌人和障碍物。把这些常量集中在一个文件里,比在编辑器里靠鼠标一格一格点碰撞矩阵要可维护得多。

动态创建子弹时,脚本里给节点挂上组件并设置分组,大致是这样的思路:

const bulletNode = new Node('bullet'); bulletNode.group = 'PLAYER_BULLET'; const collider = bulletNode.addComponent(BoxCollider2D); collider.group = GroupDef.PLAYER_BULLET; collider.sensor = true;

这里有两个关键点:一是 2D 物理里节点分组需要和碰撞矩阵配合,二是sensor即感应器模式。开了 sensor 的碰撞器不会产生物理阻挡,只负责触发碰撞回调。子弹这种对象一定要开 sensor,否则一个子弹落在地上还会被地板弹走,完全不符合视觉预期。

碰撞回调接在挂到节点上的脚本里,通过Collider2D.onBeginContact监听,典型写法是:

collider.onBeginContact = (self, other, contact) => { const otherGroup = other.group; if (otherGroup === GroupDef.ENEMY) { // 处理命中敌人 } else if (otherGroup === GroupDef.OBSTACLE) { // 处理命中障碍物 } };

注意这里other.group是节点所在分组的索引,在你的物理配置下就是GroupDef.ENEMYGroupDef.OBSTACLE。通过掩码判断能写一个通用工具函数,把所有碰撞对象的逻辑收敛到一张映射表里,避免每个子弹脚本都复制粘贴一大段 if else。

4.3 排查速查表:按症状找原因

项目做久了,你会发现自己再一次遇到的分组掩码问题翻来覆去就那么几个。我把最常见的几种症状和原因整理成一张速查表,照着查基本能解决 90% 的现场问题。

现象可能原因解决方向
两个物体穿过彼此,完全没有碰撞回调分组在碰撞矩阵中没勾选,或碰撞双方没有刚体组件在 Project Settings 里检查矩阵,给至少一方添加 RigidBody
碰撞回调只触发一次就再也不触发碰撞双方没有保持分离再 DOP 的间距,或接触没有退出检查onEndContact是否正常,确认刚体是否被 sleep
子弹打中敌人,敌人死了但子弹还在飞sensor 模式碰撞回调没写销毁逻辑在 onBeginContact 里销毁子弹节点,注意销毁时机
修改了碰撞矩阵,运行期不生效设置了错误的分组索引或掩码layer/group对应的索引确认,别和渲染层搞混
节点渲染正常,但物理碰撞“多碰了一次”节点下有多个 Collider 组件都参与碰撞检查子节点 Collider,统一挂到顶层节点或保留必要组件
UI Mask 裁剪范围对不上触摸区域Mask 节点没加 UITransform,或节点缩放导致模板坐标偏移给 Mask 节点补全宽高,确认 Canvas 缩放模式

这些症状里,最常见的是第一个。很多新手把敌人类脚本加到节点上,也在编辑器里勾了分组,但忘了给节点挂刚体,物理系统压根不认这个节点。记住一句话:碰撞矩阵只管“允许不允许”,真正决定“有没有碰撞体”的是 Collider 组件和 RigidBody 组件。分组掩码只是规则,组件才是实体。

5. 这些坑我替你们踩过了

5.1 动态改矩阵后没反应怎么办

有时候我们游戏中有“变身”玩法,玩家吃了狂暴药水后短暂无敌,需要临时改碰撞关系。代码里运行时修改 2D 物理碰撞矩阵,很多人改完发现没有任何效果,子弹照样飞过来。原因多半是改完矩阵后,已经存在的物理接触没有重新计算。

以 Cocos Creator 3.x 的 2D 物理为例,动态修改碰撞矩阵大致是改PhysicsSystem2D.instance.collisionMatrix数组:

const idx = GroupDef.PLAYER * 32 + GroupDef.ENEMY_BULLET; PhysicsSystem2D.instance.collisionMatrix[idx] = false;

但改完这一行,已经互相接触的碰撞体不会立刻解除关系。你需要让接触重新计算,简单粗暴的做法是把相关刚体sleep()wakeUp(),或者直接临时把其中一方 Collider 的 enabled 切一下。这属于物理引擎的接触缓存问题,你光改矩阵不等于重新跑一遍物理同步。

我自己的通用处理方式是这样:定义一个统一的接口refreshCollision(),里面先记录当前节点状态,再循环遍历所有固定分组,重新设置 collisionMatrix,最后强制重启相关刚体。这个方法在“切关卡”“变形态”这类全局状态切换时非常稳。

5.2 别把 Group 当成 Tag 用

分组索引本质上是一个位标记,在物理系统、事件分发、某些渲染功能里都有语义。但它在业务逻辑里不是一个好的业务标签。我做过一个项目,策划用“分组”去配置怪物种类,后来因为碰撞规则需要调整,把分组顺序一换,全项目的怪物种别判断全部错位。那一次排查花了我一个下午。

业务标签应该走自定义的entityType之类的枚举字段,跟引擎分组彻底解耦。分组只管“物理碰撞关系”,业务类型只管“游戏逻辑表现”,两者各司其职才能避免互相污染。以后你看到有人面试时说“我拿 node.group 判断怪物类型”,你就知道他对分组的理解还停留在表面。

另外,渲染层面还有一个类似的概念叫 Layer,比如相机 visibility、UI 事件 UILayers。它和物理分组也是两码事。很多新手把 Layer 和 Group 混在一起调,最后物理该碰的不碰,相机该渲染的没渲染,整个画面一片黑。记住:Layer 管渲染/射线可见,Group 管物理碰撞身份,两个维度不要互相替代。

5.3 资料阅读建议:别被“动态掩码”“反掩码”带偏

搜索“分组和掩码”相关关键词时,你会看到很多相近的词:动态掩码、反掩码、通配符掩码、时频掩码,甚至会混入数据库分组、ACL 通配符匹配之类的内容。这里面大部分是网络工程、音频处理或数据库的术语,不是 Cocos 里的概念。Cocos 的分组掩码就是一个整型位集合,和 VLAN、ACL 里那种反掩码没有任何关系,看资料时如果发现推导链路对不上,先怀疑资料在讲另一套东西。

我个人的学习习惯是:看官方文档里的碰撞矩阵配置说明,再自己写两个没有任何渲染的纯物理节点,在编辑器里模拟一遍。引擎文档里关于 Group 和 Mask 的内容本身不多,因为这里的概念太底层,官方默认你应该懂位运算和碰撞矩阵。如果心里对位运算没有概念,先把二进制、按位与、按位或、左移这四件事补一补,所有分组掩码的实现都是建立在这四个基础操作上的。

最后再分享一个我一直在用的小习惯:每个项目开工前,我会把项目用到的分组名和碰撞矩阵截图放进docs/目录,并在主入口脚本里加一个 debug 面板,可以在运行时打印当前所有分组索引和掩码值。出问题的时候,不用靠猜,直接看数据。分组和掩码调试起来其实一点都不难,难的是你愿不愿意在项目早期就把规则定义清楚,并且让所有团队成员都在同一张表格下工作。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/15 2:20:45

手写个人主页:从零搭建聚合链接页的完整指南

这几年我越来越确信一件事&#xff1a;在互联网上&#xff0c;你不主动给自己建一个“家”&#xff0c;就只能被动地散落在各个平台的主页里&#xff0c;当“流浪人口”。这个“家”&#xff0c;就是今天想聊的个人主页&#xff0c;也就是大家常说的聚合链接页。它把分散在各处…

作者头像 李华
网站建设 2026/9/15 2:20:00

2026示波器选型核心:通道数、同步精度与真实测量边界

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/15 2:19:27

Java联机版森林冰火人:Socket长连接与服务端权威状态同步实践

简介&#xff1a;这份资源是一份基于Java实现的《森林冰火人》双人联机小游戏课程设计源码包&#xff0c;主要面向Java编程学习者、课程设计答辩者以及游戏开发入门者。资源包内共八十五个文件&#xff0c;包括十二个Java源码文件及其对应的class字节码&#xff0c;还有用于工程…

作者头像 李华