<audio id="audio" title="28 | Canvas、SVG与WebGL在性能上的优势与劣势" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a9/27/a9e59409a006e3d5a01d8f8cd43f8027.mp3"></audio>

你好，我是月影。

性能优化，一直以来都是前端开发的难点。

我们知道，前端性能是一块比较复杂的内容，由许多因素决定，比如，网页内容和资源文件的大小、请求数、域名、服务器配置、CDN等等。如果你能把性能优化好，就能极大地增强用户体验。

在可视化领域也一样，可视化因为要突出数据表达的内容，经常需要设计一些有视觉震撼力的图形效果，比如，复杂的粒子效果和大量元素的动态效果。想要实现这些效果，图形系统的渲染性能就必须非常好，能够在用户的浏览器上稳定流畅地渲染出想要的视觉效果。

那么针对可视化渲染，我们都要解决哪些性能问题呢？

## 可视化渲染的性能问题有哪些？

由于前端的可视化也是在Web上展现的，因此像网页大小这些因素也会影响它的性能。而且，无论是可视化还是普通Web前端，针对这些因素进行性能优化的原理和手段都一样。

所以我今天想和你聊的是，可视化方面特殊的性能问题。它们在我们熟悉的Web前端工作中并不常见，通常只在可视化中绘制复杂图形的时候，我们才需要重点考虑。这些问题大体上可以分为两类，一类是**渲染效率问题，<strong>另一类是**计算问题</strong>。

**我们先来看它们的定义，渲染效率问题指的是图形系统在绘图部分所花费的时间，而计算问题则是指绘图之外的其他处理所花费的时间，包括图形数据的计算、正常的程序逻辑处理等等**。

我们知道，在浏览器上渲染动画，每一秒钟最高达到60帧左右。也就是说，我们可以在1秒钟内完成60次图像的绘制，那么完成一次图像绘制的时间就是1000/60（1秒=1000毫秒），约等于16毫秒。

换句话说，如果我们能在16毫秒内完成图像的计算与渲染过程，那视觉呈现就可以达到完美的60fps（即60帧每秒，fps全称是frame per second，是帧率单位）。但是，在复杂的图形渲染时，我们的帧率很可能达不到60fps。

所以，我们只能退而求其次，最低可以选择24fps，就相当于图形系统要在大约42毫秒内完成一帧图像的绘制。这是在我们的感知里，达到比较流畅的动画效果的最低帧率了。要保证这个帧率，我们就必须保证计算加上渲染的时间不能超过42毫秒。

因为计算问题与数据和算法有关，所以我们后面会专门讨论。这里，我们先关注渲染效率的问题，这个问题和图形系统息息相关。

我们知道，Canvas2D、SVG和WebGL等图形系统各自的特点不同，所以它们在绘制不同图形时的性能影响也不同，会表现出不同的性能瓶颈。其实，通过基础篇的学习，我们也大体上知道了这些图形系统的区别和优劣。那今天，我们就在此基础上，深入讨论一下影响它们各自性能的关键因素，理解了这些要素，我们针对不同图形系统，就能快速找到需要进行性能优化的点了。

## 影响Canvas渲染性能的2大要素

我们知道，Canvas是指令式绘图系统，它通过绘图指令来完成图形的绘制。那么我们很容易就会想到2个影响因素，首先绘制图形的数量越多，我们需要的绘图指令就越多，花费的渲染时间也会越多。其次，画布上绘制的图形越大，绘图指令执行的时间也会增多，那么花费的渲染时间也会越多。

这些其实都是我们现阶段得出的假设，而实践是检验真理的唯一标准，所以我们一起做个实验，来证明我们刚才的假设吧。

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

const WIDTH = canvas.width;
const HEIGHT = canvas.height;

function randomColor() {
  return `hsl(${Math.random() * 360}, 100%, 50%)`;
}

function drawCircle(context, radius) {
  const x = Math.random() * WIDTH;
  const y = Math.random() * HEIGHT;
  const fillColor = randomColor();
  context.fillStyle = fillColor;
  context.beginPath();
  context.arc(x, y, radius, 0, Math.PI * 2);
  context.fill();
}

function draw(context, count = 500, radius = 10) {
  for(let i = 0; i &lt; count; i++) {
    drawCircle(context, radius);
  }
}

requestAnimationFrame(function update() {
  ctx.clearRect(0, 0, WIDTH, HEIGHT);
  draw(ctx);
  requestAnimationFrame(update);
});

```

如上面代码所示，我们在Canvas上每一帧绘制500个半径为10的小圆，效果如下：

<img src="https://static001.geekbang.org/resource/image/b2/e3/b278a4f98413b9029dfa914ab4b88be3.jpg" alt="" title="500个小球，半径10">

注意，为了方便查看帧率的变化，我们在浏览器中开启了帧率检测。Chrome开发者工具自带这个功能，我们在开发者工具的Rendering标签页中，勾选FPS Meter就可以开启这个功能查看帧率了。

我们现在看到，即使每帧渲染500个位置和颜色都随机的小圆形，Canvas渲染的帧率依然能达到60fps。

接着，我们增加小球的数量，把它增加到1000个。

<img src="https://static001.geekbang.org/resource/image/8c/f9/8cbe753219d08a1b84753fe5f89518f9.jpg" alt="" title="1000个小球，半径10">

这时你可以看到，因为小球数量增加一倍，所以帧率掉到了50fps左右，现在下降得还不算太多。而如果我们把小球的数量设置成3000，你就能看到明显的差别了。

那如果我们把小球的数量保持在500，把半径增大到很大，如200，也会看到帧率有明显下降。

<img src="https://static001.geekbang.org/resource/image/ee/81/ee69d57ce2b1214f55650f8c3f8d9681.jpg" alt="" title="500个小球，半径200">

但是，单从上图的实验来看，图形大小对帧率的影响也不是很大。因为我们把小球的半径增加了20倍，帧率也就下降到33fps。当然这也是因为画圆比较简单，如果我们绘制的图形更复杂一些，那么大小的影响会相对显著一些。

通过这个实验，我们能得出，影响Canvas的渲染性能的主要因素有两点，一是**绘制图形的数量**，二是**绘制图形的大小。**这正好验证了我们开头的结论。

总的来说，Canvas2D绘制图形的性能还是比较高的。在普通的个人电脑上，我们要绘制的图形不太大时，只要不超过500个都可以达到60fps，1000个左右其实也能达到50fps，就算要绘制大约3000个图形，也能够保持在可以接受的24fps以上。

因此，在不做特殊优化的前提下，如果我们使用Canvas2D来绘图，那么3000个左右元素是一般的应用的极限，除非这个应用运行在比个人电脑的GPU和显卡更好的机器上，或者采用特殊的优化手段。那具体怎么优化，我会在下节课详细来说。

## 影响SVG性能的2大要素

讲完了Canvas接下来我们看一下SVG。

我们用SVG实现同样的绘制随机圆形的例子，代码如下：

```
function randomColor() {
  return `hsl(${Math.random() * 360}, 100%, 50%)`;
}

const root = document.querySelector('svg');
const COUNT = 500;
const WIDTH = 500;
const HEIGHT = 500;

function initCircles(count = COUNT) {
  for(let i = 0; i &lt; count; i++) {
    const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
    root.appendChild(circle);
  }
  return [...root.querySelectorAll('circle')];
}
const circles = initCircles();

function drawCircle(circle, radius = 10) {
  const x = Math.random() * WIDTH;
  const y = Math.random() * HEIGHT;
  const fillColor = randomColor();
  circle.setAttribute('cx', x);
  circle.setAttribute('cy', y);
  circle.setAttribute('r', radius);
  circle.setAttribute('fill', fillColor);
}

function draw() {
  for(let i = 0; i &lt; COUNT; i++) {
    drawCircle(circles[i]);
  }
  requestAnimationFrame(draw);
}

draw();


```

在我的电脑上（一台普通的MacBook Pro，内存8GB，独立显卡）绘制了500个半径为10的小球时，SVG的帧率接近60fps，会比Canvas稍慢，但是差别不是太大。

<img src="https://static001.geekbang.org/resource/image/01/fa/01f6d0d37a0aeabd8449dyyecfc4e2fa.jpg" alt="" title="SVG绘制500个小球，半径10">

当我们将小球数量增加到1000个时，SVG的帧率就要略差一些，大概45fps左右。

<img src="https://static001.geekbang.org/resource/image/4e/b6/4efb6930462da79f36e913546f5eb1b6.jpg" alt="" title="SVG绘制1000个小球，半径10">

乍一看，似乎SVG和Canvas2D的性能差别也不是很大。不过，随着小球数量的增加，两者的差别会越来越大。比如说，当我们将小球的个数增加到3000个左右的时候，Canvas2D渲染的帧率依然保持在30fps以上，而SVG渲染帧率大约只有15fps，差距会特别明显。

之所以在小球个数较多的时候，二者差距很大，因为SVG是浏览器DOM来渲染的，元素个数越多，消耗就越大。

如果我们保证小球个数在一个小数值，然后增大每个小球的半径，那么与Canvas一样，SVG的渲染效率也会明显下降。

<img src="https://static001.geekbang.org/resource/image/19/ea/19d991c1ee547d1f98fe2f504eaba1ea.jpg" alt="" title="SVG绘制500个小球，半径200">

如上图所示，当渲染500个小球时，我们把半径增加到200，帧率下降到不到20fps。

最终，我们能得到的结论与Canvas类似，影响SVG的性能因素也是相同的两点，一是**绘制图形的数量**，二是**绘制图形的大小**。但与Canvas不同的是，图形数量增多的时候，SVG的帧率下降会更明显，因此，一般来说，在图形数量小于1000时，我们可以考虑使用SVG，当图形数量大于1000但不超过3000时，我们考虑使用Canvas2D。

那么当图形数量超过3000时，用Canvas2D也很难达到比较理想的帧率了，这时候，我们就要使用WebGL渲染。

## 影响WebGL性能的要素

用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);
  const id = new Uint16Array(count);

  for(let i = 0; i &lt; 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 &lt; 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 &lt; count; i++) {
    id.set([i], i);
  }
  return new Geometry(gl, {
    position: {
      data: position,
      size: 2,
    },
    index: {
      data: index,
    },
    id: {
      instanced: 1,
      size: 1,
      data: id,
    },
  });
}

```

我们实现一个circleGeometry函数，用来生成指定数量的小球的定点数据。这里我们使用批量绘制的技术，一下子绘制了30000个小球。与绘制单个小球一样，我们计算小球的position数据和index数据，然后我们设置一个id数据，这个数据等于每个小球的下标。

我们通过instanced:1的方式告诉WebGL这是一个批量绘制的数据，让每一个值作用于一个几何体。这样我们就能区分不同的几何体，而WebGL在绘制的时候会根据id数据的个数来绘制相应多个几何体。

接着，我们实现顶点着色器，并且在顶点着色器代码中实现随机位置和随机颜色。

```
precision highp float;
attribute vec2 position;
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);
}

vec3 hsb2rgb(vec3 c){
  vec3 rgb = clamp(abs(mod(c.x*6.0+vec3(0.0,4.0,2.0), 6.0)-3.0)-1.0, 0.0, 1.0);
  rgb = rgb * rgb * (3.0 - 2.0 * rgb);
  return c.z * mix(vec3(1.0), rgb, c.y);
}

varying vec3 vColor;

void main() {
  vec2 offset = vec2(
    1.0 - 2.0 * random(vec2(id + uTime, 100000.0)),
    1.0 - 2.0 * random(vec2(id + uTime, 200000.0))
  );
  vec3 color = vec3(
    random(vec2(id + uTime, 300000.0)),
    1.0,
    1.0
  );
  vColor = hsb2rgb(color);
  gl_Position = vec4(position + offset, 0, 1);
}

```

上面的代码中的random函数和hsb2rgb函数，我们都学过了，整体逻辑也并不复杂，相信你应该能看明白。

最后，我们将uTime作为uniform传进去，结合id和uTime，用随机数就可以渲染出与前面Canvas和SVG例子一样的效果。

这个WebGL渲染的例子的性能非常高，我们将小球的个数设置为30000个，依然可以轻松达到60fps的帧率。

<img src="https://static001.geekbang.org/resource/image/84/6f/84be3a259d9d7dc0572cf8044029536f.jpg" alt="" title="WebGL，绘制30000个小球，半径10">

WebGL渲染之所以能达到这么高的性能，是因为WebGL利用GPU并行执行的特性，无论我们批量绘制多少个小球，都能够同时完成计算并渲染出来。

如果我们增大小球的半径，那么帧率也会明显下降，这一点和Canvas2D与SVG一样。当我们将小球半径增加到0.8（相当于Canvas2D中的200），那么可以流畅渲染的数量就无法达到这么多，大约渲染3000个左右可以保持在30fps以上，这个效率仍比Canvas2D有着5倍以上的提升。小球半径增加导致帧率下降，是因为图形增大，片元着色器要执行的次数就会增多，就会增加GPU运算的开销。

好了，那我们来总结一下WebGL性能的要素。WebGL情况比较复杂，上面的例子其实不能涵盖所有的情况，不过不要紧，我这里先说一下结论，你先记下来，我们之后还会专门讨论WebGL的性能优化方法。

首先，WebGL和Canvas2D与SVG不同，它的性能并不直接与渲染元素的数量相关，而是取决于WebGL的渲染次数。有的时候，图形元素虽然很多，但是WebGL可以批量渲染，就像前面的例子中，虽然有上万个小球，但是通过WebGL的instanced drawing技术，可以批量完成渲染，那样它的性能就会很高。当然，元素的数量多，WebGL渲染效率也会逐渐降低，这是因为，元素越多，本身渲染耗费的内存也越多，占用内存太多，渲染效率也会下降。

其次，在渲染次数相同的情况下，WebGL的效率取决于着色器中的计算复杂度和执行次数。图形顶点越多，顶点着色器的执行次数越多，图形越大，片元着色器的执行次数越多，虽然是并行执行，但执行次数多依然会有更大的性能开销。最后，如果每次执行着色器中的计算越复杂，WebGL渲染的性能开销自然也会越大。

总的来说，WebGL的性能主要有三点决定因素，**一是渲染次数，二是着色器执行的次数，三是着色器运算的复杂度。**当然，数据的大小也会决定内存的消耗，因此也会对性能有所影响，只不过影响没有前面三点那么明显。

## 要点总结

要针对可视化的渲染效率进行性能优化，我们就要先搞清影响图形系统渲染性能的主要因素。

对于Canvas和SVG来说，影响渲染性能的主要是绘制元素的数量和元素的大小。一般来说，Canvas和SVG绘制的元素越多，性能消耗越大，绘制的图形越大，性能消耗也越大。相比较而言，Canvas的整体性能要优于SVG，尤其是图形越多，二者的性能差异越大。

WebGL要复杂一些，它的渲染性能主要取决于三点。

第一点是渲染次数，渲染次数越多，性能损耗就越大。需注意，要绘制的元素个数多，不一定渲染次数就多，因为WebGL支持批量渲染。

第二点是着色器执行的次数，这里包括顶点着色器和片元着色器，前者的执行次数和几何图形的顶点数有关，后者的执行次数和图形的大小有关。

第三点是着色器运算的复杂度，复杂度和glsl代码的具体实现有关，越复杂的处理逻辑，性能的消耗就会越大。

最后，数据的大小会影响内存消耗，所以也会对WebGL的渲染性能有所影响，不过没有前面三点的影响大。

## 小试牛刀

<li>
刚才我们用SVG、Canvas和WebGL分别实现了随机小球，由此比较了三种图形系统的性能。但是我们并没说HTML/CSS，你能用HTML/CSS来实现这个例子吗？用HTML/CSS来实现，在性能方面与SVG、Canvas和WebGL有什么区别呢？从中，你能得出影响HTML/CSS渲染性能的要素吗？
</li>
<li>
在WebGL的例子中，我们采用了批量绘制的技术。实际上我们也可以不采用这个技术，给每个小球生成一个mesh对象，然后让Ogl来渲染。你可以试着用Ogl不采用批量渲染来实现随机小球，然后对比它们之间的渲染方案，得出性能方面的差异吗?
</li>

## 源码

 [课程中详细示例代码](https://github.com/akira-cn/graphics/tree/master/performance-basic)
