前端可视化(四)-前端可视化优化
10w 条原始数据,经过去重、去无效点、预加工之后可得约 5w 个节点,以及 4w 多条连线。这些数据保存在一个 Object 里,数据格式如下,约占用 10M 内存。
{
"nodes": ["A", "B", "C", ...],
"links": [{
"source": "A",
"target": "B"
}, {
"source": "C",
"target": "D"
}, ...]
}
在 Three.js 中构造物体时,最常使用 THREE.Geometry
构造几何体。Geometry
是 Three.js 中的一种数据结构,其包含了几何体的顶点位置、颜色等等信息,储存信息时使用了 THREE.Vector3
, THREE.Color
等数据结构,读写非常直观方便,但是性能一般。按照最寻常的思路,对于每个节点,我们需要使用 THREE.CircleGeometry
构造一个圆,对于每条线,我们需要使用 THREE.Line
构造一条线。
// 最初版本
// 每个节点绘制一个圆
this.paintData.nodes.forEach((node) => {
node.geometry = new THREE.CircleGeometry(5, 12)
node.material = new THREE.MeshBasicMaterial({color: 0xAAAAAA})
node.circle = new THREE.Mesh(node.geometry, node.material)
this.scene.add(node.circle)
})
// 每条线绘制一个线段
this.paintData.links.forEach((link) => {
link.lineMaterial = new THREE.LineBasicMaterial({color: 0xAAAAAA})
link.lineGeometry = new THREE.Geometry()
link.line = new THREE.Line(link.lineGeometry, link.lineMaterial)
link.line.frustumCulled = false
this.scene.add(link.line)
})
原始数据总是要预处理的,比如统计最有影响力的(分享数最多)的节点,筛掉没有分享关系的无用节点,进行数据剪裁等等。海量数据情况下,使用合适的算法就很重要了;初版写的很随意,遍历套遍历,复杂度较高,1w 数据还能接受,跑个几百 ms 出来了,10w 数据直接卡住六七秒。后来优化,多用 hashmap,空间换时间,改写了两三版,最终将计算耗时控制在 2s 以内,还算理想。
将计算过程迁移到 worker 中可以避免阻塞主线程,保证交互的流畅;然而为了最大化加速计算,我们可以拆分至多个 web worker 中,以此充分利用多核性能。Javascript Web Workers Test v1.4.0 是一个 web worker 测试,测试可知在多核机器上,拆分确实可以显著缩短计算时间。借助浏览器接口 navigator.hardwareConcurrency 我们可以获得处理器核心数,然后就可以拆分,比如 8 核机器拆出 7 个 worker 线程可以实现最大化利用核心。计算逻辑的拆分和结果的合并都需要自行设计,本文仅作了调研,由于计算耗时已经较短没有再做拆分工作。
服务器上头像图片都是方形的,但是绘制时我们想要圆形图像,怎么处理出圆角效果呢?按通常思路,我们可以借助 canvas api,画个圆填充图片,最后导出新图片(见张鑫旭大大文章:小tip: SVG和Canvas分别实现图片圆角效果)。但由于我们具有操作片元着色器的能力,于是可以直接在着色器上进行纹理的修改,这里不但裁成了圆角,顺便还做了描边和抗锯齿。着色器直接运行在 GPU 上,性能很好。如果用软件模拟抗锯齿,开销肯定大得多。
在节点数量庞大时,节点头像的拉取和绘制会成为一个性能问题,一般来说当视野范围很大时,节点很小,图片无需加载,可以设置只有在经过缩放,节点大于一定程度(即场景相机 Z 坐标小于一定值)时才加载视口内头像。『判断视野内有哪些节点并加载』这个操作若在每帧都执行频率太高了,可以使用 throttle 技术限制到每秒执行一次;同时头像物体缓存起来,视野移动时进行动态卸载与加载,避免头像加载过多带来性能问题。
https://juejin.cn/post/6844903709982326792#heading-6
https://bl.ocks.org/jodyphelan/5dc989637045a0f48418101423378fbd
<html>
<meta charset="utf-8">
<script src="https://d3js.org/d3-force.v1.min.js"></script>
<script src="https://d3js.org/d3.v4.min.js"></script>
<style>
.edge{
stroke: white;
stroke-width: 1;
}
.graphSVG{
background-color: black;
}
div.container {
width: 100%;
border: 1px solid gray;
}
div.tooltip {
position: absolute;
text-align: center;
width: 180px;
padding: 2px;
font: 12px sans-serif;
background: lightsteelblue;
border: 0px;
border-radius: 8px;
pointer-events: none;
}
</style>
<body>
<div id="graphDiv"></div>
<script>
var radius = 5;
var defaultNodeCol = "white",
highlightCol = "yellow";
var height = window.innerHeight;
var graphWidth = window.innerWidth;
var graphCanvas = d3.select('#graphDiv').append('canvas')
.attr('width', graphWidth + 'px')
.attr('height', height + 'px')
.node();
var context = graphCanvas.getContext('2d');
var div = d3.select("body").append("div")
.attr("class", "tooltip")
.style("opacity", 0);
var simulation = d3.forceSimulation()
.force("center", d3.forceCenter(graphWidth / 2, height / 2))
.force("x", d3.forceX(graphWidth / 2).strength(0.1))
.force("y", d3.forceY(height / 2).strength(0.1))
.force("charge", d3.forceManyBody().strength(-50))
.force("link", d3.forceLink().strength(1).id(function(d) { return d.id; }))
.alphaTarget(0)
.alphaDecay(0.05)
var transform = d3.zoomIdentity;
d3.json("data.json",function(error,data){
console.log(data)
initGraph(data)
function initGraph(tempData){
function zoomed() {
console.log("zooming")
transform = d3.event.transform;
simulationUpdate();
}
d3.select(graphCanvas)
.call(d3.drag().subject(dragsubject).on("start", dragstarted).on("drag", dragged).on("end",dragended))
.call(d3.zoom().scaleExtent([1 / 10, 8]).on("zoom", zoomed))
function dragsubject() {
var i,
x = transform.invertX(d3.event.x),
y = transform.invertY(d3.event.y),
dx,
dy;
for (i = tempData.nodes.length - 1; i >= 0; --i) {
node = tempData.nodes[i];
dx = x - node.x;
dy = y - node.y;
if (dx * dx + dy * dy < radius * radius) {
node.x = transform.applyX(node.x);
node.y = transform.applyY(node.y);
return node;
}
}
}
function dragstarted() {
if (!d3.event.active) simulation.alphaTarget(0.3).restart();
d3.event.subject.fx = transform.invertX(d3.event.x);
d3.event.subject.fy = transform.invertY(d3.event.y);
}
function dragged() {
d3.event.subject.fx = transform.invertX(d3.event.x);
d3.event.subject.fy = transform.invertY(d3.event.y);
}
function dragended() {
if (!d3.event.active) simulation.alphaTarget(0);
d3.event.subject.fx = null;
d3.event.subject.fy = null;
}
simulation.nodes(tempData.nodes)
.on("tick",simulationUpdate);
simulation.force("link")
.links(tempData.edges);
function render(){
}
function simulationUpdate(){
context.save();
context.clearRect(0, 0, graphWidth, height);
context.translate(transform.x, transform.y);
context.scale(transform.k, transform.k);
tempData.edges.forEach(function(d) {
context.beginPath();
context.moveTo(d.source.x, d.source.y);
context.lineTo(d.target.x, d.target.y);
context.stroke();
});
// Draw the nodes
tempData.nodes.forEach(function(d, i) {
context.beginPath();
context.arc(d.x, d.y, radius, 0, 2 * Math.PI, true);
context.fillStyle = d.col ? "red":"black"
context.fill();
});
context.restore();
// transform = d3.zoomIdentity;
}
}
})
</script>
</body>
提升 canvas 的性能最主要的还是得注意代码的结构,减少不必要的API调用,在每一帧中减少复杂的运算或者把复杂运算由每一帧算一次改成数帧算一次
一般情况下的性能: clearRect > fillRect > canvas.width=canvas.width;
偶尔的且较小的阻塞是可以接收的,频繁或较大的阻塞是不可以接受的。也就是说,我们需要解决两种阻塞:
对前者,我们应当仔细地优化代码,有时不得不降低动画的复杂(炫酷)程度,本文前几节中的优化方案,解决的就是这个问题。
而对于后者,主要有以下两种优化的策略。
Web Worker 是好东西,性能很好,兼容性也不错。浏览器用另一个线程来运行 Worker 中的 JavaScript 代码,完全不会阻碍主线程的运行。动画(尤其是游戏)中难免会有一些时间复杂度比较高的算法,用 Web Worker 来运行再合适不过了。