canvas离屏渲染优化

9,491 阅读7分钟

最近在做canvas粒子动画效果的研究,发现当粒子数量达到一定等级的时候,动画效果会变慢变卡。搜索了一下解决办法,发现离屏渲染是推荐最多的解决办法,那本文就利用离屏渲染实现一个动画效果来对比性能的提升。

概念

查阅了一下资料,概述一下离屏渲染的概念,相当于在屏幕渲染的时候开辟一个缓冲区,将当前需要加载的动画事先在缓冲区渲染完成之后,再显示到屏幕上。

非离屏渲染

非离屏渲染就是不建立缓冲区,直接在屏幕上逐个进行绘制,需要重复利用canvas的api。当粒子数量到达一定等级时,性能上会受到较大影响。

实现

先创建一个雪花粒子的类,构造相关的属性,定义一个名为snowArray的数组,将每个粒子都存入该数组中。count为雪花的数量。

class DrawSnow {
  constructor(count) {
    this.canvas = document.getElementById('canvas');
    this.content = this.canvas.getContext('2d')
    this.width = this.canvas.width = 1200;
    this.height = this.canvas.height = 1000;
    this.r = 2.5;

    this.timer = null;
    this.snowArray= [];
    this.count = count;

    this.useOffCanvas = false; // 是否使用离屏渲染
    this.init()
  }
}

init()函数初始化雪花粒子,根据粒子的数量,重复渲染生成随机的位置,并存入数组中。初始化完成之后,开始绘制粒子。并执行动画函数animate()

  init() {
    let OffScreen = '';

    if (this.useOffCanvas) {
      OffScreen = new OffScreen();
    }

    for (let i = 0; i < this.count; i++) {
      let x = this.width * Math.random();
      let y = this.height * Math.random();

      this.snowArray.push({
        x: x,
        y: y
      });
    this.draw(x, y);
    }
    this.animate();
  }

animate()函数实现了动画的循环,在一次动画执行完成之后,通过window.requestAnimationFrame来实现重复效果。根据存储在snowArray[]中的粒子信息,反复进行绘制。

  animate() {
    this.content.clearRect(0, 0, this.width, this.height);

    for (let i in this.snowArray) {
      let snow = this.snowArray[i];

      snow.y += 2;
      if (snow.y >= this.height + 10) {
        snow.y = Math.random() * 50;
      }

      this.draw(snow.x, snow.y);
    }
    this.timer = requestAnimationFrame(() => {
      this.animate();
    });
  }

效果

完成以上的步骤之后,来看一下在浏览器中的效果

小雪

非离屏小雪

中雪

非离屏中雪

大雪

非离屏大雪

性能分析

上述动图中,右上角为chrome自带的性能分析工具,点击开发者工具performance面板,按快捷键cmd + shift + p 然后输入show rendering (打开实时查看帧率的面板),可以看到实时的帧率变化。

performance面板的使用在之前有介绍,指路:十分钟上手chrome性能分析面板

小雪、中雪、大雪需要绘制的粒子分别是80、200、7000个粒子,当粒子数量较少时,动画效果比较顺畅,维持在60FPS左右,当数量增加到7000个时,动画开始卡顿,帧数快速下降。因为录屏工具对实际帧数会产生影响,上述动图可作为参考,实际帧数参考下图:

非离屏大雪截图

离屏渲染

实现

原理

创建缓冲区,需要额外创建一个canvas画布,将缓冲的画面现在该canvas上绘制好,在通过drawImage()的方式将该画布渲染到屏幕显示的画布上。

代码

首先实现离屏渲染的粒子构造方法,构造完成之后进行绘制,move()将绘制好的画布通过drawImage方法在屏幕上展示。

// 粒子类
class OffScreen {
  constructor() {
    this.canvas = document.createElement('canvas');
    this.r = 2.5;
    this.width = this.canvas.width = 5;
    this.height = this.canvas.height = 5;
    this.ctx = this.canvas.getContext('2d');
    this.x = this.width * Math.random();
    this.y = this.height * Math.random();

    this.create();
  }
  
  // 创建粒子
  create() {
    this.ctx.save();
    this.ctx.fillStyle = 'rgba(255,255,255)';
    this.ctx.beginPath();
    this.ctx.arc(this.x, this.y, 2.5, 0, 2 * Math.PI, false);
    this.ctx.closePath();
    this.ctx.fill();
    this.ctx.restore();
  }
  
  // 绘制粒子
  move(ctx, x, y) {
    ctx.drawImage(this.canvas, x, y);
  }
}

初始化粒子时,判断是否是离屏渲染模式,离屏模式下构造一个离屏粒子,先在画布中画出,当遍历粒子数组时,通过animate()中执行OffScreen类中的move()方法,将粒子展示出来,类似复制黏贴的操作。

class DrawSnow {
  constructor(count,useOffCanvas) {
    ......

    this.useOffCanvas = useOffCanvas; // 是否使用离屏渲染
    this.init();
  }
    
  init() {
    let offScreen = '';

    if (this.useOffCanvas) {
      offScreen = new OffScreen();
    }

    for (let i = 0; i < this.count; i++) {
      let x = this.width * Math.random();
      let y = this.height * Math.random();

      if (this.useOffCanvas) {

        this.snowArray.push({
          instance: offScreen,
          x: x,
          y: y
        });
      } else {
        this.snowArray.push({
          x: x,
          y: y
        });
        this.draw(x, y);
      }
    }
    this.animate();
  }
  
  animate() {
    this.content.clearRect(0, 0, this.width, this.height);

    for (let i in this.snowArray) {
      let snow = this.snowArray[i];

      snow.y += 2;
      if (snow.y >= this.height + 10) {
        snow.y = Math.random() * 50;
      }

      if (this.useOffCanvas) {
        snow.instance.move(this.content, snow.x, snow.y);
      } else {
        this.draw(snow.x, snow.y);
      }
    }
    this.timer = requestAnimationFrame(() => {
      this.animate();
    });
  }
}

效果

小雪

离屏小雪

中雪

离屏中雪

大雪

离屏大雪

性能分析

和非离屏渲染进行对比,发现当粒子数量不多时,差距并不明显,当粒子数量达到7000时,有了明显差距。

在上述动图中,离屏渲染下,大雪动画的帧率达到平均23FPS,录屏工具会对性能产生影响,实际的性能如下图:

离屏大雪截图

相比非离屏模式下帧率提升了一倍。

如何选择使用离屏渲染

上述例子中,使用离屏渲染确实提升了动画运行的帧率,但不是任何时候都适用离屏渲染。

基于上面这个例子,衍生实现另一个效果,即改变雪花粒子的样式,随机选择粒子的大小位置和透明度,使画面更有层次感。

案例效果

先观察一下两种模式下的实现效果以及帧率,离屏渲染的帧率反而更低,与之前的结果完全相反。

复杂非离屏大雪截图

复杂离屏大雪截图

原因

新的粒子是随机生成大小位置和透明度,如果通过之前的方式去构建离屏粒子,那么每个粒子的属性都将相同,无法实现随机效果。在本例中,需要通过循环,将不同的参数传递给构造函数,相当于多次调用了构造函数的canvas api。与非离屏渲染模式相比,还增加了创建缓冲区,从缓冲区绘制到屏幕上的性能消耗,所以帧率相比非离屏模式,反而更低。

而在之前的例子中,粒子的大小、颜色、透明度都相同,不需要重复构造,所以只调用了一次构造函数,也只调用了一次绘制的canvas api。

相关代码

观察下方代码,结合上文中在离屏模式下的构造方式,可以发现,本例中循环构造了新的粒子,也就不断调用了api,并没有降低性能的消耗。

  init() {
    let offScreen = '';

    for (let i = 0; i < this.count; i++) {
      let x = this.width*Math.random();
      let y = this.height*Math.random();
      let alpha = (Math.floor(Math.random() * 10) + 1) / 10 / 2;
      let color = "rgba(255,255,255," + alpha + ")";
      let r = Math.random() * 2 + 1;

      if (this.useOffCanvas) {
      
        // 循环构造新的粒子
        offScreen = new OffScreen();

        this.snowArray.push({
          instance: offScreen,
          x: x,
          y: y,
          color: color,
          r:r
        });
      } else {
        this.snowArray.push({
          x: x,
          y: y,
          color: color,
          r:r
        });
        this.draw(x,y,color,r);
      }
    }
    this.animate();
  }

FPS

FPS是图像领域中的定义,是指画面每秒传输帧数,通俗来讲就是指动画或视频的画面数。

理论上说,FPS 越高,动画会越流畅,目前大多数设备的屏幕刷新率为 60 次/秒,所以通常来讲 FPS 为 60 frame/s 时动画效果最好。

当不同的帧率下,动画的视觉效果如何呢?

  • 帧率能够达到 50 ~ 60 FPS 的动画将会相当流畅,让人倍感舒适;
  • 帧率在 30 ~ 50 FPS 之间的动画,因各人敏感程度不同,舒适度因人而异;
  • 帧率在 30 FPS 以下的动画,让人感觉到明显的卡顿和不适感; 帧率波动很大的动画,亦会使人感觉到卡顿。

所以流畅的动画,帧率需要达到30fps往上。

具体分析可以参考: 【前端性能】Web 动画帧率(FPS)计算

总结

离屏渲染在动画优化上非常多人推荐,但也不是任何情况下都可以利用,离屏渲染首先需要构造一个缓冲区,再将缓冲区中的画面展示到显示屏上,这两个过程也需要消耗性能。

例如上文中第二个例子,并没有减少对api的调用,反而离屏的过程增加了性能的消耗,这种情况就不合适采用这种方式。离屏渲染也需要选择合理的使用场景。