前言
最近在开发一个视频直播的应用,期间遇到一些问题和解决的方法,写一篇文章记录一下。
功能背景
后台有一张视频采集卡,需要把视频采集卡采集到的信号在前端播放出来。硬件设备是一台国产化的arm笔记本,性能比较弱,因此选用的方案不能太占用cpu资源,需要能省尽量省.操作系统是国产的linux系统,前端和后端运行在同一台笔记本上,因此网络延迟的问题不用太担心.前端使用的技术框架主要是Electron+Vue,后端使用的是JAVA.
方案
调研了市面上常用的直播方案,简单列一下
方案 | 特点 |
---|---|
HLS | 延迟高,一般是采集完成一段后,再分发出来,也可以用于点播 |
RTMP | 延迟低,Adobe公司开发的协议,需要flash支持,因为flash已经淘汰,不再推荐使用 |
RTSP | 延迟低,很多实时监控摄像头使用的协议,浏览器不是原生支持的,需要转为其他协议 |
WebRTC | 延迟低,浏览器原生支持,技术支持高,常用于点对点的视频通话 |
HTTP-FLV | 延迟低,对浏览器版本要求较高,需要浏览器支持MSE(Media Source Extensions) |
HTTP-fmp4 | 延迟低,浏览器原生支持 |
一开始选用的是RTMP,也简单实现了,但是因为flash已经被淘汰了,该方案被放弃.接下来我是想选用HTTP-fmp4方案的,但是后端没办法生成fmp4的数据,所以也放弃,最后选择了HTTP-FLV方案.
flv.js
后端把采集到的数据编码为h264,同时包装成flv发送到前端,前端用了flv.js来接收并且播放视频。使用后效果并不是很好,播放一段时间后,视频的延迟会越来越高,同时会出现卡顿.这个问题也并不是这个方案有问题,应该是cpu的孱弱的性能造成的.后来把视频的分辨率降低到360p,还是不能稳定运行.
不管效果怎么样,flv.js的这种思想还是很棒的,致敬一下开发flv.js的这位朋友
xgplayer-flv
后来又在网上找其他的方案,发现了西瓜播放器,用xgplayer-flv替换了flv.js,发现CPU的占用率和稳定性都有了一定的提升,可能是xgplayer-flv现在是字节公司维护的,里面的算法对比flv.js应该有优化.虽然比较稳定了,但是分辨率还是不能调高,只能维持在360p.
WebGL
前端两种方案的cpu占用都比较高,主要是后端会先有一次编码的工作,然后前端在播放的时候又会有一次解码的工作,编解码都是比较耗费cpu资源的,如果CPU够用,上面的方案也是一种比较可行的方案.
因此继续优化,抛弃了之前的方案,从头再来.既然编解码比较耗费资源,就想办法从这两步来进行优化.
一次偶然的机会看到WebGL的文章,就想到能不能使用显卡来渲染视频数据,减轻cpu的压力,经过一番调研后,终于实现了.
什么是YUV
YUV简单的说就是一种表示颜色的方式,我们经常听到的应该是RGB(R红色,G绿色,B蓝色),YUV跟RGB一样,有三个变量来确定一个像素点的颜色.但是往往为了节省存储空间和带宽,往往会对数据进行压缩,压缩的常见方式有YUV422,YUV420.
以1080p来举例子,每个像素点的Y、U、V三个分量分别用一个字节来存储
YUV444:1920x1080x3=6220800
YUV422:1920x1080x1+1920x1080/2x2=4147200
YUV420:1920x1080x1+1920x1080/4x2=3110400
从上面可以看到YUV422是YUV444的2/3,YUV420是YUV444的1/2,三种方案都试了一下,从肉眼观看来说,没有明显的区别,因此最后采用了YUV420,最大限度节省cpu资源.
更详细的请参考 万字详文讲解视频和视频帧基础知识,关于视频和帧看这篇就够了
使用WebGL
使用WebGL之前必须要了解的一些基础概念(以下摘自<WebGL编程指南>)
着色器
要使用WebGL进行绘图就必须使用着色器。在代码中,着色器程序是以字符串的形式"嵌入"在JavaScript 文件中的,在程序真正开始运行前它就已经设置好了。WebGL需要两种着色器:
顶点着色器(Vertex shader)
顶点着色器是用来描述顶点特性(如位置、颜色等)的程序。顶点(vertex)是指二维或三维空间中的一个点,比如二维或三维图形的端点或交点.
片元着色器(Fragment shader)
进行逐片元处理过程如光照的程序。片元(fragment)是一个WebGL术语,你可以将其理解为像素(图像的单元)
WebGL坐标系统
由于WebGL处理的是三维图形,所以它使用三维坐标系统(笛卡尔坐标系),具有X轴、¥轴和Z轴。三维坐标系统很容易理解,因为我们的世界也是三维的:具有宽度、高度和长度。在任何坐标系统中,轴的方向都非常重要。通常,在 WebGL中,当你面向计算机屏幕时,X轴是水平的(正方向为右),Y轴是垂直的(正方向为下),而Z轴垂直于屏幕(正方向为外)。观察者的眼睛位于原点(0.0,0.0,0.0)处,视线则是沿着Z轴的负方向,从你指向屏幕。


纹理坐标
纹理坐标是纹理图像上的坐标,通过纹理坐标可以在纹理图像上获取纹素颜色。WebGL 系统中的纹理坐标系统是二维的。为了将纹理坐标和广泛使用的x坐标和y坐标区分开来,WebGL使用s和t命名纹理坐标(st坐标系统)。 纹理图像四个角的坐标为左下角(0.0,0.0),右下角(1.0,0.0),右上角(1.0,1.0)和左上角(0.0,1.0)。纹理坐标很通用,因为坐标值与图像自身的尺寸无关,不管是128×128还是128×256的图像,其右上角的纹理坐标始终是(1.0,1.0)。

渲染YUV数据
参考代码:(p4prasoon/YUV-Webgl-Video-Player: Render YUV frames using Webgl canvas (github.com)) 示例中使用的数据是YUV420的, 用的是planar存储方式

js
var 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;",
"}"
].join("\n");
这是代码中用到的片元着色器, 把YUV数据通过三个分量分别输入,然后再转换为RGB数据,进行渲染, 因为WebGL并不能直接渲染YUV数据的纹理,需要先进行转换
渲染RGB数据
之前在网上找到一段使用Canvas绘制的代码
js
let data;//rgb数据数组,Uint8Array
let width,height;//图片的宽高
//验证rgb数据是否正常
let canvas = document.getElementById("canvas-test");//这个是根据html中的id找到canvas
let ctx = canvas.getContext("2d");
var imgData = ctx.createImageData(width, height);
for (var i = 0, j = 0; i < imgData.data.length; i += 4, j += 3) {
imgData.data[i + 0] = data[j + 0];
imgData.data[i + 1] = data[j + 1];
imgData.data[i + 2] = data[j + 2];
imgData.data[i + 3] = 255; //之所以这样是因为canvas只接受rgba数据,而我们只有rgb,因此最后一个直接为255即可
}
ctx.putImageData(imgData, 0, 0);//后边的两个参数是渲染的起始位置,这里就设为(0,0)了,表示左上角
这段代码有个致命的缺点,就是里面需要循环处理数据源,一旦数据量大了,绘制就会特别慢,且特别耗费CPU。
使用WebGL绘制,直接上代码
js
function Texture(gl) {
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);
}
Texture.prototype.bind = function (n, program, name) {
var 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);
};
Texture.prototype.fill = function (width, height, data) {
var gl = this.gl;
gl.bindTexture(gl.TEXTURE_2D, this.texture);
// 渲染RGB
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGB, // 注意修改的地方
width,
height,
0,
gl.RGB, // 注意修改的地方
gl.UNSIGNED_BYTE,
data
);
};
function setupCanvas(canvas, options) {
var gl =
canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
if (!gl) return gl;
var program = gl.createProgram();
var vertexShaderSource = [
'attribute highp vec4 aVertexPosition;',
'attribute vec2 aTextureCoord;',
'varying highp vec2 vTextureCoord;',
'void main(void) {',
' gl_Position = aVertexPosition;',
' vTextureCoord = aTextureCoord;',
'}',
].join('\n');
var vertexShader = gl.createShader(gl.VERTEX_SHADER);
gl.shaderSource(vertexShader, vertexShaderSource);
gl.compileShader(vertexShader);
// 片元着色器有修改,只需要用一个变量直接传递RBG数据就行了
var fragmentShaderSource = [
'precision highp float;',
'varying lowp vec2 vTextureCoord;',
'uniform sampler2D RGBTexture;',
'void main(void) {',
' gl_FragColor = texture2D(RGBTexture, vTextureCoord) ;',
'}',
].join('\n');
var fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
gl.shaderSource(fragmentShader, fragmentShaderSource);
gl.compileShader(fragmentShader);
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
gl.useProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.log('Shader link failed.');
}
var vertexPositionAttribute = gl.getAttribLocation(
program,
'aVertexPosition'
);
gl.enableVertexAttribArray(vertexPositionAttribute);
var textureCoordAttribute = gl.getAttribLocation(program, 'aTextureCoord');
gl.enableVertexAttribArray(textureCoordAttribute);
var 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);
var 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.rgb = new Texture(gl);
gl.rgb.bind(0, program, 'RGBTexture');
return gl;
}
function renderFrame(gl, videoFrame, width, height) {
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.rgb.fill(width, height, videoFrame);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
}
上面代码主要有两个地方的修改,片元着色器只使用了一个变量,直接传入RGB数据进行渲染 gl.texImage2D 纹理图片的内部格式改为了gl.RGB 使用的数据源需要RGB888格式的, 注意数据的顺序是 R、G、B排列的,否则渲染出来可能出现颜色不正确的问题

渲染灰度图
要渲染灰度图也很简单,只需要把纹理图片的内部格式再改为gl.LUMINANCE
js
// 灰度图
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.LUMINANCE, // 修改的地方
width,
height,
0,
gl.LUMINANCE, // 修改的地方
gl.UNSIGNED_BYTE,
data
);
数据源直接传入灰度的数据就行了, RGB数据一个像素点有需要三个字节存储,灰度图就需要一个字节就行了. 同理如果需要RGBA的数据,就把上面的改为gl.RGBA就行了.

总结
使用WebGL进行绘制,绘制效率上会高很多,但是要注意数据源,数据源最好就是直接能喂给WebGL使用的,不要再进行二次加工,否则会占用很多CPU资源,同时也会造成延迟增加。
另外由于使用的都是原始数据,所以绘制需要用的数据量特别大,如果使用网络传输的话,会非常占用网络资源,所以该方案并不适合网络上使用,只适合前后端都在同一台电脑上的情况。
再附上电子书(WebGL编程指南), 可能还有一些技术细节没有讲清楚,欢迎留言,我再逐步补充