项目标题是「Unity UI擦除效果」,这本质上是在做「刮刮卡 / 手指擦除遮罩」这类交互。我会按从业者口吻写一篇完整博文,涵盖实现路线选型、坐标换算、双缓冲、线段距离场、Shader 编写、进度统计和踩坑表,主体保证在 5000 字以上。
直接从正文开始。
做 UI 擦除效果这件事,我第一次接到需求的时候以为两小时能收工,结果在坐标换算和一个 shader 采样陷阱上耗了整整两天。所谓 Unity UI擦除效果,说白了就是让用户用手指或者鼠标在 UI 上「擦」出痕迹——刮刮卡、擦除显示答案、擦开灰尘看底图、签字确认后的移除动效,本质上都是同一个东西。它的核心问题是:如何把一次连续的拖动轨迹,变成一张可以当作遮罩采样的图,再让被遮的内容按这张遮罩的 alpha 一点点露出来。
这篇内容面向三类人:刚接触 UGUI、想做一个刮刮卡小游戏的新手;接过类似需求但被性能或手感问题卡住的开发者;以及想把擦除组件沉淀成可复用模块、以后随手拖进项目就用的人。我会从方案选型讲到完整代码,把每一个参数背后的数学关系、每一处踩过的坑都摊开说。代码是能直接跑起来的,Shader 也是能直接复制粘贴的,中间那些「为什么这么写」的部分,才是我觉得最值钱的地方。
1. 先把「擦除」这件事拆开看
在动手写任何一行代码之前,得先想清楚一件事:擦除这个动作在计算机里到底对应什么。屏幕上什么都没有变,变的只是一张图里某些像素的透明度。用户手指划过的地方,遮罩的 alpha 从 1 变成 0,或者从 0 变成 1,被遮住的内容就跟着显现或者消失。所以擦除本质上不是「删除」,而是「记录一条轨迹,并把轨迹当作遮罩使用」。
想明白这一点,方案空间就清晰了。你需要一张可以被持续写入的纹理,这张纹理要能记住之前画过的所有笔迹,还要能被当成遮罩传给显示层。围绕这个核心,业界大概有三条路可走,每一条的代价完全不同。
1.1 三条实现路线,我先 pass 掉两条
第一条:顶点重建法。把要擦除的 Image 网格拆成几百上千个小三角形,用户擦到哪里,就把对应位置的三角形删掉或者把顶点 alpha 改成 0。这条路听起来很「原生」,因为它没用到任何 RT,纯 CPU 计算。但实际操作下来问题很多:网格密度不够,擦除边缘就是锯齿状的;要支持软边,顶点数量得翻好几倍;每次擦除都要重建 Mesh,CanvasRenderer.SetMesh的开销在低端机上肉眼可见;而且擦除区域一旦变复杂,顶点数会失控。我见过有人用这种方式做,最后在千元机上卡成 PPT。
第二条:CPU 像素擦除法。拿一张Texture2D,用户划到哪里,就在 CPU 上把对应圆形区域的像素 alpha 改成 0,然后SetPixels+Apply。这条路代码最短,逻辑最直白,我一开始选的就是它。但跑起来就发现不对:一张 512×512 的 RGBA 纹理是 1MB,Apply()意味着每帧要往 GPU 上传 1MB 数据,拖动时帧率直接从 60 掉到 20 多。就算你只更新脏区域,SetPixels32加Apply的调用本身也是一次完整的同步等待,手机上这个等待会被放大。这条路适合「点一下擦一个圆」的场景,不适合连续拖动。
第三条:RenderTexture + 自定义 Shader。所有绘制都在 GPU 上完成,CPU 每帧只负责算一个 UV 坐标然后调一次Graphics.Blit。笔刷形状、软边、羽化全部在 fragment shader 里算,想改成方形笔刷、星形笔刷、带纹理的笔刷都是一行代码的事。RT 本身有持久性,只要你不主动Clear,之前画的东西就一直留着,天然符合「记录轨迹」的需求。
还有一条不太上台面的路:用一堆小 Image 当「遮挡块」,擦一个删一个。这在某些非常简单的场景里能用,但手感极差,边缘是方块状的,我基本不考虑。
1.2 为什么最终锁定 RenderTexture 笔刷方案
选 RT 方案不只是因为性能好,更因为它把「擦除」这个问题抽象成了一次标准的离屏渲染。你可以用完全相同的思路去做画笔涂鸦、做地图迷雾探索、做路径拖尾,甚至做那种在雾面玻璃上写字的特效。这套思路的可迁移性是我最看重的。
具体到实现上,RT 方案有三个关键设计点必须提前定下来,否则后面会反复返工:
第一,遮罩图用单通道还是 RGBA。理论上擦除只需要一个 alpha 值,用RenderTextureFormat.R8能省 3/4 显存。但R8在部分移动端 GPU 上对Graphics.Blit的兼容性不如ARGB32,而且一旦你后期想在遮罩里塞别的信息(比如笔刷颜色、笔压强度),单通道就得改结构。我个人的选择是先用ARGB32跑通,等项目稳定、确认瓶颈在显存了再考虑降级。
第二,双缓冲还是单缓冲。Graphics.Blit的语义是「读源纹理、写目标纹理」,如果源和目标指向同一张 RT,在部分 Android 驱动上会出现未定义行为——有的机器上正常,有的机器上画出花屏。稳妥的做法是准备两张 RT,每次绘制时 A 读 B 写,画完交换引用,这个技巧叫 ping-pong。多占一张 RT 的显存,换来的是跨平台的一致性,非常划算。
第三,笔刷半径用什么单位。这是新手最容易绕晕的地方。用像素?那 UI 缩放后就变形了。用 UV 的 0 到 1?那遇到非正方形的 UI 就会变成椭圆。正确答案是:先把 UV 拉伸到一个「等比空间」再算距离,具体怎么拉伸,第 2 章会详细推。
提示:如果你的项目是纯 PC 或纯高端机型,单缓冲也不是不能用。但只要你的包要发安卓,就别省这一张 RT 的显存。
2. 核心链路:一张遮罩图到底存了什么
我把整个链路拆成三个环节:坐标换算、笔刷绘制、结果采样。这三个环节任何一环出问题,表现出来的现象都很像——「擦不准」「擦出来是歪的」「边缘有毛刺」——但根因完全不同。分清楚它们,排查效率会高一个数量级。
2.1 从屏幕坐标到 UV 的完整换算
用户在屏幕上点了一下,PointerEventData.position给你的是一个屏幕像素坐标,原点在左下角(注意,Unity 的屏幕坐标原点在左下,而很多 UI 系统的原点在左上,别搞混)。这个坐标要经过两次变换才能变成遮罩图上的 UV。
第一次变换:屏幕坐标 → RectTransform 的本地坐标。用RectTransformUtility.ScreenPointToLocalPointInRectangle,这个方法会帮你处理 Canvas 的渲染模式和缩放。这里有个坑我必须单独拎出来说:第三个参数cam的取值跟 Canvas 的渲染模式强相关。Screen Space - Overlay模式下必须传null;Screen Space - Camera和World Space模式下必须传canvas.worldCamera。传错了不会报错,但返回的坐标会是一个完全不着边的值,你会以为是 UV 算错了,其实是这一步就错了。
第二次变换:本地坐标 → UV。这一步很多人会写错 pivot 的偏移。正确的公式是用rect.xMin和rect.width,而不是直接用localPosition:
var r = _rect.rect; uv.x = (local.x - r.xMin) / r.width; uv.y = (local.y - r.yMin) / r.height;r.xMin是矩形左下角相对于 pivot 的偏移量。当 pivot 是 (0.5, 0.5) 时它是负的宽的一半,当 pivot 是 (0, 0) 时它是 0。用xMin做基准,无论 pivot 怎么设都能算对。用localPosition直接除宽度,只要 pivot 不是 (0,0) 就会整体偏移半个身位,表现就是「擦的位置和手指差一截」。
两次变换之后,你拿到的是 0 到 1 之间的 UV 坐标,原点在左下。这个坐标和RenderTexture的 UV 原点是一致的,也和RawImage默认的 UV 一致,所以不需要再做一次 Y 翻转。这一点很容易搞错,因为如果你把遮罩塞给Image而不是RawImage,或者用了uvRect做了翻转,那就需要额外处理。我的建议是擦除层统一用RawImage,uvRect保持默认的(0, 0, 1, 1),能省掉一整类问题。
2.2 笔刷绘制:多画几次 vs 线段距离场
拿到 UV 之后,直觉做法是:每次拖动事件触发时,在当前 UV 位置画一个圆。但这样会有一个非常明显的现象——手划得快的时候,擦出来的痕迹是一串断开的圆点,中间有空隙。
于是自然的改进是:在上一帧的 UV 和当前 UV 之间,按固定步长插值,补画若干个圆。这个方法能用,我早期版本就是这么写的。但它有两个缺点:一是补点的数量不好定,步长太密浪费 draw call,步长太疏还是有缝;二是每个圆都是一次Blit,一次拖动事件可能触发十几次Blit,手机上顶不住。
真正优雅的解法是在 shader 里算「点到线段的距离」,一次Blit就把整个滑动段画成一条胶囊形的笔迹。核心就是一个 SDF(有向距离场)函数:
float SegmentDistance(float2 p, float2 a, float2 b) { float2 pa = p - a; float2 ba = b - a; float h = saturate(dot(pa, ba) / max(dot(ba, ba), 1e-6)); return length(pa - ba * h); }h是点p在线段ab上的投影比例,夹在 0 到 1 之间就能保证投影点永远不会跑到线段外面。分母加1e-6是为了防止用户没动、a和b重合时的除零。这个函数算出来的距离,喂给smoothstep就得到了带软边的胶囊笔迹,一次绘制搞定,帧率稳得很。
我个人实测下来,线段版方案在快速滑动时的表现要比补点版好太多——不仅没有断点,而且笔迹粗细均匀,因为线段的距离场天然是等宽的。补点版在滑动快的时候会因为点的间距变大而出现「葫芦形」,也就是圆和圆相交处特别粗。
2.3 双缓冲与图像格式的取舍
前面提到过了 ping-pong,这里说点更细的。双缓冲的交换逻辑本身很简单,就是交换两个字段的引用,但有两个细节要注意。
第一个细节:交换之后,一定要把新的前台 RT 重新SetTexture给显示层材质。因为材质里缓存的是上一次的纹理引用,你不主动更新,显示层就会一直显示上一帧的结果,表现是「画了但看不见」。
第二个细节:RT 的wrapMode必须设成Clamp,不能是默认的Repeat。这一点在笔刷贴着 UI 边缘的时候会暴露——如果是 Repeat,笔刷在左边缘画的时候会「绕」到右边缘去,出现两边同时被擦掉一部分的诡异现象。这个 bug 我排查了一下午,因为现象完全不符合直觉。
至于格式,RenderTextureFormat.ARGB32是最稳的。我试过R8,在高端机上跑得好好的,换到一台老安卓上直接黑屏,因为该机型的驱动对这种格式的Blit支持不完整。如果你的项目必须省显存,可以做一个运行时检测,先尝试创建R8,创建失败或者渲染结果异常再退回ARGB32,但这个检测本身也要花时间,我一般不建议。
| 格式 | 显存(512×512) | 兼容性 | 建议 |
|---|---|---|---|
| ARGB32 | 约 1 MB | 全平台稳 | 首选 |
| R8 | 约 256 KB | 部分老机型异常 | 谨慎 |
| RHalf | 约 512 KB | 精度过剩 | 无必要 |
还有一点:RT 的antiAliasing要显式设成 1。默认值在某些情况下会跟随项目设置,开着 MSAA 的话 RT 也会带抗锯齿,白白多占显存,而擦除遮罩这种靠smoothstep做软边的内容,根本不需要 MSAA。
3. 上手实现:一个可复用的擦除组件
到这一章开始落代码。我把它拆成三块:UI 层级、绘制脚本、两个 Shader。你可以照抄,也可以按自己的习惯改结构,但坐标系和 ping-pong 这两处的逻辑建议原样保留,那是踩过坑的地方。
3.1 UI 层级与资源准备
层级结构我推荐这样搭:
Canvas └── ErasePanel (RectTransform, 尺寸随意) ├── ContentLayer (Image 或 RawImage,被遮住的内容) └── EraseLayer (RawImage,挂 UIErasePainter 脚本,显示遮罩)ContentLayer是擦除之后要露出来的东西,比如奖品图、答案文字。EraseLayer是盖在上面的那一层「刮刮涂层」,它的材质就是我们要写的显示 shader。两层的 RectTransform 尺寸和锚点必须完全一致,否则 UV 会对不上。
如果希望擦除过程中点击不能穿透到下层,EraseLayer的Raycast Target必须勾上。同时因为是IPointerDownHandler这类事件,脚本所在的物体必须有Graphic组件(RawImage就是),否则EventSystem根本不会把事件派发给它。这一点新手很容易忽略,表现是「脚本挂上了,但怎么点都没反应」。
资源准备:准备一张「涂层」贴图,就是用户初始看到的那层,比如灰色噪点、雾面质感、金属刮层。这张图会作为_MainTex传给显示 shader。至于遮罩 RT,不需要任何资源,运行时代码里创建。
3.2 绘制脚本:完整实现
using System; using UnityEngine; using UnityEngine.EventSystems; using UnityEngine.UI; [RequireComponent(typeof(RawImage))] public class UIErasePainter : MonoBehaviour, IPointerDownHandler, IDragHandler, IPointerUpHandler { [Header("笔刷")] [Tooltip("笔刷半径,相对 UI 高度的比例,0.05 约等于高度的 5%")] [Range(0.005f, 0.5f)] public float brushRadius = 0.08f; [Tooltip("软边强度,0 是硬边,1 是完全羽化")] [Range(0f, 0.95f)] public float brushHardness = 0.35f; [Header("遮罩纹理")] [Tooltip("遮罩分辨率,建议不低于显示尺寸的一半")] public int maskResolution = 512; [Header("完成判定")] [Range(0.1f, 1f)] public float completeThreshold = 0.92f; public float sampleInterval = 0.25f; public event Action<float> OnProgressChanged; public event Action OnCompleted; private RawImage _raw; private RectTransform _rect; private Canvas _canvas; private Camera _uiCamera; private RenderTexture _rtFront; private RenderTexture _rtBack; private RenderTexture _rtSample; private Texture2D _readTex; private Material _paintMat; private Material _displayMat; private Vector2 _prevUV; private bool _hasPrev; private bool _locked; private bool _completed; private float _sampleTimer; void Awake() { _raw = GetComponent<RawImage>(); _rect = GetComponent<RectTransform>(); _canvas = GetComponentInParent<Canvas>(); // Overlay 模式下 worldCamera 必须为 null if (_canvas != null && _canvas.renderMode != RenderMode.ScreenSpaceOverlay) _uiCamera = _canvas.worldCamera; } void OnEnable() { BuildRenderTextures(); BuildMaterials(); _locked = false; _completed = false; } void OnDisable() { ReleaseRenderTextures(); } void Update() { if (_locked || _completed) return; _sampleTimer += Time.unscaledDeltaTime; if (_sampleTimer >= sampleInterval) { _sampleTimer = 0f; EvaluateProgress(); } } private void BuildRenderTextures() { _rtFront = CreateRT(maskResolution); _rtBack = CreateRT(maskResolution); _rtSample = CreateRT(48); _readTex = new Texture2D(48, 48, TextureFormat.RGBA32, false); _readTex.filterMode = FilterMode.Point; } private RenderTexture CreateRT(int size) { var rt = new RenderTexture(size, size, 0, RenderTextureFormat.ARGB32); rt.wrapMode = TextureWrapMode.Clamp; rt.filterMode = FilterMode.Bilinear; rt.antiAliasing = 1; rt.Create(); ClearRT(rt); return rt; } private void ClearRT(RenderTexture rt) { var prev = RenderTexture.active; RenderTexture.active = rt; GL.Clear(true, true, Color.black); RenderTexture.active = prev; } private void BuildMaterials() { var paintShader = Shader.Find("Hidden/UIEraseBrush"); if (paintShader == null) { Debug.LogError("找不到 Hidden/UIEraseBrush,请确认 shader 已加入 Always Included Shaders"); return; } _paintMat = new Material(paintShader) { hideFlags = HideFlags.DontSave }; // 实例化一份显示材质,避免多个控件共享同一材质互相干扰 _displayMat = new Material(_raw.material) { hideFlags = HideFlags.DontSave }; _displayMat.SetTexture("_EraseMask", _rtFront); _raw.material = _displayMat; } private void ReleaseRenderTextures() { if (_rtFront != null) { _rtFront.Release(); Destroy(_rtFront); _rtFront = null; } if (_rtBack != null) { _rtBack.Release(); Destroy(_rtBack); _rtBack = null; } if (_rtSample != null) { _rtSample.Release(); Destroy(_rtSample); _rtSample = null; } if (_readTex != null) { Destroy(_readTex); _readTex = null; } } private bool ScreenToUV(Vector2 screenPos, out Vector2 uv) { uv = Vector2.zero; if (!RectTransformUtility.ScreenPointToLocalPointInRectangle( _rect, screenPos, _uiCamera, out var local)) return false; var r = _rect.rect; if (r.width <= 0f || r.height <= 0f) return false; uv.x = (local.x - r.xMin) / r.width; uv.y = (local.y - r.yMin) / r.height; return true; } private void Paint(Vector2 uvA, Vector2 uvB) { if (_paintMat == null) return; float aspect = _rect.rect.width / _rect.rect.height; _paintMat.SetVector("_BrushA", new Vector4(uvA.x, uvA.y, 0f, 0f)); _paintMat.SetVector("_BrushB", new Vector4(uvB.x, uvB.y, 0f, 0f)); _paintMat.SetVector("_BrushParams", new Vector4(brushRadius, brushHardness, aspect, 0f)); Graphics.Blit(_rtFront, _rtBack, _paintMat); SwapBuffers(); } private void SwapBuffers() { var tmp = _rtFront; _rtFront = _rtBack; _rtBack = tmp; if (_displayMat != null) _displayMat.SetTexture("_EraseMask", _rtFront); } public void OnPointerDown(PointerEventData eventData) { if (_locked || _completed) return; if (!ScreenToUV(eventData.position, out var uv)) return; _prevUV = uv; _hasPrev = true; Paint(uv, uv); } public void OnDrag(PointerEventData eventData) { if (_locked || _completed) return; if (!ScreenToUV(eventData.position, out var uv)) return; if (!_hasPrev) { _prevUV = uv; _hasPrev = true; } Paint(_prevUV, uv); _prevUV = uv; } public void OnPointerUp(PointerEventData eventData) { _hasPrev = false; EvaluateProgress(); } private void EvaluateProgress() { if (_rtFront == null || _rtSample == null) return; Graphics.Blit(_rtFront, _rtSample); var prev = RenderTexture.active; RenderTexture.active = _rtSample; _readTex.ReadPixels(new Rect(0, 0, 48, 48), 0, 0, false); _readTex.Apply(false, false); RenderTexture.active = prev; var pixels = _readTex.GetPixels32(); int hit = 0; for (int i = 0; i < pixels.Length; i++) { if (pixels[i].r > 128) hit++; } float ratio = (float)hit / pixels.Length; OnProgressChanged?.Invoke(ratio); if (!_completed && ratio >= completeThreshold) { _completed = true; OnCompleted?.Invoke(); } } public void ResetMask() { if (_rtFront == null) return; ClearRT(_rtFront); ClearRT(_rtBack); _locked = false; _completed = false; _hasPrev = false; if (_displayMat != null) _displayMat.SetTexture("_EraseMask", _rtFront); OnProgressChanged?.Invoke(0f); } public void SetLocked(bool locked) => _locked = locked; }这段代码里我特意把_locked和_completed分开。_locked是外部主动锁(比如弹了个对话框,不希望用户继续擦),_completed是达到阈值后的自动锁。两者语义不同,混用会导致重置逻辑出错。
3.3 两个 Shader:画笔与显示
画笔 shader 只做一件事:读上一张遮罩,在指定线段上盖一层笔迹,输出新遮罩。
Shader "Hidden/UIEraseBrush" { Properties { _MainTex ("Source", 2D) = "black" {} } SubShader { Cull Off ZWrite Off ZTest Always Blend Off Pass { CGPROGRAM #pragma vertex vert_img #pragma fragment frag #include "UnityCG.cginc" sampler2D _MainTex; float4 _BrushA; // xy = 起点 UV float4 _BrushB; // xy = 终点 UV float4 _BrushParams; // x = 半径, y = 软边, z = 宽高比 float SegmentDistance(float2 p, float2 a, float2 b) { float2 pa = p - a; float2 ba = b - a; float h = saturate(dot(pa, ba) / max(dot(ba, ba), 1e-6)); return length(pa - ba * h); } fixed4 frag (v2f_img i) : SV_Target { fixed4 src = tex2D(_MainTex, i.uv); // 把 UV 拉伸到等比空间,避免非方形 UI 上笔刷变椭圆 float2 scale = float2(_BrushParams.z, 1.0); float2 p = i.uv * scale; float2 a = _BrushA.xy * scale; float2 b = _BrushB.xy * scale; float d = SegmentDistance(p, a, b); float r = _BrushParams.x; float soft = max(_BrushParams.y, 0.0001); float inner = r * (1.0 - soft); float brush = 1.0 - smoothstep(inner, r, d); float v = max(src.r, brush); return float4(v, v, v, 1.0); } ENDCG } } }这里的max(src.r, brush)是关键:它保证笔迹是「叠加」而不是「覆盖」。如果写成return brush,那每次绘制都会把之前画的全抹掉,只有最后一笔能留下。
显示 shader 负责把遮罩采样出来,做 alpha 裁剪,顺便加一圈高光边:
Shader "UI/EraseReveal" { Properties { [PerRendererData] _MainTex ("涂层贴图", 2D) = "white" {} _EraseMask ("擦除遮罩", 2D) = "black" {} _Invert ("反向(擦掉消失)", Range(0, 1)) = 0 _EdgeWidth ("边缘宽度", Range(0, 0.25)) = 0.06 _EdgeColor ("边缘颜色", Color) = (1, 0.86, 0.45, 1) _EdgeIntensity ("边缘强度", Range(0, 2)) = 0.8 _StencilComp ("Stencil Comparison", Float) = 8 _Stencil ("Stencil ID", Float) = 0 _StencilOp ("Stencil Operation", Float) = 0 _StencilWriteMask ("Stencil Write Mask", Float) = 255 _StencilReadMask ("Stencil Read Mask", Float) = 255 _ColorMask ("Color Mask", Float) = 15 } SubShader { Tags { "Queue" = "Transparent" "IgnoreProjector" = "True" "RenderType" = "Transparent" "PreviewType" = "Plane" "CanUseSpriteAtlas" = "True" } Cull Off Lighting Off ZWrite Off ZTest [unity_GUIZTestMode] Blend SrcAlpha OneMinusSrcAlpha ColorMask [_ColorMask] Stencil { Ref [_Stencil] Comp [_StencilComp] Pass [_StencilOp] ReadMask [_StencilReadMask] WriteMask [_StencilWriteMask] } Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #pragma multi_compile_local _ UNITY_UI_CLIP_RECT #pragma multi_compile_local _ UNITY_UI_ALPHACLIP #include "UnityCG.cginc" struct appdata_t { float4 vertex : POSITION; float4 color : COLOR; float2 texcoord : TEXCOORD0; }; struct v2f { float4 vertex : SV_POSITION; fixed4 color : COLOR; float2 texcoord : TEXCOORD0; float4 worldPos : TEXCOORD1; }; sampler2D _MainTex; sampler2D _EraseMask; float _Invert; float _EdgeWidth; fixed4 _EdgeColor; float _EdgeIntensity; float4 _ClipRect; v2f vert (appdata_t v) { v2f o; o.worldPos = v.vertex; o.vertex = UnityObjectToClipPos(v.vertex); o.texcoord = v.texcoord; o.color = v.color; return o; } fixed4 frag (v2f i) : SV_Target { fixed4 baseCol = tex2D(_MainTex, i.texcoord) * i.color; float mask = tex2D(_EraseMask, i.texcoord).r; float reveal = lerp(mask, 1.0 - mask, step(0.5, _Invert)); // 在 mask 从 0 到 1 的过渡带里取最大值,形成一圈描边 float edge = saturate(mask * (1.0 - mask) * 4.0); edge *= smoothstep(1.0 - _EdgeWidth, 1.0, mask + _EdgeWidth); fixed3 rgb = baseCol.rgb + _EdgeColor.rgb * edge * _EdgeIntensity; float alpha = baseCol.a * reveal; #ifdef UNITY_UI_CLIP_RECT alpha *= UnityGet2DClipping(i.worldPos.xy, _ClipRect); #endif #ifdef UNITY_UI_ALPHACLIP clip(alpha - 0.001); #endif return fixed4(rgb, alpha); } ENDCG } } }这个 shader 里我加了UNITY_UI_CLIP_RECT和 Stencil 相关属性,是因为擦除层很可能被放进RectMask2D或者Mask里。如果不加这两段,一旦你的 Panel 外面套了个滚动列表或者圆角遮罩,擦除层就会被裁得莫名其妙。这是 UGUI 自定义 shader 的通病,照抄 UGUI 自带的UI/Default就是最省事的做法。
_Invert参数用来切换两种语义:0 表示「擦开显示」(刮刮卡),1 表示「擦掉消失」(擦除确认)。用step(0.5, _Invert)而不是直接乘,是为了让这个参数在 Inspector 上表现成布尔开关的样子,避免用户填 0.3 这种中间值导致效果不明确。
4. 手感调优:参数背后的数学关系
代码能跑起来只是起点,真正决定这个效果好不好用的是手感。而手感的调节不是玄学,四个参数之间存在明确的数学关系,理解了这个关系,你就不用瞎试。
4.1 分辨率、半径、软边三者不独立
先明确三个量的定义:maskResolution是遮罩 RT 的边长,brushRadius是笔刷半径占 UI 高度的比例(0 到 1 之间的小数),brushHardness是软边占比。
笔刷在 RT 上的实际像素半径等于brushRadius × maskResolution。假设maskResolution = 512,brushRadius = 0.08,那么笔刷像素半径就是 41 像素,直径 82 像素。这个数字必须大于某个下限,否则笔刷边缘会明显锯齿,因为smoothstep的过渡带只有几个像素,不足以做出平滑的羽化。
软边的过渡带宽度是brushRadius × brushHardness × maskResolution,单位是像素。还是上面的参数,brushHardness = 0.35,过渡带就是 14 像素左右。如果这个值小于 3 像素,视觉上就会看到硬边;大于 30 像素,笔迹会显得非常虚,像在雾里画的。所以调参的思路是:先定maskResolution(一般取 UI 显示像素尺寸的一半左右),再用brushRadius调笔迹粗细,最后用brushHardness微调边缘。
这里有个反直觉的地方:RT 分辨率不需要等于显示尺寸。因为遮罩是双线性采样的,512 的遮罩拉伸到 1024 的显示区域,边缘只是稍微柔一点,肉眼看不出区别。我实测过 512 和 1024 两档在同一块屏幕上的效果,除非你把手机怼到脸上,否则分辨不出来。而显存占用和每帧 Blit 的填充率开销差了 4 倍。
| 遮罩分辨率 | 显存 | 典型用途 | 笔刷半径建议 |
|---|---|---|---|
| 256 | 256 KB | 小尺寸控件(< 300px) | 0.05 - 0.12 |
| 512 | 1 MB | 全屏刮刮卡(主流选择) | 0.06 - 0.10 |
| 1024 | 4 MB | 大屏 / 平板高清 | 0.04 - 0.08 |
还有一个容易忽略的点:brushRadius用的是「占 UI 高度的比例」,不是宽度。这意味着如果 UI 是个长条(比如 800×200 的横条),笔刷的实际视觉半径会显得偏大。这种情况下我建议改用短边作为基准,也就是Mathf.Min(rect.width, rect.height),代码里把aspect的计算也同步改一下。
4.2 边缘高光与擦除过渡
边缘高光是提升「质感」性价比最高的一个操作,加不加区别很大。用户擦的时候,如果边缘有一圈微弱的暖色描边,会觉得这个涂层是「有厚度」的,而不是一张纸。实现方式见上面 shader 里的edge计算。
这里我要说一下那个saturate(mask * (1.0 - mask) * 4.0)是怎么来的。这是一个开口向下的抛物线,在mask = 0.5时取到最大值 1,在mask = 0和1时都是 0。而mask在笔刷内部的取值是 1,在外部是 0,在边缘过渡带里从 1 平滑降到 0,所以「1 和 0 的交界处」正好是mask ≈ 0.5的位置,抛物线在这里取到峰值,就得到了一条宽度恒定的描边。乘 4 是为了把峰值归一化到 1。
第二条smoothstep(1.0 - _EdgeWidth, 1.0, mask + _EdgeWidth)是我后来加的补丁,用来压制笔刷内部的高光。因为不加的话,mask在从 1 开始衰减的初期,mask * (1-mask)就已经开始上升了,会在笔迹内侧形成一圈很宽的光晕。加上这条限制,高光就被收窄到真正靠近边界的那一带。
_EdgeIntensity建议值 0.5 到 1.0。超过 1.5 会过曝,特别是当_EdgeColor是白色的时候。我一般用暖黄,强度 0.8,配合灰色涂层效果最自然。
4.3 手感细节清单
- 笔刷起点要用
PointerDown的位置,不能用PointerUp。这个听起来像废话,但我见过有人只在OnDrag里画,导致轻点一下没反应。 OnDrag的触发频率和帧率相关,60 帧的机器上每帧最多一次,120 帧的机器上翻倍。所以绘制逻辑必须能接受「两点之间距离任意远」这个前提,线段 SDF 天然满足。brushRadius别设太小。小于 0.03 的时候,即使 RT 是 1024,笔迹也会有点发虚,因为双线性采样在小范围内插值不够。- 软边不要让
brushHardness接近 0。设成 0 的时候smoothstep的上下界重合,会产生NaN或者硬切边,我在 shader 里用max(_BrushParams.y, 0.0001)兜了底,但你最好还是别去试探这个边界。 - 拖动过程中如果 UI 有缩放动画,UV 会算错。因为
_rect.rect在动画期间是变化的。要在动画期间锁定擦除输入,或者把 UV 换算放到动画结束之后再算。
注意:
brushRadius是相对值,maskResolution变化时笔刷的视觉大小不变,但精度会变。如果你在运行时动态改了