Unity刮刮乐效果:ShaderGraph与RenderTexture动态遮罩实现

Unity刮刮乐效果:ShaderGraph与RenderTexture动态遮罩实现
1. 项目概述与核心价值最近在做一个互动营销类的小游戏里面需要一个类似刮刮乐彩票的交互效果。用户用手指或者鼠标在屏幕上涂抹就能刮开一层覆盖层露出下面的奖品信息。这个需求听起来简单但真要自己从头写Shader处理触摸交互和渲染状态没个大半天搞不定。后来我直接用Unity 2022.3内置的ShaderGraph和RenderTexture五分钟就搭出了原型效果还特别稳定。今天就把这个“快餐式”实现方案拆开揉碎了讲给你听不仅仅是贴代码更重要的是把每一步为什么这么做、可能会遇到什么坑都说明白。无论你是刚接触ShaderGraph的新手还是想快速实现类似效果的熟手这套流程都能直接拿去用。这个“刮刮乐”效果的核心本质上是一个动态的遮罩。我们需要一张初始为“不透明”的遮罩纹理当用户刮擦时实时地将刮擦区域的像素“擦除”设置为透明从而显示出底层的内容。Unity的ShaderGraph非常适合用来定义这个“擦除”的视觉规则而RenderTexture则是我们用来持久化记录“哪些区域已被刮开”的画布。整个流程清晰分为三步用ShaderGraph制作刮擦材质用C#脚本处理输入并更新RenderTexture最后将两者结合渲染到UI或模型上。下面我们就从最核心的ShaderGraph制作开始。2. ShaderGraph刮擦效果核心原理与制作2.1 效果原理与节点设计思路在动手连节点之前我们先搞清楚ShaderGraph在这里要解决什么问题。它需要根据一张“刮痕图”即我们的RenderTexture来决定当前像素是显示覆盖层还是底层内容。这张“刮痕图”的每个像素我们约定黑色RGB 0,0,0代表已被刮开透明白色RGB 1,1,1代表未被刮开不透明。因此ShaderGraph的逻辑非常简单采样刮痕图RenderTexture获取当前屏幕UV对应的刮痕信息。将刮痕信息转换为透明度通常我们使用刮痕图的R通道或计算其亮度Luminance作为透明度Alpha。白色值1- Alpha为1全遮盖黑色值0- Alpha为0全透明。混合两层颜色使用这个动态的Alpha值在覆盖层颜色和底层颜色之间进行线性插值Lerp。节点图的大致流程是UV-Sample Texture 2D(采样RenderTexture) -Split(取出R通道) -Lerp(混合底层和覆盖层)。底层和覆盖层可以是纯色、纹理或者来自另一张纹理采样。2.2 完整ShaderGraph创建与配置步骤打开Unity 2022.3在Project窗口右键 -Create - Shader Graph - URP - Lit Shader Graph如果你的项目是URP或Blank Shader Graph。这里我推荐使用Unlit Shader Graph因为刮刮乐通常用于UI或全屏效果不需要复杂的光照模型。创建主纹理属性首先我们需要一个Texture2D属性来接收作为“刮痕图”的RenderTexture。在Blackboard中创建属性命名为_ScratchTex类型为Texture2D。这将是我们的核心输入。创建底层与覆盖层属性再创建两个Color属性比如_BackgroundColor和_CoverColor分别代表刮开后的底色和覆盖层的颜色。你也可以使用Texture2D属性来让底层显示图片。构建节点网络添加一个Sample Texture 2D节点将_ScratchTex属性拖拽到其Texture输入端口。UV端口连接UV节点默认使用Tiling和Offset。添加一个Split节点将Sample Texture 2D节点的RGBA输出连接到Split的In端口。我们只需要一个通道通常用R。添加一个Lerp节点。A端口连接_BackgroundColorB端口连接_CoverColorT端口连接Split节点的R输出口。这里的关键逻辑是当T即刮痕图R通道值为1白色时输出B覆盖色完全遮盖当T为0黑色时输出A背景色完全显示。输出最终结果将Lerp节点的输出连接到Master Stack主堆栈的Color端口。对于Alpha通常可以直接连接Split节点的R输出或者根据需求进行反转用One Minus节点。如果你希望刮开的部分完全透明用于UI叠加可以将Alpha设置为Split节点的R输出这样白色遮盖处Alpha1黑色刮开处Alpha0。关键设置在Graph Inspector中确保Graph Settings里的Material的Surface Type设置为TransparentBlend Mode设置为Alpha。这样材质才能支持透明度混合。注意这里Lerp的T参数连接刮痕图的R值意味着刮痕图白色代表遮盖。有些教程可能会反着来用黑色代表遮盖这取决于你后续在C#中如何绘制刮痕。只要ShaderGraph中的逻辑和C#中的绘制逻辑对应上就行保持一致是关键。完成后的节点图应该非常简洁。将这个ShaderGraph保存比如命名为ScratchCardShader。然后基于它创建一个新的材质ScratchCardMaterial。3. RenderTexture的配置与核心作用解析3.1 为什么必须是RenderTexture你可能会问为什么不用普通的Texture2D因为刮刮乐的效果是持续且动态更新的。普通Texture2D在运行时修改其像素数据通过SetPixels性能开销较大且不便于每帧进行小范围的更新。而RenderTexture渲染纹理本质上是一块在GPU上的内存区域我们可以将它作为“画布”使用Graphics相关API进行高效绘制。它就像一个实时更新的、存在于显存中的图片非常适合用来记录帧复一帧的刮擦痕迹。3.2 创建与配置RenderTexture在Project窗口右键 -Create - Render Texture命名为ScratchRT。其关键参数配置如下Size: 这决定了刮痕的“精度”。尺寸越大刮痕边缘越精细但消耗的显存也越多。对于全屏或大区域的刮刮乐建议使用512x512或1024x1024。对于小UI元素256x256也足够。不必盲目追求高分辨率适中即可。Color Format: 选择R8G8B8A8_UNorm。这是一个包含8位RGBA通道的标准格式完全满足我们的需求用RGB或一个通道存储遮盖信息。Depth Buffer: 设置为No depth buffer。我们只是画2D信息不需要深度测试。Anti-aliasing (MSAA): 保持为None。多重采样抗锯齿对于这种动态生成的、主要用于采样的纹理来说不是必须的关闭以节省性能。Wrap Mode: 设置为Clamp。这样可以防止在UV坐标超出[0,1]范围时采样到纹理边缘的重复像素确保刮擦边界稳定。Filter Mode: 设置为Bilinear。这样在Shader中采样时刮痕的边缘会有轻微的模糊过渡看起来更自然而不是生硬的锯齿状。如果你想要非常锐利的边缘可以选Point。创建好后我们需要在游戏开始时将其初始化为全白色。因为我们的Shader逻辑是白色代表遮盖。初始化在C#脚本中进行。4. C#脚本输入处理与动态绘制这是整个效果的“驱动程序”负责三件事1. 初始化RenderTexture2. 监听玩家的刮擦输入鼠标或触摸3. 将刮擦动作“画”到RenderTexture上。4.1 脚本结构与初始化创建一个C#脚本命名为ScratchCardController。using UnityEngine; using UnityEngine.UI; // 如果用于UI public class ScratchCardController : MonoBehaviour { [Header(Render Texture Setup)] public RenderTexture scratchRenderTexture; // 拖入我们创建的ScratchRT public Material scratchMaterial; // 拖入使用ShaderGraph创建的材质 [Header(Brush Settings)] public Texture2D brushTexture; // 笔刷纹理通常是一个圆形渐变贴图 public Vector2 brushSize new Vector2(64, 64); public Color brushColor Color.black; // 我们绘制黑色到RT上代表“刮开” private Camera _mainCamera; private RectTransform _rectTransform; // 如果是UI private bool _isScratching false; void Start() { InitializeRenderTexture(); // 如果是UI获取RectTransform _rectTransform GetComponentRectTransform(); _mainCamera Camera.main; } void InitializeRenderTexture() { if (scratchRenderTexture ! null) { // 使用Graphics.Blit将一个纯白色Vector4.one绘制到RT进行初始化 RenderTexture.active scratchRenderTexture; GL.Clear(true, true, Color.white); RenderTexture.active null; // 将初始化好的RT赋值给材质 if (scratchMaterial ! null) { scratchMaterial.SetTexture(_ScratchTex, scratchRenderTexture); } } } }关键点解析GL.Clear(true, true, Color.white)这是在当前激活的RenderTexture上执行清除操作true表示清除颜色和深度Color.white将其填充为纯白色。这是最高效的初始化方式。初始化后必须将scratchRenderTexture设置到材质的对应属性这里是_ScratchTex上Shader才能采样到它。4.2 输入检测与坐标转换我们需要在Update或LateUpdate中检测输入并将屏幕坐标鼠标/触摸位置转换到RenderTexture对应的UV坐标。void Update() { if (Input.GetMouseButtonDown(0)) { _isScratching true; } if (Input.GetMouseButtonUp(0)) { _isScratching false; } if (_isScratching) { Scratch(Input.mousePosition); } // 移动端触摸处理简化版 if (Input.touchCount 0) { Touch touch Input.GetTouch(0); if (touch.phase TouchPhase.Moved || touch.phase TouchPhase.Stationary) { Scratch(touch.position); } } } void Scratch(Vector2 screenPosition) { // 关键将屏幕坐标转换为本地坐标再归一化为UV坐标 Vector2 localPoint; // 情况1如果刮刮乐效果在一个UI元素上如RawImage if (_rectTransform ! null RectTransformUtility.ScreenPointToLocalPointInRectangle( _rectTransform, screenPosition, null, out localPoint)) { // 将RectTransform的本地坐标转换为UV [0,1] Rect rect _rectTransform.rect; Vector2 uv new Vector2( (localPoint.x - rect.x) / rect.width, (localPoint.y - rect.y) / rect.height ); // 确保UV在[0,1]范围内 uv.x Mathf.Clamp01(uv.x); uv.y Mathf.Clamp01(uv.y); DrawBrush(uv); } // 情况2如果刮刮乐效果在3D物体表面更复杂需要射线检测 // else if (...){ ... } }坐标转换详解这是最容易出错的一步。RectTransformUtility.ScreenPointToLocalPointInRectangle方法将屏幕点转换到指定RectTransform的本地坐标系中。得到的localPoint是以该UI元素的中心为原点(0,0)向左向下为负向右向上为正的坐标。我们需要再根据UI元素的实际宽高rect.width/height和左下角位置rect.x,rect.y通常是负的一半宽高将其归一化到[0,1]的UV空间。这一步确保了无论你的UI在屏幕的什么位置、是什么尺寸刮擦都能准确映射到RenderTexture上。4.3 在RenderTexture上绘制笔刷得到UV坐标后我们需要在RenderTexture的对应位置“画”上一个黑色的笔刷图案。void DrawBrush(Vector2 uv) { if (scratchRenderTexture null || brushTexture null) return; // 1. 激活我们的RenderTexture作为渲染目标 RenderTexture previousRT RenderTexture.active; RenderTexture.active scratchRenderTexture; // 2. 设置绘制模式类似于设置一个临时画布 GL.PushMatrix(); GL.LoadPixelMatrix(0, 1, 0, 1); // 设置坐标系为UV空间 [0,1] // 3. 计算笔刷在UV空间中的绘制矩形 Vector2 scaledBrushSize new Vector2(brushSize.x / scratchRenderTexture.width, brushSize.y / scratchRenderTexture.height); Rect drawRect new Rect(uv.x - scaledBrushSize.x * 0.5f, uv.y - scaledBrushSize.y * 0.5f, scaledBrushSize.x, scaledBrushSize.y); // 4. 执行绘制 Graphics.DrawTexture(drawRect, brushTexture, null, null, null, null, brushColor); // 5. 恢复渲染状态 GL.PopMatrix(); RenderTexture.active previousRT; }绘制过程拆解RenderTexture.active scratchRenderTexture;这行代码至关重要它指定了后续的所有绘制操作Graphics.DrawTexture的目标是scratchRenderTexture。记得用previousRT保存之前激活的RT以便最后恢复避免影响其他渲染。GL.LoadPixelMatrix(0, 1, 0, 1);将OpenGL的像素矩阵设置为UV空间。这意味着接下来DrawTexture使用的坐标drawRect是相对于纹理[0,1]范围的而不是屏幕像素坐标。这简化了计算。scaledBrushSize笔刷大小brushSize是像素单位我们需要将其除以RT的宽高转换为UV空间的比例这样笔刷在不同分辨率的RT上显示的大小才是相对的。Graphics.DrawTexture这是Unity提供的在GUI/渲染纹理上绘制纹理的高层API。我们指定绘制区域、笔刷纹理、颜色这里是黑色。笔刷纹理通常是一张中心白、边缘渐变为黑的圆形贴图这样画出来的刮痕边缘是柔和的。实操心得Graphics.DrawTexture在每帧被频繁调用时如果笔刷较大或帧数很高可能会成为性能瓶颈。一个优化技巧是合并绘制在Scratch方法中不要每帧只画一个点而是记录上一帧的刮擦位置然后在两帧位置之间画一条由多个笔刷组成的“线段”这样可以减少绘制调用次数让刮擦感觉更顺滑。实现上可以在Scratch方法中记录lastUV然后在DrawBrush中从lastUV到当前uv进行插值并多次绘制。5. 场景搭建与效果整合5.1 UI场景整合最常用在Canvas下创建一个RawImage组件。RawImage是专门用于显示Texture包括RenderTexture的UI组件比Image更合适。将ScratchCardController脚本挂载到该RawImage游戏对象上。在Inspector面板中将之前创建的ScratchRT拖拽到脚本的scratchRenderTexture字段。将ScratchCardMaterial拖拽到scratchMaterial字段。关键一步将ScratchCardMaterial也拖拽到RawImage组件的Material属性上。这样UI渲染时就会使用我们的ShaderGraph材质。为RawImage的Texture属性指定一张底图即刮开后的奖品图。注意在我们的ShaderGraph中底层颜色/纹理是通过材质属性如_BackgroundColor或另一个纹理属性控制的而不是RawImage的Texture。所以更常见的做法是在ShaderGraph中采样另一张“奖品纹理”而RawImage的Texture可以留空或设为一个背景。或者你可以将奖品图直接作为RawImage的Texture然后在ShaderGraph中通过Sample Texture 2D节点并连接到Lerp的A端口来使用它。这两种方式都可以取决于你的设计。5.2 3D物体表面整合如果你想在3D模型比如一个平面上实现刮刮乐步骤类似创建一个Plane或Quad。将ScratchCardMaterial赋给它的MeshRenderer。将ScratchCardController脚本挂载上去并赋值RT和材质。在ScratchCardController的Scratch方法中需要将屏幕坐标通过射线检测Physics.Raycast转换为3D物体表面的UV坐标。这比UI坐标转换稍复杂需要用到RaycastHit.textureCoord。5.3 参数调优与效果打磨笔刷纹理Brush Texture使用一张Alpha通道边缘渐变的圆形贴图。你可以在Photoshop中创建一个圆形选区用渐变工具填充或者直接使用Unity自带的Default-Particle纹理调整Alpha通道。笔刷纹理的质量直接影响刮痕的边缘柔和度。笔刷大小Brush Size根据RT的分辨率调整。例如对于512x512的RT笔刷大小设为64-128像素比较合适。太大刮起来太容易太小则费劲。RenderTexture分辨率再次强调平衡效果和性能。在移动设备上256x256可能就够了PC上可以用512或1024。可以通过脚本根据屏幕分辨率动态创建RT。绘制性能如果感觉卡顿除了前面提到的合并绘制优化还可以考虑限制绘制频率。例如不在每帧都绘制而是每2-3帧绘制一次或者只在输入位置变化超过一定阈值时才绘制。6. 常见问题排查与进阶技巧6.1 问题速查表问题现象可能原因解决方案刮了没反应1. RenderTexture未初始化或未赋值给材质。2. 坐标转换错误UV始终在[0,1]外。3. 笔刷颜色错误比如画成了白色。4. ShaderGraph中Lerp的A/B端口接反。1. 检查InitializeRenderTexture是否被调用材质_ScratchTex属性是否设置。2. 在DrawBrush前Debug.Log(uv)确保其在[0,1]内。3. 确保brushColor Color.black。4. 检查ShaderGraph白色(R1)时应输出覆盖色(B端口)。刮痕边缘锯齿严重1. RenderTexture的Filter Mode为Point。2. 笔刷纹理本身边缘太硬。1. 将RT的Filter Mode改为Bilinear。2. 使用边缘更柔和的笔刷纹理。刮痕显示在错误位置坐标转换逻辑错误特别是UI的锚点Anchor和轴心Pivot非默认(0.5,0.5)时。使用RectTransformUtility时确保传入的RectTransform正确。对于复杂UI布局打印rectTransform.rect和计算出的uv进行调试。刮开后背景是黑色/奇怪颜色ShaderGraph中Lerp的A端口底层连接错误或未连接。检查并正确连接底层颜色或纹理到Lerp节点的A端口。性能差移动端发热每帧绘制调用太多RT分辨率过高。1. 实现输入位置插值合并绘制调用。2. 降低RT分辨率。3. 限制绘制帧率如用Coroutine。WebGL等平台无效Graphics.DrawTexture在部分GLES2.0或WebGL 1.0环境下可能受限。考虑使用CommandBuffer或GL.Clear/GL.*指令进行更底层的绘制或者使用一个临时Texture2D通过SetPixels修改再Graphics.Blit到RT效率较低。6.2 进阶技巧添加刮开百分比与重置功能刮开百分比我们可以在C#脚本中定期检查RenderTexture的“刮开程度”。一个简单但耗性能的方法是将RT读取回CPU计算黑色像素的比例。public float GetScratchPercentage() { if (scratchRenderTexture null) return 0f; // 创建一个临时Texture2D来读取RT数据 Texture2D tempTex new Texture2D(scratchRenderTexture.width, scratchRenderTexture.height, TextureFormat.RGBA32, false); RenderTexture.active scratchRenderTexture; tempTex.ReadPixels(new Rect(0, 0, scratchRenderTexture.width, scratchRenderTexture.height), 0, 0); tempTex.Apply(); RenderTexture.active null; // 分析像素示例计算R通道平均值因为我们是黑白图 Color[] pixels tempTex.GetPixels(); float total 0f; foreach (Color pixel in pixels) { total pixel.r; // 白色为1黑色为0 } float average total / pixels.Length; Destroy(tempTex); // average是平均亮度越接近0表示刮开的黑色越多 return (1f - average) * 100f; // 返回刮开百分比 }注意ReadPixels和GetPixels是同步操作并且涉及GPU到CPU的数据回读非常耗时。绝对不要每帧调用可以每隔1-2秒或者在玩家停止刮擦时调用一次。也可以考虑使用Compute Shader在GPU端进行统计但这属于高级话题。重置功能非常简单重新执行初始化步骤即可。public void ResetScratchCard() { InitializeRenderTexture(); }6.3 性能优化终极方案使用CommandBuffer对于追求极致性能或者需要更复杂绘制逻辑如不同笔刷、擦除动画的情况可以升级使用CommandBuffer。CommandBuffer允许你录制一系列渲染命令然后选择时机执行。用它来绘制笔刷到RT比Graphics.DrawTexture更高效、更灵活。核心思路是创建一个CommandBuffer在其中添加Blit命令使用一个特定的“绘制材质”将笔刷纹理画到RT上。在Scratch方法中不是立即绘制而是将绘制命令加入缓冲区在相机渲染的某个阶段如AfterEverything执行。这需要更多的图形学知识但能提供更好的性能和控制力。这套基于Unity 2022.3 ShaderGraph RenderTexture的刮刮乐方案从原理到实现从基础到优化基本上都覆盖了。最让我满意的是它的模块化程度ShaderGraph负责视觉效果RenderTexture负责数据存储C#脚本负责逻辑驱动。任何一部分都可以单独替换或升级。比如你觉得默认笔刷不好看换张纹理就行想改成刮开显示动态视频就在ShaderGraph底层采样一个VideoPlayer的纹理。这种可组合性才是现代游戏开发效率的源泉。下次再遇到类似“动态遮罩”的需求比如雪地足迹、灰尘擦拭、迷雾探索不妨想想这个RenderTexture作画布、Shader作解释器的组合拳思路瞬间就打开了。