半小时轻松玩转WebGL滤镜技术系列(二)

1,076 阅读10分钟

腾讯DeepOcean原创文章:dopro.io/webgl-filte…

上个章节中,我们主要从如何绘制图片和如何添加滤镜以及动态控制滤镜效果两方面入手,辅助以灰度滤镜和对比度滤镜的案例,让大家对webgl滤镜开发有了初步的认识,也见识到了glsl语言的一些特性。如果你觉得上面两个滤镜太简单,不够硬,那么,本章节我们将会以抖音故障特效为例,为大家详细讲解如何让特效动起来,以及如何实现一个复杂特效。

先贴出我们的目标效果图

picture

效果分析

1. 由静转动

2. 图片位移和rgb色彩通道分离

3. 随机片段切割

由静转动

如果你小时候也玩过这样的翻页动画,那么这里就很容易理解,动画其实就是将一张张静止的图按顺序和一定的时间间隔连续播放。那么在webgl中,我们其实只需要做两点,首先,将时间戳作为片段着色器中的一个变量传递进去参与绘图计算,然后,通过定时器(或类似功能)来不断的传入最新的时间戳并且重绘整个图形。how-to-animate

废话不多说,我们直接来上代码,这里我们继续在第一章的基础上进行改造,如果你对webgl滤镜还没有任何经验,建议先看第一篇,《半小时轻松玩转WebGL滤镜技术系列(一)》

初始化着色器阶段

javascript
// ...
// 片元着色器
FSHADER_SOURCE: `
precision highp float;
uniform sampler2D u_Sampler;
uniform float speed; // 控制速度
uniform float time; // 传入时间
varying vec2 v_TexCoord;
void main () {
	// 通过速度和时间值来确定最终的时间变量
    float cTime = floor(time * speed * 50.0);
    // gl_FragColor = texture2D(u_Sampler, v_TexCoord);
	// 这里为了测试,我们选择用sin函数把时间转化为0.0-1.0之间的随机值
	gl_FragColor = vec4(vec3(sin(cTime)), 1.0);
}
`
// ...

绘制图像

// 以当日早上0点为基准
let todayDateObj = (() => {
    let oDate = new Date()
    oDate.setHours(0, 0, 0, 0)
    return oDate
})()
// 获取time位置
let uTime = gl.getUniformLocation(gl.program, 'time')
// 获取speed位置
let uSpeed = gl.getUniformLocation(gl.program, 'speed')
// 计算差值时间传入
let diffTime = (new Date().getTime() - todayDateObj.getTime()) / 1000 // 以秒传入,保留毫秒以实现速度变化
// 获取speed位置
gl.uniform1f(uTime, diffTime)
// 传入默认的speed,0.3
gl.uniform1f(uSpeed, 0.3)
// 设置canvas背景色
gl.clearColor(0, 0, 0, 0)
// 清空<canvas>
gl.clear(gl.COLOR_BUFFER_BIT) 
// 绘制 
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4)  
// 定时循环传入最新的时间以及重新绘制
let loop = () => {
    requestAnimationFrame(() => {
        diffTime = (new Date().getTime() - todayDateObj.getTime()) / 1000 // 以秒传入,保留毫秒以实现速度变化
        gl.uniform1f(uTime, diffTime)
        gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4)
        loop()
    })
}
loop()
// 利用GUI生成控制speed的进度条
let speedController = gui.add({speed: 0.3}, 'speed', 0, 1, 0.01)
speedController.onChange(val => {
    gl.uniform1f(uSpeed, val)
})

如果一切顺利,那么你将会看到一幅闪瞎眼的画面

动态测试

这时如果我们把右上角的speed一路拉满到1.0那么,画面将会是这样的

动态测试2

由于转为了gif,所以效果可能不是很好,建议还是代码体验

下面我们来分析一下为了实现这样的效果我们做了什么

  1. 首先在着色器中,我们用float cTime = floor(time * speed * 50.0);这样的一段代码确定了最终的时间变量,那么来分析一下,time我们传入是以秒为单位的,但是保留了三位毫秒变量,如果speed是一个较小值,那么speed * 50.0可以看作是无限接近于1,那么经过floor后time * speed * 50.0几乎是等于time,也就是时间变量1000毫秒变一次,但是如果speed不断增大,当speed为0.2时,可以认为时间变量每100毫秒就要变一次,继续增大,speed为1.0时就是20毫秒变一次,可以看出毫秒间隔随着speed的增大不断减少,也就实现了我们对速度变化的要求,需要注意的是,即使speed继续增大,如果间隔超过了requestAnimationFrame的间隔值也是无效的。gl_FragColor = vec4(vec3(abs(sin(cTime)), 1.0);这段函数其实就很好理解了,我们通过abs(sin(cTime))将cTime转化为不断变化的0.0-0.1区间的值,那么也就实现了图中的闪烁情况

  2. 绘制图像环节,我们其实也主要是实现了两个事情,一是初始化time和speed两个变量,二是在requestAnimationFrame的时候传入最新的时间并且重绘画面,并提供UI组件可视化的变动speed参数

图片位移和rgb色彩通道分离

将效果图导入ps中逐帧分析,我们发现,其实整个画面在随着时间不停地进行随机位移,且每次位移还伴随着色彩通道的变化,那么我们一个一个来看
  1. 位移

实现位移的方式并不复杂,同样是在片段着色器中

// ...
// 片元着色器
FSHADER_SOURCE: `
precision highp float;
uniform sampler2D u_Sampler;
varying vec2 v_TexCoord;
void main () {
    gl_FragColor = texture2D(u_Sampler, v_TexCoord - vec2(0.3));
}
`
// ...

对比原图来看

位移测试0 位移测试1

我们通过v_TexCoord - vec2(0.3)来使图像产生了错位,但是从图中我们也看出一个问题,当错位过多时会使图像超出画面,所以要想视觉可以接受,位移值不能过大。

  1. rgb色彩通道分离

实现色彩通道分离的方式并不难,只要我们将位移的图像rgb中任意一值与原图叠加即可,同样是片段着色器

// ...
// 片元着色器
FSHADER_SOURCE: `
precision highp float;
uniform sampler2D u_Sampler;
varying vec2 v_TexCoord;
void main () {
	// 原图
	vec3 color = texture2D(u_Sampler, v_TexCoord).rgb;
	// 以通道r举例
	color.r = texture2D(u_Sampler, v_TexCoord - vec2(0.1)).r;
    gl_FragColor = vec4(color, 1.0);
}
`
// ...

结果如图

通道分离1
  1. 随机

上面两个效果中我们发现其实位移和色彩通道分离实现起来都并不复杂,但是如何让v_TexCoord - vec2(0.1)中的变量和color.r = texture2D(u_Sampler, v_TexCoord - vec2(0.1)).r;中的通道选择能够随着时间产生随机变化是我们要考虑的重点,那么就需要用到随机函数,这里给大家介绍一种随机函数。

float random (vec2 st) {
    return fract(sin(dot(st.xy, vec2(12.9898,78.233))) * 43758.5453123);
}

上述是一个实现随机的方法,你可以很轻易的在网上各种复杂效果中看到这个方法,该方法接收一个vec2类型的变量,最终可以生成一个均匀分布在0.0-1.0区间的值,这里我们直接拿来使用,有兴趣的同学可以私下了解一下随机算法相关内容。下面是我们简单的演示

// ...
// 片元着色器
FSHADER_SOURCE: `
precision highp float;
uniform sampler2D u_Sampler;
varying vec2 v_TexCoord;
float random (vec2 st) {
	return fract(sin(dot(st.xy, vec2(12.9898,78.233)))* 43758.5453123);
}
void main () {
    float rnd = random( v_TexCoord );
    gl_FragColor = vec4(vec3(rnd),1.0);
}
`
// ...

效果如下图

随机测试

分析完了三种效果,那么我们如何将他们结合起来呢,首先来看位移部分,要想实现一定区间内的随机位移,那么我们就引入第三个变量offset来控制位移距离,通过offset来确定位移的区间,再利用随机函数产生区间内随机变化的值来确定最终位移值,然后是rgb通道分离,我们可以通过随机函数产生一个0.0-1.0的随机值,通过三等份来确定rgb各自的区间,将上述叠加起来,理论上就能够实现我们要的效果,那么我们来尝试一下。

再次扩展绘图函数

// 获取offset位置
let uOffset = gl.getUniformLocation(gl.program, 'offset')
// 传入默认的offset,0.3
gl.uniform1f(uOffset, 0.3)
// 设置canvas背景色
gl.clearColor(0, 0, 0, 0)
// 清空canvas
gl.clear(gl.COLOR_BUFFER_BIT)
// 绘制
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4) // 此处的4代表我们将要绘制的图像是正方形
// 利用GUI生成控制offset的进度条
let offsetController = gui.add({speed: 0.3}, 'offset', 0, 1, 0.01)
offsetController.onChange(val => {
    gl.uniform1f(uOffset, val)
})

着色器代码

precision highp float;
uniform sampler2D u_Sampler;
uniform float offset;
uniform float speed;
uniform float time;
varying vec2 v_TexCoord;
// 随机方法
float random (vec2 st) {
	return fract(sin(dot(st.xy, vec2(12.9898,78.233)))* 43758.5453123);
}
// 范围随机
float randomRange (vec2 standard ,float min, float max) {
	return min + random(standard) * (max - min);
}
void main () {
    // 原图
    vec3 color = texture2D(u_Sampler, v_TexCoord).rgb;
    // 位移值放缩 0.0-0.5
    float maxOffset = offset / 6.0;
    // 时间计算
    float cTime = floor(time * speed * 50.0);
    vec2 texOffset = vec2(randomRange(vec2(cTime + maxOffset, 9999.0), -maxOffset, maxOffset), randomRange(vec2(cTime, 9999.0), -maxOffset, maxOffset));
    vec2 uvOff = fract(v_TexCoord + texOffset);
    // rgb随机分离
    float rnd = random(vec2(cTime, 9999.0));
    if (rnd < 0.33){
    	color.r = texture2D(u_Sampler, uvOff).r;
    }else if (rnd < 0.66){
    	color.g = texture2D(u_Sampler, uvOff).g;
    } else{
    	color.b = texture2D(u_Sampler, uvOff).b;
    }
    gl_FragColor = vec4(color, 1.0);
}

效果如下,当然,你也可以试着改变speed和offset来对效果进行调整

初步结果1

随机片段切割

最后,我们还需要实现最后一个效果,就是随机片段的切割,如果你看过ps实现glitcher的效果,那应该很容易知道,切割效果的实现就是在图片中切割出一定数量的宽100%,高度随机的长条,然后使其发生横向位移,那么我们来实现一下。

着色器代码

precision highp float;
uniform sampler2D u_Sampler;
uniform float offset;
uniform float speed;
uniform float time;
varying vec2 v_TexCoord;
// 随机方法
float random (vec2 st) {
	return fract(sin(dot(st.xy, vec2(12.9898,78.233)))* 43758.5453123);
}
// 范围随机
float randomRange (vec2 standard ,float min, float max) {
	return min + random(standard) * (max - min);
}
void main () {
    // 原图
    vec3 color = texture2D(u_Sampler, v_TexCoord).rgb;
    // 时间计算
    float cTime = floor(time * speed * 50.0);
    // 切割图片的最大位移值
    float maxSplitOffset = offset / 3.0;
    // 这里我们选择切割10次
    for (float i = 0.0; i < 10.0; i += 1.0) { // 切割纵向坐标 float sliceY = random(vec2(cTime + offset, 1999.0 + float(i))); // 切割高度 float sliceH = random(vec2(cTime + offset, 9999.0 + float(i))) * 0.25; // 计算随机横向偏移值 float hOffset = randomRange(vec2(cTime + offset, 9625.0 + float(i)), -maxSplitOffset, maxSplitOffset); // 计算最终坐标 vec2 splitOff = v_TexCoord; splitOff.x += hOffset; splitOff = fract(splitOff); // 片段如果在切割区间,就偏移区内图像 if (v_TexCoord.y > sliceY && v_TexCoord.y < fract(sliceY+sliceH)) {
        	color = texture2D(u_Sampler, splitOff).rgb;
        }
    }
    gl_FragColor = vec4(color, 1.0);
}

效果如下,通过参数调整我们可以找到自认为最理想的状态

切割1

效果融合

当我们分别实现了单独的效果后,那肯定是希望将他们融合起来啦,废话不多说,直接上代码和效果图

着色器代码

precision highp float;
uniform sampler2D u_Sampler;
uniform float offset;
uniform float speed;
uniform float time;
varying vec2 v_TexCoord;
float random (vec2 st) {
	return fract(sin(dot(st.xy, vec2(12.9898,78.233)))* 43758.5453123);
}
float randomRange (vec2 standard ,float min, float max) {
	return min + random(standard) * (max - min);
}
void main () {
  // 原图
	vec3 color = texture2D(u_Sampler, v_TexCoord).rgb;
  // 位移值放缩 0.0-0.5
  float maxOffset = offset / 6.0;
  // 时间计算
  float cTime = floor(time * speed * 50.0);
  // 切割图片的最大位移值
  float maxSplitOffset = offset / 2.0;
  // 这里我们选择切割10次
  for (float i = 0.0; i < 10.0; i += 1.0) { // 切割纵向坐标 float sliceY = random(vec2(cTime + offset, 1999.0 + float(i))); // 切割高度 float sliceH = random(vec2(cTime + offset, 9999.0 + float(i))) * 0.25; // 计算随机横向偏移值 float hOffset = randomRange(vec2(cTime + offset, 9625.0 + float(i)), -maxSplitOffset, maxSplitOffset); // 计算最终坐标 vec2 splitOff = v_TexCoord; splitOff.x += hOffset; splitOff = fract(splitOff); // 片段如果在切割区间,就偏移区内图像 if (v_TexCoord.y > sliceY && v_TexCoord.y < fract(sliceY+sliceH)) {
        color = texture2D(u_Sampler, splitOff).rgb;
      }
  }
  vec2 texOffset = vec2(randomRange(vec2(cTime + maxOffset, 9999.0), -maxOffset, maxOffset), randomRange(vec2(cTime, 9999.0), -maxOffset, maxOffset));
  vec2 uvOff = fract(v_TexCoord + texOffset);
  // rgb随机分离
  float rnd = random(vec2(cTime, 9999.0));
  if (rnd < 0.33){
    color.r = texture2D(u_Sampler, uvOff).r;
  }else if (rnd < 0.66){
    color.g = texture2D(u_Sampler, uvOff).g;
  } else{
    color.b = texture2D(u_Sampler, uvOff).b;
  }
  gl_FragColor = vec4(color, 1.0);
}

效果如下

最终

总结

当你实现了文章最后的效果时,相信你已经能够自行去改写一些效果了,其实,本文的特效还有更大的扩展空间,例如分割线的区间数量,是否也可以通过传参数来控制呢,包括纵向切割高度,也是一样,甚至你想再增加一些额外的效果,也都是没问题的,当然,前提是你对glsl足够熟悉和熟练。

本章内容的主题虽然是故障特效,但在实践过程中其实也用到了一些通用的特效处理方法,例如随机函数的运用,偏移的运用等等。另外,文中也大量运用了一些glsl的常用基本类型(vec2,vec3,vec4)及内置函数(fract),要想快速实现滤镜效果,对于glsl基本的语法一定要做到烂熟于心,看到函数即能想到效果。这里为大家推荐几个学习途径,首先是《WebGL编程指南》,能够帮你快速建立基础,《The Book of Shaders》主要讲解着色器的相关运用,《Shadertoy》主要集合了一些特效案例,webgl的出现为视觉交互和用户体验带来了无限的可能,我们身处用户体验的最前端,更应快速吸收快速掌握。

one more thing

细心的同学一定会发现,文末的效果跟开篇的效果虽然看起来很像,但是似乎还有一点点差距,没错,其实开篇的效果中不仅仅只有一种滤镜,还叠加了电视线的滤镜,那么下一篇,我们将会为大家讲解如何实现滤镜叠加,以及电视线滤镜的实现方法,敬请期待。

欢迎关注"腾讯DeepOcean"微信公众号,每周为你推送前端、人工智能、SEO/ASO等领域相关的原创优质技术文章: