刮刮乐这种交互形式,玩家手指在屏幕上蹭两下,涂层被抹开,底下露出奖品或者图案,那种即时反馈带来的爽感是很多重度玩法替代不了的。我在几个休闲项目和活动运营页里都做过类似效果,早期用自己写的 Mask 加 RenderTexture 硬啃,后来接触到现成的 Unity 刮刮乐插件,才发现很多坑其实早有人踩过并封装好了。这篇就围绕 Unity 刮刮乐插件(Scratch Card)这个主题,把它的核心原理、插件结构、参数调优、实战接入流程,以及我实际踩过的那些坑,完整地拆一遍。不管你是刚接触 Unity 不久、想给项目加个轻量互动玩法的新手,还是接过运营活动需求、需要快速交付刮奖模块的熟手,下面这些内容应该都能直接拿去用。我会尽量把“为什么这么设计”讲透,而不是只丢一堆 API 让你自己猜。
1. 刮刮乐玩法的整体设计与技术选型思路
1.1 这个玩法到底在解决什么问题
先想清楚一件事:刮刮乐在游戏或应用里,本质上不是一个“游戏机制”,而是一个反馈包装层。它把“抽奖结果”这个冷冰冰的判定,包装成“你自己刮出来的”这个过程。用户以为自己靠手指刮开了涂层,实际上奖品在刮之前就已经定好了。理解这一点非常关键,因为它决定了技术实现的重心——刮开过程要足够顺滑、真实、有手感,而结果判定要和刮开进度解耦。
所以一个合格的刮刮乐插件,必须同时处理好两件事:一是视觉上的涂层擦除,让用户看到涂层被一点点刮掉;二是逻辑上的进度统计,判断刮开了多少比例、什么时候算“刮完”、什么时候触发开奖回调。很多新手一上来只盯着“怎么把图擦掉”,结果做完发现没法判断刮开面积,或者刮到 90% 就卡住不触发,这就是把两件事混在一起了。
从技术选型角度看,市面上常见的实现路径大概有三类,我做个对照表,方便你判断自己该用哪种:
| 实现方式 | 核心原理 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 自定义 Mask + RenderTexture | 用一张遮罩图记录擦除轨迹,实时渲染到目标图 | 完全可控,效果细腻 | 实现复杂,性能开销大,跨平台适配麻烦 | 对效果有极致要求的大项目 |
| Shader 顶点/片元擦除 | 通过传入笔刷位置在片元着色器里做透明度混合 | 性能好,无额外纹理开销 | 需要写 Shader,进度统计要额外做 | 有一定图形基础的团队 |
| 现成插件(贴图读写型) | 直接对 Texture2D 做像素级 Alpha 写入 | 上手快,逻辑清晰,进度统计天然支持 | 高频写入有性能压力,需要节流 | 活动页、休闲玩法、快速交付 |
我个人的经验是:绝大多数刮刮乐需求,用现成插件里的贴图读写方案就够了。原因很实在——刮刮乐通常出现在活动弹窗、奖励领取这种低频、短时的场景里,用户刮个十几秒就结束了,性能压力远没有想象中大。真正需要上 Shader 方案的,是那种把刮刮乐做成核心玩法、要长时间反复刮的项目,这种其实很少见。
1.2 为什么插件普遍选择“贴图读写”这条路
你去看主流刮刮乐插件的源码,会发现它们大多走的是Texture2D.SetPixels或GetPixel/SetPixel这条路。具体做法是:准备一张和涂层区域等大的可读写纹理,初始全不透明(或者全白代表“未刮”),用户手指划过时,把笔刷范围内对应像素的 Alpha 设为 0,然后Apply()提交。渲染层用一个 RawImage 或 SpriteRenderer 显示这张纹理,涂层就一点点消失了。
这条路被广泛采用,理由有这么几条。第一,进度统计天然成立。既然你已经在逐像素改 Alpha,那顺手统计一下“Alpha 为 0 的像素占比”就能得到刮开比例,不需要额外做射线检测或者面积估算。第二,跨平台一致性好。像素操作是 CPU 层面的,不依赖具体图形 API,安卓、iOS、PC 表现基本一致,不像某些 Shader 方案在不同机型上会有精度差异。第三,实现门槛低。不需要懂图形学,会读写纹理数组就能改,团队里任何人接手都能看懂。
当然它也有代价。SetPixels是相对重的操作,如果每帧对整张大图调用,帧率会明显掉。所以插件里通常会有两个优化:一是只更新笔刷覆盖的那一小块区域,用SetPixels32配合局部坐标;二是节流,不是每次触摸移动都提交,而是攒一小段距离或者隔几帧提交一次。这两点在后面讲参数调优时我会展开。
1.3 插件结构拆解:一个刮刮乐组件通常包含什么
拿到一个刮刮乐插件,别急着往场景里拖,先花十分钟把它的组成看清楚。典型的插件会拆成这么几块:
- 涂层渲染层:一个挂载了 RawImage 或 SpriteRenderer 的物体,负责把可读写纹理显示出来。它通常还带一个遮罩(Mask 或 RectMask2D),保证刮开区域不超出卡片边界。
- 纹理数据层:核心的
Texture2D对象,标记为可读写(Read/Write Enabled),保存当前涂层的像素状态。 - 输入处理层:把屏幕触摸或鼠标位置转换成纹理坐标,再映射到笔刷中心点。这一层要处理坐标翻转问题,UI 坐标和纹理坐标的 Y 轴方向经常是反的,这是新手最容易翻车的地方。
- 笔刷系统:定义笔刷的形状(圆形、方形)、半径、硬度(边缘羽化程度)。圆形笔刷最常用,因为手指刮出来的痕迹本来就是圆的。
- 进度与回调层:统计刮开比例,达到阈值时触发
OnScratchComplete之类的回调,同时可能提供OnScratchProgress的实时进度事件。 - 重置与初始化接口:提供
ResetCard()之类的方法,方便同一张卡片复用,比如用户刮完一张后点“再来一次”。
理解了这个分层,你在接入时就知道该改哪里、不该动哪里。比如想换涂层图案,只动纹理数据层;想改手感,只调笔刷系统;想加音效,挂在进度回调上就行。这种“各管一摊”的结构,是判断一个插件是否成熟的重要标志。
2. 核心细节解析:涂层擦除、笔刷与进度统计
2.1 涂层擦除的像素级原理,别被“擦除”两个字骗了
很多人以为刮刮乐是“把涂层擦掉”,其实从像素角度看,根本没有“擦掉”这个动作,只有“把某个像素的 Alpha 改成 0”。涂层纹理本身一直存在,只是某些像素变透明了,底下的奖品图就透出来了。这个认知很重要,因为它解释了为什么刮刮乐的层级结构通常是“底层奖品图 + 上层涂层图”,而不是“一张图被挖了洞”。
具体到代码层面,一次笔刷落点大概做这么几件事。先根据触摸点算出笔刷在纹理上的中心坐标(cx, cy),然后遍历笔刷半径r范围内的所有像素,对每个像素判断它到中心的距离d,如果d <= r,就把这个像素的 Alpha 设为 0(或者按硬度做渐变)。伪代码大概长这样:
for (int y = cy - r; y <= cy + r; y++) { for (int x = cx - r; x <= cx + r; x++) { if (x < 0 || x >= texWidth || y < 0 || y >= texHeight) continue; float dist = Mathf.Sqrt((x - cx) * (x - cx) + (y - cy) * (y - cy)); if (dist <= r) { Color32 c = pixels[y * texWidth + x]; c.a = 0; pixels[y * texWidth + x] = c; } } }这里有个细节值得说:边界判断不能省。手指划到卡片边缘时,笔刷中心可能已经超出纹理范围,如果不做x/y的越界检查,pixels数组会直接抛越界异常。我见过不止一个项目因为这个问题在真机上闪退,编辑器里却复现不了,因为编辑器里鼠标很难精确划到边缘外。
另一个细节是硬度(Hardness)。纯圆形笔刷刮出来的边缘是硬邦邦的,像用橡皮擦擦出来的,不够自然。成熟插件会引入硬度参数,让笔刷边缘的 Alpha 从中心到边缘做渐变,模拟真实手指按压的柔和感。实现上就是把dist <= r换成按dist/r的比例做插值,越靠边缘 Alpha 衰减越多。这个参数调好了,手感提升非常明显。
2.2 笔刷半径与纹理分辨率的匹配关系
笔刷半径这个参数,新手最容易拍脑袋填。填小了刮起来费劲,填大了两下就刮完了没意思。但更隐蔽的问题是:笔刷半径的单位是像素,而像素的物理大小取决于纹理分辨率。同样填 30 像素的半径,在 512×512 的纹理上显得很小,在 2048×2048 的纹理上就显得很大。
所以正确的做法是先定纹理分辨率,再反推笔刷半径。我一般用这个思路:假设卡片在屏幕上的显示尺寸是W×H(单位是屏幕像素),纹理分辨率是TW×TH,那么纹理像素和屏幕像素的比例是TW/W。如果我希望笔刷在屏幕上看起来半径是R个屏幕像素,那纹理上的半径应该是R × TW/W。
举个例子,卡片显示尺寸 400×400,纹理 1024×1024,比例是 2.56。我希望笔刷在屏幕上半径约 25 像素(差不多是手指肚的大小),那纹理半径就是25 × 2.56 ≈ 64像素。这个换算看着简单,但不做的话,换个分辨率纹理手感就全变了,调参调到怀疑人生。
提示:纹理分辨率不是越高越好。1024×1024 对绝大多数刮刮乐场景已经足够,2048 以上除了增加内存和
SetPixels开销,肉眼几乎看不出差别。移动端尤其要克制。
2.3 进度统计的两种口径,选错了会出 bug
进度统计听起来简单,但口径选错会直接导致“刮完了不触发回调”或者“没刮完就提前开奖”。常见的有两种口径:
第一种是按像素统计,遍历整张纹理,数 Alpha 为 0 的像素占总像素的比例。这个口径最准确,但每次统计都要遍历全图,开销大,所以通常不是每帧算,而是隔一段时间算一次,或者用增量方式维护一个计数器。
第二种是按网格统计,把纹理划分成比如 20×20 的网格,每个格子记录“是否被刮过”,进度就是已刮格子数除以总格子数。这个口径开销小,但精度粗,可能出现“视觉上刮得差不多了,但某些格子还差一点点没碰到”的情况。
我一般推荐像素口径 + 增量维护的组合。具体做法是:维护一个scratchedCount计数器,每次笔刷把某个像素从“不透明”改成“透明”时,计数器加一(注意要判断它之前是不是已经不透明,避免重复计数)。进度就是scratchedCount / totalPixels。这样统计是 O(1) 的,不需要遍历全图。
这里有个坑:重复计数。如果笔刷在同一片区域反复刮,每次都会把已经是 0 的像素再设成 0,如果不判断“之前是否已经透明”,计数器会虚高,导致进度提前到 100%。所以每次改像素前要先读一下原值,只有原值 Alpha 大于阈值时才计数。这个细节插件里一般处理了,但如果你自己改代码,务必留意。
2.4 完成阈值的设定,为什么不是 100%
几乎所有刮刮乐插件都允许你设置一个“完成阈值”,比如 0.7 或 0.8,意思是刮开 70%~80% 就算完成,而不是等到 100%。这个设计非常必要,原因有两个。
一是边缘死角。卡片四个角、边缘这些地方,用户的手指很难精确刮到,如果非要 100%,用户会觉得“明明刮干净了怎么还不开奖”,体验很差。二是性能与体验的平衡。让用户把最后那点边角料也刮干净,纯属折磨,阈值设在 0.7~0.8,用户刮得差不多就自动开奖,节奏更舒服。
阈值具体设多少,我的经验是:卡片越大、涂层越厚,阈值可以越低。小卡片(比如 300×300)设 0.8,大卡片(比如全屏)设 0.6~0.7 都合理。另外可以配合一个“自动完成”逻辑:当进度超过阈值时,不是立刻开奖,而是播放一个“剩余涂层快速淡出”的动画,视觉上更完整,用户也不会觉得突兀。
3. 实战接入:从零把插件跑起来的完整流程
3.1 环境准备与插件导入
假设你拿到的是一个典型的 Unity 刮刮乐插件包,通常是一个.unitypackage或者一个包含 Scripts、Prefabs、Textures 的文件夹。导入流程没什么特别的,但有几个点要提前确认。
先看 Unity 版本兼容性。大多数刮刮乐插件对版本要求不高,2019 之后的 LTS 基本都能跑,但如果插件里用了 URP 相关的 Shader,而你的项目是内置管线,就可能出现材质显示异常。导入前扫一眼插件的 Shader 文件,确认渲染管线匹配。
然后是纹理的可读写设置。这是最容易忽略的一步。插件用到的涂层纹理,必须在导入设置里勾选Read/Write Enabled,否则运行时GetPixels会直接报错。如果是运行时动态创建的Texture2D,构造时要把TextureFormat设成带 Alpha 的格式(比如RGBA32),并且最后一个参数mipChain传false,避免多级纹理带来的额外开销。
Texture2D coating = new Texture2D(width, height, TextureFormat.RGBA32, false); coating.wrapMode = TextureWrapMode.Clamp; coating.filterMode = FilterMode.Bilinear;wrapMode设成Clamp是为了防止纹理边缘采样时出现重复拉伸,filterMode用Bilinear让涂层显示更平滑。这两个设置虽小,但对最终观感有影响。
3.2 场景搭建:层级与遮罩的正确姿势
场景搭建这块,我见过太多人把层级搞乱导致刮开区域溢出卡片。正确的结构应该是这样的:
Canvas └── ScratchCardRoot (RectTransform, 卡片尺寸) ├── PrizeImage (RawImage/Sprite, 底层奖品图) └── CoatingLayer (RawImage, 涂层) └── (可选) Mask 组件关键点在于涂层必须被限制在卡片范围内。有两种做法:一是给涂层父节点加RectMask2D,二是给涂层本身加Mask组件配合一张形状图。RectMask2D性能更好,适合矩形卡片;如果卡片是不规则形状(比如圆形、异形),就得用Mask加形状图。
还有一个容易忽略的点:涂层的 RawImage 要设置正确的 UV Rect。如果纹理和显示区域的宽高比不一致,涂层会被拉伸,刮开的手感也会跟着变形。稳妥的做法是让纹理分辨率的宽高比和卡片显示尺寸的宽高比保持一致,或者用AspectRatioFitter组件强制约束。
3.3 输入坐标到纹理坐标的映射,翻车重灾区
这一节我要重点讲,因为坐标映射是刮刮乐接入过程中翻车率最高的环节。问题出在坐标系不一致:屏幕/UI 坐标原点在左下或左上(取决于平台和 Canvas 设置),纹理坐标原点在左下,但Texture2D的像素数组是从左下往右上排的,而RawImage的 UV 又是从下往上。这几套坐标系混在一起,稍不注意就刮反了。
稳妥的映射流程是这样的。先用RectTransformUtility.ScreenPointToLocalPointInRectangle把屏幕点转成卡片本地坐标,再根据卡片尺寸归一化到 0~1,最后乘以纹理分辨率得到像素坐标:
Vector2 localPoint; RectTransformUtility.ScreenPointToLocalPointInRectangle( cardRect, screenPos, eventCamera, out localPoint); // 归一化到 0~1 float u = (localPoint.x + cardRect.rect.width * 0.5f) / cardRect.rect.width; float v = (localPoint.y + cardRect.rect.height * 0.5f) / cardRect.rect.height; // 转像素坐标 int px = Mathf.RoundToInt(u * texWidth); int py = Mathf.RoundToInt(v * texHeight);这里v的方向要和纹理数组的行方向对齐。如果发现刮出来的位置上下颠倒,把py换成texHeight - py试试。这个“颠倒”问题几乎每个新手都会遇到一次,别慌,就是坐标系方向的事。
注意:如果卡片在 Canvas 里被旋转或缩放过,
ScreenPointToLocalPointInRectangle依然能正确处理,但如果你手动用InverseTransformPoint之类的做转换,就要自己考虑旋转缩放,容易出错。优先用官方提供的 RectTransformUtility 方法。
3.4 笔刷落点与连续刮擦的处理
单点刮擦好办,难的是连续滑动。用户手指快速划过时,两次触摸采样点之间可能隔了很远,如果只在采样点画圆,刮出来的痕迹会是一串断开的圆点,像虚线一样,很难看。解决办法是在相邻两个采样点之间做插值补点,按固定间距(比如笔刷半径的一半)在两点连线上均匀撒点,每个点都画一次笔刷。
float distance = Vector2.Distance(lastPoint, currentPoint); int steps = Mathf.CeilToInt(distance / (brushRadius * 0.5f)); for (int i = 1; i <= steps; i++) { Vector2 p = Vector2.Lerp(lastPoint, currentPoint, (float)i / steps); PaintBrush(p); }这个补点逻辑是刮刮乐手感的关键。间距太大痕迹会断,间距太小又浪费性能。我实测下来,间距取笔刷半径的 0.3~0.5 倍比较合适,既连贯又不至于点太密。另外,补点只在同一笔(手指没抬起)内做,手指抬起后要重置lastPoint,否则下一笔会从上一笔的终点连一条线过来,出现莫名其妙的划痕。
3.5 性能节流:什么时候 Apply,什么时候不 Apply
Texture2D.Apply()是把 CPU 端改动的像素上传到 GPU 的操作,开销不小。如果每次触摸移动都调用,在低端机上帧率会明显下降。所以必须做节流。
常见的节流策略有两种。第一种是按距离节流:累积触摸移动距离,超过某个阈值(比如 5 个屏幕像素)才提交一次。第二种是按帧节流:每隔 N 帧提交一次,比如每 2~3 帧。我一般用距离节流,因为它和用户操作直接相关,手感更可控。
accumulatedDistance += Vector2.Distance(lastPoint, currentPoint); if (accumulatedDistance >= applyThreshold) { coating.Apply(false); // false 表示不重新生成 mipmap accumulatedDistance = 0; }Apply(false)这个参数别传错,传true会重新生成 mipmap,开销翻倍,而刮刮乐根本用不到 mipmap。另外,手指抬起时(OnPointerUp)要强制Apply一次,保证最后一段改动不丢失。
4. 常见问题排查与实战避坑实录
4.1 刮不动、刮反了、刮出界:三大高频问题速查
我把实际项目里遇到最多的问题整理成一张表,方便你对照排查:
| 现象 | 可能原因 | 排查方向 | 解决办法 |
|---|---|---|---|
| 完全刮不动 | 纹理未开 Read/Write;输入事件没接上 | 看 Console 有无报错;打日志确认触摸回调是否触发 | 勾选纹理 Read/Write;检查 EventSystem 和射线检测 |
| 刮出来的位置和手指对不上 | 坐标映射方向错;Canvas 缩放未考虑 | 打印 localPoint 和像素坐标对比 | 修正 Y 轴方向;用 RectTransformUtility 做转换 |
| 刮开区域溢出卡片 | 缺少遮罩;遮罩层级不对 | 检查涂层父节点有无 Mask/RectMask2D | 加 RectMask2D;确认遮罩在涂层之上 |
| 刮完不触发回调 | 进度统计重复计数或阈值设成 1.0 | 打印实时进度值 | 修正计数逻辑;阈值降到 0.7~0.8 |
| 低端机卡顿 | 每帧 Apply;笔刷半径过大 | Profiler 看 Apply 耗时 | 加节流;减小笔刷半径或纹理分辨率 |
这张表里,“刮不动”和“刮反了”占了新手问题的八成。前者基本是纹理没开可读写或者事件没接上,后者基本是坐标方向问题。把这两个先排除,剩下的都好办。
4.2 真机与编辑器表现不一致怎么办
编辑器里刮得好好的,打包到真机上就出问题,这种情况我遇到过好几次。最常见的原因是触摸坐标和鼠标坐标的差异。编辑器里用鼠标模拟,Input.mousePosition的坐标系和真机触摸的Input.GetTouch(0).position在多数情况下一致,但如果你的 Canvas 用了Screen Space - Camera模式,相机参数不同就会导致映射偏差。
另一个原因是纹理格式在真机上的压缩。编辑器里纹理可能是未压缩的 RGBA32,打包时如果被压成了 ETC2 之类的格式,Alpha 通道的精度会下降,导致刮开边缘出现锯齿或者半透明残留。解决办法是在纹理导入设置里,把涂层纹理的压缩格式强制设为RGBA32或者不压缩,虽然会多占点内存,但刮开效果稳定。
还有一个隐蔽的坑:多分辨率适配。真机屏幕比例和编辑器 Game 视图不一致时,卡片的实际显示尺寸变了,但笔刷半径是按固定像素算的,手感就变了。所以笔刷半径最好在运行时根据卡片实际尺寸动态计算,而不是写死一个值。
4.3 刮开后的收尾:动画、音效与状态复位
刮到阈值触发回调后,别急着直接显示奖品,加一点收尾处理体验会好很多。我的常规做法是:触发完成回调时,先播放一个“剩余涂层快速淡出”的动画(0.2~0.3 秒内把涂层整体 Alpha 降到 0),同时播放一个“叮”的开奖音效,然后再弹出奖品展示。这个短暂的过渡能掩盖“突然出现”的突兀感。
状态复位这块,如果卡片要复用(比如用户点“再刮一张”),必须把纹理恢复到初始状态。最直接的办法是重新SetPixels一遍初始数据再Apply,但要注意同时把scratchedCount归零、lastPoint清空、完成标记重置。我见过有项目复用时忘了重置计数器,结果第二张卡一刮就瞬间完成,查了半天才发现是计数器没清零。
public void ResetCard() { coating.SetPixels32(initialPixels); coating.Apply(false); scratchedCount = 0; lastPoint = Vector2.zero; isCompleted = false; hasPointer = false; }initialPixels是初始化时缓存的一份原始像素数据,复位时直接拷回去,比重新生成纹理快得多。
4.4 几个只有踩过才知道的实操心得
最后分享几个文档里不会写、但实际很影响体验的点。
第一,笔刷不要用纯圆形硬边。前面提过硬度和羽化,这里再强调一次:边缘做 20%~30% 的羽化,刮出来的痕迹会自然很多,像真的用手指蹭掉的。纯硬边看起来像用橡皮图章盖的,很假。
第二,涂层图案可以加点纹理。纯色涂层刮起来缺乏层次感,如果在涂层上叠一层细微的噪点或者金属质感纹理,刮开时的视觉反馈会丰富很多。这个成本很低,一张小纹理叠上去就行。
第三,进度回调别每帧都发。如果OnScratchProgress每帧都触发,订阅方(比如更新进度条 UI)每帧都要处理,浪费性能。改成进度变化超过 1% 才发一次,或者按固定间隔发,UI 更新完全够用。
第四,考虑“刮开提示”。有些用户不知道要刮,可以在涂层上叠一个半透明的“手指滑动”提示动画,几秒后自动消失。这个小细节能显著提升首次使用的完成率。
第五,测试时一定要用真机、用不同尺寸的屏幕测。编辑器里鼠标刮和真机手指刮的手感差异很大,小屏和大屏的笔刷半径感受也不同。我一般至少在两种尺寸的真机上各刮十几次,确认阈值和笔刷都合适再交付。
这套东西跑通之后,刮刮乐其实可以玩出不少花样——比如多层涂层(刮开一层还有一层)、限定区域刮开(只有特定区域能刮)、刮开进度联动粒子特效等等。但核心的像素擦除、坐标映射、进度统计这三块地基打牢了,上面怎么加都是顺水推舟的事。我自己现在接这类需求,基本半天就能从零搭出一个可交付的版本,剩下的时间都花在调手感和适配不同屏幕上,而这部分恰恰是最需要真机反复试的,急不来。