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:

1
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

数据排列如下:

1
2
3
4
5
6
7
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:后面会用到):

1
2
3
Y:01080*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
    2
    // 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),这样在浏览器成像就不会是反的。

详细步骤

  • 着色器部分

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    // 顶点着色器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,并使用:

    1
    2
    3
    4
    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系统中的一块内存区域,我们可以一次性地向缓冲区对象中填充大量的顶点数据,然后将这些数据保存在其中,供顶点着色器使用)。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    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)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    //激活指定的纹理单元
    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,那么计算方法可以参考刚才说的计算方式)。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    // 设置清空颜色缓冲时的颜色值
    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); // 绘制四个点,也就是长方形

上述那些步骤最终可以绘制成这张图:

完整代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
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:

1
2
// 图像预处理
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 4);

这样默认设置会每行 4 个字节 4 个字节处理,而 Y分量每行的宽度是 540,是 4 的倍数,字节对齐了,所以图像能够正常显示,而 U,V 分量宽度是 540 / 2 = 270,270 不是4 的倍数,字节非对齐,因此色素就会显示偏绿。目前有两种方法可以解决这个问题:

  • 第一个是直接让 webgl 每行 1 个字节 1 个字节处理(对性能有影响):

    1
    2
    // 图像预处理
    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?redirectedfrom=MSDN)
IOS 视频格式之YUV - 简书
图解WebGL&Three.js工作原理 - cnwander - 博客园

-------------本文结束感谢您的阅读-------------

本文标题:IVWEB 玩转 WASM 系列-WEBGL YUV渲染图像实践

文章作者:shenzekun

发布时间:2019年12月14日 - 13:20

最后更新:2019年12月14日 - 13:21

原始链接:http://www.shenzekun.cn/IVWEB-玩转-WASM-系列-WEBGL-YUV渲染图像实践.html

许可协议: 署名-非商业性使用-禁止演绎 4.0 国际 转载请保留原文链接及作者。

您的支持将鼓励我继续创作!