Cocos Creator Shader Effect 系列 - 6 - 内发光特效

本章为大家带来内发光特效。

2d-sprite-glow-inner.gif

一、内发光原理

学习 Shader 过程中,偶然在网上看到一句的内发光原理,十分精辟受用:

采样周边像素alpha取平均值,叠加发光效果

事实上,根据这句精辟的原理,就可以实现内发光了,你也试试吧?

以下为我的实现过程。

二、采样周边像素Alpha取平均值

怎么采集某个点的周边像素呢?这里我们可以用 「按圆采样」 算法

2.1 采样圆边上某点的 Alpha 值

如果我们已知圆的半径 radius ,已经某个角度 angle ,那么这个点的坐标就很好计算了,其上的 Alpha 值就不再话下 :

Step 1
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:

这里我们用到了 sincos 函数,函数接受的参数是弧度制,因此我们要实现角度转弧度

// 角度转弧度,公式为:弧度 = 角度 * (pi / 180)
float radian = angle * 0.01745329252; // 这个浮点数是 pi / 180

但实际上,GLSL ES 语言已然存在内置函数 radians(float degree):将角度值转化为弧度值,因此我们就用内置函数即可。

2.2 采样圆边上所有点的 Alpah 平均值

上面我们已经实现了获取圆上某点的颜色 Alpha 值。那么,我们只需要来一个 for 循环,遍历 0 到 360 度,那这个圆上所有点的颜色 Alpha 平均值就很容易算出来了。

但是,这样子可能会有两个问题:

  1. 计算量可能会太多,导致我们的性能低下
  2. 半径很少的时候,相邻的两个或多个角度的点可能很近,或者甚至重合,此时这两个点的 Alpha 值有可能相差不大,那么此时分别计算这些角度的 Alpha 值,就可能显得有点冗余了,取其一即可

基于以上考虑,最终我采用的是圆采样方式为:

以某个角度作为间隔,遍历由此产生的各个方向的Alpha值,将这些值的和的平均值近似看作这个圆的Alpha值

比如:

假设以 45° 角间隔,那么我只需要计算下图的 [10, 17] 共计 8 个点的 Alpha 平均值,那么这个值我就可以近似看作这个圆上所有点的 Alpha 平均值了

Step2

在 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取平均值

Step3

那么,那么我们要采样多少个圆呢?采集少了,效果可能粗糙,采集多了,可能计算量过多导致性能降低

一般而言,这种可变的属性,我们应该交给上层去传入,但是如果上层要用内发光特效,你暴露的一个参数名字叫 采样多少个圆 ,那使用者一般会很茫然。

事实上,更加贴合上层使用者理解的属性名应该为 发光宽度 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);

Test 1

可以看到右边的调试结果还是挺符合我们的输出预期,周边点明显是有一个渐变透明过程

但是,此时我们得到的是内部透明度为1,靠近边缘的为接近0的透明度,其他位置为0的透明度。而内发光效果的话,恰恰相反,我们需要的是一个内部透明度为0,靠近内边缘透明度为1的效果。

那么我们尝试反转一下

float alpha = getGlowAlpha();

// 内发光是从边缘发光的,是需要内部透明度为0,靠近边缘的接近1的透明度
// 因此我们需要反转一下透明度
alpha = 1.0 - alpha;
      
gl_FragColor = glowColor * alpha;
Test 2

现在是反转了,但是图像外边的其他位置却上色了,而在反转之前,图像外边的其他位置是透明的,为了应用这部分过来,在反转之前,我们判断一下,透明度大于某个 阈值 ,我们才反转 alpha 值。

那么这里的 阈值 要怎么定义呢?

为了更加深入理解这个问题,我们先来放大一下 Cocos 的 Logo 上方的那个角,先看清楚一个问题:

glowThreshold

可以看到图像的边缘黑色并不是立即切换到完全透明的,而是一个过渡效果,从黑色开始慢慢变透明直到完全透明,透明从1 -> 0 慢慢过渡。事实上大部分的图像边缘都差不多类似这样子,甚至部分图片的设计,本身就是有一个很长的渐变过渡带。

那么问题来了,针对这种有渐变过渡带的纹理,在我们实现的内发光特效中,我们的发光边缘要怎么定义呢?

  1. 从图像边缘最外边的透明度为0.0开始发光?
  2. 从图像边缘往内,不透明(即透明度为1.0)的地方开始发光?
  3. 从图像 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 时,效果如下:

Test 3

OK,看上去差不多的样子了,现在我们试着简单手动混合一下,看起来内发光效果就有了

Test 4

???

好像还并不是内发光的效果,看上去上方尖角的光源有点扩边了?这是那里出问题了呢?

因为我们是要做内发光,所以如果点本来是透明的或者小于我们设立的阈值,那么其实这个点是没必要进行采样周边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;
  }

现在看下来效果差不多了,是内发光了!

Test 5

但是好像发光强度不够得样子?没关系,我们给它加点料,来个一元四次方程加强,让靠近边缘的地方更加亮

flavour

对应代码如下:

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;

现在大概效果已经出来了:

Glow Inner

三、混合颜色

在上面动图中,实际上为了演示,我是有两个 Sprite, 一个用内置材质,一个用在调试中的内发光材质,通过手动移动的方式,我们已经大概看到将内发光叠加到原图上方,看起来就是内发光特效了。

Test 5

那么这一步,我们要怎么实现一步到位,直接就将内发光叠加在原图上,形成最终效果。

实际上,这也叫 混合模式 ,混合模式主要解决的是两种颜色之间,该如何混合,比如叠加、覆盖等等。

混合模式在我们平时开发中也是经常在使用着的,比如,Sprite 组件:

Blend

理解不同的组合,对于我们实现不同混合效果,是基础中的基础。

关于这部分,官方在 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;

混合后的最终效果:

Glow Inner

四、编辑器 texture 函数问题

在对比 浏览器Cocos Creator 编辑器 的预览结果的后,你可能会发现编辑器的发光效果,相比起浏览器的没有那么好,比如编辑器左右两边的发光很窄。

texture function problem

这是因为

在 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取平均值 的时候,我们采用了 「按圆采样」 算法去进行采样,实际上,这里有很多种采样方式,比如: 矩形偏移采样

矩形偏移采样:

  1. 取右、右上、上、左上、左、左下、下、右下共计8个方向的点作为周边
  2. 按照上一步的定义去扩大「周边」,从而实现收集

大概步骤如下图:

Total

不过,你也可以看到,这种方案的收集方式存在一个问题:

随着收集距离的扩大,会出现越来越多的点不会收集到,因为收集方向就只有8个,方向夹角之间的点是收集不了的(比如 23 -> 24, 33 -> 34 之间的点)

那是不是这个方案就不好呢,其实也不是,这个方案的最大优点是减少了很多 sincos 的计算,因为就收集的8个方向,而这8个方向恰好只需要加法和减法就可以的出来了,因此性能上会更好,对于部分图片,如果发光宽度很短,那么此采集方案可能更优。

那么,简单总结下现在讨论的两种「周边采样算法」的优劣:

采样算法 优点 缺点 适用场合
按圆采样 覆盖面相对较全,效果相对细腻 计算量相对偏多 绝大部分场合
矩形偏移采样 覆盖面相对少,效果相对粗糙,且由于方向固定,可能存在特殊情况下,效果不理想 计算量相对较少 发光宽度较少,比较少大转折弯的纹理

当然,还有其他很多采样算法,如果你有想法,不妨自己动手试下吧,试完之后记得分析下优劣和使用场合,这会让你有更多收获。

5.2 关于发光强度

为了实现边缘更加光亮,我直接写死了一个 一元四次方程,实际上这可能不好控制。另外一些好的公式可以使用 二次贝塞尔 或者 三次贝塞尔 可以很方便操作控制点,从而实现不同曲度。

5.3 其他

当然,在操作一遍下来后,说不准你也觉得这种实现不好,xxx地方有哪些地方可以优化,如果有更好的方案,我们不妨留言交流一下吧。

OK,本章完,完整代码在我的 Github 仓库Gitee 仓库 中可以找到。

下一篇:

上一篇: