WebRTC系列 WebGL 绘制YUV 画面

前言

前面我们已经通过软编码的一系列操作拿到我们需要用于绘制的YUV存储数据了,接下来我们还需要将这些数据展示在画面上,这里我们要借助WebGL的能力。

接下来我们按照流程一步一步来梳理他的实现。我们可能先入门一下WebGL的一些概念会有助于我们了解接下来讨论的内容。

WebGL其实来源于OpenGL的衍生,我们已经了解我们屏幕是各个RGB的排列组合出来的像素平面。我们显卡是输出数据到显示器的硬件,当前主流的显卡厂商有三家(inter, AMD,NVDIA), 底层C/C++会调用这个三个厂商显卡对外的API,如果没有规范,大家设计的相同功能的时候就会有可能有不同的函数命名,OpenGL 就是一个解决这样问题的规范,用于供三大显卡厂商保持统一的API的一个规范。是的,OpenGL其实是一个规范,他没有提供具体的技术实现,这些实现是各大显卡厂商的工作。我们只需要了解这些规范,我们就可以调用各大显卡的能力。

因为接近底层,我们使用OPENGL的能力可以让我们的数据高性能的绘制在屏幕上。但也因为WebGL是基于OpenGL衍生出来的JavaScript API,所以我们会在WebGL的MDN官网上看到一些和前端相对比较陌生的API, 我们这篇文章会尝试从简单的角度去看看他们如何使用。

OpenGL 渲染架构

WebGL出自OpenGL,我们了解OpenGL的架构便于我们理解这篇文章内容

这里的Client层代表我们平时接触的各类函数和语法,他的指令调用层其实在CPU层,在CPU层执行后,数据和指令会传到显卡GPU,在这里Server层的实际调用都是GPU执行的过程。

我们要使用opengl绘制图形,实际流应该是这样

这里可以类比成画画,通过顶点着色器去描述画面物体的轮廓,接着使用片元着色器去上色,然后通过显卡渲染展示画面,,我们了解到有三种类别的变量。TextureData变量、Uniforms变量、Atrribute变量,其中,Atrrbute变量只能作用在顶点着色器。TextureData\Uniform变量是可以同时作用两种着色器。

YUV数据预处理

我们从前面文章有说过,我们的YUV存储格式使用的是YUV420P, 他的存储格式参考图片

我们可以看到,他的Y、U、V数据是按照顺序来进行存储的,首先存储的是Y数据,然后是U数据,接着是V数据。

从前面我们也了解了,YUV420的采样规则,是Y分量按照逐行1:1采样的,U和V分量是隔行然后和Y分量按照1:2的格式进行采样。因为隔行,所以总的比例会是1:4

这样假设我们的图片分辨率是7201280,那么我们采集的Y分量的总数量将会是7201280个,U和V分量会是1280*720 / 4。

假设我们通过软解码之后拿到了分辨率widthheight的数据,类型为无符号的8位数组data, 那么这个数组的data[0] ~ data[width height] 就是我们的Y分量范围数值,data[widthheight] ~ data[width height/4]就是我们的U分量范围数值, V分量的范围就是data[widthheight + widthheight/4] ~ data[data.length]

这样我们就会有下方的代码的产生

ts 复制代码
    renderFrameByWebGL(frame: any) {
        const data = new Uint8Array(frame.data);
        const width = this.domInstance.width;
        const height = this.domInstance.height;
        const yLength = width * height;
        const uvLength = (width / 2) * (height / 2);
        if (!this.webglPlayer) {
            this.webglPlayer = new WebGLPlayer(this.domInstance as HTMLCanvasElement, {
                preserveDrawingBuffer: false
            });
        }
        this.webglPlayer.renderFrame(data, width, height, yLength, uvLength);
    }

webglPlay是我们写的一个用于WebGL渲染的类,下文我们会来讨论他,通过这个函数,我们对YUV各个分量的长度做出了计算,然后初始化了WebGL这个类,这个类接受一个canvas的dom元素,因为WebGL在网页中渲染依赖于canvas作为宿主容器。然后将其中的变量来作为入参传入给webglPlayer的函数renderFrame方法。

渲染主流程

整体的逻辑要是将YUV的各个分量计算出相应的RGB数据,最后交付给WebGL渲染

整体代码

ts 复制代码
/* eslint-disable class-methods-use-this */
/* eslint-disable */
interface ExtendedWebGLRenderingContext extends WebGLRenderingContext {
    y?: Texture;
    u?: Texture;
    v?: Texture;
}

class Texture {
    private gl: WebGLRenderingContext;
    private texture: WebGLTexture | null;

    constructor(gl: WebGLRenderingContext) { 
        this.gl = gl;
        this.texture = gl.createTexture();
        gl.bindTexture(gl.TEXTURE_2D, this.texture);

        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
        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);
    }

    bind(n: number, program: WebGLProgram, name: string) {
        const gl = this.gl;
        gl.activeTexture([gl.TEXTURE0, gl.TEXTURE1, gl.TEXTURE2][n]);
        gl.bindTexture(gl.TEXTURE_2D, this.texture);
        gl.uniform1i(gl.getUniformLocation(program, name), n);
    }

    fill(width: number, height: number, data: Uint8Array) {
        const gl = this.gl;
        gl.bindTexture(gl.TEXTURE_2D, this.texture);
        gl.texImage2D(gl.TEXTURE_2D, 0, gl.LUMINANCE, width, height, 0, gl.LUMINANCE, gl.UNSIGNED_BYTE, data);
    }
}

class WebGLPlayer {
    private canvas: HTMLCanvasElement;
    private gl: ExtendedWebGLRenderingContext;
    private program: WebGLProgram | null = null;

    constructor(canvas: HTMLCanvasElement, options?: any) {
        this.canvas = canvas;
        this.gl = canvas.getContext("webgl") || canvas.getContext("experimental-webgl") as WebGLRenderingContext;
        this.initGL(options);
    }

    private initGL(options?: any) {
        if (!this.gl) {
            console.log("[ER] WebGL not supported.");
            return;
        }

        const gl = this.gl;
        gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
        this.program = gl.createProgram();
        const vertexShaderSource = `
            attribute highp vec4 aVertexPosition;
            attribute vec2 aTextureCoord;
            varying highp vec2 vTextureCoord;
            void main(void) {
                gl_Position = aVertexPosition;
                vTextureCoord = aTextureCoord;
            }
        `;
        const vertexShader = this.createShader(gl, gl.VERTEX_SHADER, vertexShaderSource);
        const fragmentShaderSource = `
            precision highp float;
            varying lowp vec2 vTextureCoord;
            uniform sampler2D YTexture;
            uniform sampler2D UTexture;
            uniform sampler2D VTexture;
            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
            );
            void main(void) {
                gl_FragColor = vec4(
                    texture2D(YTexture, vTextureCoord).x,
                    texture2D(UTexture, vTextureCoord).x,
                    texture2D(VTexture, vTextureCoord).x,
                    1
                ) * YUV2RGB;
            }
        `;
        const fragmentShader = this.createShader(gl, gl.FRAGMENT_SHADER, fragmentShaderSource);
        gl.attachShader(this.program, vertexShader);
        gl.attachShader(this.program, fragmentShader);
        gl.linkProgram(this.program);
        gl.useProgram(this.program);
        if (!gl.getProgramParameter(this.program, gl.LINK_STATUS)) {
            console.log("[ER] Shader link failed.");
        }
        const vertexPositionAttribute = gl.getAttribLocation(this.program, "aVertexPosition");
        gl.enableVertexAttribArray(vertexPositionAttribute);
        const textureCoordAttribute = gl.getAttribLocation(this.program, "aTextureCoord");
        gl.enableVertexAttribArray(textureCoordAttribute);

        const verticesBuffer = gl.createBuffer();
        gl.bindBuffer(gl.ARRAY_BUFFER, verticesBuffer);
        gl.bufferData(gl.ARRAY_BUFFER, 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.STATIC_DRAW);
        gl.vertexAttribPointer(vertexPositionAttribute, 3, gl.FLOAT, false, 0, 0);
        const texCoordBuffer = gl.createBuffer();
        gl.bindBuffer(gl.ARRAY_BUFFER, texCoordBuffer);
        gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([1.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 1.0]), gl.STATIC_DRAW);
        gl.vertexAttribPointer(textureCoordAttribute, 2, gl.FLOAT, false, 0, 0);

        gl.y = new Texture(gl);
        gl.u = new Texture(gl);
        gl.v = new Texture(gl);
        gl.y.bind(0, this.program, "YTexture");
        gl.u.bind(1, this.program, "UTexture");
        gl.v.bind(2, this.program, "VTexture");
    }

    private createShader(gl: any, type: number, source: string): WebGLShader {
        const shader = gl.createShader(type);
        gl.shaderSource(shader, source);
        gl.compileShader(shader);
        if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
            console.error("An error occurred compiling the shaders: " + gl.getShaderInfoLog(shader));
            gl.deleteShader(shader);
            throw new Error("Shader compilation failed");
        }
        return shader;
    }

    renderFrame(videoFrame: Uint8Array, width: number, height: number, yLength: number, uvLength: number) {
        if (!this.gl) {
            console.log("[ER] Render frame failed due to WebGL not supported.");
            return;
        }

        const gl = this.gl;
        gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
        gl.clearColor(0.0, 0.0, 0.0, 0.0);
        gl.clear(gl.COLOR_BUFFER_BIT);

        gl.y?.fill(width, height, videoFrame.subarray(0, yLength));
        gl.u?.fill(width >> 1, height >> 1, videoFrame.subarray(yLength, yLength + uvLength));
        gl.v?.fill(width >> 1, height >> 1, videoFrame.subarray(yLength + uvLength, videoFrame.length));

        gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
    }
}

export { Texture, WebGLPlayer };

整体分析

这个类由一个构造函数,两个私有函数,一个公共函数构成。其中renderFrame是供外部去频繁调用渲染的。其他的都是初始化必要环境的搭建函数

WebGLPlayer类详细分析

renderFrame

这个类对外只暴漏了一个renderFrame函数,这个是关键函数,我们先看一下,这个函数接受五个入参

  • videoFrame是TypeArray,类型为8位的无单位数组

  • Width: 帧宽度

  • Height: 帧高度

  • Ylength: Y分量的数量

  • uvLength: UV分量的数量

这里gl变量出现了很多处,我们看下他的赋值

ini 复制代码
      this.gl = canvas.getContext("webgl") || canvas.getContext("experimental-webgl") as WebGLRenderingContext;

这里的类型是提供基于 OpenGL ES 2.0 的绘图上下文, 相关的MDN文档点击这里, 本文的功能基本都围绕这个上下文的功能去进行实现。

我们来对这里面的代码进行逐行去分析一下

arduino 复制代码
gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);

viewport函数的作用是用来设置视口,即指定从标准设备到窗口坐标的 x、y 仿射变换。这里有一些背景知识需要了解一下,OpenGL相关坐标系, 坐标系统

我们要将绘图的空间比喻成一个3d空间,我们的canvas的dom所展示的画面只是一个2d平面,那么我们对于3d世界的取景,就比如一个摄影机从一个点位对应一个方向去取景。这个取景出来的画面就是我们的viewport。

ini 复制代码
gl.clearColor(0.0, 0.0, 0.0, 0.0);

The WebGLRenderingContext.clearColor() method of the WebGL API specifies the color values used when clearing color buffers.

这个方法是用于清空颜色的缓冲区,相当于假设canvas的dom元素画布已经存在背景颜色,我们需要先对他进行一下清理。

ts 复制代码
gl.clear(gl.COLOR_BUFFER_BIT);

清空一下颜色深度缓冲

ts 复制代码
     gl.y?.fill(width, height, videoFrame.subarray(0, yLength));
     gl.u?.fill(width >> 1, height >> 1, videoFrame.subarray(yLength, yLength + uvLength));
     gl.v?.fill(width >> 1, height >> 1, videoFrame.subarray(yLength + uvLength, videoFrame.length));

这里的是对yuv的数值进行赋值,这里的 y, u, v 是Texture类的实例。这里暂时先理解对绘制数据的填充,后面会对fill方法详细分析, 这里u, v分量的值右移一位是因为采样需要少一半,所以右移一位相当于除2了

ini 复制代码
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);

The WebGLRenderingContext.drawArrays() method of the WebGL API renders primitives from array data.

drawArrays是一个绘制图元的方法,MDN文档参考

gl.TRIANGLE_STRIP是一个三角带的概念,计算机图形学里,很多3d的图案都是一个个三角形组成的,他们会共享一些顶点信息,他们之间拼接起来的图案我们叫做三角带。

我们在这里其实只会有两个三角形,需要N+2个点(N是三角形数量),用于拼接成矩阵。所以会有四个点。

initGL

ts 复制代码
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);

这个参数用于设置在从缓冲区读取像素数据时,每行数据的字节对齐方式。默认值为 4。因为YUV的每行的字节数是不确定4的倍数,这里将对齐字节数设置为0。

ts 复制代码
this.program = gl.createProgram();

WebGLProgram 是 WebGL API 的一部分,它由两个WebGLShaders(webgl 着色器)组成,分别为顶点着色器和片元着色器(两种着色器都是采用 GLSL 语言编写的)。创建一个 WebGLProgram 需要调用 GL 上下文的createProgram() 方法,然后调用attachShader()方法附加上着色器,之后你才能将它们连接到一个可用的程序。这里接触了着色器的核心概念了,请详细理解上方的渲染架构。

所以要将下面代码作为一个整体来看

ts 复制代码
        this.program = gl.createProgram();
        const vertexShaderSource = `xxx`; // 这里暂时忽略GLSL 语言的内容,下面会涉及
        const vertexShader = this.createShader(gl, gl.VERTEX_SHADER, vertexShaderSource);
        const fragmentShaderSource = `xxx`; // 这里暂时忽略GLSL 语言的内容
        const fragmentShader = this.createShader(gl, gl.FRAGMENT_SHADER, fragmentShaderSource);
        gl.attachShader(this.program, vertexShader);
        gl.attachShader(this.program, fragmentShader);
        gl.linkProgram(this.program);
        gl.useProgram(this.program);

这段代码是创建了一个程序program,然后创建(createShader)了一个顶点着色器和片元着色器,然后将着色器添加到程序program,然后将program和webgl的上下文链接起来(linkProgram), 最后使用webgl上下文使用(useProgram)这个程序。

createShader

创建着色器,参考文档

ts 复制代码
    private createShader(gl: any, type: number, source: string): WebGLShader {
        const shader = gl.createShader(type);
        gl.shaderSource(shader, source);
        gl.compileShader(shader);
        if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
            console.error("An error occurred compiling the shaders: " + gl.getShaderInfoLog(shader));
            gl.deleteShader(shader);
            throw new Error("Shader compilation failed");
        }
        return shader;
    }

这里的三个入参

1、gl, webgl的上下文

2、着色器的类型,着色器只有两种类型,顶点着色器和片元着色器

3、source: GLSL代码

使用 GLSL 的着色器(shader),GLSL 是一门特殊的有着类似于 C 语言的语法,在图形管道 (graphic pipeline) 中直接可执行的 OpenGL 着色语言。着色器有两种类型------顶点着色器 (Vertex Shader) 和片段着色器(Fragment Shader)。前者是将形状转换到真实的 3D 绘制坐标中,后者是计算最终渲染的颜色和其他属性用的。

我们这里有两个GLSL代码代表的着色器,一些参考查看GLSL中文手册

顶点着色器GLSL

ts 复制代码
            attribute highp vec4 aVertexPosition;
            attribute vec2 aTextureCoord;
            varying highp vec2 vTextureCoord;
            void main(void) {
                gl_Position = aVertexPosition;
                vTextureCoord = aTextureCoord;
            }

这里有四个变量,aVertexPosition 和 aTextureCoord 我们可以看到前面有attribute关键字,前面我们通过前面框架处有提到,这个类型的变量,只会作用在顶点着色器。所以等下我们会从外部将这个变量传入到此处赋值,变量vTextureCoord前面有标识符varying,这个类型的数据用于在顶点着色器和片元着色器之间传递数据。gl_position是opengl的内置变量,用于接收顶点着色器的数组信息,如果不给这个值赋值,我们将无法看到画面。

片元着色器GLSL

ts 复制代码
            precision highp float; // 定义高精度
            varying lowp vec2 vTextureCoord; // 从顶点着色器传入进来的数据
            uniform sampler2D YTexture;
            uniform sampler2D UTexture;
            uniform sampler2D VTexture;
            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
            );
            void main(void) {
                gl_FragColor = vec4(
                    texture2D(YTexture, vTextureCoord).x,
                    texture2D(UTexture, vTextureCoord).x,
                    texture2D(VTexture, vTextureCoord).x,
                    1
                ) * YUV2RGB;
            }

YTexture, UTexture, VTexture是纹理对象,用于对片元着色器进行贴图,YUV我们理解他是三个图层,所以我们这里需要三个纹理对象。mat4是矩阵对象,在GPU计算操作里面,对这一类并行计算有很高的计算能力。这里的计算矩阵数值我也不懂,应该是根据YUV转RGB的公式推断出来的,参考文档

传参操作

ts 复制代码
        const vertexPositionAttribute = gl.getAttribLocation(this.program, "aVertexPosition");
        gl.enableVertexAttribArray(vertexPositionAttribute);
        const textureCoordAttribute = gl.getAttribLocation(this.program, "aTextureCoord");
        gl.enableVertexAttribArray(textureCoordAttribute);

        const verticesBuffer = gl.createBuffer();
        gl.bindBuffer(gl.ARRAY_BUFFER, verticesBuffer);
        gl.bufferData(gl.ARRAY_BUFFER, 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.STATIC_DRAW);
        gl.vertexAttribPointer(vertexPositionAttribute, 3, gl.FLOAT, false, 0, 0);
        const texCoordBuffer = gl.createBuffer();
        gl.bindBuffer(gl.ARRAY_BUFFER, texCoordBuffer);
        gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([1.0, 0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 1.0]), gl.STATIC_DRAW);
        gl.vertexAttribPointer(textureCoordAttribute, 2, gl.FLOAT, false, 0, 0);

webgl的顶点属性在很多平台都是预设的,我们需要先enableVertexAttribArray然后才可以对他进行激活他的使用。vertexAttribPointer方法是告诉显卡从bindbuffer里面取出缓冲区,传参相应的参数到最终GLSL定义的数据。

YUV纹理初始化

ts 复制代码
        gl.y = new Texture(gl);
        gl.u = new Texture(gl);
        gl.v = new Texture(gl);
        gl.y.bind(0, this.program, "YTexture");
        gl.u.bind(1, this.program, "UTexture");
        gl.v.bind(2, this.program, "VTexture");

这里已经是 语义化的代码了,不多赘述,我们对Texture类进行详细分析一下Texture类详细分析

构造函数

ts 复制代码
    constructor(gl: WebGLRenderingContext) { 
        this.gl = gl;
        this.texture = gl.createTexture();
        gl.bindTexture(gl.TEXTURE_2D, this.texture);

        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); // 纹理放大滤镜
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); // 纹理最小化过滤器
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);// 纹理坐标S
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); // 纹理坐标T
    }

Bind

ts 复制代码
    bind(n: number, program: WebGLProgram, name: string) {
        const gl = this.gl;
        gl.activeTexture([gl.TEXTURE0, gl.TEXTURE1, gl.TEXTURE2][n]);
        gl.bindTexture(gl.TEXTURE_2D, this.texture);
        gl.uniform1i(gl.getUniformLocation(program, name), n);
    }

activeTexture用来激活指定的纹理单元。

uniform1i 这里是要拆开理解,uniform代表变量代指, 1代表的是序号,i代表的是整数

gl.getUniformLocation 是找到uniform变量在着色器中的位置

Fill

ts 复制代码
    fill(width: number, height: number, data: Uint8Array) {
        const gl = this.gl;
        gl.bindTexture(gl.TEXTURE_2D, this.texture);
        gl.texImage2D(gl.TEXTURE_2D, 0, gl.LUMINANCE, width, height, 0, gl.LUMINANCE, gl.UNSIGNED_BYTE, data);
    }
    
    // texImage2D(target, level, internalformat, width, height, border, format, type, source)

textImage2D WebGLRenderingContext.texImage2D() 方法指定了二维纹理图像。

相关推荐
ZYLAB几秒前
我写了一个简易的 SEO 教程,希望能让新手朋友看完以后, SEO 能做到 80 分
前端·seo
小桥风满袖6 分钟前
Three.js-硬要自学系列4 (阵列立方体和相机适配、常见几何体、高光材质、lil-gui库)
前端·css
深海丧鱼8 分钟前
什么!只靠前端实现视频分片?
前端·音视频开发
ohMyGod_12310 分钟前
Vue如何实现样式隔离
前端·javascript·vue.js
涵信14 分钟前
第二十节:项目经验-描述一个React性能优化案例
前端·react.js·性能优化
Danny_FD20 分钟前
前端中的浮动、定位与布局
前端·css
Abadbeginning23 分钟前
vue3后台管理框架geeker admin 横向布局(了解)
前端·javascript·vue.js
OpenTiny社区25 分钟前
直播分享|TinyVue 多端实战与轻量图标库分享
前端·vue.js·开源
旺仔不是汪25 分钟前
搞定 Promise 输出类面试题|以 BFE.dev 五道题为例全面解析
前端·面试
WEI_Gaot25 分钟前
Promise 的类方法 和 实例方法
前端·javascript