Unity刮刮乐效果实现

Unity UI 实现类似刮刮乐的刮擦效果(可自定义笔刷材质)

1.ScratchCard.shader

1
2
3
4
5
6
7
Properties
{
_MainTex ("Brush Texture", 2D) = "white" {}
_Color ("Tint", Color) = (1,1,1,1)
[PerRendererData] _Offset ("Offset", Vector) = (0,0,0,0)
[PerRendererData] _Scale ("Scale", Vector) = (0,0,0,0)
}
  • Brush Texture 笔刷贴图
  • Offset/Scale 分别存偏移和缩放
  • PerRendererData 属性标记,指示该属性可以在每个渲染器实例上设置不同的值而不用为每个对象创建独立的材质实例
1
2
3
4
5
6
7
8
9
10
11
Cull Off
Lighting Off
ZWrite Off
BlendOp RevSub
Blend Zero One, One One

举一个混合的例子
源颜色为(0.8, 0.5, 0.2, 1.0)
目标颜色为(0.3, 0.4, 0.5, 1.0)
经过混合后为(-0.5, -0.1, 0.3, 0.0)
颜色值在[0,1] 所以结果为(0.0,0.0,0.3,0.0)
  • Cull Off 关闭背面剔除
  • Lighting Off 关闭光照计算,UI不参与光照计算
  • ZWrite Off 关闭深度写入,透明对象需要正确的渲染顺序,如果深度写入打开,可能会导致透明对象在渲染时遮挡后面的物体
  • BlendOp RevSub 反向减法混合,目标颜色=目标颜色(帧缓冲区中的颜色)-源颜色(正在处理的片元颜色)
  • Blend Zero One, One One 混合时源颜色RGB不参与A参与,目标颜色RGBA都参与
1
2
3
4
5
6
7
8
9
10
v2f vert(a2v v)
{
v2f o;

o.vertex = UnityObjectToClipPos(v.vertex + _Offset);
o.texcoord = TRANSFORM_TEX(v.texcoord, _MainTex) * _Scale.xy;
o.color = v.color * _Color;

return o;
}
  • 模型空间应用偏移后UnityObjectToClipPos转换到裁剪空间
  • TRANSFORM_TEX应用材质贴图平移和缩放后再进行一次缩放
1
2
3
4
5
fixed4 frag(v2f i) : SV_Target
{
half4 color = tex2D(_MainTex, i.texcoord) * i.color;
return color;
}
  • tex2D采样纹理颜色,与顶点着色器输出的颜色进行混合后输出

2.ScratchCard.cs

1
public class ScratchCard : MonoBehaviour, IBeginDragHandler, IDragHandler
  • 实现IBeginDragHandler,IDragHandler接口
1
2
3
4
5
6
7
8
private void DrawAt(Vector2 screenPoint)
{
Vector2 point = ScreenToGraphicLocalPoint(RawImage, screenPoint);
Vector4 offsetScale = CaculateOffsetScale(RectTransform, point, brushScale);
brush.SetVector(OffsetPropertyId, new Vector4(offsetScale.x, offsetScale.y));
brush.SetVector(ScalePropertyId, new Vector4(offsetScale.z, offsetScale.w));
Graphics.Blit(brush.mainTexture, targetTexture, brush);
}
  • screenPoint为鼠标坐标
  • ScreenToGraphicLocalPoint方法将鼠标坐标转换到RawImage空间
  • CaculateOffsetScale方法根据坐标和笔刷大小计算偏移和缩放
  • 将计算得到的值赋给材质对应属性后Graphics.Blit方法应用材质将笔刷贴图绘制到targetTexture实现效果
1
2
3
4
5
6
7
8
9
10
11
12
13
private Vector4 CaculateOffsetScale(RectTransform rectTransform, Vector2 localPoint, Vector2 brushScale)
{
Rect rect = rectTransform.rect;
localPoint += rectTransform.pivot * rect.size;

float aspect = rect.height / rect.width;
float sx = brushScale.x * aspect;
float sy = brushScale.y;
float px = (localPoint.x / rect.width) - (0.5f * sx);
float py = (localPoint.y / rect.height) - (0.5f * sy);

return new Vector4(px, py, 1.0f / sx, 1.0f / sy);
}
  • localPoint是以纹理中心为原点的,需要转化到左下角为原点(OpenGL以左下角为uv原点)
  • sx需要乘以aspect保持宽高比
  • 这里先将localPoint归一化,乘以 0.5f 是为了将画笔的中心对准 localPoint,而不是默认的左下角。这相当于将画笔的起始位置从左下角调整到中心,这样绘制时画笔的中心会与 localPoint 对齐

3.刮开一定比例后Dissolve

实现比较简单,需要维护一个结构体,记录访问过的点位,访问的比例到达设置值后触发溶解效果即可

4.完整代码

  1. ScratchCard.shader
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
Shader "UI/ScratchCard/Brush"
{
Properties
{
_MainTex ("Brush Texture", 2D) = "white" {}
_Color ("Tint", Color) = (1,1,1,1)
[PerRendererData] _Offset ("Offset", Vector) = (0,0,0,0)
[PerRendererData] _Scale ("Scale", Vector) = (0,0,0,0)
}

SubShader
{
Tags
{
"Queue"="Transparent"
"RenderType" = "Transparent"
"IgnoreProjector"="True"
"PreviewType"="Plane"
"CanUseSpriteAtlas"="True"
}

Cull Off
Lighting Off
ZWrite Off
BlendOp RevSub
Blend Zero One, One One

Pass
{
Name "Default"
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#pragma target 2.0

#include "UnityCG.cginc"

struct a2v
{
float4 vertex : POSITION;
float4 color : COLOR;
float2 texcoord : TEXCOORD0;
};

struct v2f
{
float4 vertex : SV_POSITION;
float4 color : COLOR;
float2 texcoord : TEXCOORD0;
};

sampler2D _MainTex;
float4 _MainTex_ST;
float4 _Color;
float4 _Offset;
float4 _Scale;

v2f vert(a2v v)
{
v2f o;

o.vertex = UnityObjectToClipPos(v.vertex + _Offset);
o.texcoord = TRANSFORM_TEX(v.texcoord, _MainTex) * _Scale.xy;
o.color = v.color * _Color;

return o;
}

fixed4 frag(v2f i) : SV_Target
{
half4 color = tex2D(_MainTex, i.texcoord) * i.color;
return color;
}
ENDCG
}
}
}
  1. ScratchCard.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;

[RequireComponent(typeof(RawImage))]
[RequireComponent(typeof(RectTransform))]
public class ScratchCard : MonoBehaviour, IBeginDragHandler, IDragHandler
{
private static readonly int OffsetPropertyId = Shader.PropertyToID("_Offset");
private static readonly int ScalePropertyId = Shader.PropertyToID("_Scale");

internal RawImage RawImage
{
get
{
if (rawImage == null)
{
rawImage = GetComponent<RawImage>();
}
return rawImage;
}
}

internal RectTransform RectTransform
{
get
{
if (rectTransform == null)
{
rectTransform = GetComponent<RectTransform>();
}
return rectTransform;
}
}

[Header("Brush")]
public Material brushMaterial;
public Vector2 brushScale = new Vector2(0.25f, 0.25f);
public int interpolationMaxCount = 4;
public int interpolationMinDistance = 8;

[Header("MaskTexture")]
public float maskTextureScale = 10f;
public float maskTextureMaxSize = 1024f;

private RawImage rawImage;
private RectTransform rectTransform;
private Texture baseTexture;
private RenderTexture targetTexture;
private Material brush;
private Vector2 previousDrawPosition;

private void Awake()
{
SaveBaseTexture();
}

private void OnEnable()
{
Reload();
}

private void OnDisable()
{
DisposeTargetTexture();
DisposeBrush();
}

private void OnRectTransformDimensionsChange()
{
Reload();
}

public void OnBeginDrag(PointerEventData eventData)
{
previousDrawPosition = eventData.position;
}

public void OnDrag(PointerEventData eventData)
{
if (targetTexture == null || brush == null) return;

Vector2 drawPositon = eventData.position;
Vector2 delta = drawPositon - previousDrawPosition;
float distance = delta.magnitude;
int count = Mathf.Min(interpolationMaxCount, (int)distance / interpolationMinDistance);

for(int i = 1; i < count; i++)
{
DrawAt(previousDrawPosition + delta * ((float)i / count));
}
DrawAt(drawPositon);
previousDrawPosition = drawPositon;
}

public void Restory()
{
ClearTargetTexture();
}

private void SaveBaseTexture()
{
if (RawImage.texture != null)
{
baseTexture = RawImage.texture;
}
}

private void Reload()
{
Rect rect = RectTransform.rect;
if (rect.width <= 0 || rect.height <= 0) return;

AcquireTargetTexture(rect);
CreateBrush();
}

private void AcquireTargetTexture(Rect rect)
{
float aspect = rect.height / rect.width;
int width = (int)Mathf.Min(maskTextureMaxSize, rect.width * maskTextureScale);
int height = (int)(width * aspect);

if (targetTexture != null && (targetTexture.width != width || targetTexture.height != height))
{
DisposeTargetTexture();
}

targetTexture = RenderTexture.GetTemporary(width, height, -1, RenderTextureFormat.ARGB32);
RawImage.texture = targetTexture;
ClearTargetTexture();
}

private void DisposeTargetTexture()
{
if(targetTexture != null)
{
RenderTexture.ReleaseTemporary(targetTexture);
targetTexture = null;
}
}

private void ClearTargetWithColor(Color color)
{
RenderTexture temp = RenderTexture.active;
RenderTexture.active = targetTexture;
GL.Clear(true, true, color);
RenderTexture.active = temp;
}

private void ClearTargetTexture()
{
if(baseTexture != null)
{
ClearTargetWithColor(Color.clear);
Graphics.Blit(baseTexture, targetTexture, RawImage.material);
}
else
{
ClearTargetWithColor(Color.white);
}
}

private void CreateBrush()
{
if(brushMaterial == null)
{
brushMaterial = Resources.Load<Material>("Materials/ScratchCard_DefaultBrush");
}

DisposeBrush();

brush = new Material(brushMaterial);
}

private void DisposeBrush()
{
if(brush != null)
{
Destroy(brush);
brush = null;
}
}

private void DrawAt(Vector2 screenPoint)
{
// 这里计算得到的坐标以rect中心为原点
Vector2 point = ScreenToGraphicLocalPoint(RawImage, screenPoint);
Vector4 offsetScale = CaculateOffsetScale(RectTransform, point, brushScale);
brush.SetVector(OffsetPropertyId, new Vector4(offsetScale.x, offsetScale.y));
brush.SetVector(ScalePropertyId, new Vector4(offsetScale.z, offsetScale.w));
Graphics.Blit(brush.mainTexture, targetTexture, brush);
}

private Vector2 ScreenToGraphicLocalPoint(Graphic graphic, Vector2 screenPoint)
{
Canvas canvas = graphic.canvas.rootCanvas;
Camera camera = canvas.renderMode == RenderMode.ScreenSpaceOverlay ? null : canvas.worldCamera;
RectTransform rectTransform = graphic.rectTransform;

return RectTransformUtility.ScreenPointToLocalPointInRectangle(rectTransform, screenPoint, camera, out Vector2 localPoint) ? localPoint : default;
}

private Vector4 CaculateOffsetScale(RectTransform rectTransform, Vector2 localPoint, Vector2 brushScale)
{
Rect rect = rectTransform.rect;
localPoint += rectTransform.pivot * rect.size;

float aspect = rect.height / rect.width;
float sx = brushScale.x * aspect;
float sy = brushScale.y;
float px = (localPoint.x / rect.width) - (0.5f * sx);
float py = (localPoint.y / rect.height) - (0.5f * sy);

return new Vector4(px, py, 1.0f / sx, 1.0f / sy);
}
}

Unity刮刮乐效果实现
https://baifabaiquan.cn/2024/08/13/Unity刮刮乐效果实现/
作者
白发败犬
发布于
2024年8月13日
许可协议