Web H5视频滤镜的“百搭”解决方案——WebGL着色器

2018-10-19 20:30:23 浏览数 (1)

视频滤镜,顾名思义,是在视频素材上duang特效的一种操作。 随着H5页面越做越炫酷的趋势,单一的视频播放已经不能满足我们的需求,视频滤镜在Web页面上的应用越来越广泛。

问题概述

如何实现视频滤镜呢?最容易想到的方案是使用CSS3内置的滤镜。

CSS3为我们封装了一些常用的滤镜算法,如模糊,灰阶、饱和度等,使用filter属性来定义,详细参见 https://www.w3cplus.com/css3/ten-effects-with-css3-filter

除了作用于图片,该属性也可以作用于video标签,即视频滤镜。 同理,svg的filter也可以实现类似的效果,实现方式大同小异。

小伙伴的IceVideo组件便置入了基于CSS3 filter实现的视频滤镜,链接内有包括案例在内的详细说明,本文不再赘述。

本文主要讨论的是上述方案无法覆盖的场合。 对于一些特殊风格化、定制化的效果,我们很难通过现有的filter来做出,比如

上述的抠图效果、旧电视雪花效果,本身计算方式复杂,无法使用简单的规则来定义。 对于这类“很难归类”需求,难道就没有一种更加自由的,泛用的滤镜实现方式,可以满足复杂场景吗? 答案当然是有的。 本文便介绍一种“百搭”的解决办法——WebGL着色器。 使用WebGL提供的api,在像素操作级别,定制只属于你的一款滤镜。

先睹为快的示例

(示例中的视频均来自QQ-AR项目合作商的线上素材)

为了探索合适的方案,我们需要从问题的本质入手分析。

问题一、视频滤镜的本质是什么?

滤镜的本质是一种映射。即通过某种特定的算法,将图像中的像素点从一个值,映射成另一个。 对于视频,则是对每一个图像帧进行映射。 映射算法的设计,是图形图像处理的内容,目前已经有很多成熟的算法。

举几个简单的例子:

灰阶的映射算法。 new rgb = (0.2989*r 0.5870*g 0.1140*b)

反相(底片)的映射算法 new r=1.0 - r; new g=1.0 - g; new b=1.0 - b;

通过调节其中的计算参数,就可以控制效果的强弱。

在Web上,如何实现这些算法呢?

我们不能够直接操作video标签的内容,但我们能够做一个“中转”,把video绘制到canvas里,然后直接使用canvas提供的绘制api,修改像素值。 具体的方式,在我的另一篇介绍“视频吸色”的文章中有详细描述。

概括地说,代码如下。

代码语言:javascript复制
function playCanvas() {
      var mycanvas = $(_this).find("#mycanvas")[0]
      var myvideo = $(_this).find("#myvideo")[0]
      var context = mycanvas.getContext("2d")
      context.drawImage(myvideo, 0, 0, opts.videoWidth, opts.videoHeight)
      colorData = getPixelColor(mycanvas, canvasMousePos.x, canvasMousePos.y)
      requestAnimationFrame(playCanvas)
}

将原始的video标签设为隐藏,然后使用requestAnimationFrame回调,不断地用video的内容来更新canvas。 使用canvas方案,我们有了处理单帧图片的方法,而且它的兼容性比CSS3 filter要好,只要支持canvas的浏览器都可以渲染。 这种方法对于图片来说是足够的,几乎没有时间延迟,但处理每秒24-60帧的视频,就会产生较大的延迟,引发严重的性能问题。

上图是使用canvas的像素操作实现灰阶滤镜时,在chrome console录制的资源消耗图 可以看到,cpu的主线程已经被占满,在电脑上有明显卡顿,在手机上几乎是无法使用的。

这种方案的问题在于,将所有的像素都输入给cpu,逐点串行,没有考虑并行化的可能。 那么视频滤镜操作能否并行呢?主要取决于滤镜的实现方式,即“像素是怎么映射的”。

问题二、能否并行?

笔者考察了图形图像处理中,常见的滤镜实现方式,将其归纳总结为以下三类。

1、单像素映射法 对单个像素的颜色值进行操作。 比如反相,灰阶,变亮变暗,饱和度效果。 乃至在笔者的需求中遇到的,更为复杂的绿幕视频抠图效果(后文会有详细叙述)。

2、区域卷积法 计算一个像素时,同时使用邻近n个像素的值。 可以描述为卷积操作,使用一个矩阵作为卷积核,遍历整个图像。 比如模糊,浮雕等效果,都是用这种方式做出的。

3、颜色查表法 对于一些高度风格化的处理,很难采用单一算法描述,此时可以将颜色保存在一个512x512的表里,通过查找和差值,推算出每个像素的映射结果。 这种算法叫做Color Lookup Table,简称Color LUT,最经典的实现来自于ios内置算法库GPUImage。 该算法库已开源,github地址 https://github.com/BradLarson/GPUImage

以上三种类别,虽然原理各异,但都是局限在图像局部的操作,空间复杂度是O(1)级别的。

那么,这些算法,一定是可以并行化的。

问题三、如何并行?

实际上,css3中的filter属性,和我们熟悉的transform一样,是强制使用强制使用GPU渲染的。 也就是说,如果我们给video标签设置一个filter,像素间的计算便已经并行化了。

如果不使用css3中定义的属性,而自定义计算方式,仅靠video或者canvas方案,都无法唤起cpu,前面说的“中转”方案也无法直接使用。 这时候,我们就需要用到前端的一个强大武器——WebGL。

WebGL是一套实现了OpenGL标准的Web API,这其中也包括像素级的并行计算API——着色器(Shader)。 着色器定义了一个三维空间中的点,如何渲染成为屏幕上的一个像素点。 可以理解为WebGL渲染管道的最后一个步骤。 分为顶点着色器(Vertex Shader)和片元着色器(Fragment Shader)两个步骤,具体的工作原理有很多介绍OpenGL的教程都有提及,此处不再赘述。

利用WebGL提供的api,我们可以定义自己的Shader。 虽然是在Web上实现,但并不是使用Javascript语法,而是使用GLSL语法书写的。 关于具体的语法,这里也不再展开赘述。

在Web上使用自定义Shader进行渲染的过程,可以用下图来概括。

落实到具体实现过程,可以分为三步。

1、建立一个场景,并且把视频作为材质,贴到一个平面物体上。

2、对这个材质指定顶点着色器和片元着色器。

3、将物体置入场景,在屏幕中的canvas对象中渲染出来。

因为物体是简单的平面,所以我们的顶点着色器很简单,只要计算出每个像素的UV纹理坐标,传递给片元着色器就可以了。

代码语言:javascript复制
varying vec2 vUv;
void main()
{
	vUv = uv;
	vec4 mvPosition = modelViewMatrix * vec4( position, 1.0 );
	gl_Position = projectionMatrix * mvPosition;
}

在片元着色器里,我们通过下面的语句

代码语言:javascript复制
gl_FragColor = texture2D( texture, vUv );

取到这个点的实际色值,然后开始真正的像素映射计算。

灰阶:

代码语言:javascript复制
float gray = 0.2989*gl_FragColor.r 0.5870*gl_FragColor.g 0.1140*gl_FragColor.b;
gl_FragColor = vec4(gray,gray,gray , gl_FragColor.a);

反向:

代码语言:javascript复制
float reverser=1.0 - gl_FragColor.r;
float reverseg=1.0 - gl_FragColor.g;
float reverseb=1.0 - gl_FragColor.b;
gl_FragColor = vec4(reverser,reverseg,reverseb,gl_FragColor.a);

下面是两个较为复杂的效果实现。

雪花怀旧效果:

代码语言:javascript复制
float dx = fract(sin(dot(vUv ,vec2(12.9898,78.233))) * 43758.5453);
vec3 cResult = gl_FragColor.rgb   gl_FragColor.rgb * clamp( 0.1   dx, 0.0, 1.0 );
vec2 sc = vec2( sin( vUv.y * 4096.0 ), cos( vUv.y * 4096.0 ) );
cResult  = gl_FragColor.rgb * vec3( sc.x, sc.y, sc.x ) * 0.025;
cResult = gl_FragColor.rgb   clamp( 0.35, 0.0,1.0 ) * ( cResult - gl_FragColor.rgb );
if( false ) {
  cResult = vec3( cResult.r * 0.3   cResult.g * 0.59   cResult.b * 0.11 );
}
float oldr=0.393*cResult[0] 0.769*cResult[1] 0.189*cResult[2];
float oldg=0.349*cResult[0] 0.686*cResult[1] 0.168*cResult[2];
float oldb=0.272*cResult[0] 0.534*cResult[1] 0.131*cResult[2];
gl_FragColor =  vec4( oldr,oldg,oldb , gl_FragColor.a);

(参考了Threejs官方范例)

绿幕抠图Chroma Keying:

代码语言:javascript复制
float rgb2cb(float r, float g, float b){
  return 0.5   -0.168736*r - 0.331264*g   0.5*b;
}
float rgb2cr(float r, float g, float b){
  return 0.5   0.5*r - 0.418688*g - 0.081312*b;
}
float smoothclip(float low, float high, float x){
  if (x <= low){
    return 0.0;
  }
  if(x >= high){
    return 1.0;
  }
  return (x-low)/(high-low);
}
vec4 greenscreen(vec4 colora, float Cb_key,float Cr_key, float tola,float tolb, float clipBlack, float clipWhite){
  float cb = rgb2cb(colora.r,colora.g,colora.b);
  float cr = rgb2cr(colora.r,colora.g,colora.b);
  float alpha = distance(vec2(cb, cr), vec2(Cb_key, Cr_key));
  alpha = smoothclip(tola, tolb, alpha);
  float r = max(gl_FragColor.r - (1.0-alpha)*color.r, 0.0);
  float g = max(gl_FragColor.g - (1.0-alpha)*color.g, 0.0);
  float b = max(gl_FragColor.b - (1.0-alpha)*color.b, 0.0);
  if(alpha < clipBlack){
    alpha = r = g = b = 0.0;
  }
  if(alpha > clipWhite){
    alpha = 1.0;
  }
  if(clipWhite < 1.0){
    alpha = alpha/max(clipWhite, 0.9);
  }
  return vec4(r,g,b, alpha);
}

float tola = 0.0;
float tolb = u_threshold/2.0;
float cb_key = rgb2cb(color.r, color.g, color.b);
float cr_key = rgb2cr(color.r, color.g, color.b);
gl_FragColor = greenscreen(gl_FragColor, cb_key, cr_key, tola, tolb, u_clipBlack, u_clipWhite);

(参考了github上的开源项目greenscreen)

以Chroma Keying算法为例,看起来代码比较长,我们可以分解一下它的核心原理,简要描述如下:

1、计算key色的红、蓝分量,组成向量A。 2、计算目标颜色的红蓝分量,组成向量B。 3、计算两个向量的距离(一个分量在另一个分量上的投影) 当AB向量接近,alpha趋于1 AB向量很远,alpha趋于0 4、以alpha作为过滤指标,滤掉目标颜色rgb值中的key色分量,计算出该点的rgb值 5、将1-alpha作为该点的透明度值(rgba中的a) 6、将该点像素值设置为新的rgba

提取分量A、B,计算alpha值,并设置新颜色的算法,可以用下图表示

通过这样的映射,我们可以很好地处理半透明边缘、模糊边缘

上图是应用在QQ-AR透明Webview项目中的案例

更多的滤镜算法,可以参考其他图形图像方面的资料。

虽然看似复杂,但上述所有算法,都是局部像素的浮点数计算。 我们把它们放进GPU中充分并行之后

得到是Chrome console资源消耗图

可以看出,计算重心转移到了GPU,cpu仍是相对空闲的。

我们对QQ-AR透明Webview中的示例进行帧率考察

可以看出,在使用gpu并行计算时,滤镜几乎不会引发掉帧。

除了定义Shader之外,我们在建立场景时,还要考虑如何完成从3D到2D的合理映射。 如何把视频作为材质渲染到场景中,并且刚好填满视口? 我们知道,一个三维场景是通过摄像机来映射到二维视口的。

传统的投影相机,有近大远小的问题。 实际上,我们很难通过视频素材本身的宽高,计算出最终视口的宽高。

这里要用到OrthographicCamera(正交相机)

正交相机没有投影变形,所以也就不存在近大远小准则。 在建立场景时,只要保证相机视口的尺寸和渲染物体的尺寸相同。 渲染物体尺寸又根据视频本身的长宽来取。 就可以建立一个视频同等大小的WebGL Canvas场景。

下面是核心代码

(使用了Three.js操作WebGL api)

代码语言:javascript复制
//取到video标签
var video = document.getElementById(videoId);

//设置场景
var scene = new THREE.Scene();
var renderer = new THREE.WebGLRenderer( { antialias: true,alpha: true } );
document.getElementById(container).appendChild(renderer.domElement);
renderer.setClearColor(0xffffff,0);
renderer.setSize( video.width, video.height );
//设置正交相机
var camera = new THREE.OrthographicCamera(-2, 2, 1.5, -1.5, 1, 10);
camera.position.set(0, 0, 1);
scene.add(camera);
//设置平面物体,并将视频作为材质
var movieMaterial = new ChromaKeyMaterial(videoId, video.width, video.height, 0x00ff05,0);
var movieGeometry = new THREE.PlaneGeometry(4, 3);
var movie = new THREE.Mesh(movieGeometry, movieMaterial);
movie.position.set(0, 0, 0);
movie.scale.set(1, 1, 1);
movie.visible=false;
scene.add(movie);
//开始动画
video.play();
animate();
function animate() {
  if( (video.currentTime>1) && movie.visible==false){
    movie.visible=true;
  }
  requestAnimationFrame(animate);
  renderer.render(scene, camera);
}

说明1:ChromaKeyMaterial是继承了Three默认的ShaderMeterial实现的自定义材质类。

自定义类的代码较长,此处不再贴出,详细可以右键本文提供的案例代码。

说明2:animate函数里,通过video.currentTime来切换movie物体的显示隐藏,是为了预防平面物体在材质贴图完成前(视频还在载入时)的一段时间黑屏,实际项目中可以加入一些loading效果,以保证体验

问题四、兼容性如何?

不是所有的设备都兼容CSS3 filter(仅限Chrome内核) 也不是所有设备都支持WebGL标准(比如万恶的ie) 这是CANIUSE提供的WebGL兼容性结果。

这是腾讯大数据中心对移动设备兼容WebGL的统计结果。

实际上,由于x5内核的存在,在手机QQ中兼容WebGL的比例要比图上的16%更高一些。 下面则是我们使用上报的方式,对移动设备进行考察,得到的结果。

在移动端大部分设备都越来越先进的今天,为了duang出更好更酷炫的效果,在必要的场合使用WebGL方案是可取的。

总结

以上就是本文主要介绍的内容,在文章结尾,我们再重新看一遍开头的例子。

传送门

例子中,左边是一个普通视频,右边是使用Chroma Keying算法进行抠图的绿幕视频。 我对二者都应用了自定义的滤镜,并且开放了一部分参数由用户控制。

从例子中可以看出。 1、滤镜是可以叠加的(因为这些滤镜算法本质都是像素计算,只要把算法叠加起来就好了) 2、参数是可控的(因为算法的实现完全透明,所以我们对它有全权控制权,用起来足够灵活)

当然代价就是实现成本比较高,所以,对于简单的需求,我们仍推荐使用简单的方案(比如css3滤镜,svg滤镜)。 对于复杂的需求,再来使用本文提出的方案,定制个性化特效。

并且注意对于不兼容情况的降级处理(推荐降级成使用普通video标签来渲染,放弃滤镜)

WebGL的强大之处绝不仅于此,使用自定义Shader,我们还可以做更多的事情,比如曲面视频,球面视频等等,详细的应用场景,有待各位看官大神继续发掘。

0 人点赞