gitbook/跟月影学可视化/docs/279075.md
2022-09-03 22:05:03 +08:00

23 KiB
Raw Blame History

29 | 怎么给Canvas绘制加速

你好,我是月影。

上节课,我们从宏观上了解了各个图形系统在性能方面的优劣,以及影响性能的要素。实际上,想要解决性能问题,我们就必须要知道真正消耗性能的点,从而结合项目需求进行有针对的处理,否则性能优化就是纸上谈兵、空中楼阁。

所以这节课我们就深入讨论一下影响Canvas绘图性能的因素一起来分析几个不同类型的Canvas项目找到的性能瓶颈以及对应的解决办法从而学会对大部分Canvas项目进行性能优化。

我们知道Canvas是指令式绘图系统它有状态设置指令、绘图指令以及真正的绘图方法fill和stroke等各类API。通常情况下利用Canvas绘图我们要先调用状态设置指令设置绘图状态然后用绘图指令决定要绘制的图形最后调用真正的fill()或stroke()方法将内容输出到画布上。

那结合上节课的实验我们知道影响Canvas性能的两大因素分别是图形的数量和图形的大小。它们都会直接影响绘图指令一个决定了绘图指令的多少另一个决定了绘图指令的执行时间。通常来说绘图指令越多、执行时间越长渲染效率就越低性能也就越差。

因此我们想要对Canvas性能进行优化最重要的就是优化渲染效率。常用的手段有5种分别是优化Canvas指令、使用缓存、分层渲染、局部重绘和优化滤镜。此外还有一种手段叫做多线程渲染,是用来优化非渲染的计算和交互方面导致的性能问题。

首先我们来说说优化Canvas指令。

手段一优化Canvas指令

刚刚我们说了Canvas执行的绘图指令越多性能的消耗就越大。那如果希望Canvas绘图达到更好的性能我们要尽可能减少绘图指令的数量。这就是“优化Canvas指令”要做的事情。

那具体怎么做呢?我们看一个例子。

假设我们要在一个600 X 600的画布上实现一些位置随机的多边形并且不断刷新这些图形的形状和位置效果如下

结合我们之前学过的知识这个效果其实并不难实现可以分为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();


这个效果实现起来虽然不难但性能却不是很好因为它在我的Macbook Pro电脑上只有不到30fps的帧率。那问题出在哪呢我们还是要回到代码中。

我们注意到drawShape函数里的for循环它是根据顶点来绘制图形的一个点对应一条绘图指令。而在我们绘制的随机图形里有3、4、5、6边形和100边形。对于一个100边形来说它的顶点数量非常多所以Canvas需要执行的绘图指令也会非常多那绘制很多个100边形自然会造成性能问题了。因此如何减少绘制100边形的绘图指令的数量才是我们要优化的重点。具体该怎么做呢

我们知道对于半径为10的小图形来说正100边形已经完全是正圆形了所以我们可以用arc指令来替代for循环。

我们修改shapeTypes和draw函数用-1代替正100边形然后判断type是否大于0如果是就用之前的方式绘制正多边形否则用arc指令来画圆。这么做了之后整个效果的帧率就会从30fps提升到40fps效果还是比较明显的。

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 {
      // 画圆
      ctx.beginPath();
      ctx.arc(x, y, 10, 0, TAU);
      ctx.stroke();
      ctx.fill();
    }
  }
  requestAnimationFrame(draw);
}

到这里,你会发现,我们讲的其实是个特例,那在实际工作中,我们是需要针对特例来优化的。我希望我讲完今天的内容你能够做到举一反三。

手段二:使用缓存

在上面的方法中,我们优化了绘图指令,让渲染性能有了比较明显的提升。不过,因为这个绘图任务的图形数量和状态都是有限的,我们还有更好的优化方法,那就是使用缓存

因为Canvas的性能瓶颈主要在绘图指令方面如果我们能将图形缓存下来保存到离屏的Canvasoffscreen Canvas然后在绘制的时候作为图像来渲染那我们就可以将绘制顶点的绘图指令变成直接通过drawImage指令来绘制图像而且也不需要fill()方法来填充图形,这样性能就会有大幅度的提升。

具体的做法,是我们先实现一个创建缓存的函数。代码如下:

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;
}

然后,我们一次性创建缓存,直接通过缓存来绘图。

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

这样我们就通过缓存渲染把原本数量非常多的绘图指令优化成了只有drawImage的一条指令让渲染帧率达到了60fps从而大大提升了性能。

缓存的局限性

不过虽然使用缓存能够显著降低Canvas的性能消耗但是缓存的使用也有局限性。

首先因为缓存是通过创建离屏Canvas对象实现的如果我们要绘制的图形状态指不同形状、颜色等非常多的话那将它们都缓存起来就需要创建大量的离屏Canvas对象。这本身对内存消耗就非常大有可能反而降低了性能。

其次缓存适用于图形状态本身不变的图形元素如固定的几何图形它们每次刷新只需要更新它的transform这样的图形比较适合用缓存。如果是经常发生状态改变的图形元素那么缓存就必须一直更新缓存更新本身也是绘图过程。因此这种情况下采用缓存根本起不到减少绘图指令的作用反而因为增加了一条drawImage指令产生了更大的开销。

第三严格上来说从缓存绘制和直接用绘图指令绘制还是有区别的尤其是在fillText渲染文字或者我们绘制一个图形有较大缩放scale的时候。因为不使用缓存直接绘制的是矢量图而通过缓存drawImage绘制出的则是位图所以缓存绘制的图形在清晰度上可能不是很好。

但是总体来说缓存的应用还是非常多的我们应该要掌握它的用法学会在合适的时候运用缓存来提升Canvas的渲染性能。

手段三:分层渲染

前面两种手段是操作Canvas上所有元素来优化性能的但有的时候我们要绘制的元素很多其中大部分元素状态是不变的只有一小部分有变化。这个时候我们又该如何进行优化呢

我们知道Canvas是将上一次绘制的内容擦除然后绘制新的内容来实现状态变化的。利用这一特点我们就可以将变化的元素和不变的元素进行分层处理。也就是说我们可以用两个Canvas叠在一起将不变的元素绘制在一个Canvas中变化的元素绘制在另一个Canvas中。

我们还是来看一个例子。

假设,我们要实现一个如上图的效果。这个效果的特点是,画面上有一个飞机在运动,运动的物体比较少,而其他静止不动的图形很多(如背景中的上千个三角形)。

在绘制的时候我们如果将运动的物体和其他物体都绘制在同一个Canvas画布中要改变飞机的运动状态我们就要重新绘制所有的物体这会非常浪费性能。因此更好的做法是我们使用两层画布一层Canvas作为背景来绘制静态的图形就是这个例子里的上千个小三角形而另一层Canvas作为前景用来绘制运动的物体就是运动的飞机。

这样的话我们只需要一次绘制就能得到背景层Canvas并且不管飞机的状态怎么改变我们都不需要重绘而前景的飞机可以每一帧重绘也就大大减少了图形绘制的数量并且提升了性能。

下面我列出具体的代码虽然很长但逻辑并不复杂核心就是用两个Canvas元素来分别绘制你可以看一下。

function drawRandomTriangle(path, context) {
  const {width, height} = context.canvas;
  context.save();
  context.translate(Math.random() * width, Math.random() * height);
  context.fill(path);
  context.restore();
}

function drawBackground(context, count = 2000) {
  context.fillStyle = '#ed7';
  const d = 'M0,0L0,10L8.66, 5z';
  const p = new Path2D(d);
  for(let i = 0; i < count; i++) {
    drawRandomTriangle(p, context);
  }
}

function loadImage(src) {
  const img = new Image();
  img.crossOrigin = 'anonymous';
  return new Promise((resolve) => {
    img.onload = resolve(img);
    img.src = src;
  });
}

async function drawForeground(context) {
  const img = await loadImage('http://p3.qhimg.com/t015b85b72445154fe0.png');
  const {width, height} = context.canvas;
  function update(t) {
    context.clearRect(0, 0, width, height);
    context.save();
    context.translate(0, 0.5 * height);
    const p = (t % 3000) / 3000;
    const x = width * p;
    const y = 0.1 * height * Math.sin(3 * Math.PI * p);
    context.drawImage(img, x, y);
    context.restore();
    requestAnimationFrame(update);
  }
  update(0);
}

const bgcanvas = document.querySelector('#bg');
const fgcanvas = document.querySelector('#fg');
drawBackground(bgcanvas.getContext('2d'));
drawForeground(fgcanvas.getContext('2d'));

手段四:局部重绘

但是,我们用分层渲染解决性能问题的时候,所绘制的图形必须满足两个条件:一是有大量静态的图形元素不需要重新绘制,二是动态和静态图形元素绘制顺序是固定的,先绘制完静态元素再绘制动态元素。如果元素都有可能运动,或者动态元素和静态元素的绘制顺序是交错的,比如先绘制几个静态元素,再绘制几个动态元素,然后再绘制静态元素,这样交替进行,那么分层渲染就不好实现了。这时候,我们还有另外一种优化手段,它叫做局部重绘。

**局部重绘顾名思义就是不需要清空Canvas的全局区域而是根据运动的元素的范围来清空部分区域。**在很大一部分可视化大屏项目中,我们不会让整个屏幕的所有元素都不断改变,而是只有一些固定的区域改变,所以我们直接刷新那部分区域,重绘区域中的元素就可以了。

如上图所示一个可视化大屏只有2块动态区域需要不断重绘那我们用Canvas上下文的clearRect方法控制要刷新的动态区域,只对这些区域进行擦除然后重绘。

要注意的是动态区重绘的时候区域内的静态元素也需要跟着重绘。如果有静态元素跨越了动态和静态区域范围那在重绘时我们自然不希望破坏了静态区的图形。这时候我们可以使用Canvas上下文的clip方法它是一种特殊的绘图指令可以设定一个绘图区让图形的绘制限制在这个绘图区内部。这样的话图形中超过clip范围的部分浏览器就不会把它渲染到Canvas上。

这种固定区域的局部重绘使用起来不难,但有时候我们不知道具体的动态区域究竟多大。这个时候,我们可以使用动态计算要重绘区域的技术,它也被称为脏区检测。它的基本原理是根据动态元素的包围盒,动态算出需要重绘的范围。

那什么是包围盒呢?

我们知道,多边形由顶点构成,包围盒就是指能包含多边形所有顶点,并且与坐标轴平行的最小矩形。

在Canvas平面直角坐标系下求包围盒并不复杂只要分别找到所有顶点坐标中x的最大、最小值xmin和xmax以及y的最大、最小值ymin和ymax那么包围盒就是矩形[(xmin, ymin), (xmin, ymax), (xmax, ymax), (xmax, ymin)]。

对所有的动态元素计算出包围盒我们就能知道局部刷新的范围了。不过在实际操作的时候我们经常会遇到各种复杂的细节问题需要解决。因为涉及的细节比较多我没法全都讲到所以如果你遇到了问题可以看看蚂蚁金服AntV团队的Canvas局部渲染优化总结这篇文章。

手段五:优化滤镜

实际上分层渲染和局部重绘解决的都是图形重绘的问题。那除了重绘影响渲染效率的还有Canvas滤镜。

我们知道滤镜是一种对图形像素进行处理的方法Canvas支持许多常用的滤镜。不过Canvas渲染滤镜的性能开销比较大。到底有多大呢我们还是用前面绘制随机图形的例子来体验一下。

这次我们用缓存优化版本的代码这一版代码的性能最高。在绘制前我们给Canvas设置一个blur滤镜。代码如下

ctx.filter = 'blur(5px)';

这样呢我们让Canvas绘制出来的图形有了模糊的效果。但是这么设置了之后你会发现原本60fps的帧率直接掉到2fps画面看上去一顿一顿的卡得惨不忍睹。这就是因为滤镜对渲染性能的开销实在太大了。

那这种情况下其实我们也有优化手段。针对这个场我们实际上是对Canvas应用一个全局的blur滤镜把绘制的所有元素都变得模糊所以我们完全没必要对每个元素应用滤镜而是可以采用类似后期处理通道的做法先将图形以不使用滤镜的方式绘制到一个离屏的Canvas上然后直接将这个离屏Canvas以图片方式绘制到要显示的画布上在这次绘制的时候采用滤镜。这样我们就把大量滤镜绘制的过程缩减为对一张图片使用一次滤镜了。大大减少了处理滤镜的次数之后效果立竿见影帧率立即回到了60fps。

那么具体实现的代码和效果我也列出来,你可以看一下。

ctx.filter = 'blur(5px)';

// 创建离屏的 Canvas
const ofc = new OffscreenCanvas(canvas.width, canvas.height);
const octx = ofc.getContext('2d');
function draw() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  octx.clearRect(0, 0, canvas.width, canvas.height);
  // 将图形不应用滤镜绘制到离屏Canvas上
  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;
    octx.drawImage(shape, x, y);
  }
  // 再将离屏Canvas图像绘制到画布上这一次绘制采用了滤镜
  ctx.drawImage(ofc, 0, 0);
  requestAnimationFrame(draw);
}

draw();

当然这种优化滤镜的方式只有当我们要对画布上绘制的所有图形都采用同一种滤镜的时候才有效。不过如果有部分图形采用相同的滤镜而且它们是连续绘制的我们也可以采用类似的办法把这部分图形绘制到离屏Canvas上之后再将图像应用滤镜并绘制回画布。这样也能够减少滤镜的处理次数明显提升性能。总之想要达到比较好的性能我们要记住一个原则尽量合并图形应用相同滤镜的过程。

手段六:多线程渲染

到这里,我们说完了几种提升渲染性能的常见手段。不过,影响用户体验的不仅仅是渲染性能,有时候,我们还要对绘制的内容进行交互,而如果渲染过程消耗了大量的时间,它也可能会阻塞其他的操作,比如对事件的响应。

遇到这种问题的时候以前我们会比较头疼甚至不得不降低渲染性能以减少CPU资源占用从而让交互行为不被阻塞。不过现在浏览器支持的Canvas可以在WebWorker中以单独的线程来渲染这样就可以避免对主线程的阻塞也不会影响用户交互行为了。

那么具体怎么才能在Worker中绘制呢其实也很简单。我们在浏览器主线程中创建Worker然后将Canvas对象通过 transferControlToOffscreen 转成离屏Canvas对象发送给Worker线程去处理。

const canvas = document.querySelector('canvas');

const worker = new Worker('./random_shapes_worker.js');
const ofc = canvas.transferControlToOffscreen();
worker.postMessage({
  canvas: ofc,
  type: 'init',
}, [ofc]);

这样从使用上来说无论在Worker线程中还是在主线程中操作都没有太大的区别还能不阻塞浏览器主线程的任何操作。

我这里列出一部分核心代码完整的代码我放在GitHub仓库里你可以试着运行一下看看效果。

function draw(ctx, shapes) {
  const canvas = ctx.canvas;
  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.bind(null, ctx, shapes));
}

self.addEventListener('message', (evt) => {
  if(evt.data.type === 'init') {
    const canvas = evt.data.canvas;
    if(canvas) {
      const ctx = canvas.getContext('2d');
      const shapes = createCache();
      draw(ctx, shapes);
    }
  }
});

要点总结

这节课我们讲了Canvas性能优化的6种手段其中前5种是针对渲染效率进行优化分别是优化Canvas指令、使用缓存、分层渲染、局部重绘以及针对滤镜的优化。最后一种是通过多线程来优化计算的性能让计算过程能够并行执行不会阻塞浏览器的UI。下面我再带你一起梳理一下性能优化的原则。

首先我们在绘制图形时用越简单的绘图指令来绘制渲染的效率就越高。所以我们要想办法减少Canvas绘图指令的数量比如用arc指令画圆来代替绘制边数很多的正多边形。

然后当我们大批量绘制有限的几种形状的图形时可以采用缓存将图形一次绘制后保存在离屏的Canvas中下一次绘制的时候我们直接绘制缓存的图片来取代原始的绘图指令也能大大提升性能。

可如果我们绘制的元素中只有一部分元素发生改变,我们就可以采用分层渲染,将变化的元素绘制在一个图层,剩下的元素绘制在另一个图层。这样每次只需要重新绘制变化元素所在的图层,大大减少绘制的图形数,从而显著提升了性能。

还有一种情况是如果Canvas只有部分区域发生变化那我们只需要刷新局部区域不需要刷新整个Canvas这样能显著降低消耗、提升性能。

还要注意的是一些Canvas滤镜渲染起来非常耗费性能所以我们可以对滤镜进行合并让多个元素只应用一次滤镜从而减少滤镜对性能的消耗。

最后除了优化渲染性能外我们还可以通过WebWork以多线程的手段优化计算性能以达到渲染不阻塞UI操作的目的。

小试牛刀

学会了使用多种优化手段之后,我们来尝试实现一个粒子效果吧!

具体效果是,我们要让小三角形以不同的角度和速度,由画布中心点向四周运动,同时小三角形自身也以随机的角速度旋转。

你可以尝试用两种方式来实现这个效果,分别是使用性能优化和不使用性能优化。在这两种情况下,你的电脑最多能支持同时绘制多少个小三角形?

我们今天学的这6种性能优化手段对你的工作是不是很有帮助那不妨就把这节课分享出去吧我们下节课再见


源码

课程中详细示例代码GitHub仓库

推荐阅读

1
2