首先介绍下canvas, 前端的同学可能很熟悉,举个很简单的例子, 平常用的网页截图、H5游戏、前端动效、可视化图表...,都有canvas 的应用场景,我们看下官方的定义:

canvas是HTML5提供的一种新标签, ie9才开始支持的,canvas是一个矩形区域的画布,可以用JS控制每一个像素在上面绘画。canvas 标签使用 JavaScript 在网页上绘制图像,本身不具备绘图功能。canvas 拥有多种绘制路径、矩形、圆形、字符以及添加图像的方法。

看着很简单,其实canvas这个标签的加入,赋予了我们更多创建惊艳的前端效果的能力。但是你知道他也有性能问题??本篇文章就简单谈一谈canvas的性能优化。

# 前言

好了现在进入今天的主题: canvas 性能优化, 读完本篇文章你可以学到下面:

  1. 哪些因素会影响canvas的性能
  2. canvas优化的几种方式

# 到底是什么因素影响了canvas

我们都知道浏览器上渲染动画 每一秒高达60帧,也就是1秒钟内我们完成60次图像绘制, 也就是每一帧图像的绘制时间其实就是(1000/ 60)。 如果在每一帧动画的时间小于 16.7 ms 辣么就会出现卡顿、丢帧。而canvas 其实是一个指令式绘图系统, 他通过绘图指令来完成绘图操作。 那么很容易想到两个很关键的因素:

  1. 绘制图形的个数
  2. 绘制图形的大小

很容易理解,假设绘制 绘制一个图形 几毫秒 那么如果绘制10000个图形呢?? 肯定时间就长了, 如果后面其他操作、ui交互、渲染其他图形... 这就会导致渲染的时间比较长了。canvas 绘制的图像都是一个个小像素点构成的、 你绘制一个半径为5 的圆 和半径为1000的圆所需要的像素点 肯定是不一样的。这里给大家看一张图清晰的明白一个绘制的构成。

图片

你图形数量越大需要的像素点就越多,那么 片元着色器就要不断的去执行。

我写了两个小demo 来验证我们的猜想,纸上得来终觉浅,绝知此事要躬行哇!

# 绘制图形的个数

主要是绘制100个 和绘制10000个小球作对比

我们先看下代码:

      const canvas = document.getElementById('canvas')
      const ctx = canvas.getContext('2d')
      const WIDTH = canvas.width
      const HEIGHT = canvas.height

      function randomColor() {
        return (
          'rgb( ' +
          ((Math.random() * 255) >> 0) +
          ',' +
          ((Math.random() * 255) >> 0) +
          ',' +
          ((Math.random() * 255) >> 0) +
          ' )'
        )
      }
      function drawCirle(radius = 10) {
        const x = Math.random() * WIDTH
        const y = Math.random() * HEIGHT
        ctx.fillStyle = randomColor()
        ctx.beginPath()
        ctx.arc(x, y, radius, 0, Math.PI * 2)
        ctx.fill()
      }

      function draw(count = 1) {
        for (let i = 0; i < count; i++) {
          drawCirle()
        }
      }
      function update() {
        ctx.clearRect(0, 0, WIDTH, HEIGHT)
        draw()
        requestAnimationFrame(update)
      }
      requestAnimationFrame(update)

# 绘制100个

绘制100 个小球的fps 还是能够稳定在30fps 的如图:

100个

# 绘制10000个

100个

绘制10000个小球的fps 掉帧的厉害 是真的卡。 所以也就验证了我们的猜想 ,canvas 的性能是和绘制图形个数有关系的

这个fps 帧率显示器是 chrome 自带的 输入 command + shift + p 搜索🔍 render fps 自然就找到了。

# 绘制图形的大小

我们再来验证绘制图形的大小会不会影响fps 。我将小球的数量改成1000, 半径改成10 。

目前的小球的数量 是1000半径大小是10

我们看下gif 图:

Oct-24-2021 20-23-42

我们看到帧率大概是稳定在30fps 的, 这时候 数量不变的情况下,我将小球的半径改成200, 我们在看下帧率变化,然后呢 你会看到帧率有较为明显的下跌。 如图:

Oct-24-2021 20-24-16

所以总结以上来看,影响canvas的因素就是有以上两点:

  1. 第一个渲染的图形数量
  2. 第二个就是渲染图形的大小

其实我来深度分析, 第一个渲染的图形数量多,就是调用绘图指令的次数比较多,

第二个渲染的图形大,就是一次绘图渲染的时间比较长

然后下面我就开始优化canvas

# 减少绘图指令的调用

这句话怎么理解呢 , 假设你要在场景中画正n变形,这是一个 很常见的需求可能你稍不注意写下了下面这几行代码:

 function drawAnyShape(points) {
      for(let i=0; i<points.length; i++) {
            const p1 = points[i]
            const p2 =  i=== points.length - 1 ? points[0] : points[i+1]
            ctx.fillStyle = 'black'
            ctx.beginPath();
            ctx.moveTo(...p1)
            ctx.lineTo(...p2)
            ctx.closePath();
            ctx.stroke()
      }
   }

points 对应的生成多边形的点,代码如下:

 function  generatePolygon(x,y,r, edges = 3) {
      const points = []
      const detla = 2* Math.PI / edges;
      for(let i= 0;i<edges;i++) {
          const theta = i * detla;
          points.push([x+ r * Math.sin(theta), y + r * Math.cos(theta)])
      }
      return points 
  }

主要是根据一个圆心,根据边数生成对应的点。

乍一看这代码没什么问题,生成一个正多边形, 这时候我在页面上画了1000个正多边形: 如下图:

优化前

一看这fps低成这个样子,很多人这时候说,你画的图形多,那我只要悄悄的改下代码,就能让fps 回归正常

我又重写了正多边形的方法:

function drawAnyShape2(points) {
      ctx.beginPath();
      ctx.moveTo(...points[0]);
      ctx.fillStyle = 'black'
      for(let i=1; i<points.length; i++) {
            ctx.lineTo(...points[i])
      }
      ctx.closePath();
      ctx.stroke()
  }

我们看下这时候的fps 帧率:

优化后

看了下fps 已经成功升到了30fps, 这是为什么呢, 第一段我们在循环中去做绘图操作, 循环一次, stoke() 一次,这显然是不合理的,第二个直接把stoke() ,放到循环外,其实就调用了一次,所以我们可以得出减少绘图指令是可以提高canvas的性能的

# 分层渲染

为什么需要分层渲染, 在游戏中,假设人物的不停地在移动,但是呢背景可能加了很多花里呼哨的元素,但是我在每一次更新的时候,场景本身是不变的,变的只有人物不停的移动,如果每一帧再去重绘不就造成了性能浪费, 这时候分层canvas就出现了 我们先看下一张图你可能就明白了。

分层渲染

我通过3个canvas叠在一起,通过设置每个canvas的 z-index 达到了3个画布还是在同一层的错觉,这样我在requestAnimation中,只需要对 动的图形去做重新绘制就好了,其余的依旧是保持不动 。

我写了下面这些伪代码

<canvas id="backgroundCanvas" />
<canvas id="peopleActionCanvas" />
const peopleActionCanvas = document.getElementById('peopleActionCanvas');
const backgroundCanvas = document.getElementById('backgroundCanvas');

function draw(){
  drawPeopleAction(peopleActionCanvas);
  if (needDrawBackground) {
    drawBackground(backgroundCanvas);
  }
  requestAnimationFrame(draw);
}

一个背景层一个运动层, 在抽象一点,我们什么时候应该去做分层 ,如果画布纯是静态的就没有必要去做分层了, 如果当前有静态有东动态的,你可以逻辑层放在最上面,然后展示层 放在最底下就可以实现所谓的 分层渲染了,但是最好保持在3-5个。

# 局部渲染

局部渲染的话其实就是调用canvas 的 clip方法, 这个方法很多同学不知道是什么。我们先看下官方文档MDN 对这个方法的使用

**CanvasRenderingContext2D**.clip() 是 Canvas 2D API 将当前创建的路径设置为当前剪切路径的方法

你可以试着思考一下 如何用canvas 画一个1/4圆。

const canvas  = document.getElementById('canvas');
const ctx  = canvas.getContext('2d');
ctx.fillStyle = 'red'
ctx.arc(100, 100, 75, 0, Math.PI*2, false);
//ctx.clip();
ctx.fillRect(0, 0, 100,100);

这里填充的时候 没有用clip 画面上应该是一个矩形。 如图:

矩形

这时候我把clip注释解开来, 你会发现矩形 变成了一个半圆。 所以clip 这个 api 结合 fillRect 填充 就是实现填充任意图形路径。我们看下图:

半圆

这时候就会有同学问这东西和我们局部渲染有什么关系,大家都知道canvas 实现动画,每一帧都会把当前画面的上的东西全部清楚然后再重新更新一遍,看下下面这个场景:

 for(let i=0;i< 20;i++) {
          ctx.beginPath();
          ctx.fillStyle = `rgba(${Math.random() * 255},${Math.random() * 255},${Math.random() * 255},1)`
          ctx.arc(Math.random() * 800,Math.random() * 800, Math.random() * 100, 0, Math.PI*2, false);
          ctx.fill()
        }

随机生成了20个圆形, 如图所示:

random

这时候如果我说我只想改中间那个绿色的圆颜色我只想把它改成蓝色,但是其他的圆其实没有属性上的改变,按照正常的操作应该就是下面几步

  1. 清除当前画布
  2. 重新画所有图形
  3. 找到我们要改的图形 然后把颜色改掉

这对于画布中图形比较少的情况下是比较OK的,但是如果当画布出现下面这样子是不是就会出现所谓的渲染浪费嘛

1000个图形

这是canvas 中画了1000 个圆形, 如果你只改一个颜色,那其他999都是不变的 这种浪费是肯定存在性能问题, 如果在做动画效果可想而知,丢帧非常厉害。 这里就可以使用我们上面的api

红色区域

正确的做法其实就是我们要做局部刷新:

  1. 确定改变的元素的包围盒(是否存在相交)
  2. 画出路径 然后 clip
  3. 最后重新绘制绘制改变的图形

clip() 确定绘制的的裁剪区域,区域之外的图形不能绘制,详情查看 CanvasRenderingContext2D.clip() clearRect(x, y, width, height) 擦除指定矩形内的颜色,查看 CanvasRenderingContext2D.clearRect()

# 包围盒

我们刚才说的用一个框去把图形包围住, 其实在几何中我们叫包围盒 或者是boundingBox。 可以用来快速检测两个图形是否相交, 但是还是不够准确。最好还是用图形算法去解决。 或者游戏中的碰撞检测,都有这个概念。因为我这里讨论的是2d的boudingbox, 还是比较简单的。我给你看几张图, 或许你就瞬间明白了。

image-20210822113735608

任意多边形

虚线框其实就是boundingBox, 其实就是根据图形的大小,算出一个矩形边框。理论我们知道了,映射到代码层次, 我们怎么去表达呢? 我这里带大家原生实现一下bound2d 类, 其实我们每个2d图形,都可以去实现。 因为2d图形都是由点组成的,所以只要获得每一个图形的离散点集合, 然后对这些点,去获得一个2d空间的boundBox。

想看具体的实现可以参考three.js box2d 的源码, 写的很清楚,这里就不过多介绍了。

# 离屏canvas 和webworker

我们先说下 什么是离屏canvas???

OffscreenCanvas提供了一个可以脱离屏幕渲染的canvas对象。它在窗口环境和web worker环境均有效。

脱离屏幕渲染的canvas对象,这对我们实际写动画的时候真的有用吗???

答案是肯定的

想象以下这个场景:如果发现自己在每个动画帧上重复了一些相同的绘制操作,请考虑将其分流到屏幕外的画布上。 然后,您可以根据需要频繁地将屏幕外图像渲染到主画布上,而不必首先重复生成该图像的步骤。由于浏览器是单线程,canvas的计算和渲染其实是在同一个线程的。这就会导致在动画中(有时候很耗时)的计算操作将会导致App卡顿,降低用户体验。

幸运的是, OffscreenCanvas (opens new window) 离屏Canvas可以非常棒的解决这个麻烦!

到目前为止,canvas的绘制功能都与<canvas>标签绑定在一起,这意味着canvas API和DOM是耦合的。而OffscreenCanvas,正如它的名字一样,通过将Canvas移出屏幕来解耦了DOM和canvas API。

由于这种解耦,OffscreenCanvas的渲染与DOM完全分离了开来,并且比普通canvas速度提升了一些,而这只是因为两者(Canvas和DOM)之间没有同步。但更重要的是,将两者分离后,canvas将可以在Web Worker中使用,即使在Web Worker中没有DOM。这给canvas提供了更多的可能性。

这就离屏canvas 为啥和webworker 这么配的缘故了。

# 如何创建离屏canvas?

创建离屏canvas有两种方式:

一种是通过OffscreenCanvas的构造函数直接创建。比如下面的示例代码:

 // 离屏canvas 
 const offscreen = new OffscreenCanvas(200, 200);

第二种是使用canvas的transferControlToOffscreen函数获取一个OffscreenCanvas对象,绘制该OffscreenCanvas对象,同时会绘制canvas对象。比如如下代码:

const canvas  = document.getElementById('canvas');const offscreen = canvas.transferControlToOffscreen();

我写了下面这个小demo 验证下到底是不是可靠的

  const canvas  = document.getElementById('canvas');  // 离屏canvas   const offscreen1 = new OffscreenCanvas(200, 200);  const offscreen2 = canvas.transferControlToOffscreen();  console.error(offscreen1,offscreen2, '222')    

打开浏览器的截图如下:

离屏canvas

没什么问题,我们的猜想是对的

那么问题又来了,离屏canvas怎么与主线程的canvas通信呢

这时候引用另外一个api transferToImageBitmap

通过transferToImageBitmap函数可以从OffscreenCanvas对象的绘制内容创建一个ImageBitmap对象。该对象可以用于到其他canvas的绘制。

比如一个常见的使用是,把一个比较耗费时间的绘制放到web worker下的OffscreenCanvas对象上进行,绘制完成后,创建一个ImageBitmap对象,并把该对象传递给页面端,在页面端绘制ImageBitmap对象。

我们写个小demo测试下:

# 优化前

我们画 10000 * 10000 个矩形看看页面的响应和时间,代码如下:

  const canvas  = document.getElementById('canvas');  const ctx  =  canvas.getContext('2d');  function draw() {      for(let i = 0;i < 10000;i ++){        for(let j = 0;j < 1000;j ++){          ctx.fillRect(i*3,j*3,2,2);        }      }  }  draw()  ctx.arc(100,75,50,0,2*Math.PI);  ctx.stroke()

我们直接看效果:

before

可以很明显的感受到,在渲染出图形前,浏览器是失去响应的,我们无法做认可操作。这样的用户体验肯定是非常差的。

# 优化后

我们使用离屏canvas + webworker 进行优化,代码如下:

我们先看下worker 的代码:

let offscreen,ctx;// 监听主线程发的信息onmessage = function (e) {  if(e.data.msg == 'init'){    init();    draw();  }}function init() {  offscreen = new OffscreenCanvas(512, 512);  ctx = offscreen.getContext("2d");}// 绘制图形function draw() {   ctx.clearRect(0,0,offscreen.width,offscreen.height);   for(var i = 0;i < 10000;i ++){    for(var j = 0;j < 1000;j ++){      ctx.fillRect(i*3,j*3,2,2);    }  }  const imageBitmap = offscreen.transferToImageBitmap();    // 传送给主线程  postMessage({imageBitmap:imageBitmap},[imageBitmap]);}

看下主线程的代码:

const worker = new Worker('./worker.js')worker.postMessage({msg:'init'});worker.onmessage = function (e) {  // 这里就接受到work 传来的离屏canvas位图  ctx.drawImage(e.data.imageBitmap,0,0);} ctx.arc(100,75,50,0,2*Math.PI); ctx.stroke()

直接看效果:

after

对比两个很明显的变化, 画多个矩形是个非常耗时的操作会影响其他图形渲染,可以采用离屏canvas + webworker 来解决这种失去响应。

# 最后

本期的canvas优化就到这里了,如果有什么问题,欢迎评论区和我交流,或者有什么写的不对的地方欢迎指正。我简单做一个总结

  1. 绘制的图形的数量和大小会影响canvas的性能
  2. 图形数量过多,但是只刷新部分 可以使用局部渲染
  3. 逻辑层和背景图层分离 可以使用分层渲染
  4. 某些长时间的逻辑影响主线程的, 可以使用离屏渲染 和webworker 来解决问题
上一次更新时间: 2021/12/26 下午6:37:38