02月22, 2023

浏览器绘制图表的N种方法

哆啦可视化大屏编辑器,是植根于浏览器的可视化平台。我们不难发现,哆啦的众多图表中,有多种实现方案。如,基于ECharts二次开发的BI图表,有基于Mapbox/leaflet等实现的地理图表,以及根据业务需要制作的基于Dom的内容、视频、图片等组件。 以ECharts为例,我们知道它的底层是基于zrender的。因此,ECharts也可以自由的切换渲染引擎。默认地,ECharts使用的是Canvas作为渲染器。如果你希望看到节点结构,你还可以使用下面的语句,用SVG作为渲染器。

echarts.init(domId,null,{renderer:'svg'})

我们把上面的方式总结一下,得到绘制浏览器图表的方式,大概有如下四种:Dom方式、SVG方式、Canvas2d方式以及WebGL方式。

本次分享我们抛开具体的图表库,分别用这些方式的原始方案来讲解简单的绘图原理。并分析它们的优势和难点。经过本次分享,你可能在使用封装库时候,会更加了解底层的细节。

DOM方式

我们认为这就是普通的HTML+CSS方式。很多同学可能认为一般dom不能做可视化图表。这是一种偏见。可能我们平时用各种charts库有关,我们认为charts必然和传统的HTML有区别。这实际是一种偏见。借用一下可视化大名鼎鼎的D3.js的理念。可视化图表,一方面需要解决数据的组织方法,一方面需要解决底层的渲染方式。D3.js重点关注的是数据的组织形式,而具体的渲染交由具体的渲染底层。那这里面说的dom方式,实际也就是一种渲染底层。

我们来看第一个例子:https://jsbin.com/kexamup/edit?html,css,output

柱形图使用dom还是非常容易达成的。而且,使用这种方式,可以比较方便的利用成熟的事件系统进行对交互的控制。比如鼠标事件、tips等等。

不过,用这种方式的缺点也是比较明显的。

1、性能。对于一般的可视化图表,比复杂的网页元素要简单的多。而基于html渲染引擎的绘制,还会考虑html、css的解析、元素位置的安排,元素变化引起的重排重绘等等。这对于可视化图表来讲,我们对于布局的需求并不复杂,调用这个渲染引擎有些过于铺张,而且大部分的工作对于我们要完成的事情是空耗的性能。

2、dom对于非矩形元素支持复杂。举例,如果我们需要在图表上表现圆形、椭圆形、直线、不规则多边形等等,传统dom实现将变得比较复杂。像饼图、折线图这种,单用dom元素模拟就显得比较啰嗦。即便实现起来,也非常不直观。

SVG方式

SVG是一种以XML语法为基础的图像格式。SVG有如下特点。

1、图片是矢量图(内连位图除外),可缩放不失真。

2、有线、圆弧、矩形、多边形、圆形等直接的元素。

3、可以用img 元素的 src 属性加载。

4、可内连在html中。

5、部分CSS可对SVG元素起作用。

我们来看第二个例子:https://jsbin.com/homajes/edit?html,output

用SVG方式绘制图表一方面可以使用html中的事件、样式等便利工具,另一方面,也简化了不规则图形的编写工作。

我们来看用SVG写的一个层次关系图的例子:https://jsbin.com/kasexen/edit?html,js,output

我们看到,SVG作为跟DOM类似的做法,事件系统是完备的,可以很方便应用。很多绘图的库也使用SVG作为默认的绘图渲染引擎。比如@antv/x6,d3等等。

但是,性能依旧是一个值得关注的问题。虽然,在SVG中,内部元素的布局比HTML做了适度的简化,但是如果我们要绘制的图形非常复杂,元素节点的数量就会非常多,这会大大增加渲染和重绘所需时间。

由于SVG也是图片的一种,所以也可以放在Canvas上。而Canvas一般会比SVG有更高的性能。可以结合双方的优点来使用。

Canvas方式

Canvas是大名鼎鼎的HTML5引入的一个标签。Canvas绘图一般分为以下几个步骤:

1.Canvas 在浏览器上创造一个空白的画布,通过提供渲染上下文,赋予我们绘制内容的能力。

2.调用渲染上下文,设置各种属性,然后调用绘图指令完成输出,就能在画布上呈现图形。

这里我们定义了一个canvas元素。

<body>
  <canvas width="512" height="512"></canvas>
</body>

下面这个代码我们用来获取canvas的上下文:

const canvas = document.querySelector('canvas');
const context = canvas.getContext('2d');

然后我们就能直接调用绘图API进行绘图了:

const rectSize = [100, 100];
context.fillStyle = 'red';
context.beginPath();
context.rect(0.5 * canvas.width, 0.5 * canvas.height, ...rectSize);
context.fill();
context.translate(-0.5 * rectSize[0], -0.5 * rectSize[1]); //平移到中心

这里是MDN列出的canvas的绘图API。

我们同样看看canvas实现的层次关系图:https://jsbin.com/hixoxex/edit?html,js,output

Canvas直接把绘图API提供出来。这样给开发者的能力更多。同时,对于图形本身的封装以及事件等处理没有做更多的封装。它不需要进行上下文、布局等。单论绘图这一动作,是比Dom以及SVG要快的。

Canvas画布大小有限制,不同的浏览器不同,最新的Chrome下应该是不超过16384 X 16384,单个宽高不超过 32767像素,一般的可视化大屏足够用了。检测设备的Canvas大小可以用这个项目:https://github.com/jhildenbiddle/canvas-size

Canvas要比SVG和dom的更底层。所以需要一些额外的工作。有时候也可以结合SVG和Canvas的优点。有很多库对Canvas做了不同程度的封装,如: https://github.com/fabricjs/fabric.js https://github.com/pixijs/pixijs 等等。

在Chrome上canvas的底层封装的是是一个超快的图像引擎叫skia。这个搞过Flutter的同学对它可能会比较了解。

WebGL方式

理论上,Canvas2D已经足够快了。不过当以下几种情况,我们就需要更底层的技术了:

1.要绘制的图形数量非常多,而且位置、形态都有变化,这种情况即使用Canvas2D依然有性能问题。

2.对较大图像的细节做像素处理,如实现物体的光影、流体效果和一些复杂的像素滤镜。由于这些效果往往要精准地改变一个图像全局或局部区域的所有像素点,要计算的像素点数量非常的多(一般是数十万甚至上百万数量级的)。这时,即使采用 Canvas2D 操作,也会达到性能瓶颈。

3.绘制 3D 物体。

浏览器给出了一个基于浏览器的底层接口:WebGL。WebGL迄今发展不过10多年。但是已经经历了两代。是对OpenGL接口的封装,并进行了一定程度的取舍,给浏览器直接面对GPU接口的机会。

我们的Dom、SVG、Canvas其实也都是对GPU程序的各种封装。只不过WebGL让我们直面底层的接口。

那有CPU为什么还要引入GPU呢?一方面,CPU在整个机器中个数较少,成本较高,GPU成本较低,数量较多;另一方面,由于GPU个数多,适合做大量重复、较为简单的计算。图像渲染恰好就是GPU的良好适用场景。

image.png

CPU 和 GPU 都属于处理单元,但是结构不同。形象点来说,CPU 就像个大的工业管道,等待处理的任务就像是依次通过这个管道的货物。一条 CPU 流水线串行处理这些任务的速度,取决于 CPU(管道)的处理能力。

这样的结构用来处理大型任务是足够的,但是要处理图像应用就不太合适了。这是因为,处理图像应用,实际上就是在处理计算图片上的每一个像素点的颜色和其他信息。每处理一个像素点就相当于完成了一个简单的任务,而一个图片应用又是由成千上万个像素点组成的,所以,我们需要在同一时间处理成千上万个小任务。

GPU 是由大量的小型处理单元构成的,它可能远远没有 CPU 那么强大,但胜在数量众多,可以保证每个单元处理一个简单的任务。即使我们要处理一张 800 * 600 大小的图片,GPU 也可以保证这 48 万个像素点分别对应一个小单元,这样我们就可以同时对每个像素点进行计算了。

image.png

秦1.0计算系统

“启动太阳轨道计算软件‘Three-Bodyl.0’!”牛顿声嘶力竭地发令,“启动计算主控!加载差分模块!加载有限元模块!加载谱方法模块……调入初始条件参数!计算启动!!”主板上波光粼粼,显示阵列上的各色标志此起彼伏地闪动,人列计算机开始了漫长的计算。“真是很有意思。”秦始皇手指壮观的计算机说,“每个人如此简单的行为,竟产生了如此复杂的大东西!” -- 刘慈欣 《三体》

我们来看一下,WebGL如何绘制一个三角形的例子: https://jsbin.com/minigiz/edit?html,js,output

理论上,绘制一个WebGL的图形需要以下几步:

1.创建 WebGL 上下文

2.创建 WebGL 程序(WebGL Program)

3.将数据存入缓冲区

4.将缓冲区数据读取到 GPU

5.GPU 执行 WebGL 程序,输出结果

/* 1 */
const canvas = document.querySelector('canvas');
const gl = canvas.getContext('webgl');

/* 2 - 1 着色器*/
const vertex = `
  attribute vec2 position;

  void main() {
    gl_PointSize = 1.0;
    gl_Position = vec4(position, 1.0, 1.0);
  }
`;

const fragment = `
  precision mediump float;

  void main()
  {
    gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
  }    
`;

/* 2-2 构建上下文和着色器的联系 */
const vertexShader = gl.createShader(gl.VERTEX_SHADER);
gl.shaderSource(vertexShader, vertex);
gl.compileShader(vertexShader);

const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
gl.shaderSource(fragmentShader, fragment);
gl.compileShader(fragmentShader);

const program = gl.createProgram();
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);

gl.useProgram(program);

/* 3 */
const points = new Float32Array([ -1, -1, 0, 1, 1, -1,]);

const bufferId = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, bufferId);
gl.bufferData(gl.ARRAY_BUFFER, points, gl.STATIC_DRAW);

/* 4 */ 
const vPosition = gl.getAttribLocation(program, 'position'); //获取顶点着色器中的position变量的地址
gl.vertexAttribPointer(vPosition, 2, gl.FLOAT, false, 0, 0); //给变量设置长度和类型
gl.enableVertexAttribArray(vPosition); //激活这个变量 

/* 5 */
gl.clear(gl.COLOR_BUFFER_BIT);
gl.drawArrays(gl.TRIANGLES, 0, points.length / 2);

我们看到,上面的着色器分为两种,一种是确定顶点数据的顶点着色器,一种是片元着色器。

顶点着色器和片元着色器是职责清晰、依次运行的。

可以把顶点着色器理解为处理顶点的 GPU 程序代码。它可以改变顶点的信息(如顶点的坐标、法线方向、材质等等),从而改变我们绘制出来的图形的形状或者大小等等。 顶点处理完成之后,WebGL 就会根据顶点和绘图模式指定的图元,计算出需要着色的像素点,然后对它们执行片元着色器程序。简单来说,就是对指定图元中的像素点着色。

WebGL 从顶点着色器和图元提取像素点给片元着色器执行代码的过程,就是生成光栅信息的过程,也叫光栅化过程。所以,片元着色器的作用,就是处理光栅化后的像素信息。

由于定点、片元是依次执行的,所以,我们可以定义变量,把它传给片元着色器。

我们把上一个例子加工一下,实现一个带有渐变的三角形。https://jsbin.com/dedubos/edit?html,js,output

从工程上看,如果把WebGL程序也归到前端的话,我们前端传统的三件套HTML、CSS和Javascript。也就变为了4件套,加上Shader,也就是着色器。

WebGL由于偏底层,所以,比其他图形系统的使用稍显复杂。但是在精细图像控制以及3d上是一个比较好的选择。当然,对于WebGL是有一定的库做封装的。如Three.JS、Babylon.js等等。

WebGL其实没有把所有的GPU计算功能都给Web浏览器,比如通用计算GPU。同时,CPU到GPU会有一定的传输带宽。传输相对于计算是更加耗时的操作。

image.png

这张图片表示了WebGL渲染图片的流程。

其他

WebGPU

2017年开始正式提案的WebGPU,目标就是取代现有的WebGL,当然这是一个漫长的过程,WebGL和WebGPU注定需要共存一段时间而且很多概念还是相通的。

WebGPU目前(2023.02)在浏览器届还没有完全支持。即便如Chrome也需要在金丝雀版本上也要打开最新的flag,才能使用。据传闻今年4月份会出正式版本。

WebGPU一方面重新定义了Shader语言。一方面,把引入WebGPU控制权和Canvas分离,也就把计算同主线程解耦。从而在WebWorker以及WebAssembly中也可以使用了。也使GPU的并行计算能力充分释放,不再完全绑定在“绘图”这一任务上。一个热门的方向叫GPGPU,也即GPU通用计算,现在正在逐步引入到Web上。以后在网页上挖矿和做大数据训练也许即将成为可能。

WebGPU也可以替代WebGL做相关的工作,而且能够更快更统一的运行。

WebRTC

可视化渲染引擎虚幻,为了解决3d物体与多种终端互操作,引入了像素流技术。将控制流上行,将预置的像素流下传。借助WebRTC实现信息的低延迟传输。3D游戏引擎将cpu/显卡计算好的像素流通过DP/HTMI系统总线直接传导至显示器,这样看来,所有的过程发生在同一台电脑上;但基于WebRTC的像素流技术让视频的计算和显示发生在由计算机网络相连的不同设备上,这种情况下,一台机器运行引擎,另一台机器显示画面。除此之外还有一个重要区别:由于计算机网络的带宽远小于数据总线,还要保证网络安全,像素流在机器间的传输必须经过压缩和加密,这无疑给该项技术增加了许多难度,好在,WebRTC本身就支持媒体流的压缩和加密,这也是虚幻引擎选择WebRTC的原因。

image.png

参考资料

https://time.geekbang.org/column/article/252076 https://blog.csdn.net/u013850277/article/details/103746615 https://juejin.cn/post/7090921455893872647

本文链接:https://www.leon82.com/post/draw-in-browser.html

-- EOF --

Comments