IVWEB 玩转 WASM 系列-WEBGL YUV渲染图像实践

最近团队在用 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 进行并行加速,我们采用了第二种方法。

在讲 YUV 之前,我们先来看下 YUV 是怎么获取到的:


实现播放器必定要经过的步骤

由于我们是写播放器,实现一个播放器的步骤必定会经过以下这几个步骤:

  1. 将视频的文件比如 mp4,avi,flv等等,mp4,avi,flv 相当于是一个容器,里面包含一些信息,比如压缩的视频,压缩的音频等等, 进行解复用,从容器里面提取出压缩的视频以及音频,压缩的视频一般是 H265、H264 格式或者其他格式,压缩的音频一般是 aac或者 mp3。
  2. 分别在压缩的视频和压缩的音频进行解码,得到原始的视频和音频,原始的音频数据一般是pcm ,而原始的视频数据一般是 yuv 或者 rgb。
  3. 然后进行音视频的同步。
    可以看到解码压缩的视频数据之后,一般就会得到 yuv。

YUV

YUV 是什么

对于前端开发者来说,YUV 其实有点陌生,对于搞过音视频开发的一般会接触到这个,简单来说,YUV 和我们熟悉的 RGB 差不多,都是颜色编码方式,只不过它们的三个字母代表的意义与 RGB 不同,YUV 的 “Y” 表示明亮度(Luminance或Luma),也就是灰度值;而 ”U” 和 ”V” 表示的则是色度(Chrominance或Chroma),描述影像色彩及饱和度,用于指定像素的颜色。

为了让大家对 YUV 有更加直观的感受,我们来看下,Y,U,V 单独显示分别是什么样子,这里使用了 FFmpeg 命令将一张火影忍者的宇智波鼬图片转成YUV420P:

ffmpeg -i frame.jpg -s 352x288 -pix_fmt yuv420p test.yuv

GLYUVPlay软件上打开 test.yuv,显示原图:

原图

Y分量单独显示:
Y

U分量单独显示:
U

V 分量单独显示:
V

由上面可以发现,Y 单独显示的时候是可以显示完整的图像的,只不过图片是灰色的。而U,V则代表的是色度,一个偏蓝,一个偏红。

使用YUV 的好处

  1. 由刚才看到的那样,Y 单独显示是黑白图像,因此YUV格式由彩色转黑白很简单,可以兼容老式黑白电视,这一特性用在于电视信号上。
  2. YUV的数据尺寸一般都比RGB格式小,可以节约传输的带宽。(但如果用YUV444的话,和RGB24一样都是24位)

YUV 采样

常见的YUV的采样有YUV444,YUV422,YUV420:


注:黑点表示采样该像素点的Y分量,空心圆圈表示采用该像素点的UV分量。

  1. YUV 4:4:4采样,每一个Y对应一组UV分量。
  2. YUV 4:2:2采样,每两个Y共用一组UV分量。
  3. YUV 4:2:0采样,每四个Y共用一组UV分量。

YUV 存储方式

YUV的存储格式有两类:packed(打包)和 planar(平面):

  • packed 的YUV格式,每个像素点的Y,U,V是连续交错存储的。
  • planar 的YUV格式,先连续存储所有像素点的Y,紧接着存储所有像素点的U,随后是所有像素点的V。

举个例子,对于 planar 模式,YUV 可以这么存 YYYYUUVV,对于 packed 模式,YUV 可以这么存YUYVYUYV。

YUV 格式一般有多种,YUV420SP、YUV420P、YUV422P,YUV422SP等,我们来看下比较常见的格式:

  • YUV420P(每四个 Y 会共用一组 UV 分量):


  • YUV420SP(packed,每四个 Y 会共用一组 UV 分量,和YUV420P不同的是,YUV420SP存储的时候 U,V 是交错存储):


  • YUV422P(planar,每两个 Y 共用一组 UV 分量,所以 U和 V 会比 YUV420P U 和 V 各多加一行):


  • YUV422SP(packed,每两个 Y 共用一组 UV 分量):


其中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,比如 YUV411YUV420格式多见于数码摄像机数据中,前者用于NTSC制,后者用于 PAL制。至于电视广播制式的介绍我们可以看下这篇文章【标准】NTSC、PAL、SECAM三大制式简介

YUV 计算方法

以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 是什么

简单来说,WebGL是一项用来在网页上绘制和渲染复杂3D图形,并允许用户与之交互的技术。

WEBGL 组成

在 webgl 世界中,能绘制的基本图形元素只有点、线、三角形,每个图像都是由大大小小的三角形组成,如下图,无论是多么复杂的图形,其基本组成部分都是由三角形组成。

图来源于网络

着色器

着色器是在GPU上运行的程序,是用OpenGL ES着色语言编写的,有点类似 c 语言:


具体的语法可以参考着色器语言 GLSL (opengl-shader-language)入门大全,这里不在多加赘述。

在 WEBGL 中想要绘制图形就必须要有两个着色器:

  • 顶点着色器
  • 片元着色器

其中顶点着色器的主要功能就是用来处理顶点的,而片元着色器则是用来处理由光栅化阶段生成的每个片元(PS:片元可以理解为像素),最后计算出每个像素的颜色。

WEBGL 绘制流程

一、提供顶点坐标
因为程序很傻,不知道图形的各个顶点,需要我们自己去提供,顶点坐标可以是自己手动写或者是由软件导出:


在这个图中,我们把顶点写入到缓冲区里,缓冲区对象是WebGL系统中的一块内存区域,我们可以一次性地向缓冲区对象中填充大量的顶点数据,然后将这些数据保存在其中,供顶点着色器使用。接着我们创建并编译顶点着色器和片元着色器,并用 program 连接两个着色器,并使用。举个例子简单理解下为什么要这样做,我们可以理解成创建Fragment 元素: let f = document.createDocumentFragment()
所有的着色器创建并编译后会处在一种游离的状态,我们需要将他们联系起来,并使用(可以理解成 document.body.appendChild(f),添加到 body,dom 元素才能被看到,也就是联系并使用)。
接着我们还需要将缓冲区与顶点着色器进行连接,这样才能生效。

二、图元装配
我们提供顶点之后,GPU根据我们提供的顶点数量,会挨个执行顶点着色器程序,生成顶点最终的坐标,将图形装配起来。可以理解成制作风筝,就需要将风筝骨架先搭建起来,图元装配就是在这一阶段。

三、光栅化
这一阶段就好比是制作风筝,搭建好风筝骨架后,但是此时却不能飞起来,因为里面都是空的,需要为骨架添加布料。而光栅化就是在这一阶段,将图元装配好的几何图形转成片元(PS: 片元可以理解成像素)。

四、着色与渲染


着色这一阶段就好比风筝布料搭建完成,但是此时并没有什么图案,需要绘制图案,让风筝更加好看,也就是光栅化后的图形此时并没有颜色,需要经过片元着色器处理,逐片元进行上色并写到颜色缓冲区里,最后在浏览器才能显示有图像的几何图形。

总结
WEBGL 绘制流程可以归纳为以下几点:

  1. 提供顶点坐标(需要我们提供)
  2. 图元装配(按图元类型组装成图形)
  3. 光栅化(将图元装配好的图形,生成像素点)
  4. 提供颜色值(可以动态计算,像素着色)
  5. 通过 canvas 绘制在浏览器上。

WEBGL YUV 绘制图像思路

由于每个视频帧的图像都不太一样,我们肯定不可能知道那么多顶点,那么我们怎么将视频帧的图像用 webgl 画出来呢?这里使用了一个技巧—纹理映射。简单来说就是将一张图像贴在一个几何图形表面,使几何图形看起来像是有图像的几何图形,也就是将纹理坐标和 webgl 系统坐标进行一一对应:


如上图,上面那个是纹理坐标,分为 s 和 t 坐标(或者叫 uv 坐标),值的范围在【0,1】之间,值和图像大小、分辨率无关。下面那张图是webgl坐标系统,是一个三维的坐标系统,这里声明了四个顶点,用两个三角形组装成一个长方形,然后将纹理坐标的顶点与 webgl 坐标系进行一一对应,最终传给片元着色器,片元着色器提取图片的一个个纹素颜色,输出在颜色缓冲区里,最终绘制在浏览器里(PS:纹素你可以理解为组成纹理图像的像素)。但是如果按图上进行一一对应的话,成像会是反的,因为 canvas 的图像坐标,默认(0,0)是在左上角:


而纹理坐标则是在左下角,所以绘制时成像就会倒立,解决方法有两种:

  • 对纹理图像进行 Y 轴翻转,webgl 提供了api:
// 1代表对纹理图像进行y轴反转
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 1);
  • 纹理坐标和 webgl 坐标映射进行倒转,举个栗子🌰,如上图所示,本来的纹理坐标(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);
}
  • 创建并编译着色器,将顶点着色器和片段着色器连接到 program,并使用:
let vertexShader=this._compileShader(vertexShaderSource,gl.VERTEX_SHADER);// 创建并编译顶点着色器
let fragmentShader=this._compileShader(fragmentShaderSource,gl.FRAGMENT_SHADER);// 创建并编译片元着色器

let program=this._createProgram(vertexShader,fragmentShader);// 创建program并连接着色器

  • 创建缓冲区,存顶点和纹理坐标(PS:缓冲区对象是WebGL系统中的一块内存区域,我们可以一次性地向缓冲区对象中填充大量的顶点数据,然后将这些数据保存在其中,供顶点着色器使用)。
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); 
  • 初始化并激活纹理单元(YUV)
//激活指定的纹理单元
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
  • 渲染绘制(PS:由于我们获取到的数据是YUV420P,那么计算方法可以参考刚才说的计算方式)。
 // 设置清空颜色缓冲时的颜色值
 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 的倍数,字节非对齐,因此色素就会显示偏绿。目前有两种方法可以解决这个问题:

  • 第一个是直接让 webgl 每行 1 个字节 1 个字节处理(对性能有影响):
// 图像预处理
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
  • 第二个是让获取到的图像的宽度是 8 的倍数,这样就能做到 YUV 字节对齐,就不会显示绿屏,但是不建议这样做, 转的时候CPU占用极大,建议采取第一个方案。

参考文章

图像视频编码和FFmpeg(2)——YUV格式介绍和应用 - eustoma - 博客园
YUV pixel formats
https://wiki.videolan.org/YUV/
使用 8 位 YUV 格式的视频呈现 | Microsoft Docs
IOS 视频格式之YUV - 简书
图解WebGL&Three.js工作原理 - cnwander - 博客园