WebGL在视频播放器上的应用

前言

最近在开发一个视频直播的应用,期间遇到一些问题和解决的方法,写一篇文章记录一下。

功能背景

后台有一张视频采集卡,需要把视频采集卡采集到的信号在前端播放出来。硬件设备是一台国产化的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编程指南), 可能还有一些技术细节没有讲清楚,欢迎留言,我再逐步补充

相关推荐
小小小小宇13 分钟前
前端模拟一个setTimeout
前端
萌萌哒草头将军17 分钟前
🚀🚀🚀 不要只知道 Vite 了,可以看看 Farm ,Rust 编写的快速且一致的打包工具
前端·vue.js·react.js
芝士加1 小时前
Playwright vs MidScene:自动化工具“双雄”谁更适合你?
前端·javascript
Carlos_sam2 小时前
OpenLayers:封装一个自定义罗盘控件
前端·javascript
前端南玖2 小时前
深入Vue3响应式:手写实现reactive与ref
前端·javascript·vue.js
wordbaby3 小时前
React Router 双重加载器机制:服务端 loader 与客户端 clientLoader 完整解析
前端·react.js
itslife3 小时前
Fiber 架构
前端·react.js
3Katrina3 小时前
妈妈再也不用担心我的课设了---Vibe Coding帮你实现期末课设!
前端·后端·设计
hubber3 小时前
一次 SPA 架构下的性能优化实践
前端
可乐只喝可乐3 小时前
从0到1构建一个Agent智能体
前端·typescript·agent