前言
上一篇文章《视频解码篇》主要介绍了原始HEVC码流如何解码成YUV数据(通常视频采用的都是YUV格式),本章主要介绍如何将解码的YUV数据渲染成图像。在此之前我们先回顾一下DEMO架构
![](https://i-blog.csdnimg.cn/blog_migrate/485cea652f7e704ac1364fb5172934dd.png)
上图中可以看到,我们接收到YUV数据后需要使用WebGL对YUV处理转换成RGB数据然后进行渲染。那么为什么要转换成RGB呢,首先我们先了解下什么是YUV,以及YUV和RGB的区别。
什么是YUV
(从上至下分别是原图,Y分量,U分量,V分量)
节选一段维基百科的描述:
YUV是编译true-color颜色空间的种类,Y'UV, YUV, YCbCr,YPbPr等专有名词都可以称为YUV,彼此有重叠。“Y”表示明亮度(Luminance、Luma),“U”和“V”则是色度、浓度(Chrominance、Chroma)。通俗讲就是Y可以用来渲染黑白图像,而UV用来上色。
YUV Formats分成两个格式:
紧缩格式(packed formats):将Y、U、V值存储成Macro Pixels数组,和RGB的存放方式类似。
平面格式(planar formats):将Y、U、V的三个分量分别存放在不同的矩阵中。
紧缩格式中的YUV是混合在一起的,对于YUV4:4:4格式而言,用紧缩格式很合适的,因此就有了UYVY、YUYV等。平面格式是指每Y分量,U分量和V分量都是以独立的平面组织的,也就是说所有的U分量必须在Y分量后面,而V分量在所有的U分量后面,此一格式适用于采样。平面格式有I420(4:2:0)、YV12、IYUV等。
![](https://i-blog.csdnimg.cn/blog_migrate/4b3342ecb9153e28ac76f7f0c4d79d3a.png)
本文用例中的视频为420p采样,故后续代码均以YUV-420p采样为准
与RGB的区别
![](https://i-blog.csdnimg.cn/blog_migrate/b84a34c7fe4dafe2f6ba99b0f97c5798.png)
RGB,三原色光模式,又称RGB颜色模型或红绿蓝颜色模型,是一种加色模型,将红(Red)、绿(Green)、蓝(Blue)三原色的色光以不同的比例相加,以合成产生各种色彩光。
至今为止,所有的彩色显示屏都是使用三原色光加色技术,以RGB三原色作为子像素构成一像素,由多个像素构成整个画面,通过发射出三种不同强度的电子束,使屏幕内侧覆盖的红、绿、蓝磷光材料发光而产生色彩。包括如今的液晶显示屏(LCD)。
RGB诉求于人眼对色彩的感应,YUV则着重于视觉对于亮度的敏感程度。因为人眼相比色度,对亮度更敏感。所以YUV对亮度的完全采样,色度的选择采样。即可在人眼察觉不到的范围内最大限度的压缩图像。色度抽样
为节省带宽起见,大多数YUV格式平均使用的每像素位数都少于24位。主要的抽样(subsample)格式有YCbCr 4:2:0、YCbCr 4:2:2、YCbCr 4:1:1和YCbCr 4:4:4。YUV的表示法称为A:B:C表示法:
![](https://i-blog.csdnimg.cn/blog_migrate/1f6f486e8a6de86d622e5eb68e728efb.png)
4:4:4表示完全取样。
4:2:2表示2:1的水平取样,垂直完全采样。
4:2:0表示2:1的水平取样,垂直2:1采样。
4:1:1表示4:1的水平取样,垂直完全采样。
由于YUV占用较少的带宽,而显示器又是使用RGB发光,所以一般都是采用YUV传输,然后转换成RGB渲染到显示器上。
WebGL-YUV渲染
目前在Web上高性能渲染YUV数据需要借助WebGL的能力,将YUV转RGB的计算过程放在shader里可以获得硬件加速。GPU对浮点数运算要快于CPU。
WebGL工作原理
WebGL脱胎于OpenGL,Web开发者可通过HTML5Canvas获取gl对象从而使用WebGL能力为图像绘制提供硬件加速。
大家学过几何的应该都知道点线面概念,而WebGL可以通过对应方法绘制点(Point)、线(Line)、三角(TRIANGLES),其它图形则是通过拼凑三角而成,比如矩形就是两个三角。绘制图形需要用到着色器,主要分为顶点着色器(vertex shader)和片段着色器(fragment shader),这两者一般成对出现。着色器有着C Like语法的强类型脚本语言GLSL,使用该语言进行函数计算。每一对组合关联在一起就是一个program(着色程序)。
如上图所示,vertex array指的是模型数据,主要分为VBO和IBO,前者是顶点数据,后者是顶点索引。输入到vertex shader确定顶点坐标,通过IBO确定哪几个VBO连接成三角形,再将这些三角形进行光栅化(通俗讲就是矢量图转像素图)。fragment shader接收到光栅化后的像素面进行着色。
![](https://i-blog.csdnimg.cn/blog_migrate/e8dfe824a2b297a9984ab56ce5be1a47.png)
在JS中创建着色器并关联program的步骤如下:
const gl = canvas.getContext('webgl')
// 创建着色器
const vertexShader = gl.createShader(gl.VERTEX_SHADER)
const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER)
const program = gl.createProgram()
if (!(vertexShader && fragmentShader && program)) {
console.warn('shaders create failed')
}
// vertexShaderScript420 为 yuv420p 顶点着色器脚本内容,后文再介绍
gl.shaderSource(vertexShader, vertexShaderScript420)
gl.compileShader(vertexShader)
if (!gl.getShaderParameter(vertexShader, gl.COMPILE_STATUS)) {
console.warn('Vertex shader failed to compile: ', gl.getShaderInfoLog(vertexShader))
}
// fragmentShaderScript420 为 yuv420p 片段着色器脚本内容
gl.shaderSource(fragmentShader, fragmentShaderScript420)
gl.compileShader(fragmentShader)
if (!gl.getShaderParameter(fragmentShader, gl.COMPILE_STATUS)) {
console.log('Fragment shader failed to compile: ', gl.getShaderInfoLog(fragmentShader))
}
// 关联并使用此着色程序
gl.attachShader(program, vertexShader)
gl.attachShader(program, fragmentShader)
gl.linkProgram(program)
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.log('Program failed to compile: ', gl.getProgramInfoLog(program))
}
gl.useProgram(program)
GLSL脚本
基础概念
前段代码中提到的vertexShaderScript420
和fragmentShaderScript420
都是对应着色器的脚本代码内容,基于GLSL脚本语言。下面将简单介绍下GLSL中的概念和语法:
属性(Attributes)和缓冲(WebGLBuffer)
缓冲(WebGLBuffer)用来发送到GPU的数据队列,你可以用来存储位置、法向量等任何数据。
属性(Attributes)用来指明怎么从缓冲中获取所需数据并将它提供给顶点着色器。
全局变量(Uniforms)
全局变量在着色程序运行前赋值,在运行过程中全局有效。
纹理(Textures)
纹理是一个数据序列,可以在着色程序运行中随意读取其中的数据。大多数情况存放的是图像数据。
可变量(Varyings)
可变量是一种顶点着色器给片断着色器传值的方式。即可以在片段着色器代码中访问顶点着色器的varying可变量
代码实例
WebGL绘制只关心两件事:裁剪空间中的坐标值和颜色值。顶点着色器提供裁剪空间坐标值,片断着色器提供颜色值。
那我们要怎么绘制视频图像数据呢,大家玩过3D游戏的游戏都有听说过贴图这个说法吧,在WebGL里这个技术叫做纹理映射。把纹理空间的像素映射到几何物体的表面。FFmpeg产生的每一帧YUV数据都可以当作是一个纹理图案,映射到2个三角形拼接的矩形上。如下图所示:
![](https://i-blog.csdnimg.cn/blog_migrate/d6e3735a626d4868bb6f1bd8773676d9.png)
纹理空间的坐标系称为UV(ST)坐标,分别表示显示器水平、垂直方向的坐标。一般取值范围为0-1。由于Canvas坐标系Y轴朝下,与纹理坐标对比相当于Y轴翻转。所以要么使用GL的方法gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 1)
,要么针对顶点坐标作特殊处理。
首先我们先编写着色器脚本程序,代码如下:
vertexShaderScript420
attribute vec4 vertexPos; // 顶点坐标
attribute vec2 texturePos; // 纹理坐标
varying vec2 textureCoord; // 传递纹理坐标
void main() {
gl_Position = vertexPos; // 设置顶点坐标
textureCoord = texturePos; // 设置纹理坐标
}
fragmentShaderScript420
// 片断着色器没有默认精度,所以我们需要设置一个精度
// 这里选择高精度
precision highp float;
varying highp vec2 textureCoord; // 接收纹理坐标
uniform sampler2D ySampler; // y图片纹理数据取样器
uniform sampler2D uSampler; // u...
uniform sampler2D vSampler; // v...
const mat4 YUV2RGB = mat4(
1.1643828125, 0, 1.59602734375, -.87078515625,
1.1643828125, -.39176171875, -.81296875, .52959375,
1.1643828125, 2.017234375, 0, -1.081390625,
0, 0, 0, 1
); // YUV 转 RGB 的数学计算公式。
void main(void) {
highp float y = texture2D(ySampler, textureCoord).r; // .r等同于.x、.s、[0]
highp float u = texture2D(uSampler, textureCoord).r;
highp float v = texture2D(vSampler, textureCoord).r;
// gl_FragColor是一个片断着色器主要设置的变量,后面则是矩阵运算,将YUV转换成RGB
gl_FragColor = vec4(y, u, v, 1) * YUV2RGB;
}
vertexShaderScript420
代码负责接收设置顶点坐标、接收并传递纹理坐标。fragmentShaderScript420
代码负责接收yuv纹理贴图数据并通过转换公式(GLSL支持矩阵向量乘法运算)将YUV转换成RGB。
纹理映射
创建了着色器的实例对象以及着色器的内部计算逻辑后,需要填充顶点数据,告诉着色器要绘制几个顶点,以及纹理与几何面的关系。
顶点坐标取值范围为-1到1,我们渲染平面图,所以只提供x,y坐标即可。总共两个三角片,顶点每三个连接在一起即[1, 1, -1, 1, 1, -1, 1, -1, -1, 1, -1, -1]
纹理坐标取值范围为0到1,因为canvas和uv坐标为y轴翻转关系,正常来说我们需要对顶点也做翻转处理。即[1, 1, 0, 1, 1, 0, 1, 0, 0, 1, 0, 0]
为了让各位更清晰的了解纹理映射的关系,我们把顶点坐标固定(代表三角形也是固定的),纹理坐标则罗列三种情况如下所示:
![](https://i-blog.csdnimg.cn/blog_migrate/e28584fa70221844fecdd1c7d64f7535.png)
不进行坐标翻转渲染出了纹理的原图,但可以发现图片的方向反了,原因便是前面提到的Canvas Y轴朝下的原因。需要额外调用
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 1)
即可正常渲染A三角坐标翻转,同时B坐标映射乱序一下,会发现A是正常的,但B却是旋转了45度的翻转图。
对A、B都做正常顺序的翻转映射,不需要调用额外的API也可正常渲染
这里我们选择第三种情况的坐标映射关系,具体代码如下:
// 创建缓冲并存入相关顶点数据
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer())
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([1, 1, -1, 1, 1, -1, 1, -1, -1, 1, -1, -1]), gl.STATIC_DRAW)
// 找到顶点坐标属性(Attribute)的地址
const vertexPos = gl.getAttribLocation(program, 'vertexPos')
// 告诉WebGL怎么从缓冲中获取数据传递给属性
gl.enableVertexAttribArray(vertexPos)
gl.vertexAttribPointer(vertexPos, 2, gl.FLOAT, false, 0, 0) // (属性地址, 坐标数, 32位浮点数, 不标准化, stride, offset)
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer())
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([1, 1, 0, 1, 1, 0, 1, 0, 0, 1, 0, 0]), gl.STATIC_DRAW)
const texturePos = gl.getAttribLocation(program, 'texturePos')
gl.enableVertexAttribArray(texturePos)
gl.vertexAttribPointer(texturePos, 2, gl.FLOAT, false, 0, 0)
绑定了顶点数据之后,还需要绑定下纹理数据。
function createTexture(gl: WebGL2RenderingContext) {
const texture = gl.createTexture()
gl.bindTexture(gl.TEXTURE_2D, texture)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR) // 当放大时选择4个像素混合
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR) // 当缩小时选择4个像素混合
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE) // 表示U方向不需要重复贴图
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE) // 表示V方向不需要重复贴图
gl.bindTexture(gl.TEXTURE_2D, null)
return texture
}
// 创建y纹理对象
const yTexture = createTexture(gl)
// 找到ySampler地址,并告诉sampler取样器使用第0个纹理单元,即gl.TEXTURE0
const ySampler = gl.getUniformLocation(program, 'ySampler')
gl.uniform1i(ySampler, 0)
const uTexture = createTexture(gl)
const uSampler = gl.getUniformLocation(program, 'uSampler')
gl.uniform1i(uSampler, 1)
const vTexture = createTexture(gl)
const vSampler = gl.getUniformLocation(program, 'vSampler')
gl.uniform1i(vSampler, 2)
绘制YUV数据
在《视频解码篇》中,我们通过FFmpeg解码得到了每一帧的YUV数据,且采用了yuv420p排列,所以平铺模式下y数据在前,u数据紧跟,v数据最后,将yuv数据分别填充到对应的纹理取样器中即可绘制出图像了
// buffer 即为解码后的帧数据,videoWidth、videoHeight分别为视频画面的宽和高
const size = videoWidth * videoHeight
gl.viewport(0, 0, videoWidth, videoHeight)
// 根据前面YUV的说明已经清楚,有多少个像素就有多少y分量,所以y分量数据长度=宽*高
const yLen = size
const yData = buffer.subarray(0, yLen)
gl.activeTexture(gl.TEXTURE0)
gl.bindTexture(gl.TEXTURE_2D, yTexture)
// 指明纹理的具体属性
gl.texImage2D(gl.TEXTURE_2D, 0, gl.LUMINANCE, videoWidth, videoHeight, 0, gl.LUMINANCE, gl.UNSIGNED_BYTE, yData)
// 420模式下u和v都为y分量的1/4.
const uLen = size / 4
const uData = buffer.subarray(yLen, yLen + uLen)
gl.activeTexture(gl.TEXTURE1)
gl.bindTexture(gl.TEXTURE_2D, uTexture)
gl.texImage2D(gl.TEXTURE_2D, 0, gl.LUMINANCE, videoWidth / 2, videoHeight / 2, 0, gl.LUMINANCE, gl.UNSIGNED_BYTE, uData)
const vLen = uLen
const vData = buffer.subarray(yLen + uLen, yLen + uLen + vLen)
gl.activeTexture(gl.TEXTURE2)
gl.bindTexture(gl.TEXTURE_2D, vTexture)
gl.texImage2D(gl.TEXTURE_2D, 0, gl.LUMINANCE, videoWidth / 2, videoHeight / 2, 0, gl.LUMINANCE, gl.UNSIGNED_BYTE, vData)
// 按照多个三角形的方式绘制,从顶点0开始绘制,总计6个顶点
gl.drawArrays(gl.TRIANGLES, 0, 6)
结语
《视频解码篇》中通过FFmpeg解码出的帧数据即可通过以上步骤渲染到Canvas中。以上内容是我在H265播放器应用中的WebGL实践总结,WebGL的世界很大,本人也尚在学习中,此文如有错误之处欢迎指出。
尽请期待后续的系列文章:《从0到1实现Web端H.265播放器:MP4/fMP4 解封装篇》
关于奇舞团
奇舞团是 360 集团最大的大前端团队,代表集团参与 W3C 和 ECMA 会员(TC39)工作。奇舞团非常重视人才培养,有工程师、讲师、翻译官、业务接口人、团队 Leader 等多种发展方向供员工选择,并辅以提供相应的技术力、专业力、通用力、领导力等培训课程。奇舞团以开放和求贤的心态欢迎各种优秀人才关注和加入奇舞团。