canvas 性能优化原理,加亿点点细节🏄

4,372 阅读19分钟

前言

这个章节我们主要讲的是 canvas 中一些具体的性能优化思路。虽然平时在写页面的时候不需要太过关注,但是在 cavnas 中是 hin 容易写出卡顿、甚至崩溃的代码的,所以讲一下大体思路是很有必要的。当然天下乌鸦一般黑,道理都是一样的🥳。
我们知道 canvas 渲染是一个不断擦除和重绘的过程,一般会配合 requestAnimationFrame 使用,要想让人觉得流畅,就要把每一帧的时间控制在 16ms 以内,当然越少越好,而每一帧主要又包含两个部分:计算和渲染。于是乎要想提高 canvas 的性能,无非就是从这两方面下手:

  • 计算:
    • 减少数据量
    • 加快处理
  • 绘制:
    • 尽可能少的绘制
    • 尽可能快的绘制

其实它们又好像是一个意思,就是能偷懒就偷懒,能少做一点就绝不多做,能不重新绘制的时候就不重新绘制,必须要绘制的时候就少绘制一些东西。接下来咱们就基于这些基本原则进行详细说明,冲🏄。

尽可能少的绘制

这是最重要的原则,也是效果最为显著的手段,因为所有的绘制都是有成本的:

  • 执行各种逻辑、各种计算
  • js 调用 canvas api 进行绘制
  • 浏览器把渲染后的结果呈现在屏幕上(通常是另一个渲染线程)
  • ...

刚才我们说每一帧的时间有 16ms,但实际上是更少的。所以尽可能少的绘制是必须的,下面就来看看基于这个原则的一些实用方法。

可视区外的不绘制

这个是最简单也是最直白的想法了,就是超出画布可视区我们就不进行绘制,那具体是怎么操作嘞🤔?就是我们在重绘画布的时候,肯定是需要遍历每个物体然后才能把每个元素画上去的,当遍历到某个物体时,我们可以先判断该物体是否在画布可视区内,亦即物体的 AABB 包围盒是否在画布这个矩形内,不在的话就直接跳过,一般 canvas 库都会提供物体的坐标点信息,所以还是很好判断的。

分层

分层顾名思义就是生成多个 canvas,然后将它们依次堆叠即可。很多文章都会提到说把动态物体和静态物体进行分层,这确实是一种很经典的做法,比如可以将不常动的背景单独放在一个 canvas 里。不过其实不一定是要放在 canvas 中,放在一个普通 div 里也是 ok 的。那除了动静分层,还有什么其他分层原则吗🤔?有的,比如:

  • 按功能分层
    • 这个思想在 fabric.js(一个canvas 库)中应该算是很明显了,该库的实现用了两层 canvas,上层 canvas 主要负责响应各种交互事件,下层 canvas 则专注于单纯的渲染画布,适当的分层有利于代码清晰、理顺逻辑和 bug 调试。
  • 临时分层
    • 当我们在一个复杂的画布中拖动一个物体时,为了避免重绘该图层,可以将拖动的物体单独移出到另外一个 canvas 中渲染,当拖拽结束时再将物体移动回原来的图层。

当然层数也不建议创建太多,毕竟是要占内存的,通常可以是 5 个以内(其实看情况啦🥳)。另外要注意的就是分层会导致层与层之间的顺序是固定的,对于物体之间总是相互交错的时候它并不是一个很好的选择。

批量绘制

假如画布中有很多个物体在运动,每个物体都会触发重新渲染,那画布就会一直重绘,这其实没有必要,所以我们可以优化一下,使之只重绘一次,好比现在前端的框架,如果我们在短时间内改变同一个值,页面是不会反复渲染的,而是等到下一个周期再统一执行。虽说叫批量绘制,其实就是统一到下一帧执行,给人的感觉更像是防抖,就像下面这张图: image.png 说起来好像很简单,写起来其实也还好,这里简单贴下代码加深下印象:

batchRender() {  
    if (!this.pending) {
      this.pending = true;
      requestAnimFrame(() => {
        this.render();
        this.pending = false;
      });
    }
    return this;
}

局部绘制

通常情况下,为了简单起见,我们都是用 clearRect 清空整个画布,然后重新绘制所有物体的。但事实上并非所有物体都是需要重绘的,此时局部更新就派上用场了。用的还是 clearRect 这个 api,它是可以传递参数的,类似矩形的用法,形如这样:clearRect(x, y, w, h),那么我们就可以用该方法清空要重绘的动态区域(就是个矩形),然后通过 clip 这个 api 来限制绘制范围,之后进行正常绘制即可。很多文章虽然都提到了这一点,但是具体怎么操作并没有说的很清楚🤯,所以下面就来详细说明下。
首先我们要找出需要局部绘制的区域,这需要亿点点计算,也被称为脏矩形检测。想想我们之所以要清空矩形选区是因为什么,是因为该矩形区域内的物体状态发生了改变,所以矩形区域理应由这些变化的物体来决定,让我们以颜色变化为例子来进行说明,看看下面这张图👇🏻: image.png 假设上图中的红色物体都将变成黄色,其他物体状态不变,那我们只需要重绘上图中红色虚框的区域即可(物体的 AABB 包围盒),但是这还不够,会产生两个问题:

  • 我们看看图中右侧的脏矩形区域,该区域和两个蓝色的物体会相交,如果单单重绘绿色虚框是不行的,那样子两个蓝色的图形就会都缺了一块,所以这两个蓝色物体也是需要重绘的,而我们要做的就是遍历所有物体找出与这个右侧绿色虚框有交集的物体。
  • 每个包围盒都需要遍历找出与其相交的元素,所以如果包围盒一多、一零散,这个复杂度就上去了。因此我们需要进行包围盒的合并,一般包围盒的数量会控制在 5 个以内(当然也看情况啦🥳),基本原则就是合并相交的包围盒,比如上图中的左侧绿框就是合并之后的结果,不过也不一定要相交,相邻的也可以合并,比如下图这个样子👇🏻: image.png

现在我们来看看另一种情况,当物体位置改变又是怎么算呢?同样的先看图: image.png 假设上图中红色物体往右下移动了一点点,那这时候要重绘的矩形区域是哪个呢?其实我们不仅要重绘物体上一帧的区域,还要重绘物体下一帧的区域(这点很重要),所以物体运动前后所在位置都会形成一个包围盒,接着就按照正常逻辑合并即可。
对了,局部绘制还有个很核心的问题就是我们怎么知道一个物体是不是需要重绘呢?那就要看物体的状态有没有改变,也就是如何感知数据的变化,巧了,这个也和现在前端的框架很像,一般有以下几种方法:

  • 数据劫持
  • 入口收敛
  • 对数据进行 diff

虽然脏矩形看起来很实用,不过它也有局限性,比如:

  • 它不适合画布大部分区域都是动态的场景,因为这种情况下我们算出来的包围盒大小可能就会占画布大小的百分之八九十,相当于不仅要重绘整个画布,还多了个计算的过程,所以这也是一个权衡的过程。
  • 局部绘制 hin 容易造成残影,容易产生一些奇奇怪怪的 bug。根本原因是最小只能绘制 1px 的问题(大家应该有听过 1px 模糊的问题),基于这个原因,导致物体计算出来的大小与实际绘制出来的大小会有一些偏差,所以我们需要将计算出来的包围盒分别向上向下取整一下,也就是向外扩充一圈。同样的,浮点数也会造成这样的问题,不过也是一样的处理方法。此外还需要考虑到阴影的影响,计算包围盒的时候需要加上阴影的大小。特殊点的还要考虑折线之间的拐角,canvas 在实现拐角的时候会在线段连接处做一些额外处理,使折线看起来更自然美观一些,也会导致我们绘制出来的拐角比计算的要大。

另外再补充几个小点:

  • clip 也能是其他图形,但没必要,AABB 包围盒效率还是很高的,就像碰撞检测一样,一般也是先检测 AABB 包围盒的碰撞。
  • 除了物体的属性变化外,添加物体、删除物体或层级调节都是需要重绘的。
  • 局部绘制并不一定只是重绘动态物体,与之相关的静态物体也是需要重绘的。
  • 合并完包围盒之后,还可以和画布这个大包围盒取下交集,因为我们只需要绘制可视区域。

尽可能快的绘制

减少指令代码

  • 同绘制一样,指令的执行也是有成本的,所以能少一点是一点。比如一般我们画一个新的图形都会调用一次 beginPath,这样比较好保证每个图形互不干扰。但如果画的是 n 条连续的线段,我们就不用一直 beginPath 了,只要在一开始调用一次就行,绘制内容一多,省下来的时间还是很可观的。
  • 有的时候相同的指令能达到同样的效果,比如 putImage 和 drawImage 都可以将图像绘制到 canvas 中,但是 putImageData 是一项开销极大的操作,而 drawImage 的效率会更高,并且 drawImage 还能将某个 canvas 绘制到另一个 canvas 中,所以同等条件下优先使用 drawImage。
  • 对 cavnas 上下文赋值的开销远大于对一个普通对象赋值的开销,毕竟 canvas 不是一个普通对象,比如当你调用了 ctx.lineWidth = 10 时,浏览器就会立马去做一些事情(最基本的比如处理非法输入等),等到我们调用 stroke 等真正绘制的 api 时,就能够直接绘制了)。类似的属性还有 font、shadow、text 等。这个大家简单去循环个几十万次就能对比出结果了。但不是说我们就不去设置了,而是要知道是否设置的合理,避免无谓的开销。

缓存

缓存应该是各种优化手段中最重要的一把利器了,不仅仅是在 canvas 中,各个领域也是应用广泛,尤其是各种数据缓存,所以我们得好好讲讲。想想我们是怎么将物体绘制到画布中的,是不是每个物体都会有一个自己的 render 方法,然后一笔一笔地画出来,如果每次重绘都要把 render 里面的代码拿出来一条一条执行,好像不是太好。。。那有没有什么更快的绘制方法呢?有的,那就是缓存,一个时空转换的超典型。这里我们以绘制矩形为例子吧,简单看下矩形的 render 方法:

 _render(ctx: CanvasRenderingContext2D) {
    const rx = this.rx || 0,
        ry = this.ry || 0,
        x = -this.width / 2,
        y = -this.height / 2,
        w = this.width,
        h = this.height;

    // 绘制一个新的东西,大部分情况下都要开启一个新路径,要养成习惯
    ctx.beginPath();

    // 从左上角开始顺时针画矩形,这里就是单纯的绘制一个规规矩矩的带圆角的矩形,
    ctx.moveTo(x + rx, y);
    ctx.lineTo(x + w - rx, y);
    ctx.bezierCurveTo(x + w, y, x + w, y + ry, x + w, y + ry);
    ctx.lineTo(x + w, y + h - ry);
    ctx.bezierCurveTo(x + w, y + h, x + w - rx, y + h, x + w - rx, y + h);
    ctx.lineTo(x + rx, y + h);
    ctx.bezierCurveTo(x, y + h, x, y + h - ry, x, y + h - ry);
    ctx.lineTo(x, y + ry);
    ctx.bezierCurveTo(x, y, x + rx, y, x + rx, y);
    ctx.closePath();

    if (this.fill) ctx.fill();
    if (this.stroke) ctx.stroke();
}

上面的代码说明我们每次去重绘矩形的时候都得一行一行的执行代码,一笔一笔的去画出来,那如果我们能一次性地画出来是不是会好点呢?这时候 drawImage 就派上用场了(怎么又是你🤔)。首先在第一绘制的时候,肯定是要一笔一笔画的,这个毋庸置疑,但是到了第二次的时候,因为之前已经画过一次了,所以我们可以把第一次的绘制好的结果缓存起来,再次绘制的时候直接调用 drawImage 把刚才的缓存拿过来即可,那这个缓存具体是啥样呢?来看看下面的代码加深理解👇🏻:

_createCacheCanvas: function() {
    this._cacheCanvas = Utils.createCanvasElement(); // 这个就是缓存,就是创建一个 canvas 元素,你可以当做是离屏 canvas
    this._cacheContext = this._cacheCanvas.getContext('2d');
    this._updateCacheCanvas();
}

不在页面上展示的 canvas 都可以称为离屏 canvas,canvas 也不一定要绘制到页面中。然后矩形的绘制方法也要随之更改👇🏻:

_render(ctx: CanvasRenderingContext2D) {
    if (this.shouldCache()) { // 如果开启缓存
        this.renderCache(); // 在离屏 canvas 中绘制
        this.drawCacheOnCanvas(ctx);  // 将离屏 canvas 用 drawImage 绘制到页面的 canvas 中
    }
}

现在我们再次绘制矩形的时候就把代码从原来的好几步变成了一步(也是尽可能少的执行代码的一种体现),drawImage 这玩意还是很快的,妥妥的空间换时间。一般情况下如果物体的状态没有改变,都可以直接利用缓存。那变了怎么处理呢?比如拖拽控制点对物体进行缩放,那缓存不就失效了吗,是这样没错,但是我们可以做一些优化,就是在拖拽的过程中仍然用缓存来绘制,只不过会有拉伸、模糊的副作用,最后在缩放结束时及时更新缓存并重新绘制新的缓存就可以了,说起来晦涩,那就看看下面这个动图感受一下😄: 20220824000824-convert.gif 当然如果你不想拉伸过程中变模糊,也可以关掉这个功能,也是一种权衡。其实缓存对于复杂图形的影响还是很大的,比如导入一个复杂的 svg,有兴趣的可以点下这个官方链接(fabric缓存对比)感受下效果,对比贼明显,大概像下面这样子(左图是有缓存,右图是无缓存,明显卡顿,因为缩放过程中需要不停重绘),不过可能看不太出来😂(我没骗你): 20220822-104914.gif 缓存是 canvas 中优化的一个重点,其核心就是利用 drawImage 这个 api,它适用于一些偏静态的物体,如果物体的状态时常改变,那缓存就会一直更新,也就失去了缓存的意义。这里再举个具体的例子加深下印象吧:在线 Excel 表格大家应该都用过(用 canvas 写的那种),假设表格向上滚动了 100px,想想是不是只有 100px 的区域发生了改变,而剩余部分是不变的,所以我们只需要重新绘制那 100px,另外的部分用 drawIamge 从缓存里面拿出来贴上去即可。
小提示:预渲染的本质也是缓存,就是预先在离屏 canvas 上绘制相似或重复的图形,用的时候也是直接 drawImage 即可。

滤镜

滤镜是一种对图形像素进行处理的方法,虽然 canvas 支持多种常用滤镜,但是它的性能开销比较大,所以要尤为注意。比如我们需要对整个 canvas 做一个全局滤镜的话,通常会在循环绘制每个物体的时候应用上这个效果,显然不是很好,这时候可以先将所有物体都正常绘制到离屏 canvas 上,然后再把这个离屏 canvas 绘制到页面的画布上,此时再应用滤镜效果,这样就可以把多次滤镜改成一次应用,效果还是挺不错的。等等🤔。。。话是这样说,但如果不是全局统一滤镜呢?要是有的用滤镜1,有的用滤镜2咋整。em。。。这是个好问题,不过解决办法也是一样的,我们可以把应用相同滤镜的物体都在一个离屏 canvas 中绘制,然后再分批绘制到主画布中,换汤不换药。

减少计算

通常情况下,渲染比计算的开销大很多,除非我们写了一些复杂度较高的算法或者业务逻辑,或者使用姿势不当,不然一般很难改。不过这里还是要简单过下一些减少计算的方法。

减少数据量

一般来说计算耗时是因为要计算的东西太多,所以一个首要的目标就是减少数据量,比如绘制一些复杂的图表或动效,我们可以:

  • 不计算可视区域外的东西
  • 按条件过滤掉部分数据
  • 抽稀算法:这个和过滤有点类似,但不是单纯的过滤,抽稀算法能够在减少数据的基础上保证整个数据的形没有太多偏差,啥意思呢?比如一条曲线,如果是单纯的过滤,比如只留下奇数点,那绘制出来的曲线就很难维持原有的曲线形状,但是抽稀算法会尽可能的贴近原有图形,这个有兴趣的可以自行查阅看看。

加快数据处理速度

假设数据就是那么多,计算开销实在大,我们又该怎么做呢?可以这样子搞:

  • 先说下遍历
    • 细心的同学会发现,很多情况下我们都需要遍历所有物体才行,有没有什么方法能够加快遍历呢?有的,那就是分区,比如我们把画布分成左上、左下、右上、右下四个区域,然后把物体都分别分配到这四个区域,现在假设我们点击了画布的左上角,那就只需要遍历左上区域的物体就行。em🤔。。。听起来好像好不错,但是我们得维护这个物体和所在区域关系才行,当物体的位置或大小变了,就需要重新分配区域。em。。。有点道理。那这样就没问题了吗,当然不,如果物体分布不均匀呢,假设我们所有的物体都恰好在左上角(就是这么杠🏋🏻),那不还是得遍历所有物体才行,em。。。是这样没错,所以有个专业名词 n 叉树上场了,比如四叉树,大概意思就是:首先整体还是一棵树,只不过每个节点的子节点限制在四个,然后每个节点附属 x 个物体,当某个节点的物体超过 x 个,则将该节点继续往下划分四个子节点,将多余的物体放在新增的几个子节点中,以此类推,这样就解决了刚才说的物体划分不均匀的情况,当某个区域物体很多的话,就会有很多节点划分,反之亦然。当然它也是有代价的,就是要去维护这棵树,不过遍历的时候那速度是杠杠滴,尤其是在某个位置查找某个物体,感兴趣的可以自行百度😂。
  • 分批计算或延迟计算
    • 既然计算不过来那就将计算的任务进行拆分,然后在每个 requestAnimationFrame 或 requestIdleCallback 回调中慢慢执行,虽然这样做会使得代码复杂度增高、执行任务的总时间变长,但是不会导致卡顿。当然这种方法有一个前提就是我们允许任务是异步的,并不需要立马感知到结果,所以用的时候需要确定使用场景。
  • 上 web worker
    • web worker 是个好东西,性能很好,兼容性也不错。canvas 中一些复杂的逻辑和算法搬到 web worker 中运行是个不错的选择。不过和主线程来回通信所消耗的成本也是这个方法需要考虑的,因为它仅仅是操作数据并不是操作 canvas。不过现在有一个在实验中的功能 transferControlToOffscreen 可以实现操作数据的同时可以顺带将效果映射到原来的 canvas 中,是个不错的特性。
  • 上 webgl
    • 通常情况下,不同的绘制方法能够绘制的物体数量也会有所不同。svg 一般是 1000 多左右,canvas 一般在 3000 多,再多要想不卡顿就得用 webgl 了。webgl 通过引入一个与 OpenGL ES 2.0 非常一致的 api 使得我们可以利用用户设备的硬件图形加速功能(GPU 的并行能力)。当需要执行大量绘制任务时,它的性能远超 cavnas 2d。
  • 试试 gpu.js
  • 试试 webAssembly

小结

通常来说对于 canvas 的优化,核心就围绕着两个点:

  • 计算
  • 绘制

优化手段要因地制宜,不同场景有不同取舍,没有万能的方法,全靠经验,这里简单重复下最核心的几个点:

  • 局部绘制 || 批量绘制
  • 离屏 canvas || 缓存
  • 分批计算 || 延迟计算 || 并行计算

前端是障眼法的技术,在 canvas 中体现的那是一个淋漓尽致。 好啦,本次分享就到这里,有什么问题欢迎点赞评论留言,我们下期再见,拜拜👋🏻