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,
    },
  });
}
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
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();
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
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);
}
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

优化后的代码实现 (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;
}
1
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);
}
1
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)],
  ];
}
1
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();
1
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;
}
1
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)],
  ],
});
1
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();
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

而位置和角度的计算了放到顶点着色器内完成。

attribute vec2 a_vertexPosition;

uniform mat3 modelMatrix;

void main() {
  vec3 pos = modelMatrix * vec3(a_vertexPosition, 1);
  gl_Position = vec4(pos, 1);
}
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},
  },
});
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);
1
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);
}
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};
}
1
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,
}]);
1
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};
}
1
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);
1
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);
}
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);
1

不过,混合颜色本身有计算量,所以开启混合模式会造成一定的性能开销。因此,如果不需要处理半透明图形,我们尽量不开启混合模式,这样性能好就会更好一些

此外,WebGL 本身对图形有反锯齿的优化,反锯齿可以避免图形边缘在绘制时出现锯齿,当然反锯齿本身也会带来性能开销。因此,如果对反锯齿的要求不高,我们在获取 WebGL 上下文时,关闭反锯齿设置也能减少开销、提升渲染性能

const gl = canvas.getContext('webgl', {antiAlias: false}); //不消除反锯齿
1

# Shader 的效率

Shader 的效率也是我们在使用 WebGL 时需要注意的。前面说过,为了尽可能合并数据,动态批量绘制图形,我们要求图形尽量使用同一个 WebGLProgram,并且避免在绘制过程中切换 WebGLProgram。

但如果不同图形的绘制都使用同一个 WebGLProgram,这也会造成着色器本身的代码逻辑复杂,从而影响 Shder 的效率。

最好的解决办法就是尽可能拆分不同的着色器代码,然后在绘制过程中根据不同元素进行切换。所以,批量绘制和简化 WebGLProgram 是一对矛盾,我们只能对两者进行取舍,尽可能让性能达到最优。

另外,shader 代码不同于常规的 JavaScript 代码,它最大的特性是并行计算,因此处理逻辑的过程与普通的代码不同。

可以来看一个简单的例子。

下面是一段常规的 JavaScript 代码。

如果 if 语句中的条件值为 true,那么第一个分支被执行,否则第二个分支被执行,这两个分支是不能同时被执行的。

if (Math.random() > 0.5) {
  do something
} else {
  do somthing else
}
1
2
3
4
5

但如果是 Shader 中的代码,情况就完全不同了。

无论是 if 还是 else 分支,在 glsl 中都会被执行,最终的值则根据条件表达式结果不同取不同分支计算的结果。

if (random(st) > 0.5) {
  gl_FragColor = vec4(1)
} else {
  gl_FragColor = vec4(0)
}
1
2
3
4
5

之所以会这样,就是因为 GPU 是并行计算的,也就是说并行执行大量 glsl 程序,但是每个子程序并不知道其他子程序的执行结果,所以最优的办法就是事先计算好 if 和 else 分支中的结果,再根据不同子程序的条件返回对应的结果。因此,if 语句必然要同时执行两个分支,但这样就会造成性能上一定的损耗,解决这个问题的办法是尽可能不用 if 语句

比如,对上面的代码,我们不用 if 语句,而是用 step 函数来解决问题,这样性能就会好一些。

gl_FragColor = vec4(1) * step(random(st), 0.5);
1

此外,一些耗时的计算,比如开平方、反正切、反余弦等等,我们的优化原则也是能避免就尽可能避免,多使用简单的加法和乘法,这样就能保证着色器的高效率运行了。

# 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();
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

在这段代码中,我们将 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);
}
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);
}
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 这些通用的绘图库,也并不会自己针对特定场景来优化。

因此,单纯使用图形库,我们绘制出来的图形就没法真正达到性能极致。也正是因为这个原因,我们需要深入到图形渲染的底层原理。只有掌握了这些,才能真正学会如何驾驭图形库,做出高性能的可视化解决方案来。

上次更新时间: 2022年03月04日 21:09:41