从0到1实现Web端H.265播放器:YUV渲染篇

前端 @ 阿里巴巴

前言

上一篇文章《视频解码篇》主要介绍了原始HEVC码流如何解码成YUV数据(通常视频采用的都是YUV格式),本章主要介绍如何将解码的YUV数据渲染成图像。在此之前我们先回顾一下DEMO架构

image

上图中可以看到,我们接收到YUV数据后需要使用WebGL对YUV处理转换成RGB数据然后进行渲染。那么为什么要转换成RGB呢,首先我们先了解下什么是YUV,以及YUV和RGB的区别。

什么是YUV

image (从上至下分别是原图,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等。

image

本文用例中的视频为420p采样,故后续代码均以YUV-420p采样为准

与RGB的区别

image

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表示法:

image

  • 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 Fundamentals,可多语言切换(含中文)。

鉴于部分读者可能没时间翻阅,下面我也将简单介绍下WebGL是如何工作的

WebGL工作原理

WebGL脱胎于OpenGL,Web开发者可通过HTML5Canvas获取gl对象从而使用WebGL能力为图像绘制提供硬件加速。

大家学过几何的应该都知道点线面概念,而WebGL可以通过对应方法绘制点(Point)、线(Line)、三角(TRIANGLES),其它图形则是通过拼凑三角而成,比如矩形就是两个三角。绘制图形需要用到着色器,主要分为顶点着色器(vertex shader)和片段着色器(fragment shader),这两者一般成对出现。着色器有着C Like语法的强类型脚本语言GLSL,使用该语言进行函数计算。每一对组合关联在一起就是一个program(着色程序)。
image

如上图所示,vertex array指的是模型数据,主要分为VBO和IBO,前者是顶点数据,后者是顶点索引。输入到vertex shader确定顶点坐标,通过IBO确定哪几个VBO连接成三角形,再将这些三角形进行光栅化(通俗讲就是矢量图转像素图)。fragment shader接收到光栅化后的像素面进行着色。

image

在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脚本

基础概念

前段代码中提到的vertexShaderScript420fragmentShaderScript420都是对应着色器的脚本代码内容,基于GLSL脚本语言。下面将简单介绍下GLSL中的概念和语法:

  • 属性(Attributes)和缓冲(WebGLBuffer)

  • 缓冲(WebGLBuffer)用来发送到GPU的数据队列,你可以用来存储位置、法向量等任何数据。

  • 属性(Attributes)用来指明怎么从缓冲中获取所需数据并将它提供给顶点着色器。

  • 全局变量(Uniforms)

  • 全局变量在着色程序运行前赋值,在运行过程中全局有效。

  • 纹理(Textures)

  • 纹理是一个数据序列,可以在着色程序运行中随意读取其中的数据。 大多数情况存放的是图像数据。

  • 可变量(Varyings)

  • 可变量是一种顶点着色器给片断着色器传值的方式。即可以在片段着色器代码中访问顶点着色器的varying可变量

代码实例

WebGL绘制只关心两件事:裁剪空间中的坐标值和颜色值。顶点着色器提供裁剪空间坐标值,片断着色器提供颜色值。

那我们要怎么绘制视频图像数据呢,大家玩过3D游戏的游戏都有听说过贴图这个说法吧,在WebGL里这个技术叫做纹理映射。把纹理空间的像素映射到几何物体的表面。FFmpeg产生的每一帧YUV数据都可以当作是一个纹理图案,映射到2个三角形拼接的矩形上。如下图所示:

image

纹理空间的坐标系称为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]

为了让各位更清晰的了解纹理映射的关系,我们把顶点坐标固定(代表三角形也是固定的),纹理坐标则罗列三种情况如下所示:

image

  • 不进行坐标翻转渲染出了纹理的原图,但可以发现图片的方向反了,原因便是前面提到的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)
复制代码

绑定了顶点数据之后,还需要绑定下纹理数据。WebGL使用多个纹理单元

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数据分别填充到对应的纹理取样器中即可绘制出图像了
image

// 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 解封装篇》

分类:
前端