本章为大家带来内发光特效。
一、内发光原理
学习 Shader 过程中,偶然在网上看到一句的内发光原理,十分精辟受用:
采样周边像素alpha取平均值,叠加发光效果
事实上,根据这句精辟的原理,就可以实现内发光了,你也试试吧?
以下为我的实现过程。
二、采样周边像素Alpha取平均值
怎么采集某个点的周边像素呢?这里我们可以用 「按圆采样」 算法
2.1 采样圆边上某点的 Alpha 值
如果我们已知圆的半径 radius
,已经某个角度 angle
,那么这个点的坐标就很好计算了,其上的 Alpha 值就不再话下 :
x = radius * cos(angle);
y = radius * sin(angle);
在 Cocos Creator 的 Shader 中,代码如下:
/**
* 获取指定角度方向,距离为xxx的像素的透明度
*
* @param angle 角度 [0.0, 360.0]
* @param dist 距离 [0.0, 1.0]
*
* @return alpha [0.0, 1.0]
*/
float getColorAlpha(float angle, float dist) {
// 角度转弧度,公式为:弧度 = 角度 * (pi / 180)
// float radian = angle * 0.01745329252; // 这个浮点数是 pi / 180
float radian = radians(angle);
vec4 color = getTextureColor(texture, v_uv0 + vec2(dist * cos(radian), dist * sin(radian)));
return color.a;
}
PS:
这里我们用到了 sin
和 cos
函数,函数接受的参数是弧度制,因此我们要实现角度转弧度
// 角度转弧度,公式为:弧度 = 角度 * (pi / 180)
float radian = angle * 0.01745329252; // 这个浮点数是 pi / 180
但实际上,GLSL ES 语言已然存在内置函数 radians(float degree)
:将角度值转化为弧度值,因此我们就用内置函数即可。
2.2 采样圆边上所有点的 Alpah 平均值
上面我们已经实现了获取圆上某点的颜色 Alpha 值。那么,我们只需要来一个 for
循环,遍历 0 到 360 度,那这个圆上所有点的颜色 Alpha 平均值就很容易算出来了。
但是,这样子可能会有两个问题:
- 计算量可能会太多,导致我们的性能低下
- 半径很少的时候,相邻的两个或多个角度的点可能很近,或者甚至重合,此时这两个点的 Alpha 值有可能相差不大,那么此时分别计算这些角度的 Alpha 值,就可能显得有点冗余了,取其一即可
基于以上考虑,最终我采用的是圆采样方式为:
以某个角度作为间隔,遍历由此产生的各个方向的Alpha值,将这些值的和的平均值近似看作这个圆的Alpha值
比如:
假设以 45° 角间隔,那么我只需要计算下图的 [10, 17] 共计 8 个点的 Alpha 平均值,那么这个值我就可以近似看作这个圆上所有点的 Alpha 平均值了
在 Cocos Creator 的 Shader 中,代码如下:
/**
* 获取指定距离的周边像素的透明度平均值
*
* @param dist 距离 [0.0, 1.0]
*
* @return average alpha [0.0, 1.0]
*/
float getAverageAlpha(float dist) {
float totalAlpha = 0.0;
// 以30度为一个单位,那么「周边一圈」就由0到360度中共计12个点的组成
totalAlpha += getColorAlpha(0.0, dist);
totalAlpha += getColorAlpha(30.0, dist);
totalAlpha += getColorAlpha(60.0, dist);
totalAlpha += getColorAlpha(90.0, dist);
totalAlpha += getColorAlpha(120.0, dist);
totalAlpha += getColorAlpha(150.0, dist);
totalAlpha += getColorAlpha(180.0, dist);
totalAlpha += getColorAlpha(210.0, dist);
totalAlpha += getColorAlpha(240.0, dist);
totalAlpha += getColorAlpha(270.0, dist);
totalAlpha += getColorAlpha(300.0, dist);
totalAlpha += getColorAlpha(330.0, dist);
return totalAlpha * 0.0833; // 1 / 12 = 0.08333
}
2.3 采样点周边像素 Alpha 平均值
上面两个步骤,我们已经实现了 近似采样一个圆上所有点的 Alpha 平均值 。
而如果我们把「周边」这个词语理解为由很多个半径不同的圆组合起来,那么现在我们只需要采样多几个圆,那么就可以实现我们的最终需求了—— 采样周边像素Alpha取平均值 。
那么,那么我们要采样多少个圆呢?采集少了,效果可能粗糙,采集多了,可能计算量过多导致性能降低
一般而言,这种可变的属性,我们应该交给上层去传入,但是如果上层要用内发光特效,你暴露的一个参数名字叫 采样多少个圆
,那使用者一般会很茫然。
事实上,更加贴合上层使用者理解的属性名应该为 发光宽度 glowColorSize
。
那我们又如何在程序上,在这个发光宽度上,控制采样多少个圆呢?
划分方案有很多种,这里我们采用按照发光宽度,等比划分10个圆,只采样这10个圆。(当然你可以改动这里的划分方案)
在 Cocos Creator 的 Shader 中,代码如下:
/**
* 获取发光的透明度
*/
float getGlowAlpha() {
// 如果发光宽度为0,直接返回0.0透明度,减少计算量
if (glowColorSize == 0.0) {
return 0.0;
}
// 将传入的指定距离,平均分成10圈,求出每一圈的平均透明度,
// 然后求和取平均值,那么就可以得到该点的平均透明度
float totalAlpha = 0.0;
totalAlpha += getAverageAlpha(glowColorSize * 0.1);
totalAlpha += getAverageAlpha(glowColorSize * 0.2);
totalAlpha += getAverageAlpha(glowColorSize * 0.3);
totalAlpha += getAverageAlpha(glowColorSize * 0.4);
totalAlpha += getAverageAlpha(glowColorSize * 0.5);
totalAlpha += getAverageAlpha(glowColorSize * 0.6);
totalAlpha += getAverageAlpha(glowColorSize * 0.7);
totalAlpha += getAverageAlpha(glowColorSize * 0.8);
totalAlpha += getAverageAlpha(glowColorSize * 0.9);
totalAlpha += getAverageAlpha(glowColorSize * 1.0);
return totalAlpha * 0.1;
}
2.4 调试发光
Ok,有了上面的采样手段,现在我们可以来调试了。
首先,那么发光颜色选什么好呢?
交给上层控制吧,我们只需要定义一个 发光颜色 glowColor
即可。
float alpha = getGlowAlpha();
gl_FragColor = glowColor * alpha;
先来个内发红光看下: glowColor = vec4(1.0, 0.0, 0.0, 1.0);
可以看到右边的调试结果还是挺符合我们的输出预期,周边点明显是有一个渐变透明过程
但是,此时我们得到的是内部透明度为1,靠近边缘的为接近0的透明度,其他位置为0的透明度。而内发光效果的话,恰恰相反,我们需要的是一个内部透明度为0,靠近内边缘透明度为1的效果。
那么我们尝试反转一下
float alpha = getGlowAlpha();
// 内发光是从边缘发光的,是需要内部透明度为0,靠近边缘的接近1的透明度
// 因此我们需要反转一下透明度
alpha = 1.0 - alpha;
gl_FragColor = glowColor * alpha;
现在是反转了,但是图像外边的其他位置却上色了,而在反转之前,图像外边的其他位置是透明的,为了应用这部分过来,在反转之前,我们判断一下,透明度大于某个 阈值 ,我们才反转 alpha 值。
那么这里的 阈值 要怎么定义呢?
为了更加深入理解这个问题,我们先来放大一下 Cocos 的 Logo 上方的那个角,先看清楚一个问题:
可以看到图像的边缘黑色并不是立即切换到完全透明的,而是一个过渡效果,从黑色开始慢慢变透明直到完全透明,透明从1 -> 0 慢慢过渡。事实上大部分的图像边缘都差不多类似这样子,甚至部分图片的设计,本身就是有一个很长的渐变过渡带。
那么问题来了,针对这种有渐变过渡带的纹理,在我们实现的内发光特效中,我们的发光边缘要怎么定义呢?
- 从图像边缘最外边的透明度为0.0开始发光?
- 从图像边缘往内,不透明(即透明度为1.0)的地方开始发光?
- 从图像 0.0 到 1.0 之间的某个值开始发光?
不好取舍,不同图片可能是需要不同处理,效果才好。
既然如此,我们就可以将这几种定义抽象一下,比如叫 发光阈值 glowThreshold
,范围[0.0, 1.0]。我们暴露给上层使用者,交由上层使用者自行根据纹理去控制此值的大小即可。
现在我们的代码就可以修改为这样子了:
float alpha = getGlowAlpha();
if (alpha > glowThreshold) {
// 内发光是从边缘发光的,是需要内部透明度为0,靠近边缘的接近1的透明度
// 因此我们需要反转一下透明度
alpha = 1.0 - alpha;
}
gl_FragColor = glowColor * alpha;
在 glowThreshold 为 0.2 时,效果如下:
OK,看上去差不多的样子了,现在我们试着简单手动混合一下,看起来内发光效果就有了
???
好像还并不是内发光的效果,看上去上方尖角的光源有点扩边了?这是那里出问题了呢?
因为我们是要做内发光,所以如果点本来是透明的或者小于我们设立的阈值,那么其实这个点是没必要进行采样周边Alpha平均值的,否则就会有上面这种 扩边 的问题,那么我们在取发光透明度的时候,在判断一下即可
/**
* 获取发光的透明度
*/
float getGlowAlpha() {
// 如果发光宽度为0,直接返回0.0透明度,减少计算量
if (glowColorSize == 0.0) {
return 0.0;
}
// 因为我们是要做内发光,所以如果点本来是透明的或者接近透明的
// 那么就意味着这个点是图像外的透明点或者图像内透明点(如空洞)之类的
// 内发光的话,这些透明点我们不用处理,让它保持原样,否则就是会有内描边或者一点扩边的效果
// 同时也是提前直接结束,减少计算量
vec4 srcColor = getTextureColor(texture, v_uv0);
if (srcColor.a <= glowThreshold) {
return srcColor.a;
}
// 将传入的指定距离,平均分成10圈,求出每一圈的平均透明度,
// 然后求和取平均值,那么就可以得到该点的平均透明度
float totalAlpha = 0.0;
totalAlpha += getAverageAlpha(glowColorSize * 0.1);
totalAlpha += getAverageAlpha(glowColorSize * 0.2);
totalAlpha += getAverageAlpha(glowColorSize * 0.3);
totalAlpha += getAverageAlpha(glowColorSize * 0.4);
totalAlpha += getAverageAlpha(glowColorSize * 0.5);
totalAlpha += getAverageAlpha(glowColorSize * 0.6);
totalAlpha += getAverageAlpha(glowColorSize * 0.7);
totalAlpha += getAverageAlpha(glowColorSize * 0.8);
totalAlpha += getAverageAlpha(glowColorSize * 0.9);
totalAlpha += getAverageAlpha(glowColorSize * 1.0);
return totalAlpha * 0.1;
}
现在看下来效果差不多了,是内发光了!
但是好像发光强度不够得样子?没关系,我们给它加点料,来个一元四次方程加强,让靠近边缘的地方更加亮
对应代码如下:
float alpha = getGlowAlpha();
if (alpha > glowThreshold) {
// 内发光是从边缘发光的,是需要内部透明度为0,靠近边缘的接近1的透明度
// 因此我们需要反转一下透明度
alpha = 1.0 - alpha;
// 给点调料,让靠近边缘的更加亮
alpha = -1.0 * (alpha - 1.0) * (alpha - 1.0) * (alpha - 1.0) * (alpha - 1.0) + 1.0;
}
gl_FragColor = glowColor * alpha;
现在大概效果已经出来了:
三、混合颜色
在上面动图中,实际上为了演示,我是有两个 Sprite, 一个用内置材质,一个用在调试中的内发光材质,通过手动移动的方式,我们已经大概看到将内发光叠加到原图上方,看起来就是内发光特效了。
那么这一步,我们要怎么实现一步到位,直接就将内发光叠加在原图上,形成最终效果。
实际上,这也叫 混合模式 ,混合模式主要解决的是两种颜色之间,该如何混合,比如叠加、覆盖等等。
混合模式在我们平时开发中也是经常在使用着的,比如,Sprite 组件:
理解不同的组合,对于我们实现不同混合效果,是基础中的基础。
关于这部分,官方在 UI渲染批次合并指南的 Blend 模式章节 一文中有说到,觉得纯文字比较难以理解的,可以参考网上 2dx 关于混合模式的相关文章。
回归我们的主题,要实现在原图上叠加我们的内发光特效,那么
// 源颜色就是内发光颜色
vec4 color_dest = o;
// 目标颜色就是图案颜色色
vec4 color_src = glowColor * alpha;
// 按照官方的混合颜色介绍和规则
//
// 要在图案上方,叠加一个内发光,将两者颜色混合起来,那么最终选择的混合模式如下:
//
// (内发光)color_src: GL_SRC_ALPHA
// (原图像)color_dest: GL_ONE
//
// 即最终颜色如下:
// color_src * GL_SRC_ALPHA + color_dest * GL_ONE
gl_FragColor = color_src * color_src.a + color_dest;
混合后的最终效果:
四、编辑器 texture 函数问题
在对比 浏览器 和 Cocos Creator 编辑器 的预览结果的后,你可能会发现编辑器的发光效果,相比起浏览器的没有那么好,比如编辑器左右两边的发光很窄。
这是因为
在 Cocos Creator 2.2.1 的编辑器中,超出边界的uv并不是返回 vec4(0.0, 0.0, 0.0, 0.0),实际返回为
- 超出左边界的uv,返回 v_uv0.x = 0 的颜色
- 超出右边界的uv,返回 v_uv0.x = 1 的颜色
- 超出上边界的uv,返回 v_uv0.y = 1 的颜色
- 超出下边界的uv,返回 v_uv0.y = 0 的颜色
而这样子的处理,会导致我们获取图像边缘位置的周边像素的的 alpha 值有可能偏低。
比如:在我们这个例子上,以图像中间左边缘为例,采样周边平均 Alpha 的时候,因为超出图像边界的都是 1.0 ,因此这个图像左边缘的 平均 Alpha 就是1.0,相当于没有内发光了,光不起来,同理图像其他边缘也是。
要修复这个问题其实也很简单,我们只需要封装一层获取 uv 像素的函数
vec4 getTextureColor(sampler2D texture, vec2 v_uv0) {
if (v_uv0.x > 1.0 || v_uv0.x < 0.0 || v_uv0.y > 1.0 || v_uv0.y < 0.0) {
return vec4(0.0, 0.0, 0.0, 0.0);
}
return texture(texture, v_uv0);
}
然后将原来所有的 texture()
函数的地方直接替换为 getTextureColor()
即可
PS:上面用到的静图、动图都是修复后的效果图
五、总结
5.1 采样算法
在实现 采样周边像素Alpha取平均值 的时候,我们采用了 「按圆采样」 算法去进行采样,实际上,这里有很多种采样方式,比如: 矩形偏移采样
矩形偏移采样:
- 取右、右上、上、左上、左、左下、下、右下共计8个方向的点作为周边
- 按照上一步的定义去扩大「周边」,从而实现收集
大概步骤如下图:
不过,你也可以看到,这种方案的收集方式存在一个问题:
随着收集距离的扩大,会出现越来越多的点不会收集到,因为收集方向就只有8个,方向夹角之间的点是收集不了的(比如 23 -> 24, 33 -> 34 之间的点)
那是不是这个方案就不好呢,其实也不是,这个方案的最大优点是减少了很多 sin
, cos
的计算,因为就收集的8个方向,而这8个方向恰好只需要加法和减法就可以的出来了,因此性能上会更好,对于部分图片,如果发光宽度很短,那么此采集方案可能更优。
那么,简单总结下现在讨论的两种「周边采样算法」的优劣:
采样算法 | 优点 | 缺点 | 适用场合 |
---|---|---|---|
按圆采样 | 覆盖面相对较全,效果相对细腻 | 计算量相对偏多 | 绝大部分场合 |
矩形偏移采样 | 覆盖面相对少,效果相对粗糙,且由于方向固定,可能存在特殊情况下,效果不理想 | 计算量相对较少 | 发光宽度较少,比较少大转折弯的纹理 |
当然,还有其他很多采样算法,如果你有想法,不妨自己动手试下吧,试完之后记得分析下优劣和使用场合,这会让你有更多收获。
5.2 关于发光强度
为了实现边缘更加光亮,我直接写死了一个 一元四次方程,实际上这可能不好控制。另外一些好的公式可以使用 二次贝塞尔 或者 三次贝塞尔 可以很方便操作控制点,从而实现不同曲度。
5.3 其他
当然,在操作一遍下来后,说不准你也觉得这种实现不好,xxx地方有哪些地方可以优化,如果有更好的方案,我们不妨留言交流一下吧。
OK,本章完,完整代码在我的 Github 仓库 或 Gitee 仓库 中可以找到。
下一篇:
上一篇: