1. 项目概述:为什么我们需要Mask组件?
在Cocos Creator里做UI或者特效,尤其是新手阶段,经常会遇到一个让人头疼的问题:我画了一个圆形的头像框,但图片是方形的,四个角露出来了;我想做一个滚动公告,但希望文字只在特定的矩形区域内显示,超出部分别让我看见;更经典的是刮刮卡效果,刮开前是一层涂层,刮开后露出底下的奖品图片。这些场景的核心需求其实就一个:如何把显示内容限制在一个特定的形状区域内,区域外的部分统统隐藏掉?
这个需求,光靠调整节点位置和大小是搞不定的。比如那个圆形头像,你总不能把一张方形图片的四个角用PS抠掉吧?那样既不灵活,性能也差。这时候,Cocos Creator内置的Mask(遮罩)组件就派上用场了。你可以把它理解为一个“裁剪模具”或者“蒙版”。它定义了一个可见区域,只有在这个区域内的子节点内容才会被渲染出来,区域外的像素则被“遮住”或“隐藏”了。
Mask组件是Cocos Creator中实现这类“局部显示”效果最基础、最核心的组件之一。无论是简单的UI裁剪,还是复杂的交互效果(如刮刮卡、雷达图、技能冷却等),其底层往往都离不开Mask的支撑。理解并熟练运用Mask,是从“能做出东西”到“能做好东西”的关键一步。这篇文章,我就结合自己踩过的坑和实战经验,带你彻底搞懂Mask组件,并实现一个完整的刮刮卡案例。
2. Mask组件核心原理与类型详解
Mask组件的工作原理,简单说就是利用模板测试(Stencil Test)。这是图形渲染管线中的一个环节。你可以想象Mask节点自己先画出一个形状(比如矩形或圆形),这个画的过程会在一个叫“模板缓冲区”的地方做上标记。然后,它的所有子节点在渲染时,会去检查这个缓冲区:只有对应位置有标记(即在Mask形状内)的像素才会被画到屏幕上,没标记的(形状外)就直接跳过。这个过程是GPU硬件加速的,效率非常高。
在Cocos Creator中,Mask组件主要提供三种类型的遮罩,对应不同的“模具”形状:
2.1 矩形遮罩 (RECT)
这是最常用、性能最好的遮罩类型。它定义一个轴对齐的矩形区域。
- 原理:根据节点本身的
contentSize(内容尺寸)来确定矩形范围。 - 应用场景:所有需要矩形裁剪的地方。例如:
- 滚动视图(ScrollView)的内容容器:确保内容只在滚动视窗内显示。
- 聊天框:限制文本和表情的显示范围。
- 技能图标冷却:一个灰色的矩形遮罩从上往下移动,覆盖技能图标。
- 注意事项:
- 矩形的轴心点(Anchor)会影响遮罩的起始位置。通常我们会把轴心点设为(0, 0)或(0.5, 0.5),具体看需求。
- 修改节点的
scale属性会同步缩放遮罩区域。如果你只想缩放子节点内容而不想改变遮罩范围,需要将Mask节点和内容节点分开层级。
2.2 圆形遮罩 (ELLIPSE)
定义一个圆形(或椭圆形)区域。
- 原理:以节点中心为圆心,以节点宽度和高度为椭圆的长短轴,绘制一个椭圆区域。当宽高相等时,就是正圆形。
- 应用场景:
- 圆形头像:这是最典型的应用。将一张方形图片作为Mask节点的子节点,即可得到圆形头像。
- 圆形进度条:结合脚本动态修改遮罩范围,可以实现圆形的血量条、加载进度条。
- 雷达图/波图:多个扇形或圆形区域的组合显示。
- 注意事项:
- 圆形遮罩的性能开销比矩形遮罩稍大。
- 边缘锯齿问题:在低分辨率设备上,圆形边缘可能出现锯齿。可以通过开启抗锯齿(项目设置中)或为Mask节点添加一个略大的、同样形状的边框背景图来视觉上缓解。
2.3 图像遮罩 (GRAPHICS)
这是最灵活,也是最需要手动控制的一种类型。
- 原理:通过调用
Graphics组件的绘图API(如rect,circle,lineTo,fill等),动态绘制出一个任意形状的多边形区域作为遮罩。 - 应用场景:
- 不规则形状遮罩:如星形、心形、多边形头像框。
- 刮刮卡效果:通过触摸轨迹,动态擦除(绘制)遮罩层。
- 自定义形状的进度条。
- 注意事项:
- 性能敏感:每一帧重绘
Graphics(例如在update中)开销很大,必须谨慎优化。对于静态形状,绘制一次即可;对于动态变化的形状(如刮刮卡),需要控制重绘的区域和频率。 - 需要脚本驱动:你必须编写代码来定义绘制的形状。
- 性能敏感:每一帧重绘
重要选择:
SECTOR类型去哪了?在Cocos Creator的早期版本(如v1.x)中,存在SECTOR(扇形)遮罩类型。但在v2.x及以后的版本中,官方移除了这个类型。这是因为扇形遮罩完全可以通过更强大的GRAPHICS类型绘制出来,且GRAPHICS更通用。如果你需要扇形遮罩(比如做技能释放范围指示器),请直接使用GRAPHICS类型,并用arc方法绘制扇形。
3. 实战:从零构建一个刮刮卡效果
理论说再多不如动手做一遍。我们来实现一个经典的刮刮卡效果:上层是一层灰色涂层,用户用手指或鼠标刮开涂层,露出底下的中奖信息图片。
3.1 场景结构与节点准备
- 创建UI节点:新建一个场景,创建一个Canvas下的空节点,命名为
ScratchCard。 - 创建底层(奖品层):在
ScratchCard下创建一个Sprite节点,命名为Prize。为其添加一个Sprite组件,导入一张作为奖品的图片(比如“恭喜中奖”的文字图)。 - 创建Mask层:在
ScratchCard下创建一个空节点,命名为MaskLayer。这个节点将承载我们的遮罩。- 为
MaskLayer节点添加Mask组件。将Type属性设置为GRAPHICS。同时,勾选Inverted(反向)属性。这是关键!默认遮罩是显示区域内内容,隐藏区域外。而刮刮卡逻辑是:涂层(我们接下来要画的)一开始覆盖全部,刮掉的地方才显示奖品。所以我们需要“反向遮罩”——即Graphics画出的部分不显示奖品,没画到的部分才显示。 - 接着,为
MaskLayer节点添加Graphics组件。我们将用这个组件来绘制涂层。
- 为
- 创建涂层(表面层):在
MaskLayer节点下,创建一个Sprite节点,命名为Cover。为其添加Sprite组件,使用一张灰色或带有“刮开”纹理的图片。这个涂层将作为视觉上的刮刮卡涂层,它的显示不受Mask影响,但我们会通过Mask来控制它底下的奖品层的显示。
最终的节点树应该是这样的:
Canvas └── ScratchCard (Node) ├── Prize (Sprite) // 奖品图 └── MaskLayer (Node) // 遮罩层 ├── Mask (Component, Type: GRAPHICS, Inverted: true) ├── Graphics (Component) └── Cover (Sprite) // 视觉上的涂层3.2 编写刮刮卡交互脚本
在MaskLayer节点上创建一个新的TypeScript脚本,命名为ScratchCardMask.ts。
import { _decorator, Component, Graphics, Mask, Input, input, EventTouch, Vec2, Color } from 'cc'; const { ccclass, property } = _decorator; @ccclass('ScratchCardMask') export class ScratchCardMask extends Component { // 获取Graphics组件引用,用于绘图 private _graphics: Graphics = null!; // 获取Mask组件引用 private _mask: Mask = null!; // 上一次触摸点的世界坐标,用于画线 private _lastPos: Vec2 | null = null; // 刮擦的笔触半径 @property({ tooltip: '刮擦的笔触半径' }) public brushRadius: number = 20; start() { // 初始化组件引用 this._graphics = this.node.getComponent(Graphics)!; this._mask = this.node.getComponent(Mask)!; // 确保Mask类型是GRAPHICS this._mask.type = Mask.Type.GRAPHICS; this._mask.inverted = true; // 必须开启反向 // 初始化Graphics,绘制一个覆盖全屏的矩形作为初始涂层(即全部遮住奖品) this._initMaskCover(); // 注册触摸事件监听 input.on(Input.EventType.TOUCH_START, this.onTouchStart, this); input.on(Input.EventType.TOUCH_MOVE, this.onTouchMove, this); input.on(Input.EventType.TOUCH_END, this.onTouchEnd, this); } onDestroy() { // 记得移除事件监听,防止内存泄漏 input.off(Input.EventType.TOUCH_START, this.onTouchStart, this); input.off(Input.EventType.TOUCH_MOVE, this.onTouchMove, this); input.off(Input.EventType.TOUCH_END, this.onTouchEnd, this); } // 初始化:用黑色填充整个节点区域,这样奖品就完全被遮住了 private _initMaskCover() { const g = this._graphics; g.clear(); // 清除之前的绘制 // 获取当前节点的包围盒(在本地坐标系) // 这里我们简单画一个足够大的矩形,覆盖整个可能区域 // 实际项目中,可能需要根据奖品图大小动态计算 g.rect(-1000, -1000, 2000, 2000); // 画一个很大的矩形 g.fillColor = Color.BLACK; // 填充颜色,颜色本身不重要,重要的是填充了这个区域 g.fill(); // 重要:调用`mask`的`updateGraphics`方法,通知遮罩更新 this._mask.updateGraphics(); } // 触摸开始 private onTouchStart(event: EventTouch) { const touchPos = event.getUILocation(); // 获取UI坐标系下的触摸点 this._drawCircle(touchPos); // 在触摸点画一个圆,表示刮开了一个点 this._lastPos = touchPos; // 记录为上一次位置,为画线做准备 } // 触摸移动 private onTouchMove(event: EventTouch) { if (!this._lastPos) return; const touchPos = event.getUILocation(); // 在上一位置和当前位置之间画线,用连续的圆来模拟笔触 this._drawLine(this._lastPos, touchPos); this._lastPos = touchPos; } // 触摸结束 private onTouchEnd() { this._lastPos = null; // 清空上一次位置 } // 在指定位置(世界坐标)画一个圆(实际上是绘制一个“洞”) private _drawCircle(pos: Vec2) { const g = this._graphics; // 1. 将世界坐标转换到Graphics节点的本地坐标 const localPos = this.node.inverseTransformPoint(new Vec3(pos.x, pos.y, 0)); // 2. 设置绘制模式为“打洞”。这是关键! // `setFillRule`是Graphics的API,`Graphics.FillRule.EVEN_ODD`是奇偶填充规则。 // 简单理解:后画的图形如果和先画的图形有重叠,重叠部分会被“挖空”。 g.setFillRule(Graphics.FillRule.EVEN_ODD); // 3. 先“保留”原有填充(这里其实不需要额外操作,因为_initMaskCover已经画了一个大矩形) // 4. 在触摸点位置画一个圆,并填充。由于使用了奇偶规则,这个圆区域会从之前的大矩形中被“减去”。 g.circle(localPos.x, localPos.y, this.brushRadius); g.fillColor = Color.BLACK; // 颜色任意,填充只是为了应用“打洞”规则 g.fill(); // 5. 通知Mask更新 this._mask.updateGraphics(); } // 在两点之间画线(用连续的圆模拟) private _drawLine(startPos: Vec2, endPos: Vec2) { const g = this._graphics; g.setFillRule(Graphics.FillRule.EVEN_ODD); // 计算两点之间的距离和方向 const deltaX = endPos.x - startPos.x; const deltaY = endPos.y - startPos.y; const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY); const step = this.brushRadius * 0.5; // 步长,决定圆的密度 if (distance > 0) { const segments = Math.ceil(distance / step); for (let i = 0; i <= segments; i++) { const t = i / segments; const x = startPos.x + deltaX * t; const y = startPos.y + deltaY * t; // 将每个插值点转换到本地坐标并画圆 const localPos = this.node.inverseTransformPoint(new Vec3(x, y, 0)); g.circle(localPos.x, localPos.y, this.brushRadius); } g.fillColor = Color.BLACK; g.fill(); this._mask.updateGraphics(); } } // 提供一个重置刮卡的方法 public resetScratch() { this._initMaskCover(); } }3.3 关键逻辑解析与避坑指南
Inverted(反向)属性的核心作用:这是实现刮刮卡逻辑的灵魂。我们视觉上有一个涂层(Cover),逻辑上有一个遮罩(Mask)。我们希望用户“刮掉”涂层,看到奖品。在代码里,我们实际上是在Graphics上“挖洞”。Inverted: false(默认)时,画出的图形区域是显示奖品的。这不符合直觉。Inverted: true时,画出的图形区域不显示奖品(即被遮住),而画布上其他没画到的区域则显示奖品。这正好对应了“刮掉涂层(挖洞),露出奖品”的流程。Graphics.FillRule.EVEN_ODD(奇偶填充规则):这是实现“挖洞”效果的技术关键。默认的填充规则是Graphics.FillRule.NON_ZERO,后画的图形会直接覆盖先画的。而EVEN_ODD规则下,从一点向外发射射线,穿过图形边界,奇数个交点算内部,偶数个算外部。当我们先画一个大矩形(全遮罩),再在上面画一个小圆时,小圆区域的重叠部分会被计算为“外部”,从而实现“挖洞”。你可以尝试注释掉g.setFillRule(Graphics.FillRule.EVEN_ODD);这行,会发现刮擦无效,因为圆只是覆盖了上去,没有形成空洞。坐标转换:触摸事件获取的坐标是UI世界坐标。而
Graphics绘图是在其节点本地坐标系下进行的。必须使用inverseTransformPoint方法进行转换,否则画的位置会完全不对。这是新手最容易忽略导致“刮不动”或“刮的位置错位”的原因。性能优化:在
_drawLine方法中,我们通过插值画多个圆来模拟连续线条。step(步长)越小,线条越连续平滑,但需要绘制的圆越多,性能开销越大。需要根据brushRadius(笔刷半径)来权衡。通常步长为半径的0.3-0.5倍即可取得较好效果。另外,updateGraphics的调用要节制,我们在每次绘制操作(fill)后调用一次,而不是在for循环里每画一个圆就调用一次。重置功能:
resetScratch方法通过重新调用_initMaskCover来清空所有绘制,恢复初始的全遮盖状态。这在游戏需要重置刮卡时非常有用。
4. Mask组件深度使用技巧与常见问题排查
掌握了基础用法和实战后,我们再来深入一些高级技巧和常见坑点。
4.1 性能优化要点
Mask组件虽然方便,但滥用或误用会对性能造成显著影响,尤其是在移动设备上。
- 层级合并与Draw Call:Mask会打断合批。简单说,引擎为了优化,会尽量将相同材质的精灵合并一次绘制(一个Draw Call)。但如果两个精灵中间隔着一个Mask节点,它们就无法合并了,导致Draw Call增加。优化建议:尽可能将需要被相同遮罩裁剪的节点放在同一个Mask节点下,并确保它们的渲染顺序连续。避免一个Mask只包含一个子节点,然后大量使用这样的结构。
GRAPHICS类型的重绘开销:动态Graphics(每帧变化)是性能杀手。对于刮刮卡这种必须动态绘制的场景,要控制绘制频率和区域。例如,可以积累一定数量的触摸点再进行一次批量绘制,而不是每帧都画。- 遮罩区域大小:尽量让Mask节点的尺寸(
contentSize)精确匹配需要显示的区域,不要设置得过大。过大的遮罩区域意味着更多的像素需要进行模板测试。 - 慎用嵌套Mask:多个Mask嵌套使用会导致模板测试多次进行,性能开销成倍增加。除非绝对必要,否则应避免。
4.2 与其他组件的配合与冲突
- 与Widget(对齐挂件)的配合:Mask节点本身可以使用Widget组件来定位。但要注意,Mask的裁剪区域是基于其节点变换后的最终区域。如果子节点也使用了Widget,要确保计算无误,防止子节点跑出遮罩范围。
- 与Spine/Skeleton动画:Mask可以正常裁剪Spine骨骼动画。但需要注意,如果Spine动画的边界计算(bounding box)很大,即使视觉上在遮罩内,也可能因为边界超出而被错误裁剪。可以在Spine软件中优化网格边界,或在Cocos Creator中调整Mask大小。
- 与粒子系统(ParticleSystem):Mask可以裁剪粒子,但性能开销需要关注。大量粒子与动态Mask结合时,建议进行充分的性能测试。
4.3 常见问题排查速查表
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 遮罩完全不起作用,子节点全部显示 | 1. Mask组件未启用(enabled为false)。2. 子节点不在Mask节点层级之下。 3. 对于 GRAPHICS类型,未调用updateGraphics()。 | 1. 检查Inspector中Mask组件的勾选框。 2. 在层级管理器中将子节点拖拽为Mask节点的子级。 3. 在绘制代码后手动调用 this.mask.updateGraphics()。 |
| 遮罩区域显示反了(想显示的部分被隐藏) | Inverted属性设置错误。 | 检查Inverted属性。对于刮刮卡这类“擦除”效果,通常需要设为true。 |
GRAPHICS遮罩绘制的位置不对 | 绘图坐标未进行坐标转换。 | 确保将触摸或逻辑的世界坐标/UI坐标,通过node.inverseTransformPoint()转换为Graphics节点的本地坐标后再绘图。 |
| 圆形/图像遮罩边缘有锯齿 | 分辨率较低,且抗锯齿未开启。 | 1. 在项目设置 -> 项目数据 -> 抗锯齿中开启抗锯齿。 2. 美术资源:为被遮罩的图片添加一个1-2像素的透明外边框(出血)。 3. 使用 Sprite的Sliced模式并设置合适的inset,有时能改善边缘。 |
| 使用Mask后,游戏变得很卡 | 1. 嵌套使用了多个Mask。 2. 动态 GRAPHICS遮罩每帧重绘且区域很大。3. Mask节点下子节点过多、材质各异,导致Draw Call暴涨。 | 1. 检查节点层级,消除不必要的嵌套Mask。 2. 优化动态绘制的逻辑,减少绘制调用和区域。 3. 使用渲染分组调试工具(如Cocos Creator内置的 cc.renderer信息)查看Draw Call,合并同材质子节点,调整渲染顺序。 |
| 在ScrollView中使用Mask,滚动时内容闪烁或裁剪异常 | 可能是Mask节点与ScrollView的content节点在变换更新顺序上存在冲突。 | 1. 确保Mask节点是content的直接子节点,且结构清晰。2. 尝试调整节点或组件在Inspector中的 Priority(优先级)。3. 在极端情况下,可以考虑使用 RectMask代替GraphicsMask来实现滚动区域的裁剪。 |
| Web/Mobile平台效果不一致 | 不同平台对图形API(WebGL, OpenGL ES)的实现有细微差异,特别是Graphics的填充规则。 | 1. 尽量使用简单的图形(矩形、圆)。 2. 避免使用过于复杂的 Graphics路径。3. 在所有目标平台进行测试。 |
4.4 进阶思路:非交互式动态遮罩
Mask组件不仅能用于交互,还能用于创造纯视觉的动态效果。例如,实现一个波浪形的进度条:
- 创建一个使用
GRAPHICS类型Mask的节点,其子节点是一个填充了颜色的长条形Sprite(进度条底色)。 - 编写脚本,在
update中,使用Graphics的bezierCurveTo或一系列lineTo绘制一条正弦曲线路径。 - 将这条路径填充。由于Mask的作用,只有曲线下方的条形Sprite部分会显示出来。
- 通过随时间偏移正弦曲线的相位,就能产生波浪上升的动画效果。这里的关键是控制重绘频率,可以每几帧重绘一次,而不是每帧都重绘,以平衡效果和性能。
Mask组件是Cocos Creator中一个强大而灵活的“空间裁剪”工具。理解其“模板测试”的底层原理,能帮助你更准确地预测其行为。从简单的头像裁剪到复杂的刮刮卡交互,其核心都在于对“可见区域”的定义与控制。在实际项目中,多思考是否真的需要Mask,以及如何以最节省性能的方式使用它,是成为一名优秀Cocos开发者的必经之路。