大型节点链接图快速渲染方案

虽然Canvas可以用于渲染万级数据量,但是当节点数超过1w时,尽管一次渲染的时间很短,但还是会产生视觉上的卡顿。为此,我们继续调研了一些优化方案,包括

1 基于Web Worker的计算与渲染的并行方法

浏览器渲染页面是一个复杂的过程,因为浏览器内核是多线程的,整个过程需要涉及到多个线程,其中最重要的就是JS引擎线程和GUI渲染线程,其中JS引擎线程用来执行脚本文件,依照代码逻辑计算页面元素的位置;而GUI线程则将这些对应的页面元素渲染到页面上。为了防止在渲染过程中因元素的位置发生变化而导致渲染出错,**浏览器将GUI渲染线程与JS引擎设置为互斥的关系**,即当JS引擎执行时GUI线程会被挂起,GUI更新会被保存在一个队列中等到JS引擎空闲时被执行。在对一个图进行布局的时候,需要多次迭代以至于图中所有节点的位置达到稳定,那么就**需要JS引擎线程和GUI线程串行执行**,先利用JS引擎线程计算出下一次布局所有元素的位置,然后再利用GUI线程将所有元素渲染至页面上,以此类推,直到所有的迭代都完成。我们猜测这是导致大规模数据在渲染过程中产生卡顿的原因,因为计算节点下一次迭代的过程需要耗时,无法直接进行连续渲染。

**Web Worker**是一种可为JavaScript创造多线程环境,并将一些高密度计算任务分配给子线程运行的方法,其具体工作流程如下图所示。我们尝试使用Web Worker将可视化工作流并行化来解决渲染卡顿的问题。我们将渲染工作与布局工作分开,具体操作如下:声明一个Worker子线程来执行高时间复杂度的布局迭代计算工作,并将每一次迭代后的计算结果返回给主线程。而主线程通过接收子线程的计算结果,进行每次迭代的布局渲染。

image-20220802200137368

由于布局渲染的方法有两种,分别为基于矢量的SVG布局渲染方法以及基于位图的Canvas布局渲染方法,我同时利用Web Worker进行计算与渲染的并行计算优化,实验Web Worker的有效性。

实验数据规模:

1.1 基于Web Worker计算与渲染并行的SVG布局渲染方法

实验结果:

案例Case1Case2Case3Case4Case5Case6Case7
节点数1141212073845891079301
未使用Web Worker1877170520012757493473362247
使用Web Worker10012544246030375460115093830
速度提升(ms)87.51%-32.98%-18.66%-9.22%-9.63%-36.26%-41.33%
案例Case8Case9Case10Case113-1(3k)1-1(6k)6-1(1w)
节点数114429234515893228798718460
未使用Web Worker1869414816431119542327650808201359
使用Web Worker2753534429163201762979798663
速度提升(ms)-32.11%-22.38%-43.66%-40.75%-21.88%-48.50%
在基于矢量的SVG布局渲染方法的基础上,经过Web Worker计算与渲染并行的优化实验可知,只有在案例1中使用Web Worker的耗时比不使用Web Worker有所提升,但在其他的案例中,通过将计算和渲染分为两个线程反而会造成耗时成本增加。我们分析导致这个问题的原因是:**在计算和渲染中,一次渲染的时间远远大于一次布局迭代计算的时间**。这样就会出现子线程的布局结果早已计算完毕,但主线程的渲染工作还未完成的情况,消息队列会因此堆积大量子线程发送的布局结果 ,而每次都需要从消息队列中取出数据存在的耗时比在主线程上完成布局迭代计算的时间成本还高。因此,经过实验可知,**如果选择SVG作为渲染方法,使用Web Worker无法给用户带来良好的视觉体验**。

完整代码见github -> svgWorker

1.2 基于Web Worker计算与渲染并行的Canvas布局渲染方法

当选择使用Canvas作为渲染方法的时候,由于Canvas单次渲染的时间很短,不会出现消息队列大量堆积子线程发送的布局结果。我们猜想,在这种情况下,使用Web Worker对渲染性能的提升是有效的。因此,我们对Canvas开展了计算与渲染并行对实验。具体的实验(**数据规模同上**)如下表所示:
案例Case1Case2Case3Case4Case5Case6Case7
节点数1141212073845891079301
未使用Web Worker5676103169267516160
使用Web Worker121122143256452576213
速度提升(ms)-53.72%-37.70%-27.97%-33.98%-40.9%-10.4%-24.88%
案例Case8Case9Case10Case113-11-16-1
节点数114429234515893228798718460
未使用Web Worker88197133313461739512012337
使用Web Worker183244190813901755507213837
速度提升(ms)-51.91%-19.26%-30.14%-3.17%-0.91%0.95%-10.84%
对比起svg渲染,使用canvas可以明显地提升渲染性能。主要原因是canvas的单词渲染耗时大幅缩短。但是经过多次实验对比,发现**在节点数量在超过3k时,会出现明显的卡顿,渲染效果仍并不理想**。更糟糕的是,比起不使用Web Worker,使用Web Worker时Canvas的渲染表现反而更差劲了,用户界面仍然存在非常明显的卡顿。

完整代码见 github -> canvas worker

2 优化I/O损耗

在实验过程中我们发现,尽管使用Canvas可以缩短单次渲染的时间,不会出现消息队列大量堆积Worker线程发送的布局结果,但用户界面渲染仍出现较明显的卡顿。经过**性能分析**,我们发现**主要是主线程中的一个数据接收函数占用了大量时间**,这个函数在主线程中的作用是接收Worker线程发送过来的数据。当节点数量超过1w时,主线程与Worker线程的数据交换会占用大量时间。因此,需要尽可能缩短主线程与Worker线程的数据交换的时间以达到流畅渲染的目的。

而Worker线程与主线程之间进行数据传递的方式有两种:一种是通过**对象拷贝**的方式,另一种是通过**转移对象引用的所有权**的方式。使用对象拷贝的方式,通过内部的克隆算法,将主线程的数据拷贝一份,传给worker。这样worker改变数据并不会影响到主线程。

img

另一种通过转移的方式(Transferrable Objects),**不做任何拷贝,而是直接将数据值的引用所有权转移给 worker。**如果一个对象的引用所有权被转移,主线程不会再持有该对象的引用,那么该对象在它被发送的上下文中将变得不可用,并且只对它被转移到的Worker线程可用。

img

我们实验了这两种数据传递方式的性能指标,发现在单次数据传递的耗时上,使用转移的方式明显优于对象拷贝。下图是在1w节点数据集上,两种数据传递方式的实验结果。

img

img

可以看出性能面板上,使用对象拷贝方式一次数据传递的任务耗时107.0ms,而使用传递的方式任务耗时仅有21.2ms。**使用转移的方式进行数据传递,要求传递的对象必须为如ArrayBuffer等的指定格式**,因此这**牺牲了数据的可读性**,但能大幅提升数据I/O性能。对此,我们进行了数据I/O的性能实验。我们测试了主线程与Worker线程单次I/O的时间损耗,实验结果如下表所示:
案例Case1Case2Case3Case4Case5Case6Case7
节点数1141212073845891079301
未使用Web Worker5676103169267516160
使用对象拷贝28182224323220
使用Transfer Object34263030437435
速度提升(ms)17.65%30.77%26.67%20.00%25.58%56.76%42.86%
案例Case8Case9Case10Case113-11-16-1
节点数114429234515893228798718460
未使用Web Worker88197133313461739512012337
使用对象拷贝2127414766145452
使用Transfer Object28351171261514831151
速度提升(ms)25.00%22.86%64.96%62.70%56.29%69.98%60.73%
对实验结果进行分析,由表可以看出,随着节点数的增加,使用Transferrable Objects的数据传输方式,对数据I/O性能的提升效果显著。当节点数量超过2k时,数据I/O的速度平均能够提升65%。

2.1 将json转为ArrayBuffer处代码实现

**只需要将links数据处理并传到worker线程中**
//DEFINE IN MAIN
let nodeInfoMap = {},
	e = 0,
	linkInfoMap = {};
//创建node地图
data.nodes.forEach((n) => {
	nodeInfoMap[n.id] ||
		((nodeInfoMap[n.id] = {
			index: e,
			id: n.id,
		}),
		e++);
});
//创建i,linkbuffer的原型
let i = [];
data.links.forEach((e) => {
	let r = ''.concat(e.source, '-').concat(e.target); //每条边对应的唯一id
	linkInfoMap[r] ||
		(i.push(nodeInfoMap[e.source].index, nodeInfoMap[e.target].index),
		(linkInfoMap[r] = {
			id: r,
		}));
});
//得到linkbuffer
let linkBuffer = new Int32Array(i);

完整代码见 github -> worker-transfer

1.1.3 基于Web Worker+离屏渲染的优化方法—zqc实验【已完成】

在正常的渲染过程中,CPU会将计算好的内容提交到GPU,GPU渲染完成后将渲染结果放入缓冲区,随后显示器会显示缓冲区中的数据。其中GPU屏幕渲染有以下两种方式:

(1)当前屏幕渲染(On-Screen Rendering):指的是GPU的渲染操作作用于当前所显示的屏幕缓冲区;

(2)离屏渲染(Off-Screen Rendering):指的是GPU在当前屏幕缓冲区之外,新开辟一个缓冲区进行渲染操作。渲染的结果不会直接呈现到当前屏幕上,而是等待合适的时机才会显示。相当于在某个时间直接将已经渲染好的图片显示在屏幕上,则不必再执行所有绘图指令。

实现离屏渲染的基本思路,是要将需要重复渲染的图形缓存为图片,在渲染时将图片直接从缓存中读取至另外的画布上。这样做的目的是希望减少在主画布中原生Canvas渲染接口的调用次数,以提升渲染效率。由于在大图可视化任务中,我们需要大量重复地绘制圆点,因此我们尝试将它们缓存为图片,在渲染时直接读取至除主画布外的画布上即可。

由于浏览器的离屏渲染技术是基于Canvas的,所以在这个部分,我们只对Canvas采取离屏渲染优化。大图可视化任务可分为布局与渲染两个子任务,离屏渲染技术只能提升渲染这一子任务的性能,而对布局这一子任务的性能没有任何影响。因此,将离屏渲染应用于大图可视化中具有一项前提条件:计算布局的Worker线程速度快于主线程渲染的速度。

如下图实验结果所示,使用canvas渲染大图的过程中,计算布局的Worker线程的时间开销远远高于主线程渲染。因此在基于Canvas的大图可视化任务中,使用离屏渲染并不会达到提升性能的目的。

img

完整代码见github -> offscreen