全能挖孔 Shader

本文最后更新于:2020年5月3日 凌晨

前言

  • 来了来了,今天给大家分享的绝对是 好东西 !!!

  • 相信很多人都遇到需要 在图片上挖孔(镂空) 的需求,最常见的例子就是 新手引导中的镂空遮罩 。虽然可以用 Mask 实现,但是效果太勉强,也不好控制,而且很不优雅,更好的解决方案就是用 Shader 来实现。

  • 所以今天给大家带来的是 可以满足几乎所有挖孔需求的 Shader 和炒鸡方便的配套组件

  • 矩形、圆形、圆角、边缘虚化,位置可控,统统 打包带走 ,而且可通过代码轻松控制!

  • 什么?想要三角形和五角星?不,你不想!


效果展示

  • 镂空 Shader 与 HollowOut 组件搭配使用效果顶呱呱~

是矩形还是圆形呢

圆形

大小位置变化丝毫不影响

  • 下图是我配合 TouchBlocker 组件实现的新手引导功能。

TouchBlocker 是 Eazax-CCC 中一个用来限制可点击的节点的独立组件,完整文件在这里 TouchBlocker.ts

Eazax-CCC 是我目前维护的一个开源游戏开发脚手架,包含各种实用的组件,目前也在不断更新中,有需要的童鞋在公众号发送“开源”即可获取链接,不要忘记 Star 哦~

让你点啥就点啥

  • 实现上面的新手引导需要的核心代码还不到 15 行,嗐!苏福~
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
// 以下为新手引导实现核心代码,多简单啊

protected onLoad() {
this.startBtn.on('touchend', this.onStartBtnClick, this);
this.oneBtn.on('touchend', this.onOneBtnClick, this);
this.twoBtn.on('touchend', this.onTwoBtnClick, this);
}

protected start() {
this.hollowOut.nodeSize(); // 将遮罩镂空设为节点大小
this.touchBlocker.setTarget(this.startBtn); // 设置可点击节点
}

private async onStartBtnClick() {
this.touchBlocker.blockAll(); // 屏蔽所有点击
await this.hollowOut.rectTo(1, this.oneBtn.getPosition(), this.oneBtn.width + 10, this.oneBtn.height + 10, 5, 5);
this.touchBlocker.setTarget(this.oneBtn); // 设置可点击节点
}

private async onOneBtnClick() {
this.hollowOut.nodeSize(); // 将遮罩镂空设为节点大小
this.touchBlocker.blockAll(); // 屏蔽所有点击
await this.hollowOut.rectTo(1, this.twoBtn.getPosition(), this.twoBtn.width + 10, this.twoBtn.height + 10, 5, 5);
this.touchBlocker.setTarget(this.twoBtn); // 设置可点击节点
}

private onTwoBtnClick() {
this.hollowOut.nodeSize(); // 将遮罩镂空设为节点大小
this.touchBlocker.passAll(); // 放行所有点击
}

整体思路

  1. 镂空的具体实现思路无非就是渲染时判断每个点的位置,是否符合我们的要求,符合的设为透明或者直接放弃渲染,否则正常渲染即可。
  2. 由于 Shader 在渲染时使用的是标准屏幕坐标系(左上角为原点),与我们平时在 Creator 中使用的笛卡尔坐标系(左下角为原点)和本地坐标系(中间为原点)不同,使用时需要经过坐标转换。
  3. 同时 Shader 中的点的坐标使用的不是相对于坐标系的位置,而是点处于节点宽高的百分比值,比如在屏幕中间的位置为(0, 0),在 Shader 中就为 (0.5, 0.5),这也是需要我们自己去计算的地方。
  4. 由于我接触 Shader 的时间还不是很长,很多地方都不熟悉,一路跌跌撞撞边学边写花了几个晚上才把这个 Shader 和配套组件做完,而且我觉得还有优化的空间。
  5. 以后我也会持续学习并深入理解 Shader 的编写,自己学习的同时也不忘记把知识分享给大家。后面我会写一系列入门文章,给同样想要学习 Shader 的童鞋参考,感兴趣的童鞋可以关注下哦~

代码实现

注:本 Shader 基于 Cocos Creator 2.3.3 开发

重要提醒: 使用自定义 Shader 需要禁用动态合图功能,否则在运行的时候会出现渲染单色图片 Shader 失效的情况(编辑器中正常显示)

1
2
// 禁用动态合图
cc.dynamicAtlasManager.enabled = false;

Shader

  1. 由于完整 Shader 代码过于冗长,这里只贴出来比较关键的片段着色器部分。完整的代码在这里 eazax-hollowout.effect
  2. 另外我对 Shader 编写还不是很熟悉,主函数中使用了很多 if else 判断,我也在尝试优化中,如果有大佬知道如何优化,还请多多指教!
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
// 以下为镂空 Shader 中的片段着色器部分

CCProgram fs %{
precision highp float;

in vec2 v_uv0;
in vec4 v_color;

uniform sampler2D texture;

uniform BaseParams {
vec2 center;
float ratio;
};

uniform RectParams {
float width;
float height;
float round;
float feather;
};

void main () {
vec4 color = v_color;
color *= texture(texture, v_uv0);
// 边缘
float minX = center.x - (width / 2.0);
float maxX = center.x + (width / 2.0);
float minY = center.y - (height * ratio / 2.0);
float maxY = center.y + (height * ratio / 2.0);
if (v_uv0.x >= minX && v_uv0.x <= maxX && v_uv0.y >= minY && v_uv0.y <= maxY) {
if (round == 0.0) discard; // 没有圆角则直接丢弃
// 圆角处理
float roundY = round * ratio;
vec2 vertex;
if (v_uv0.x <= minX + round) {
if (v_uv0.y <= minY + roundY) {
vertex = vec2(minX + round, (minY + roundY) / ratio); // 左上角
} else if (v_uv0.y >= maxY - roundY) {
vertex = vec2(minX + round, (maxY - roundY) / ratio); // 左下角
} else {
vertex = vec2(minX + round, v_uv0.y / ratio); // 左中
}
} else if (v_uv0.x >= maxX - round) {
if (v_uv0.y <= minY + roundY){
vertex = vec2(maxX - round, (minY + roundY) / ratio); // 右上角
} else if (v_uv0.y >= maxY - roundY) {
vertex = vec2(maxX - round, (maxY - roundY) / ratio); // 右下角
} else {
vertex = vec2(maxX - round, v_uv0.y / ratio); // 右中
}
} else if (v_uv0.y <= minY + roundY) {
vertex = vec2(v_uv0.x, (minY + roundY) / ratio); // 上中
} else if (v_uv0.y >= maxY - roundY) {
vertex = vec2(v_uv0.x, (maxY - roundY) / ratio); // 下中
} else {
discard; // 中间
}
float dis = distance(vec2(v_uv0.x, v_uv0.y / ratio), vertex);
color.a = smoothstep(round - feather, round, dis);
} else {
color.a = 1.0;
}

color.a *= v_color.a;
gl_FragColor = color;
}
}%

HollowOut

  1. 然后是配套使用的 HollowOut 组件,开箱即用~组件中已经实现了坐标以及距离的转换,使用非常的方便快捷。完整文件在这里 HollowOut.ts
  2. 这个组件的代码也比较多,这里只贴出较为关键的代码,大多数的情况处理我都已经封装好了,通过下面的代码大家可以轻易得知我是如何转换参数的,所以你也可以参照实现自己需要的特效或功能~
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
/**
* 渲染
* @param keepUpdating 是否每帧自动更新
*/
private render(keepUpdating: boolean) {
switch (this.shape) {
case Shape.Rect:
this.rect(this.center, this.width, this.height, this.round, this.feather, keepUpdating);
break;
case Shape.Circle:
this.circle(this.center, this.radius, this.feather, keepUpdating);
break;
}
}

/**
* 矩形镂空
* @param center 中心坐标
* @param width 宽
* @param height 高
* @param round 圆角半径
* @param feather 边缘虚化宽度
* @param keepUpdating 是否每帧自动更新
*/
public rect(center?: cc.Vec2, width?: number, height?: number, round?: number, feather?: number, keepUpdating: boolean = false) {
this.shape = Shape.Rect;
if (center !== null) this.center = center;
if (width !== null) this.width = width;
if (height !== null) this.height = height;
if (round !== null) {
this.round = round >= 0 ? round : 0;
let min = Math.min(this.width / 2, this.height / 2);
this.round = this.round <= min ? this.round : min;
}
if (feather !== null) {
this.feather = feather >= 0 ? feather : 0;
this.feather = this.feather <= this.round ? this.feather : this.round;
}
this.material.setProperty('ratio', this.getRatio());
this.material.setProperty('center', this.getCenter(this.center));
this.material.setProperty('width', this.getWidth(this.width));
this.material.setProperty('height', this.getHeight(this.height));
this.material.setProperty('round', this.getRound(this.round));
this.material.setProperty('feather', this.getFeather(this.feather));
this.keepUpdating = keepUpdating;
}

/**
* 圆形镂空
* @param center 中心坐标
* @param radius 半径
* @param feather 边缘虚化宽度
* @param keepUpdating 是否每帧自动更新
*/
public circle(center?: cc.Vec2, radius?: number, feather?: number, keepUpdating: boolean = false) {
this.shape = Shape.Circle;
if (center !== null) this.center = center;
if (radius !== null) this.radius = radius;
if (feather !== null) this.feather = feather >= 0 ? feather : 0;
this.material.setProperty('ratio', this.getRatio());
this.material.setProperty('center', this.getCenter(this.center));
this.material.setProperty('width', this.getWidth(this.radius * 2));
this.material.setProperty('height', this.getHeight(this.radius * 2));
this.material.setProperty('round', this.getRound(this.radius));
this.material.setProperty('feather', this.getFeather(this.feather));
this.keepUpdating = keepUpdating;
}

/**
* 缓动镂空(矩形)
* @param time 时间
* @param center 中心坐标
* @param width 宽
* @param height 高
* @param round 圆角半径
* @param feather 边缘虚化宽度
*/
public rectTo(time: number, center: cc.Vec2, width: number, height: number, round: number = 0, feather: number = 0): Promise<void> {
return new Promise(res => {
cc.Tween.stopAllByTarget(this);
this.tweenRes && this.tweenRes();
this.tweenRes = res;
if (round > width / 2) round = width / 2;
if (round > height / 2) round = height / 2;
if (feather > round) feather = round;
this.shape = Shape.Rect;
cc.tween<HollowOut>(this)
.call(() => this.keepUpdating = true)
.to(time, {
center: center,
width: width,
height: height,
round: round,
feather: feather
})
.call(() => {
this.scheduleOnce(() => {
this.keepUpdating = false;
this.tweenRes();
this.tweenRes = null;
});
})
.start();
});
}

/**
* 缓动镂空(圆形)
* @param time 时间
* @param center 中心坐标
* @param radius 半径
* @param feather 边缘虚化宽度
*/
public circleTo(time: number, center: cc.Vec2, radius: number, feather: number = 0): Promise<void> {
return new Promise(res => {
cc.Tween.stopAllByTarget(this);
this.tweenRes && this.tweenRes();
this.tweenRes = res;
this.shape = Shape.Circle;

cc.tween<HollowOut>(this)
.call(() => this.keepUpdating = true)
.to(time, {
center: center,
radius: radius,
feather: feather
})
.call(() => {
this.scheduleOnce(() => {
this.keepUpdating = false;
this.tweenRes();
this.tweenRes = null;
});
})
.start();
});
}

/**
* 取消所有挖孔
*/
public reset() {
this.rect(cc.v2(), 0, 0, 0, 0);
}

/**
* 挖孔设为节点大小(就整个都挖没了)
*/
public nodeSize() {
this.rect(this.node.getPosition(), this.node.width, this.node.height, 0, 0);
}

/**
* 获取中心点
* @param center
*/
private getCenter(center: cc.Vec2) {
let x = (center.x + (this.node.width / 2)) / this.node.width;
let y = (-center.y + (this.node.height / 2)) / this.node.height;
return cc.v2(x, y);
}

/**
* 获取节点宽高比
*/
private getRatio() {
return this.node.width / this.node.height;
}

/**
* 获取挖孔宽度
* @param width
*/
private getWidth(width: number) {
return width / this.node.width;
}

/**
* 获取挖孔高度
* @param height
*/
private getHeight(height: number) {
return height / this.node.width;
}

/**
* 获取圆角半径
* @param round
*/
private getRound(round: number) {
return round / this.node.width;
}

/**
* 获取边缘虚化宽度
* @param feather
*/
private getFeather(feather: number) {
return feather / this.node.width;
}
  • 另外我还提供了矩形和圆形的独立版本 Shader ,独立版本需要自行设置 Material 才能使用,同时不适用于 HollowOut 组件,当然可以自行实现。传送门

独立版本


使用方法

  1. 在带有 Sprite 组件的节点上添加 HollowOut 组件。
  2. 如果使用了 Eazax-CCC 脚手架则组件会自动绑定资源,如果是单独导入项目的,需要按照以下内容的操作。
  3. 镂空 Shader 文件 eazax-hollowout.effect 拖到 HollowOut 组件的 Effect 属性上即可。
  4. 在编辑器上调整需要的属性,或者使用代码获取 HollowOut 组件来设置属性。

How to use?


结束语

以上皆为陈皮皮的个人观点,小生不才,文采不佳,如果写得不好还请各位多多包涵。如果有哪些地方说的不对,还请各位指出,希望与大家共同进步。

接下来我会持续分享自己所学的知识与见解,欢迎各位关注本公众号。

我们,下次见!


传送门集合

微信推文版本

Eazax Cocos 游戏开发工具包

eazax-hollowout.effect

HollowOut.ts

TouchBlocker.ts


更多分享

多平台通用的屏幕分辨率适配方案

围绕物体旋转的方案以及现成的组件


公众号

菜鸟小栈

我是陈皮皮,这是我的个人公众号,专注但不仅限于游戏开发、前端和后端技术记录与分享。

每一篇原创都非常用心,你的关注就是我原创的动力!

Input and output.


全能挖孔 Shader
https://chenpipi.cn/post/shader-hollow-out/
作者
陈皮皮
发布于
2020年5月3日
更新于
2020年5月3日
许可协议