精准光影动效解决之道-透明视频

对于某些特殊场景,我们会遇到一些涉及光影的动画,实现这种动画的方案有很多,视频是其中成本最低的方案,但怎么才能让视频透明呢?如何可以快速导出支持透明的视频素材呢?

背景

对于光影动效,设计师往往是使用AE来制作,还会通过插件来制作一些粒子效果。如果通过手写代码来实现成本很高。

比如最近直播特别火,直播产品往往会为土豪定制炫酷的豪华礼物,甚至加一些duang一下的光影特效。比如企鹅电竞(强行广告一波)里的豪华礼物特效,不单需要应用于PC web,还需要应用于终端页面,所以需要有统一的方案。

 

方案对比

这种动画,游戏里面往往使用unitycocos2dxthreejs(web端)等来实现,unitycocos2dx会配套一些编辑工具,进行粒子和模型的编辑。但是对于普通的APP场景,为了豪华礼物加入这些库不实际,而且制作成本太高,所以对于豪华礼物的动画方案,我们常见的有三种:序列帧、动态图片、Lottie。我们从最关键的制作成本、性能、效果、体积这几个方面来对比:

1493886049_43_w1894_h694

为了突出豪华礼物的效果,设计师说AE插件制作的光影效果不能去,Lottie方案不满足要求,而从动态图片和序列帧来对比,明显动态图片更有优势。

所以一开始我们选用sharpP来实现,但是发现sharpP的体积还是偏大(上面视频的礼物动画大小为3M),而且在一些低端Android机上FPS偏低,那么有没有更好的实现方案呢?

我们都知道,视频无论体积和解码效率都优于图片,所以我们开始从视频入手,但是,视频是否能支持半透明呢?实际上在pc chrome已经支持透明视频了,但是移动端上并不支持,所以我们必须进行一些特殊处理。下面主要介绍下web端的实现方案(终端类似)。

透明jpg

先撇开视频不讲,我们先看看一个不透明的图片怎么让它变透明呢?也就是说怎么让jpg变透明?比如下面这张图片,我们怎么让黑色区域变透明,我们只需要给图片加上一个遮罩,而这个遮罩就是把原图透明的地方变成黑色,不透明的地方变成白色就可以了。

1493947380_55_w2180_h1194

 

对于半透明来说也是一样的,只是遮罩层是灰色而已。我们可以给原本透明的PNG图片,底部加一层黑色作为图片层,然后遮罩层的RGB值都设置为png图片的Alpha对应值,比如原图的RGBA(255,255,255,0.5)变成遮罩层的RGBA(127,127,127,1),这样进行遮罩就可以达到半透明的效果了。如下图:

 

1493948192_55_w2184_h1204

大家也可以看看这个demo

透明视频 – Canvas

视频当然不能进行遮罩,但是我们可以使用Canvas达到相同的效果。核心是将视频分为RGB视频和Alpha视频(跟图片类似,Alpha视频就是遮罩视频),为了保证取到两个视频的同一帧,可以将两个视频合在一个视频上,取出右侧视频的RGB值,并且取出左侧视频RGB最大值(实际3个值都是一样的)作为Alpha,合成RGBA 4个通道,然后绘制到Canvas上。

最终效果:

但是,半透明区域黑黑的一坨是什么鬼?

这主要是因为右侧视频叠加了黑底以后,原本半透明区域的RGB的值也改变了,比如原来白色(255,255,255,128)叠加黑底以后就变成灰色(128128128255)了,那怎么还原到原本的颜色呢?半透明叠加黑色以后,RGBA实际变成了(R*A,G*A,B*A,255),如果要还原回去,只需要除于Alpha值就可以了,具体Canvas代码如下:

  1. var bufferCtx=this.bufferCtx;
  2. var displayCtx=this.ctx;
  3. bufferCtx.drawImage(this.video, 0, 0, this.options.width*2,this.options.height);
  4. // 取右半边的图像
  5. var rgbaData = bufferCtx.getImageData(this.options.width, 0, this.options.width, this.options.height);
  6. // 取左半边的图像
  7. var alphaData = bufferCtx.getImageData(0, 0, this.options.width, this.options.height).data;
  8. for (var i = 3; i < rgbaData.data.length; i = i + 4) {
  9. // 计算alpha值,取左半边rgb最大的值(实际上都一样)作为alpha的值
  10. var alpha=Math.max(alphaData[i - 1], alphaData[i - 2], alphaData[i - 3]);
  11. // 半透明叠加黑色以后,RGBA实际变成了(R*A,G*A,B*A,255),如果要还原回去,只需要除于alpha值就可以了。
  12. var alphaRadio=alpha/255;
  13. if(alphaRadio==0){
  14. alphaRadio=1;
  15. }
  16. rgbaData.data[i-1]=rgbaData.data[i-1]/alphaRadio;
  17. rgbaData.data[i-2]=rgbaData.data[i-2]/alphaRadio;
  18. rgbaData.data[i-3]=rgbaData.data[i-3]/alphaRadio;
  19. rgbaData.data[i] = alpha;
  20. }
  21. displayCtx.putImageData(rgbaData, 0, 0, 0, 0, this.options.width, this.options.height);

虽然效果达到了,但是可以发现,Canvas需要对每一帧进行像素遍历运算,为了缓解CPU压力,能否让这一块的处理放到GPU里面处理呢?很自然地想到webGL。

透明视频 – webGL

webGL的实现方案与Canvas的基本一致,只是写法会大不相同。对于webGL,我们往往会基于three.jspixi.js等框架来编写代码,但是这里要实现的效果非常简单,用这些框架不太实际,那如何使用原生webGL来实现呢?这就要求对webGL有一定的了解,可以参考这篇《A Gentle Intro to WebGL》学习下,构建webGL程序大概的步骤有下面几步:

对于这里的场景,要求很简单,就是要按要求提取视频中的颜色信息,主要涉及到顶点着色器和片元着色器的处理。

首先,我们需要将每一帧视频图像与顶点坐标进行映射,视频右半侧映射到画面中。需要注意的是,webGL的坐标系与纹理坐标系不同,webGL的坐标原点(0,0)在中心,取值为[-1,1]之间,纹理坐标原点(0,0)在左下角,取值为[0,1]之间。

 

相关代码如下:

  1. // 顶点数据,前两位为顶点坐标,后两位为对应的纹理坐标,顶点坐标原点(0,0)在中心,取值为[-1,1]之间,纹理坐标原点(0,0)在左下角,取值为[0,1]之间
  2. // 这里将整个画布填充纹理的右半边。
  3. // x,y为顶点坐标,s,t为纹理坐标,也就是视频图像的坐标
  4. // 这里将纹理右半部分映射到整个画布上
  5. var vertices = new Float32Array([
  6. // x , y , s , t
  7. -1, 1, 0.5, 1.0,
  8. 1, 1, 1.0, 1.0,
  9. -1, -1, 0.5, 0.0,
  10. 1, -1, 1.0, 0.0
  11. ]);
  12. // 创建buffer
  13. var buffer = gl.createBuffer();
  14. // 把缓冲区对象绑定到目标
  15. gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
  16. // 向缓冲区对象写入刚定义的顶点数据
  17. gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);
  18. // 获取attribute变量的存储位置
  19. var a_position = gl.getAttribLocation(program, 'a_position');
  20. gl.enableVertexAttribArray(a_position);
  21. var a_texCoord = gl.getAttribLocation(program, 'a_texCoord');
  22. gl.enableVertexAttribArray(a_texCoord);
  23. // 将缓冲区对象分配给a_position变量、a_texCoord变量
  24. var size = vertices.BYTES_PER_ELEMENT;
  25. gl.vertexAttribPointer(a_position, 2, gl.FLOAT,false, size * 4, 0);
  26. gl.vertexAttribPointer(a_texCoord, 2, gl.FLOAT, false, size * 4, size * 2);
  27. [/cc]
  28. 这样其实只是把右半部分视频显示在webGL上,但是我们需要提取左半部分的颜色值作为alpha的值,这就需要使用到片元着色器了,理解好webGL和纹理的坐标系以后,其实这个比较简单。着色器代码如下:
  29. [cc lang="javascript"]
  30. addShader(program, gl.VERTEX_SHADER,
  31. "attribute vec2 a_position;"+ // 接受顶点坐标
  32. "attribute vec2 a_texCoord;"+ // 接受纹理坐标
  33. "varying vec2 v_texcoord;"+ // 传递纹理坐标给片元着色器
  34. "void main(void){"+
  35. "gl_Position = vec4(a_position, 0.0, 1.0);"+ // 设置坐标
  36. "v_texcoord = a_texCoord;"+ // 设置纹理坐标
  37. "}"
  38. );
  39. // 片元着色器
  40. addShader(program, gl.FRAGMENT_SHADER,
  41. "precision lowp float;"+ //!!! 需要声明浮点数精度,否则报错
  42. "varying vec2 v_texcoord;"+ // 取样器
  43. "uniform sampler2D u_sampler;"+ // 接受顶点着色器传递过来的纹理坐标
  44. "void main(void){"+
  45. // 渲染的颜色取视频每一帧右半部分的rgb+左半部分的r(或g\b,都相同)作为alpha通道的值。这里之所以不用除于alpha,是因为webgl默认的内部混合模式是gl.blendFunc(gl.ONE, gl.ZERO);而与页面的混合模式是gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);最终的颜色正好除了alpha
  46. "gl_FragColor = vec4(texture2D(u_sampler, v_texcoord).rgb, texture2D(u_sampler, v_texcoord+ vec2(-0.5, 0)).r);"+ // 设置对应坐标的色值
  47. "}"
  48. );

最终的效果:

隐藏的坑

细心的你可能会发现,片元着色器提取颜色的时候,并没有像Canvas一样给提取出来的RGB都除于Alpha的值,但是呈现出来的颜色效果却是对的,这主要是由webGL的透明原理决定的。

计算机并不认识透明度,我们在屏幕上看到的透明度只是程序的混色方式决定的,正常来说(比如CSS和Canvas中),半透明颜色的计算方法为color(RGBA) = (sourceColor * alpha) + (destinationColor * (1-alpha))。既然webgl表现跟canvas不同,那webgl用的肯定不是同样的混色方法。

webGL的混色方法可以通过blendfunc函数来设置,我们代码里面并没有特别设置混合模式,那就是使用默认的模式gl.blendfunc(gl.ONE,gl.ZERO);这个混合模式的计算方法为:

color(RGBA) = (sourceColor * 1) + (destinationColor * 0) = sourceColor

比如原来rgba(255,0,0,0.5)混合后还是rgba(255,0,0,0.5),还是半透明,需要跟网页的背景再进行混色,而与网页的混合方式很明显不是gl.blendfunc(gl.ONE,gl.ZERO),也不是正常的透明度混合方式(因为最终的效果与canvas的不一致)。那具体是什么呢?Google了半天都没有找到,后来写了个Demo进行验证,发现网页和webGL透明度混合模式是gl.blendFunc(gl.ONE,gl.ONE_MINUS_SRC_ALPHA);混合方程为:

color(RGBA) = (sourceColor * 1) + (destinationColor * (1-Alpha))

并且无法更改,至于webGL为什么需要这样处理也不得而知。

那么,一个webGL里的颜色,会经过两次混色以后,最终的颜色为color(RGBA) = (sourceColor * 1) + (destinationColor * (1-Alpha))。我们再回顾下前面Canvas的处理,我们对取到的rgb进行了除于Alpha的处理,所以混合以后的颜色为:

color(RGBA) = ((sourceColor/alpha) * alpha) + (destinationColor * (1-alpha))= sourceColor + (destinationColor * (1-Alpha))

正好与webGL的混色方程一致,所以最终表现与Canvas一致。webGL会比Canvas流畅不少。

 

素材制作:

前面提到,为了保证Alpha视频与RGB视频保持同步,我们在视频制作的时候,就把两个视频合在一起,设计师往往会通过AE来制作视频,但是如果让设计师直接制作左右拼合的视频明显不科学,我们完全可以借助ImageMagickffmpeg等工具,通过一个RGBA的序列帧来生成左右拼合的视频,整个过程如下图所示:

 

但是每次素材制作都要经过这些环节,门槛太高,效率也太低,所以为了配合透明视频的方案,我们还制作了一个快速制作素材的工具,直接把中间环节省掉了,用过都说好。

写在最后

透明视频的方案在体积和素材制作成本上都有很大优势,而且不单可以应用于豪华礼物,还可以应用于其他复杂动画场景。但是在web上应用还是有一些局限性,主要在于video标签的表现问题和webGL的兼容问题,但是如果在x5内核下,这两个问题会有所缓解。而在终端上则没有这些限制,iOS和Android都可以通过硬解码视频的方式达到高性能动画的要求,而在GL实现上其实跟web类似。目前这套方案还可以应用在PC的直播间中,使三端的素材和方案保持一致。

 

2 Responses

发表评论