最近团队在用 WASM + FFmpeg 打造一个 WEB 播放器。咱们是经过写 C 语言用 FFmpeg 解码视频,经过编译 C 语言转 WASM 运行在浏览器上与 JavaScript 进行通讯。默认 FFmpeg 去解码出来的数据是 yuv,而 canvas 只支持渲染 rgb,那么此时咱们有两种方法处理这个yuv,第一个使用 FFmpeg 暴露的方法将 yuv 直接转成 rgb 而后给 canvas 进行渲染,第二个使用 webgl 将 yuv 转 rgb ,在 canvas 上渲染。第一个好处是写法很简单,只需 FFmpeg 暴露的方法将 yuv 直接转成 rgb ,缺点呢就是会耗费必定的cpu,第二个好处是会利用 gpu 进行加速,缺点是写法比较繁琐,并且须要熟悉 WEBGL 。考虑到为了减小 cpu 的占用,利用 gpu 进行并行加速,咱们采用了第二种方法。javascript
在讲 YUV 以前,咱们先来看下 YUV 是怎么获取到的:
因为咱们是写播放器,实现一个播放器的步骤一定会通过如下这几个步骤:php
- 将视频的文件好比 mp4,avi,flv等等,mp4,avi,flv 至关因而一个容器,里面包含一些信息,好比压缩的视频,压缩的音频等等, 进行解复用,从容器里面提取出压缩的视频以及音频,压缩的视频通常是 H26五、H264 格式或者其余格式,压缩的音频通常是 aac或者 mp3。
- 分别在压缩的视频和压缩的音频进行解码,获得原始的视频和音频,原始的音频数据通常是pcm ,而原始的视频数据通常是 yuv 或者 rgb。
- 而后进行音视频的同步。
能够看到解码压缩的视频数据以后,通常就会获得 yuv。html
对于前端开发者来讲,YUV 其实有点陌生,对于搞过音视频开发的通常会接触到这个,简单来讲,YUV 和咱们熟悉的 RGB 差很少,都是颜色编码方式,只不过它们的三个字母表明的意义与 RGB 不一样,YUV 的 “Y” 表示明亮度(Luminance或Luma),也就是灰度值;而 ”U” 和 ”V” 表示的则是色度(Chrominance或Chroma),描述影像色彩及饱和度,用于指定像素的颜色。前端
为了让你们对 YUV 有更加直观的感觉,咱们来看下,Y,U,V 单独显示分别是什么样子,这里使用了 FFmpeg 命令将一张火影忍者的宇智波鼬图片转成YUV420P:java
ffmpeg -i frame.jpg -s 352x288 -pix_fmt yuv420p test.yuv
在 GLYUVPlay
软件上打开 test.yuv
,显示原图:
Y份量单独显示:
U份量单独显示:
V 份量单独显示:
由上面能够发现,Y 单独显示的时候是能够显示完整的图像的,只不过图片是灰色的。而U,V则表明的是色度,一个偏蓝,一个偏红。ios
常见的YUV的采样有YUV444,YUV422,YUV420:git
注:黑点表示采样该像素点的Y份量,空心圆圈表示采用该像素点的UV份量。
YUV的存储格式有两类:packed(打包)和 planar(平面):github
举个例子,对于 planar 模式,YUV 能够这么存 YYYYUUVV,对于 packed 模式,YUV 能够这么存YUYVYUYV。web
YUV 格式通常有多种,YUV420SP、YUV420P、YUV422P,YUV422SP等,咱们来看下比较常见的格式:canvas
其中YUV420P和YUV420SP根据U、V的顺序,又可分出2种格式:
YUV420P
:U前V后即YUV420P
,也叫I420
,V前U后,叫YV12
。YUV420SP
:U前V后叫NV12
,V前U后叫NV21
。数据排列以下:
I420: YYYYYYYY UU VV =>YUV420P YV12: YYYYYYYY VV UU =>YUV420P NV12: YYYYYYYY UV UV =>YUV420SP NV21: YYYYYYYY VU VU =>YUV420SP
至于为啥会有这么多格式,通过大量搜索发现缘由是为了适配不一样的电视广播制式和设备系统,好比 ios 下只有这一种模式NV12
,安卓的模式是 NV21
,好比 YUV411
、YUV420
格式多见于数码摄像机数据中,前者用于 NTSC
制,后者用于 PAL
制。至于电视广播制式的介绍咱们能够看下这篇文章【标准】NTSC、PAL、SECAM三大制式简介
以YUV420P存储一张1080 x 1280图片为例子,其存储大小为 ((1080 x 1280 x 3) >> 1)
个字节,这个是怎么算出来的?咱们来看下面这张图:
以 Y420P 存储那么 Y 占的大小为 W x H = 1080x1280
,U 为(W/2) * (H/2)= (W*H)/4 = (1080x1280)/4
,同理 V为(W*H)/4 = (1080x1280)/4
,所以一张图为 Y+U+V = (1080x1280)*3/2
。
因为三个部份内部均是行优先存储,三个部分之间是Y,U,V 顺序存储,那么YUV的存储位置以下(PS:后面会用到):
Y:0 到 1080*1280 U:1080*1280 到 (1080*1280)*5/4 V:(1080*1280)*5/4 到 (1080*1280)*3/2
## WEBGL
简单来讲,WebGL是一项用来在网页上绘制和渲染复杂3D图形,并容许用户与之交互的技术。
在 webgl 世界中,能绘制的基本图形元素只有点、线、三角形,每一个图像都是由大大小小的三角形组成,以下图,不管是多么复杂的图形,其基本组成部分都是由三角形组成。
着色器是在GPU上运行的程序,是用OpenGL ES着色语言编写的,有点相似 c 语言:
具体的语法能够参考着色器语言 GLSL (opengl-shader-language)入门大全,这里不在多加赘述。
在 WEBGL 中想要绘制图形就必需要有两个着色器:
其中顶点着色器的主要功能就是用来处理顶点的,而片元着色器则是用来处理由光栅化阶段生成的每一个片元(PS:片元能够理解为像素),最后计算出每一个像素的颜色。
1、提供顶点坐标
由于程序很傻,不知道图形的各个顶点,须要咱们本身去提供,顶点坐标能够是本身手动写或者是由软件导出:
在这个图中,咱们把顶点写入到缓冲区里,缓冲区对象是WebGL系统中的一块内存区域,咱们能够一次性地向缓冲区对象中填充大量的顶点数据,而后将这些数据保存在其中,供顶点着色器使用。接着咱们建立并编译顶点着色器和片元着色器,并用 program 链接两个着色器,并使用。举个例子简单理解下为何要这样作,咱们能够理解成建立Fragment 元素: let f = document.createDocumentFragment()
,
全部的着色器建立并编译后会处在一种游离的状态,咱们须要将他们联系起来,并使用(能够理解成 document.body.appendChild(f)
,添加到 body,dom 元素才能被看到,也就是联系并使用)。
接着咱们还须要将缓冲区与顶点着色器进行链接,这样才能生效。
2、图元装配
咱们提供顶点以后,GPU根据咱们提供的顶点数量,会挨个执行顶点着色器程序,生成顶点最终的坐标,将图形装配起来。能够理解成制做风筝,就须要将风筝骨架先搭建起来,图元装配就是在这一阶段。
3、光栅化
这一阶段就比如是制做风筝,搭建好风筝骨架后,可是此时却不能飞起来,由于里面都是空的,须要为骨架添加布料。而光栅化就是在这一阶段,将图元装配好的几何图形转成片元(PS: 片元能够理解成像素)。
4、着色与渲染
着色这一阶段就比如风筝布料搭建完成,可是此时并无什么图案,须要绘制图案,让风筝更加好看,也就是光栅化后的图形此时并无颜色,须要通过片元着色器处理,逐片元进行上色并写到颜色缓冲区里,最后在浏览器才能显示有图像的几何图形。
总结
WEBGL 绘制流程能够概括为如下几点:
因为每一个视频帧的图像都不太同样,咱们确定不可能知道那么多顶点,那么咱们怎么将视频帧的图像用 webgl 画出来呢?这里使用了一个技巧—纹理映射。简单来讲就是将一张图像贴在一个几何图形表面,使几何图形看起来像是有图像的几何图形,也就是将纹理坐标和 webgl 系统坐标进行一一对应:
如上图,上面那个是纹理坐标,分为 s 和 t 坐标(或者叫 uv 坐标),值的范围在【0,1】之间,值和图像大小、分辨率无关。下面那张图是webgl坐标系统,是一个三维的坐标系统,这里声明了四个顶点,用两个三角形组装成一个长方形,而后将纹理坐标的顶点与 webgl 坐标系进行一一对应,最终传给片元着色器,片元着色器提取图片的一个个纹素颜色,输出在颜色缓冲区里,最终绘制在浏览器里(PS:纹素你能够理解为组成纹理图像的像素)。可是若是按图上进行一一对应的话,成像会是反的,由于 canvas 的图像坐标,默认(0,0)是在左上角:
而纹理坐标则是在左下角,因此绘制时成像就会倒立,解决方法有两种:
// 1表明对纹理图像进行y轴反转 gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 1);
(0.0,1.0)
对应的是webgl 坐标(-1.0,1.0,0.0)
,(0.0,0.0)
对应的是(-1.0,-1.0,0.0)
,那么咱们倒转过来,(0.0,1.0)
对应的是(-1.0,-1.0,0.0)
,而(0.0,0.0)
对应的是(-1.0,1.0,0.0)
,这样在浏览器成像就不会是反的。详细步骤
// 顶点着色器vertexShader attribute lowp vec4 a_vertexPosition; // 经过 js 传递顶点坐标 attribute vec2 a_texturePosition; // 经过 js 传递纹理坐标 varying vec2 v_texCoord; // 传递纹理坐标给片元着色器 void main(){ gl_Position=a_vertexPosition;// 设置顶点坐标 v_texCoord=a_texturePosition;// 设置纹理坐标 } // 片元着色器fragmentShader precision lowp float;// lowp表明计算精度,考虑节约性能使用了最低精度 uniform sampler2D samplerY;// sampler2D是取样器类型,图片纹理最终存储在该类型对象中 uniform sampler2D samplerU;// sampler2D是取样器类型,图片纹理最终存储在该类型对象中 uniform sampler2D samplerV;// sampler2D是取样器类型,图片纹理最终存储在该类型对象中 varying vec2 v_texCoord; // 接受顶点着色器传来的纹理坐标 void main(){ float r,g,b,y,u,v,fYmul; y = texture2D(samplerY, v_texCoord).r; u = texture2D(samplerU, v_texCoord).r; v = texture2D(samplerV, v_texCoord).r; // YUV420P 转 RGB fYmul = y * 1.1643828125; r = fYmul + 1.59602734375 * v - 0.870787598; g = fYmul - 0.39176171875 * u - 0.81296875 * v + 0.52959375; b = fYmul + 2.01723046875 * u - 1.081389160375; gl_FragColor = vec4(r, g, b, 1.0); }
let vertexShader=this._compileShader(vertexShaderSource,gl.VERTEX_SHADER);// 建立并编译顶点着色器 let fragmentShader=this._compileShader(fragmentShaderSource,gl.FRAGMENT_SHADER);// 建立并编译片元着色器 let program=this._createProgram(vertexShader,fragmentShader);// 建立program并链接着色器
let vertexBuffer = gl.createBuffer(); let vertexRectangle = new Float32Array([ 1.0, 1.0, 0.0, -1.0, 1.0, 0.0, 1.0, -1.0, 0.0, -1.0, -1.0, 0.0 ]); gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer); // 向缓冲区写入数据 gl.bufferData(gl.ARRAY_BUFFER, vertexRectangle, gl.STATIC_DRAW); // 找到顶点的位置 let vertexPositionAttribute = gl.getAttribLocation(program, 'a_vertexPosition'); // 告诉显卡从当前绑定的缓冲区中读取顶点数据 gl.vertexAttribPointer(vertexPositionAttribute, 3, gl.FLOAT, false, 0, 0); // 链接vertexPosition 变量与分配给它的缓冲区对象 gl.enableVertexAttribArray(vertexPositionAttribute); // 声明纹理坐标 let textureRectangle = new Float32Array([1.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 1.0]); let textureBuffer = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, textureBuffer); gl.bufferData(gl.ARRAY_BUFFER, textureRectangle, gl.STATIC_DRAW); let textureCoord = gl.getAttribLocation(program, 'a_texturePosition'); gl.vertexAttribPointer(textureCoord, 2, gl.FLOAT, false, 0, 0); gl.enableVertexAttribArray(textureCoord);
//激活指定的纹理单元 gl.activeTexture(gl.TEXTURE0); gl.y=this._createTexture(); // 建立纹理 gl.uniform1i(gl.getUniformLocation(program,'samplerY'),0);//获取samplerY变量的存储位置,指定纹理单元编号0将纹理对象传递给samplerY gl.activeTexture(gl.TEXTURE1); gl.u=this._createTexture(); gl.uniform1i(gl.getUniformLocation(program,'samplerU'),1);//获取samplerU变量的存储位置,指定纹理单元编号1将纹理对象传递给samplerU gl.activeTexture(gl.TEXTURE2); gl.v=this._createTexture(); gl.uniform1i(gl.getUniformLocation(program,'samplerV'),2);//获取samplerV变量的存储位置,指定纹理单元编号2将纹理对象传递给samplerV
// 设置清空颜色缓冲时的颜色值 gl.clearColor(0, 0, 0, 0); // 清空缓冲 gl.clear(gl.COLOR_BUFFER_BIT); let uOffset = width * height; let vOffset = (width >> 1) * (height >> 1); gl.bindTexture(gl.TEXTURE_2D, gl.y); // 填充Y纹理,Y 的宽度和高度就是 width,和 height,存储的位置就是data.subarray(0, width * height) gl.texImage2D( gl.TEXTURE_2D, 0, gl.LUMINANCE, width, height, 0, gl.LUMINANCE, gl.UNSIGNED_BYTE, data.subarray(0, uOffset) ); gl.bindTexture(gl.TEXTURE_2D, gl.u); // 填充U纹理,Y 的宽度和高度就是 width/2 和 height/2,存储的位置就是data.subarray(width * height, width/2 * height/2 + width * height) gl.texImage2D( gl.TEXTURE_2D, 0, gl.LUMINANCE, width >> 1, height >> 1, 0, gl.LUMINANCE, gl.UNSIGNED_BYTE, data.subarray(uOffset, uOffset + vOffset) ); gl.bindTexture(gl.TEXTURE_2D, gl.v); // 填充U纹理,Y 的宽度和高度就是 width/2 和 height/2,存储的位置就是data.subarray(width/2 * height/2 + width * height, data.length) gl.texImage2D( gl.TEXTURE_2D, 0, gl.LUMINANCE, width >> 1, height >> 1, 0, gl.LUMINANCE, gl.UNSIGNED_BYTE, data.subarray(uOffset + vOffset, data.length) ); gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); // 绘制四个点,也就是长方形
上述那些步骤最终能够绘制成这张图:
完整代码:
export default class WebglScreen { constructor(canvas) { this.canvas = canvas; this.gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl'); this._init(); } _init() { let gl = this.gl; if (!gl) { console.log('gl not support!'); return; } // 图像预处理 gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1); // GLSL 格式的顶点着色器代码 let vertexShaderSource = ` attribute lowp vec4 a_vertexPosition; attribute vec2 a_texturePosition; varying vec2 v_texCoord; void main() { gl_Position = a_vertexPosition; v_texCoord = a_texturePosition; } `; let fragmentShaderSource = ` precision lowp float; uniform sampler2D samplerY; uniform sampler2D samplerU; uniform sampler2D samplerV; varying vec2 v_texCoord; void main() { float r,g,b,y,u,v,fYmul; y = texture2D(samplerY, v_texCoord).r; u = texture2D(samplerU, v_texCoord).r; v = texture2D(samplerV, v_texCoord).r; fYmul = y * 1.1643828125; r = fYmul + 1.59602734375 * v - 0.870787598; g = fYmul - 0.39176171875 * u - 0.81296875 * v + 0.52959375; b = fYmul + 2.01723046875 * u - 1.081389160375; gl_FragColor = vec4(r, g, b, 1.0); } `; let vertexShader = this._compileShader(vertexShaderSource, gl.VERTEX_SHADER); let fragmentShader = this._compileShader(fragmentShaderSource, gl.FRAGMENT_SHADER); let program = this._createProgram(vertexShader, fragmentShader); this._initVertexBuffers(program); // 激活指定的纹理单元 gl.activeTexture(gl.TEXTURE0); gl.y = this._createTexture(); gl.uniform1i(gl.getUniformLocation(program, 'samplerY'), 0); gl.activeTexture(gl.TEXTURE1); gl.u = this._createTexture(); gl.uniform1i(gl.getUniformLocation(program, 'samplerU'), 1); gl.activeTexture(gl.TEXTURE2); gl.v = this._createTexture(); gl.uniform1i(gl.getUniformLocation(program, 'samplerV'), 2); } /** * 初始化顶点 buffer * @param {glProgram} program 程序 */ _initVertexBuffers(program) { let gl = this.gl; let vertexBuffer = gl.createBuffer(); let vertexRectangle = new Float32Array([ 1.0, 1.0, 0.0, -1.0, 1.0, 0.0, 1.0, -1.0, 0.0, -1.0, -1.0, 0.0 ]); gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer); // 向缓冲区写入数据 gl.bufferData(gl.ARRAY_BUFFER, vertexRectangle, gl.STATIC_DRAW); // 找到顶点的位置 let vertexPositionAttribute = gl.getAttribLocation(program, 'a_vertexPosition'); // 告诉显卡从当前绑定的缓冲区中读取顶点数据 gl.vertexAttribPointer(vertexPositionAttribute, 3, gl.FLOAT, false, 0, 0); // 链接vertexPosition 变量与分配给它的缓冲区对象 gl.enableVertexAttribArray(vertexPositionAttribute); let textureRectangle = new Float32Array([1.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 1.0]); let textureBuffer = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, textureBuffer); gl.bufferData(gl.ARRAY_BUFFER, textureRectangle, gl.STATIC_DRAW); let textureCoord = gl.getAttribLocation(program, 'a_texturePosition'); gl.vertexAttribPointer(textureCoord, 2, gl.FLOAT, false, 0, 0); gl.enableVertexAttribArray(textureCoord); } /** * 建立并编译一个着色器 * @param {string} shaderSource GLSL 格式的着色器代码 * @param {number} shaderType 着色器类型, VERTEX_SHADER 或 FRAGMENT_SHADER。 * @return {glShader} 着色器。 */ _compileShader(shaderSource, shaderType) { // 建立着色器程序 let shader = this.gl.createShader(shaderType); // 设置着色器的源码 this.gl.shaderSource(shader, shaderSource); // 编译着色器 this.gl.compileShader(shader); const success = this.gl.getShaderParameter(shader, this.gl.COMPILE_STATUS); if (!success) { let err = this.gl.getShaderInfoLog(shader); this.gl.deleteShader(shader); console.error('could not compile shader', err); return; } return shader; } /** * 从 2 个着色器中建立一个程序 * @param {glShader} vertexShader 顶点着色器。 * @param {glShader} fragmentShader 片段着色器。 * @return {glProgram} 程序 */ _createProgram(vertexShader, fragmentShader) { const gl = this.gl; let program = gl.createProgram(); // 附上着色器 gl.attachShader(program, vertexShader); gl.attachShader(program, fragmentShader); gl.linkProgram(program); // 将 WebGLProgram 对象添加到当前的渲染状态中 gl.useProgram(program); const success = this.gl.getProgramParameter(program, this.gl.LINK_STATUS); if (!success) { console.err('program fail to link' + this.gl.getShaderInfoLog(program)); return; } return program; } /** * 设置纹理 */ _createTexture(filter = this.gl.LINEAR) { let gl = this.gl; let t = gl.createTexture(); // 将给定的 glTexture 绑定到目标(绑定点 gl.bindTexture(gl.TEXTURE_2D, t); // 纹理包装 参考https://github.com/fem-d/webGL/blob/master/blog/WebGL基础学习篇(Lesson%207).md -> Texture wrapping gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); // 设置纹理过滤方式 gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, filter); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, filter); return t; } /** * 渲染图片出来 * @param {number} width 宽度 * @param {number} height 高度 */ renderImg(width, height, data) { let gl = this.gl; // 设置视口,即指定从标准设备到窗口坐标的x、y仿射变换 gl.viewport(0, 0, gl.canvas.width, gl.canvas.height); // 设置清空颜色缓冲时的颜色值 gl.clearColor(0, 0, 0, 0); // 清空缓冲 gl.clear(gl.COLOR_BUFFER_BIT); let uOffset = width * height; let vOffset = (width >> 1) * (height >> 1); gl.bindTexture(gl.TEXTURE_2D, gl.y); // 填充纹理 gl.texImage2D( gl.TEXTURE_2D, 0, gl.LUMINANCE, width, height, 0, gl.LUMINANCE, gl.UNSIGNED_BYTE, data.subarray(0, uOffset) ); gl.bindTexture(gl.TEXTURE_2D, gl.u); gl.texImage2D( gl.TEXTURE_2D, 0, gl.LUMINANCE, width >> 1, height >> 1, 0, gl.LUMINANCE, gl.UNSIGNED_BYTE, data.subarray(uOffset, uOffset + vOffset) ); gl.bindTexture(gl.TEXTURE_2D, gl.v); gl.texImage2D( gl.TEXTURE_2D, 0, gl.LUMINANCE, width >> 1, height >> 1, 0, gl.LUMINANCE, gl.UNSIGNED_BYTE, data.subarray(uOffset + vOffset, data.length) ); gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); } /** * 根据从新设置 canvas 大小 * @param {number} width 宽度 * @param {number} height 高度 * @param {number} maxWidth 最大宽度 */ setSize(width, height, maxWidth) { let canvasWidth = Math.min(maxWidth, width); this.canvas.width = canvasWidth; this.canvas.height = canvasWidth * height / width; } destroy() { const { gl } = this; gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT | gl.STENCIL_BUFFER_BIT); } }
最后咱们来看下效果图:
在实际开发过程当中,咱们测试一些直播流,有时候渲染的时候图像显示是正常的,可是颜色会偏绿,经研究发现,直播流的不一样主播的视频宽度是会不同,好比在主播在 pk 的时候宽度368,热门主播宽度会到 720,小主播宽度是 540,而宽度为 540 的会显示偏绿,具体缘由是 webgl 会通过预处理,默认会将如下值设置为 4:
// 图像预处理 gl.pixelStorei(gl.UNPACK_ALIGNMENT, 4);
这样默认设置会每行 4 个字节 4 个字节处理,而 Y份量每行的宽度是 540,是 4 的倍数,字节对齐了,因此图像可以正常显示,而 U,V 份量宽度是 540 / 2 = 270
,270 不是4 的倍数,字节非对齐,所以色素就会显示偏绿。目前有两种方法能够解决这个问题:
// 图像预处理 gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
图像视频编码和FFmpeg(2)——YUV格式介绍和应用 - eustoma - 博客园
YUV pixel formats
https://wiki.videolan.org/YUV/
使用 8 位 YUV 格式的视频呈现 | Microsoft Docs?redirectedfrom=MSDN)
IOS 视频格式之YUV - 简书
图解WebGL&Three.js工做原理 - cnwander - 博客园