Canvas、SVG 与 WebGL 性能对比
# Canvas、SVG 与 WebGL 性能对比
# 可视化渲染的性能问题有哪些
由于前端的可视化也是在 Web 上展现的,因此像网页大小这些因素也会影响它的性能。而且,无论是可视化还是普通 Web 前端,针对这些因素进行性能优化的原理和手段都一样。
不过,可视化也有可视化方面特殊的性能问题。它们在我们熟悉的 Web 前端工作中并不常见,通常只在可视化中绘制复杂图形的时候,我们才需要重点考虑。
这些问题大体上可以分为两类:
渲染效率问题指的是图形系统在绘图部分所花费的时间;
计算问题则是指绘图之外的其他处理所花费的时间,包括图形数据的计算、正常的程序逻辑处理等等。
在浏览器上渲染动画,每一秒钟最高达到 60 帧左右。也就是说,我们可以在 1 秒钟内完成 60 次图像的绘制,那么完成一次图像绘制的时间就是 1000/60(1 秒 = 1000 毫秒),约等于 16 毫秒。
但是,在复杂的图形渲染时,我们的帧率很可能达不到 60fps。所以,我们只能退而求其次,最低可以选择 24fps,就相当于图形系统要在大约 42 毫秒内完成一帧图像的绘制。这是在我们的感知里,达到比较流畅的动画效果的最低帧率了。要保证这个帧率,我们就必须保证计算加上渲染的时间不能超过 42 毫秒。
# 影响 Canvas 性能的要素
影响 Canvas 的渲染性能的主要因素有两点:
一是绘制图形的数量,绘制图形的数量越多,我们需要的绘图指令就越多,花费的渲染时间也会越多;
二是绘制图形的大小,画布上绘制的图形越大,绘图指令执行的时间也会增多,那么花费的渲染时间也会越多。
通过实验可知,Canvas2D 绘制图形的性能还是比较高的。在普通的个人电脑上,我们要绘制的图形不太大时,只要不超过 500 个都可以达到 60fps,1000 个左右其实也能达到 50fps,就算要绘制大约 3000 个图形,也能够保持在可以接受的 24fps 以上。
因此,在不做特殊优化的前提下,如果我们使用 Canvas2D 来绘图,那么 3000 个左右元素是一般的应用的极限,除非这个应用运行在比个人电脑的 GPU 和显卡更好的机器上,或者采用特殊的优化手段。
测试例子看这里 (opens new window)。
# 影响 SVG 性能的要素
与 Canvas 类似,影响 SVG 的性能因素也是相同的两点,一是绘制图形的数量,二是绘制图形的大小。
但与 Canvas 不同的是,图形数量增多的时候,SVG 的帧率下降会更明显,因此,一般来说,在图形数量小于 1000 时,我们可以考虑使用 SVG,当图形数量大于 1000 但不超过 3000 时,我们考虑使用 Canvas2D。
测试例子看这里 (opens new window)。
# 影响 WebGL 性能的要素
WebGL 要复杂一些,它的渲染性能主要取决于三点。
第一点是渲染次数,渲染次数越多,性能损耗就越大。需注意,要绘制的元素个数多,不一定渲染次数就多,因为 WebGL 支持批量渲染。
第二点是着色器执行的次数,这里包括顶点着色器和片元着色器,前者的执行次数和几何图形的顶点数有关,后者的执行次数和图形的大小有关。
第三点是着色器运算的复杂度,复杂度和 glsl 代码的具体实现有关,越复杂的处理逻辑,性能的消耗就会越大。
最后,数据的大小会影响内存消耗,所以也会对 WebGL 的渲染性能有所影响,不过没有前面三点的影响大。
# 实例化渲染:InstancedDrawing
用 WebGL 渲染大量小球时,我们不需要一个一个小球去渲染,利用 GPU 的并行处理能力,我们可以一次完成渲染。因为我们要渲染的小球形状相同,所以它们的顶点数据是可以共享的。
我们可以采用一种 WebGL 支持的批量绘制技术,叫做 InstancedDrawing(实例化渲染)。
在 OGL 库中,我们只需要给几何体数据传递带有 instanced 属性的顶点数据,就可以自动使用 instanced drawing 技术来批量绘制图形。
// 用来生成指定数量的小球的定点数据
function circleGeometry(gl, radius = 0.04, count = 30000, segments = 20) {
const tau = Math.PI * 2;
const position = new Float32Array(segments * 2 + 2);
const index = new Uint16Array(segments * 3);
// 设置一个 id 数据,这个数据等于每个小球的下标
// WebGL 在绘制的时候会根据 id 数据的个数来绘制相应多个几何体
const id = new Uint16Array(count);
for (let i = 0; i < segments; i++) {
const alpha = i / segments * tau;
position.set([radius * Math.cos(alpha), radius * Math.sin(alpha)], i * 2 + 2);
}
for (let i = 0; i < segments; i++) {
if (i === segments - 1) {
index.set([0, i + 1, 1], i * 3);
} else {
index.set([0, i + 1, i + 2], i * 3);
}
}
for (let i = 0; i < count; i++) {
id.set([i], i);
}
return new Geometry(gl, {
position: {
data: position,
size: 2,
},
index: {
data: index,
},
id: {
instanced: 1, // 告诉 WebGL 这是一个批量绘制的数据,让每一个值作用于一个几何体
size: 1,
data: id,
},
});
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
这个 WebGL 渲染的例子的性能非常高,我们将小球的个数设置为 30000 个,依然可以轻松达到 60fps 的帧率。
测试例子看这里 (opens new window)。
# 如何查看帧率的变化
为了方便的看到渲染性能的变化,可以直接在浏览器中开启帧率检测。Chrome 开发者工具自带这个功能,我们在开发者工具的 Rendering 标签页中,勾选 FPS Meter 就可以开启这个功能查看帧率了。不过,新版的 Chrome 把这个选项改成了 Frame Rendering Stats。
# Canvas 绘制性能提升
Canvas 是指令式绘图系统,它有状态设置指令、绘图指令以及真正的绘图方法(fill 和 stroke)等各类 API。通常情况下利用 Canvas 绘图,我们要先调用状态设置指令设置绘图状态,然后用绘图指令决定要绘制的图形,最后调用真正的 fill() 或 stroke() 方法将内容输出到画布上。
影响 Canvas 性能的两大因素分别是图形的数量和图形的大小。它们都会直接影响绘图指令,一个决定了绘图指令的多少,另一个决定了绘图指令的执行时间。通常来说,绘图指令越多、执行时间越长,渲染效率就越低,性能也就越差。
因此,想要对 Canvas 性能进行优化,最重要的就是优化渲染效率。常用的手段有 5 种,分别是优化 Canvas 指令、使用缓存、分层渲染、局部重绘和优化滤镜。此外,还有一种手段叫做多线程渲染,是用来优化非渲染的计算和交互方面导致的性能问题。
# 方法一:优化 Canvas 指令
优化 Canvas 指令要做的事情就是,尽可能减少绘图指令的数量。
假设现在要在一个 600 X 600 的画布上,实现一些位置随机的多边形,并且不断刷新这些图形的形状和位置。效果可以看这里 (opens new window)。
这个效果的实现可以分为 4 步,分别是创建多边形的顶点,根据顶点绘制图形,生成随机多边形,执行绘制。
const canvas = document.querySelector('canvas');
const ctx = canvas.getContext('2d');
// 创建正多边形,返回顶点
function regularShape(x, y, r, edges = 3) {
const points = [];
const delta = 2 * Math.PI / edges;
for(let i = 0; i < edges; i++) {
const theta = i * delta;
points.push([x + r * Math.sin(theta), y + r * Math.cos(theta)]);
}
return points;
}
// 根据顶点绘制图形
function drawShape(context, points) {
context.fillStyle = 'red';
context.strokeStyle = 'black';
context.lineWidth = 2;
context.beginPath();
context.moveTo(...points[0]);
for(let i = 1; i < points.length; i++) {
context.lineTo(...points[i]);
}
context.closePath();
context.stroke();
context.fill();
}
// 多边形类型,包括正三角形、正四边形、正五边形、正六边形和正 100 边形
const shapeTypes = [3, 4, 5, 6, 100];
const COUNT = 1000;
// 执行绘制
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
for(let i = 0; i < COUNT; i++) {
const type = shapeTypes[Math.floor(Math.random() * shapeTypes.length)];
const points = regularShape(Math.random() * canvas.width,
Math.random() * canvas.height, 10, type);
drawShape(ctx, points);
}
requestAnimationFrame(draw);
}
draw();
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
不过,这段代码实现性能却不是很好。因为 drawShape 函数里的 for 循环,它是根据顶点来绘制图形的,一个点对应一条绘图指令。而在我们绘制的随机图形里,有 3、4、5、6 边形和 100 边形。对于一个 100 边形来说,它的顶点数量非常多,所以 Canvas 需要执行的绘图指令也会非常多,那绘制很多个 100 边形自然会造成性能问题了。因此,如何减少绘制 100 边形的绘图指令的数量,才是我们要优化的重点。
其实,对于半径为 10 的小图形来说,正 100 边形已经完全是正圆形了,所以我们可以用 arc (opens new window) 指令来替代 for 循环。
const shapeTypes = [3, 4, 5, 6, -1];
const COUNT = 1000;
const TAU = Math.PI * 2;
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
for(let i = 0; i < COUNT; i++) {
const type = shapeTypes[Math.floor(Math.random() * shapeTypes.length)];
const x = Math.random() * canvas.width;
const y = Math.random() * canvas.height;
if(type > 0) {
// 画正多边形
const points = regularShape(x, y, 10, type);
drawShape(ctx, points);
} else {
// 用 -1 代替正 100 边形
// 用 arc 指令来画圆
ctx.beginPath();
ctx.arc(x, y, 10, 0, TAU);
ctx.stroke();
ctx.fill();
}
}
requestAnimationFrame(draw);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
优化后的代码实现 (opens new window)在性能上有了明显的提升。这里举的例子其实是个特例,在实际工作中,我们也是需要针对一些特例来优化的。
# 方法二:使用缓存
因为 Canvas 的性能瓶颈主要在绘图指令方面,如果我们能将图形缓存下来,保存到离屏的 Canvas(offscreen Canvas)中,然后在绘制的时候作为图像来渲染,那我们就可以将绘制顶点的绘图指令变成直接通过 drawImage (opens new window) 指令来绘制图像,而且也不需要 fill() 方法来填充图形,这样性能就会有大幅度的提升。具体操作可以看这个例子 (opens new window)。
首先实现一个创建缓存的函数。
function createCache() {
const ret = [];
for(let i = 0; i < shapeTypes.length; i++) {
// 创建离屏 Canvas 缓存图形
const cacheCanvas = new OffscreenCanvas(20, 20);
// 将图形绘制到离屏 Canvas 对象上
const type = shapeTypes[i];
const context = cacheCanvas.getContext('2d');
context.fillStyle = 'red';
context.strokeStyle = 'black';
if(type > 0) {
const points = regularShape(10, 10, 10, type);
drawShape(context, points);
} else {
context.beginPath();
context.arc(10, 10, 10, 0, TAU);
context.stroke();
context.fill();
}
ret.push(cacheCanvas);
}
// 将离屏 Canvas 数组(缓存对象)返回
return ret;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
然后,我们一次性创建缓存,直接通过缓存来绘图。
const shapes = createCache();
const COUNT = 1000;
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (let i = 0; i < COUNT; i++) {
const shape = shapes[Math.floor(Math.random() * shapeTypes.length)];
const x = Math.random() * canvas.width;
const y = Math.random() * canvas.height;
ctx.drawImage(shape, x, y);
}
requestAnimationFrame(draw);
}
2
3
4
5
6
7
8
9
10
11
12
13
这样,我们就通过缓存渲染,把原本数量非常多的绘图指令优化成了只有 drawImage 的一条指令,让渲染帧率达到了 60fps,从而大大提升了性能。
📌 缓存的局限性
首先,因为缓存是通过创建离屏 Canvas 对象实现的,如果我们要绘制的图形状态(指不同形状、颜色等)非常多的话,那将它们都缓存起来,就需要创建大量的离屏 Canvas 对象。这本身对内存消耗就非常大,有可能反而降低了性能。
其次,缓存适用于图形状态本身不变的图形元素,如固定的几何图形,它们每次刷新只需要更新它的 transform,这样的图形比较适合用缓存。如果是经常发生状态改变的图形元素,那么缓存就必须一直更新,缓存更新本身也是绘图过程。因此,这种情况下,采用缓存根本起不到减少绘图指令的作用,反而因为增加了一条 drawImage 指令产生了更大的开销。
第三,严格上来说,从缓存绘制和直接用绘图指令绘制还是有区别的,尤其是在 fillText 渲染文字或者我们绘制一个图形有较大缩放(scale)的时候。因为不使用缓存直接绘制的是矢量图,而通过缓存 drawImage 绘制出的则是位图,所以缓存绘制的图形,在清晰度上可能不是很好。
# 方法三:分层渲染
有的时候,我们要绘制的元素很多,其中大部分元素状态是不变的,只有一小部分有变化。此时,我们可以利用 “Canvas 是将上一次绘制的内容擦除,然后绘制新的内容来实现状态变化的” 这一特点,将变化的元素和不变的元素进行分层处理。
即我们可以用两个 Canvas 叠在一起,将不变的元素绘制在一个 Canvas 中,变化的元素绘制在另一个 Canvas 中。
可以参考这个例子 (opens new window)的实现。
使用分层渲染解决性能问题的时候,所绘制的图形必须满足两个条件:
一是有大量静态的图形元素不需要重新绘制;
二是动态和静态图形元素绘制顺序是固定的,先绘制完静态元素再绘制动态元素。
如果元素都有可能运动,或者动态元素和静态元素的绘制顺序是交错的,比如先绘制几个静态元素,再绘制几个动态元素,然后再绘制静态元素,这样交替进行,那么分层渲染就不好实现了。
# 方法四:局部重绘
局部重绘就是不需要清空 Canvas 的全局区域,而是根据运动的元素的范围来清空部分区域。
在很大一部分可视化大屏项目中,我们不会让整个屏幕的所有元素都不断改变,而是只有一些固定的区域改变,所以我们直接刷新那部分区域,重绘区域中的元素就可以了。
我们可以使用 Canvas 上下文的 clearRect (opens new window) 方法控制要刷新的动态区域,只对这些区域进行擦除然后重绘。具体操作可以看这个例子 (opens new window)。
# 脏区检测和包围盒
在动态区重绘的时候,区域内的静态元素也需要跟着重绘。如果有静态元素跨越了动态和静态区域范围,那在重绘时,我们自然不希望破坏了静态区的图形。这时候,我们可以使用 Canvas 上下文的 clip (opens new window) 方法,它是一种特殊的绘图指令,可以设定一个绘图区,让图形的绘制限制在这个绘图区内部。这样的话,图形中超过 clip 范围的部分,浏览器就不会把它渲染到 Canvas 上。
这种固定区域的局部重绘使用起来不难,但有时候我们不知道具体的动态区域究竟多大。
这个时候,我们可以使用动态计算要重绘区域的技术,它也被称为脏区检测。它的基本原理是根据动态元素的包围盒,动态算出需要重绘的范围。
包围盒就是指能包含多边形所有顶点,并且与坐标轴平行的最小矩形。
在 Canvas 平面直角坐标系下,求包围盒并不复杂,只要分别找到所有顶点坐标中 x 的最大、最小值 xmin 和 xmax,以及 y 的最大、最小值 ymin 和 ymax,那么包围盒就是矩形 [(xmin, ymin), (xmin, ymax), (xmax, ymax), (xmax, ymin)]。
对所有的动态元素计算出包围盒,我们就能知道局部刷新的范围了。不过在实际操作的时候,我们经常会遇到各种复杂的细节问题需要解决。
如果在实际工作中遇到了问题,可以参考蚂蚁金服 AntV 团队的 Canvas 局部渲染优化总结 (opens new window)这篇文章。
# 方法五:优化滤镜
滤镜是一种对图形像素进行处理的方法,Canvas 支持许多常用的滤镜。不过 Canvas 渲染滤镜的性能开销很大。比如这个例子 (opens new window)。
因此,我们完全没必要对每个元素应用滤镜,而是可以采用类似后期处理通道的做法,先将图形以不使用滤镜的方式绘制到一个离屏的 Canvas 上,然后直接将这个离屏 Canvas 以图片方式绘制到要显示的画布上,在这次绘制的时候采用滤镜。这样,我们就把大量滤镜绘制的过程缩减为对一张图片使用一次滤镜了。具体操作可以看这个例子 (opens new window)。
不过,这种优化滤镜的方式,只有当我们要对画布上绘制的所有图形,都采用同一种滤镜的时候才有效。
当然,如果有部分图形采用相同的滤镜,而且它们是连续绘制的,我们也可以采用类似的办法,把这部分图形绘制到离屏 Canvas 上,之后再将图像应用滤镜并绘制回画布。这样也能够减少滤镜的处理次数,明显提升性能。
# 方法六:多线程渲染
影响用户体验的不仅仅是渲染性能,有时候,我们还要对绘制的内容进行交互,而如果渲染过程消耗了大量的时间,它也可能会阻塞其他的操作,比如对事件的响应。
此时,可以利用浏览器支持的 Canvas 可以在 WebWorker 中能以单独的线程来渲染这一点,这样就可以避免对主线程的阻塞,也不会影响用户交互行为了。
在 WebWorker 中绘制的方法也不难,我们可以在浏览器主线程中创建 Worker,然后将 Canvas 对象通过 transferControlToOffscreen 转成离屏 Canvas 对象发送给 Worker 线程去处理。具体操作可以看这个例子 (opens new window)以及 random_shapes_worker.js (opens new window),也可以参考 OffscreenCanvas — Speed up Your Canvas Operations with a Web Worker (opens new window) 这篇文章。
# Canvas 性能优化总结
首先,我们在绘制图形时,用越简单的绘图指令来绘制,渲染的效率就越高。所以,我们要想办法减少 Canvas 绘图指令的数量,比如,用 arc 指令画圆来代替绘制边数很多的正多边形。
然后,当我们大批量绘制有限的几种形状的图形时,可以采用缓存将图形一次绘制后保存在离屏的 Canvas 中,下一次绘制的时候,我们直接绘制缓存的图片来取代原始的绘图指令,也能大大提升性能。
可如果我们绘制的元素中只有一部分元素发生改变,我们就可以采用分层渲染,将变化的元素绘制在一个图层,剩下的元素绘制在另一个图层。这样每次只需要重新绘制变化元素所在的图层,大大减少绘制的图形数,从而显著提升了性能。
还有一种情况是,如果 Canvas 只有部分区域发生变化,那我们只需要刷新局部区域,不需要刷新整个 Canvas,这样能显著降低消耗、提升性能。还要注意的是,一些 Canvas 滤镜渲染起来非常耗费性能,所以我们可以对滤镜进行合并,让多个元素只应用一次滤镜,从而减少滤镜对性能的消耗。
最后,除了优化渲染性能外,我们还可以通过 WebWork 以多线程的手段优化计算性能,以达到渲染不阻塞 UI 操作的目的。
# WebGL 绘制性能提升
# 尽量发挥 GPU 的优势
WebGL 的优势就是能直接操作 GPU。因此,我们只有尽量发挥出 GPU 的优势,才能让 WebGL 保持高性能。
下面通过一个例子来体会发挥 GPU 优势的重要性。
# 常规绘图方式的性能瓶颈
假设,我们要在一个画布上渲染 3000 个不同颜色的、位置随机的三角形,并且让每个三角形的旋转角度也随机。
常规的实现方法就是用 JavaScript 来创建随机三角形的顶点,然后依次渲染。下面的代码在创建随机三角形顶点的时候,是使用向量角度旋转的方法创建了正三角形。
function randomTriangle(x = 0, y = 0, rotation = 0.0, radius = 0.1) {
const a = rotation,
b = a + 2 * Math.PI / 3,
c = a + 4 * Math.PI / 3;
return [
[x + radius * Math.sin(a), y + radius * Math.cos(a)],
[x + radius * Math.sin(b), y + radius * Math.cos(b)],
[x + radius * Math.sin(c), y + radius * Math.cos(c)],
];
}
2
3
4
5
6
7
8
9
10
11
然后,在下面代码的 for 循环中依次渲染每个三角形。
const COUNT = 3000;
function render() {
for (let i = 0; i < COUNT; i++) {
const x = 2 * Math.random() - 1;
const y = 2 * Math.random() - 1;
const rotation = 2 * Math.PI * Math.random();
renderer.uniforms.u_color = [
Math.random(),
Math.random(),
Math.random(),
1];
const positions = randomTriangle(x, y, rotation);
renderer.setMeshData([{
positions,
}]);
renderer._draw();
}
requestAnimationFrame(render);
}
render();
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
接着只给着色器传入了一个颜色参数,其他的运算都是在 JavaScript 中完成的。
// 顶点着色器
attribute vec2 a_vertexPosition;
void main() {
gl_Position = vec4(a_vertexPosition, 1, 1);
}
// 片元着色器
#ifdef GL_ES
precision highp float;
#endif
uniform vec4 u_color;
void main() {
gl_FragColor = u_color;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
最终就完成了渲染 3000 个随机三角形的功能,最终效果可以看这里 (opens new window)。
上面这个实现图形性能很一般,大概和 Canvas2D 渲染出来的性能差不多,可以说完全没能发挥出 WebGL 应有的优势。
下面针对这个实现进行优化,从而尽量发挥出 GPU 的优势。
# 减少 CPU 计算次数
首先,我们可以不用生成这么多个三角形。根据前面的知识,我们可以创建一个正三角形,然后通过视图矩阵的变化来实现绘制多个三角形,而视图矩阵可以放在顶点着色器中计算。这样,我们就只要在渲染每个三角形的时候更新视图矩阵就行了。
首先,我们直接生成一个正三角形顶点,并设置数据到缓冲区。
const alpha = 2 * Math.PI / 3;
const beta = 2 * alpha;
renderer.setMeshData({
positions: [
[0, 0.1],
[0.1 * Math.sin(alpha), 0.1 * Math.cos(alpha)],
[0.1 * Math.sin(beta), 0.1 * Math.cos(beta)],
],
});
2
3
4
5
6
7
8
9
10
然后,我们用随机坐标和角度更新每个三角形的 modelMatrix 数据。
const COUNT = 3000;
function render() {
for (let i = 0; i < COUNT; i++) {
const x = 2 * Math.random() - 1;
const y = 2 * Math.random() - 1;
const rotation = 2 * Math.PI * Math.random();
renderer.uniforms.modelMatrix = [
Math.cos(rotation), -Math.sin(rotation), 0,
Math.sin(rotation), Math.cos(rotation), 0,
x, y, 1,
];
renderer.uniforms.u_color = [
Math.random(),
Math.random(),
Math.random(),
1];
renderer._draw();
}
requestAnimationFrame(render);
}
render();
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
而位置和角度的计算了放到顶点着色器内完成。
attribute vec2 a_vertexPosition;
uniform mat3 modelMatrix;
void main() {
vec3 pos = modelMatrix * vec3(a_vertexPosition, 1);
gl_Position = vec4(pos, 1);
}
2
3
4
5
6
7
8
这么做了之后,三角形渲染的 fps 会略有提升,因为我们通过在顶点着色器中并行矩阵运算减少了顶点计算的次数。最终效果看这里 (opens new window)。
不过,这个性能提升在最新的 chrome 浏览器下可能并不明显,因为现在浏览器的 JavaScript 引擎的运算速度很快,尽管将顶点计算放到顶点着色器中进行了,性能差别也很微小。但不管怎么样,这种方法依然是可以提升性能的。
# 静态批量绘制(多实例绘制)
实际上,还有更好的方法能更大程度的提升性能。
对于需要重复绘制的图形,最好的办法是使用批量绘制。重复图形的批量绘制,在 WebGL 中也叫做多实例绘制(Instanced Drawing),它是一种减少绘制次数的技术。
在 WebGL 中,一个几何图形一般需要一次渲染,如果我们要绘制多个图形的话,因为每个图形的顶点、颜色、位置等属性都不一样,所以我们只能一一渲染,不能一起渲染。
但是,如果几何图形的顶点数据都相同,颜色、位置等属性就都可以在着色器计算,那么我们就可以使用 WebGL 支持的多实例绘制方式,一次性地把所有的图形都渲染出来。
多实例绘制在前面有提到过,下面再通过一个例子来演示下。
首先,我们也是创建三角形顶点数据,然后使用多实例绘制的方式传入数据。因为 gl-renderer 中已经封装好了多实例绘制的方法,我们只需要传入 instanceCount 表示要绘制的图形数量即可。
const alpha = 2 * Math.PI / 3;
const beta = 2 * alpha;
const COUNT = 3000;
renderer.setMeshData({
positions: [
[0, 0.1],
[0.1 * Math.sin(alpha), 0.1 * Math.cos(alpha)],
[0.1 * Math.sin(beta), 0.1 * Math.cos(beta)],
],
instanceCount: COUNT,
attributes: {
id: {data: [...new Array(COUNT).keys()], divisor: 1},
},
});
2
3
4
5
6
7
8
9
10
11
12
13
14
15
原生 WebGL 使用多实例绘制
在原生的 WebGL 中使用多实例绘制会稍微复杂一点,我们一般不会这么做,但如果想要尝试一下,可以参考这篇文章 (opens new window)。
这样,我们就只需要每帧渲染一次就可以了。为了能在顶点着色器中完成图形的位置和颜色计算,我们传入了时间 uTime 参数。
function render(t) {
renderer.uniforms.uTime = t;
renderer.render();
requestAnimationFrame(render);
}
render(0);
2
3
4
5
6
7
对应的顶点着色器如下:
attribute vec2 a_vertexPosition;
attribute float id;
uniform float uTime;
highp float random(vec2 co) {
highp float a = 12.9898;
highp float b = 78.233;
highp float c = 43758.5453;
highp float dt= dot(co.xy,vec2(a,b));
highp float sn= mod(dt,3.14);
return fract(sin(sn) * c);
}
varying vec3 vColor;
void main() {
float t = id / 10000.0;
float alpha = 6.28 * random(vec2(uTime, 2.0 + t));
float c = cos(alpha);
float s = sin(alpha);
mat3 modelMatrix = mat3(
c, -s, 0,
s, c, 0,
2.0 * random(vec2(uTime, t)) - 1.0, 2.0 * random(vec2(uTime, 1.0 + t)) - 1.0, 1
);
vec3 pos = modelMatrix * vec3(a_vertexPosition, 1);
vColor = vec3(
random(vec2(uTime, 4.0 + t)),
random(vec2(uTime, 5.0 + t)),
random(vec2(uTime, 6.0 + t))
);
gl_Position = vec4(pos, 1);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
这样优化之后,每一帧的实际渲染次数(即 WebGL 执行 drawElements 的次数)从原来的 3000 减少到了只有 1 次,而且计算都放到着色器里,利用 GPU 并行处理了,因此性能提升了 3000 倍。最终效果看这里 (opens new window)。
虽然在绘制大量图形的时候,使用多实例绘制是一种非常好的方式,但是多实例渲染也有局限性,那就是只能在绘制相同的图形时使用。
# 动态批量绘制
不过,如果是绘制不同的几何图形,只要它们使用同样的着色器程序,而且没有改变 uniform 变量,我们也还是可以将顶点数据先合并再渲染,以减少渲染次数。
可以看下面这个例子。
假设,我们现在不只显示正三角形,而是显示随机的正三角形、正方形和正五边形。最常规的实现方式和前面显示随机正三角形的例子类似,我们只要修改一下顶点生成的函数,根据不同的边数生成对应的正多边形就可以了。
function randomShape(x = 0, y = 0, edges = 3, rotation = 0.0, radius = 0.1) {
const a0 = rotation;
const delta = 2 * Math.PI / edges;
const positions = [];
const cells = [];
for (let i = 0; i < edges; i++) {
const angle = a0 + i * delta;
positions.push([x + radius * Math.sin(angle), y + radius * Math.cos(angle)]);
if (i > 0 && i < edges - 1) {
cells.push([0, i, i + 1]);
}
}
return {positions, cells};
}
2
3
4
5
6
7
8
9
10
11
12
13
14
这样,我们就可以随机生成三、四、五、六边形。
const {positions, cells} = randomShape(x, y, 3 + Math.floor(4 * Math.random()), rotation);
renderer.setMeshData([{
positions,
cells,
}]);
2
3
4
5
不过这个例子的性能更差,因为正四边形、正五边形、正六边形每个分别要用 2、3、4 个三角形,所以虽然要绘制 3000 个图形,但我们实际绘制的三角形数量要远多于 3000 个。最终效果看这里 (opens new window)。
此时我们可以用动态批量绘制的方式来进行优化。
我们依然可以将顶点合并起来绘制。因为每个图形都是由顶点(positions)和索引(cells)构成的,所以我们可以批量创建图形,将这些图形的顶点和索引全部合并起来。
首先创建两个类型数组 positions 和 cells,我们可以假定所有的图形都是正六边形,算出要创建的类型数组的总长度。注意,这里我们用的是三维顶点而不是二维顶点,这并不是说我们要绘制的图形是 3D 图形,而是我们使用 z 轴来保存当前图形的 id,提供给着色器中的伪随机函数使用。
计算顶点的方式和前面一样,都用的是向量旋转的方法。值得注意的是,在计算索引的时候,我们只要将之前已经算过的几何图形顶点总数记录下来,保存到 offset 变量里,从 offset 值开始计算就可以了。
function createShapes(count) {
const positions = new Float32Array(count * 6 * 3); // 最多 6 边形
const cells = new Int16Array(count * 4 * 3); // 索引数等于 3倍顶点数 - 2
let offset = 0;
let cellsOffset = 0;
for (let i = 0; i < count; i++) {
const edges = 3 + Math.floor(4 * Math.random());
const delta = 2 * Math.PI / edges;
for (let j = 0; j < edges; j++) {
const angle = j * delta;
positions.set([0.1 * Math.sin(angle), 0.1 * Math.cos(angle), i], (offset + j) * 3);
if (j > 0 && j < edges - 1) {
cells.set([offset, offset + j, offset + j + 1], cellsOffset);
cellsOffset += 3;
}
}
offset += edges;
}
return {positions, cells};
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
最终,createShapes 函数会返回一个包含几万个顶点和索引的几何体数据,然后我们将它一次性渲染出来就行了。
const {positions, cells} = createShapes(COUNT);
renderer.setMeshData([{
positions,
cells,
}]);
function render(t) {
renderer.uniforms.uTime = t;
renderer.render();
requestAnimationFrame(render);
}
render(0);
2
3
4
5
6
7
8
9
10
11
12
13
14
下面是对应的顶点着色器代码。
attribute vec3 a_vertexPosition;
uniform float uTime;
highp float random(vec2 co) {
highp float a = 12.9898;
highp float b = 78.233;
highp float c = 43758.5453;
highp float dt= dot(co.xy ,vec2(a,b));
highp float sn= mod(dt,3.14);
return fract(sin(sn) * c);
}
varying vec3 vColor;
void main() {
vec2 pos = a_vertexPosition.xy;
float t = a_vertexPosition.z / 10000.0;
float alpha = 6.28 * random(vec2(uTime, 2.0 + t));
float c = cos(alpha);
float s = sin(alpha);
mat3 modelMatrix = mat3(
c, -s, 0,
s, c, 0,
2.0 * random(vec2(uTime, t)) - 1.0, 2.0 * random(vec2(uTime, 1.0 + t)) - 1.0, 1
);
vColor = vec3(
random(vec2(uTime, 4.0 + t)),
random(vec2(uTime, 5.0 + t)),
random(vec2(uTime, 6.0 + t))
);
gl_Position = vec4(modelMatrix * vec3(pos, 1), 1);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
采用动态批量绘制之后,性能也得到了很大提升。最终效果可以看这里 (opens new window)。
# 批量渲染总结
批量渲染几乎是 WebGL 绘制最大的优化手段,因为它充分发挥了 GPU 的优势,所以能极大地提升性能。因此,在实际的 WebGL 项目中,如果我们遇到性能瓶颈,第一步就是要看看绘制的几何图形有哪些是可以批量渲染的,如果能批量渲染的,要尽量采用批量渲染,以减少一帧中的绘制次数。
不过批量渲染也有局限性:
如果我们绘制的图形必须要用到不同的 WebGLProgram,或者每个图形要用到不同的 uniform 变量,那么它们就无法合并渲染。因此,我们在设计程序的时候,要尽量避免 WebGLProgram 切换,以及 uniform 的修改。
在前面的例子中,我们将 id 传入着色器,然后根据 id 在着色器中用伪随机函数计算位置和颜色。这样的好处自然是渲染起来特别快,但坏处是这些数据是在着色器中计算出来的,如果我们想从 JavaScript 中拿到一些有用信息,比如,图形的位置、颜色等等,就很难拿到了。
因此,如果业务中需要用到这些信息,我们就不能将它们放在着色器中计算。当然,我们可以通过 JavaScript 来计算位置和颜色信息,然后把它们写到 attribute 中。不过这样的话,我们使用的内存消耗就会增加一些,而且用 JavaScript 计算这些值的过程会比在着色器中略慢。当然这也是因为项目需求不得不做出的选择。
批量绘制对性能的影响最大,不过,除此之外,还有两个因素对性能也有影响,分别是透明与反锯齿和 Shader 效率。
# 透明度与反锯齿
在 WebGL 中,我们要处理半透明图形,可以开启混合模式(Blending Mode)让透明度生效。只有这样,WebGL 才会根据 Alpha 通道值和图形的层叠关系正确渲染并合成出叠加的颜色值。开启混合模式的代码如下:
gl.enable(gl.BLEND);
不过,混合颜色本身有计算量,所以开启混合模式会造成一定的性能开销。因此,如果不需要处理半透明图形,我们尽量不开启混合模式,这样性能好就会更好一些。
此外,WebGL 本身对图形有反锯齿的优化,反锯齿可以避免图形边缘在绘制时出现锯齿,当然反锯齿本身也会带来性能开销。因此,如果对反锯齿的要求不高,我们在获取 WebGL 上下文时,关闭反锯齿设置也能减少开销、提升渲染性能。
const gl = canvas.getContext('webgl', {antiAlias: false}); //不消除反锯齿
# Shader 的效率
Shader 的效率也是我们在使用 WebGL 时需要注意的。前面说过,为了尽可能合并数据,动态批量绘制图形,我们要求图形尽量使用同一个 WebGLProgram,并且避免在绘制过程中切换 WebGLProgram。
但如果不同图形的绘制都使用同一个 WebGLProgram,这也会造成着色器本身的代码逻辑复杂,从而影响 Shder 的效率。
最好的解决办法就是尽可能拆分不同的着色器代码,然后在绘制过程中根据不同元素进行切换。所以,批量绘制和简化 WebGLProgram 是一对矛盾,我们只能对两者进行取舍,尽可能让性能达到最优。
另外,shader 代码不同于常规的 JavaScript 代码,它最大的特性是并行计算,因此处理逻辑的过程与普通的代码不同。
可以来看一个简单的例子。
下面是一段常规的 JavaScript 代码。
如果 if 语句中的条件值为 true,那么第一个分支被执行,否则第二个分支被执行,这两个分支是不能同时被执行的。
if (Math.random() > 0.5) {
do something
} else {
do somthing else
}
2
3
4
5
但如果是 Shader 中的代码,情况就完全不同了。
无论是 if 还是 else 分支,在 glsl 中都会被执行,最终的值则根据条件表达式结果不同取不同分支计算的结果。
if (random(st) > 0.5) {
gl_FragColor = vec4(1)
} else {
gl_FragColor = vec4(0)
}
2
3
4
5
之所以会这样,就是因为 GPU 是并行计算的,也就是说并行执行大量 glsl 程序,但是每个子程序并不知道其他子程序的执行结果,所以最优的办法就是事先计算好 if 和 else 分支中的结果,再根据不同子程序的条件返回对应的结果。因此,if 语句必然要同时执行两个分支,但这样就会造成性能上一定的损耗,解决这个问题的办法是尽可能不用 if 语句。
比如,对上面的代码,我们不用 if 语句,而是用 step 函数来解决问题,这样性能就会好一些。
gl_FragColor = vec4(1) * step(random(st), 0.5);
此外,一些耗时的计算,比如开平方、反正切、反余弦等等,我们的优化原则也是能避免就尽可能避免,多使用简单的加法和乘法,这样就能保证着色器的高效率运行了。
# WebGL 性能优化总结
WebGL 的性能优化原则就是尽量发挥出 GPU 的优势。核心原则有两个:
首先,我们尽量减少 CPU 计算次数,把能放在 GPU 中计算的部分放在 GPU 中并行计算;
其次,也是更重要的,我们应该减少每一帧的绘制次数。
对应的优化方法也有两个:
一是如果我们要绘制大量相同的图形,可以利用多实例渲染来实现静态批量绘制;
二是如果绘制的图形不同,但是采用的 WebGL 程序相同、以及 uniform 的值没有改变,那我们可以人为合并顶点并进行渲染。减少绘制次数一般来说对性能会有比较明显的提升。
除此之外,我们还可以在不需要处理透明度的时候不启用混合模式,在不需要抗锯齿的时候关闭抗锯齿功能,它们都能减少性能开销。
并且,我们还要注意 Shader 的效率,尽量用函数代替分支,避免一些耗时的计算,多使用简单的加法和乘法,这样能够保证着色器高效运行。
总的来说,性能优化是一个非常复杂的问题,我们应该结合实际项目的需求、数据的特征、技术方案等等综合考虑,最终才能得出最适合的方案。在实际项目中,无论你是直接用原生的 WebGL,还是使用 OGL、SpriteJS 或者 ThreeJS,大体的优化思路肯定离不开这些点。但怎么既恰到好处的优化,又保持性能与产品功能、开发效率以及扩展性的平衡,就需要我们通不断积累项目经验,才能慢慢做到最好。
# 针对海量数据,如何优化性能
前面学习了 Canvas2D 和 WebGL 性能优化的一些基本原则和处理方法。在正确运用这些方法后,我们能让渲染性能达到较高的程度,满足我们项目的需要。
不过,在数据量特别多的时候,我们会遇到些特殊的渲染需求,比如,要在一个地图上标记非常多的地理位置点(数千到数万),或者在地图上同时需要渲染几万条黑客攻击和防御数据。这些需求可能超过了常规优化手段所能达到的层次,需要我们针对数据和渲染的特点进行性能优化。
下面会通过 一个渲染动态地理位置的例子,来说明如何对特殊渲染需求迭代优化。
不过,这里所用到特殊优化手段,只是一种具体的方法和手段,我们可以借鉴它去理解思路,但千万不要陷入到思维定式中。因为解决这些特殊渲染需求,并没有固定的路径或方法,它是一个需要迭代优化的过程,需要我们对 WebGL 的渲染机制非常了解,并深入思考,才能创造出最适合的方法来。在我们实际的工作里,还有许多其他的方法可以使用,一定要根据自己的实际情况随机应变。
# 渲染动态的地理位置
在地图可视化应用中,渲染地理位置信息是一类常见的需求,例如在某张地图上,我们就用许多不同颜色的小圆点标注出了某个国家一些不同的地区。
如果我们要实现这些静态的标准点,方法其实很简单,用 Canvas2D 或者 WebGL 都可以轻松实现。就算点数量比较多也没关系,因为一次性渲染对性能影响也不会很大。不过,如果我们想让圆点运动起来,比如,做出一种闪烁或者呼吸灯的效果,那我们就要考虑点的数量对性能的影响了。
常规的做法就是一个一个圆绘制上去,也就是先创建圆的几何顶点数据,然后对每个圆设置不同的参数来分别绘制。效果可以看这里 (opens new window)。不过这么做的话,整体的性能就会非常低。
# 优化大数据渲染的常见方法
优化大数据渲染的思路就是减少渲染次数和减少几何体顶点数。
# 1. 使用批量渲染优化
在绘制大量同种几何图形的时候,通过减少渲染次数来提升性能最好的做法是直接使用批量渲染。因此,我们可以用实例渲染来代替逐个渲染,将之前的 uniform 变量替换成 attribute 变量,其他的逻辑几乎不变。这样做了之后,性能得到了很大提升,具体效果可以看这里 (opens new window)。
# 2. 使用点图元优化
绘制规则的图形,我们还可以使用点图元。WebGL 的基本图元包括点、线、三角形等等。
在采用常规做法绘制圆的时候,我们是用 circle 函数生成三角网格,然后通过三角形绘制的。这样绘制一个圆需要许多顶点。但实际上,这种简单的图形,我们还可以直接采用点图元。
# (1)点图元画矩形
在 WebGL 中,点图元是最简单的图元,它用来显示画布上的点。在顶点着色器里,我们可以设置 gl_PointSize 来改变点图元的大小,所以我们就可以用点图元来表示一个矩形。
const canvas = document.querySelector('canvas');
const renderer = new GlRenderer(canvas);
const vertex = `
attribute vec2 a_vertexPosition;
uniform vec2 uResolution;
void main() {
gl_PointSize = 0.2 * uResolution.x;
gl_Position = vec4(a_vertexPosition, 1, 1);
}
`;
const fragment = `
#ifdef GL_ES
precision highp float;
#endif
void main() {
gl_FragColor = vec4(0, 0, 1, 1);
}
`;
const program = renderer.compileSync(fragment, vertex);
renderer.useProgram(program);
renderer.uniforms.uResolution = [canvas.width, canvas.height];
renderer.setMeshData({
mode: renderer.gl.POINTS,
positions: [[0, 0]],
});
renderer.render();
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
在这段代码中,我们将 meshData 的 mode 设为 gl.POINTS,只绘制一个点(0, 0)。在顶点着色器中,我们通过 gl_PointSize 来设置顶点的大小。由于 gl_PointSize 的单位是像素,所以我们需要传一个画布宽高 uResolution 进去,然后将 gl_Position 设为 0.2 * uResolution,这就让这个点的大小设为画布的 20%,最终在画布上就呈现出一个蓝色矩形。最终效果可以看这里 (opens new window)。
之前我们绘制矩形的时候,是将矩形剖分为两个三角形,然后用填充三角形来绘制的。而这里采用点图元来实现的好处是,我们只需要一个顶点就可以绘制,而不需要用四个顶点、两个三角形来填充。
# (2)点图元画圆
使用点图元绘制其他图形,比如圆的原理就是,使用距离场和造型函数。
首先是顶点着色器代码。
attribute vec2 a_vertexPosition;
uniform vec2 uResolution;
varying vec2 vResolution;
varying vec2 vPos;
void main() {
gl_PointSize = 0.2 * uResolution.x;
vResolution = uResolution;
vPos = a_vertexPosition;
gl_Position = vec4(a_vertexPosition, 1, 1);
}
2
3
4
5
6
7
8
9
10
11
12
然后是片元着色器代码。
#ifdef GL_ES
precision highp float;
#endif
varying vec2 vResolution;
varying vec2 vPos;
void main() {
vec2 st = gl_FragCoord.xy / vResolution;
st = 2.0 * st - 1.0;
float d = distance(st, vPos);
d = 1.0 - smoothstep(0.195, 0.2, d);
gl_FragColor = d * vec4(0, 0, 1, 1);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
通过计算到圆心的距离得出距离场,然后通过 smoothstep 将一定距离内的图形绘制出来,就能得到一个蓝色的圆。最终效果可以看这里 (opens new window)。
利用这样的思路,就可以得到新的绘制大量圆的方法。具体就是,不通过 circle 函数来生成圆的顶点数据,而是直接使用 gl.POINTS 来绘制,并在着色器中用距离场和造型函数来画圆。这么做之后,我们大大减少了顶点的运算,原先我们每绘制一个圆,需要 32 个顶点、30 个三角形,而现在用一个点就解决了问题。性能又得到了很大的提升,最终效果可以看这里 (opens new window)。
# 其他优化方法
# 1. 使用后期处理通道优化
前面我们知道了使用后期处理通道的基本方法。实际上,后期处理通道十分强大,它最重要的特性就是可以把各种数据存储在纹理图片中。这样在迭代处理的时候,我们就可以用 GPU 将这些数据并行地读取和处理,从而达到非常高效地渲染。
可以看看 OGL 官网上的这个例子 (opens new window),它就是用后期处理通道实现了粒子流的效果。这样的效果,在其他图形系统中,或者 WebGL 不使用后期处理通道是不可能做到的。
这里面的具体实现比较复杂,但其中最关键的一点是,我们要将每个像素点的速度值保存到纹理图片中,然后利用 GPU 并行计算的能力,对每个像素点同时进行处理。
# 2. 使用 GPGPU 优化
GPGPU 也叫做通用 GPU 方式,优化思路和后期处理通道很像,就是把每个粒子的速度保存到纹理图片里,实现同时渲染几万个粒子并产生运动的效果。OGL 官网上就有一个这样的例子 (opens new window)。
# 3. 使用服务端渲染优化
假设我们需要渲染数十万条历史数据的记录,如果单纯在前端渲染,性能会成为瓶颈。由于这些数据都是历史数据,因此针对这个场景我们可以在服务端进行渲染,然后直接将渲染后的图片输出给前端。
要使用服务端渲染,我们可以使用 Node-canvas-webgl (opens new window) 这个库,它可以在 Node.js 中启动一个 Canvas2D 和 WebGL 环境,这样我们就可以在服务端进行渲染,然后再将结果缓存起来直接提供给客户端。
# 一些关于 WebGL 性能的启示
从上面的例子中可以看出,即使是使用 WebGL,不同的渲染方式,性能的差别也会很大,甚至会达到数千倍的差别。
因此,在可视化业务中,我们一定要学会根据不同的应用场景来有针对性地进行优化。说起来简单,要做到这一点并不容易,你需要对 WebGL 本身非常熟悉,而且对于 GPU 的使用、渲染管线等基本原理有着比较深刻的理解。这不是一朝一夕可以做到的,需要持续不断地学习和积累。
就像有些同学使用绘图库 ThreeJS 或者 SpriteJS 来绘图的时候,做出来的应用性能很差,就会怀疑是图形库本身的问题。实际上,这些问题很可能不是库本身的问题,而是我们使用方法上的问题。换句话说,是我们使用的绘图方式并不是最适用于当前的业务场景。而 ThreeJS、SpriteJS 这些通用的绘图库,也并不会自己针对特定场景来优化。
因此,单纯使用图形库,我们绘制出来的图形就没法真正达到性能极致。也正是因为这个原因,我们需要深入到图形渲染的底层原理。只有掌握了这些,才能真正学会如何驾驭图形库,做出高性能的可视化解决方案来。