最近在开发一个小程序,其中涉及动效需求,我们原先的计划是使用gif图实现该动效,但是gif图有如下三个缺点:

  1. 高质量的动效表现的gif图单个大小至少2MB。
  2. 动效细节过多难以实现无缝循环。
  3. 技术b格不高:)。

于是笔者开始着手利用canvas实现动效,首先第一步也是最重要的一步:打开github,搜索particle。在长达5分钟的搜索浏览后,发现实现一个粒子系统绝对是一个前无古人的创举。 enter description here em....,容我想几个理由解释一下这种重复造轮子的心态:

  1. 大多数repository实现的并不是我们想要的效果。
  2. 有部分repository看demo好像能实现我们想要的效果,但是除了demo就没有其他了;好歹说一下怎么用吧。
  3. 无法兼容小程序:核心代码包含DOM API或者只支持webgl模式。

开始我们重复造轮子工作之前,说明一下这个轮子的由来。 在开发下述版本的粒子系统之前,其实笔者已经完成了一个JavaScript版本,但是回看代码时觉得API设计不合理、灵活性不够,所以决定用TypeScript重新写一个,期间也拜读了egret-libs的代码后,优化了API设计和粒子发射控制。项目地址

面向对象

简要说一下我们需要抽象的两个东西,粒子系统ParticleSystemParticleParticleSystem借用物理引擎中的world概念,就是粒子存在的空间,假设空间中有两个属性,有纵向的重力加速度,有横向的加速度(横向的风)。Particle就是空间中存在的物体,物体有大小、质量、速度、位置、旋转角度等属性。

Particle类

先从简单的开始吧,构建一个Particle

class Particle {
  // 生命周期
  public lifespan: number
  // 速度
  public velocityX: number
  public velocityY: number
  // 位置
  public x: number
  public y: number
  // 已经经历的时间
  public currentTime: number
  // 粒子大小
  private _startSize: number

  // 缩放比例
  public scale: number
  // 结束时的旋转角度
  public endRotation: number
  // 宽高比
  private ratio: number

  // 输入的图像宽高
  private _width: number
  private _height: number
  // 粒子纹理
  public texture: CanvasImageSource

  set startSize (size: number) {
    this._startSize = size;

    this._width = size;
    this._height = size / this.ratio;
  }

  // 获得粒子大小
  get startSize (): number {
    return this._startSize;
  }

  // 设置粒子纹理和纹理宽高信息
  public setTextureInfo (texture: CanvasImageSource, config: {
    width: number,
    height: number
  }) {
    this.texture = texture;
    this.ratio = config.width / config.height;
  }
}

由于篇幅原因,以上代码展示了绝大多数最重要的信息。其实在开发过程中,粒子的属性定义也不是一气呵成的,有些属性是后期需要再填补上去的,有些属性发现实现的功能是重复的需要精简的。 Particle中虽然有很多public属性和public方法,但是这不是对开发者开放的,实际上,整个Particle类都不对外开发,使用时也不需要手动实例化这个类,因为整个系统设计为ParticleParticleSystem是高度耦合的。 粒子类中有一个成员方法setTextureInfo,设置粒子的纹理和宽高信息,texturectx.drawImage(..)时的第一个参数,后面会再次提到。需要手动设置宽高是基于兼容性的考虑,虽然这里可以把所有兼容情况都写出来,但是最后还是决定整个粒子系统中尽量不包含DOM API,选择将获取图片属性的操作留给开发者,而只需要传入宽高信息,聚焦核心功能,不实现有兼容问题的功能就是最好的兼容:)。

ParticleSystem类

ParticleSystem类无疑是粒子系统的核心,下面一步步剖析他的重要功能。

constructor

由传入的参数初始化粒子系统

constructor (
  texture: CanvasImageSource,
  textureInfo: {
    width: number,
    height: number
  },
  config: string | any,
  ctx?: CanvasRenderingContext2D,
  canvasInfo?: {
    width: number,
    height: number
  }
) {
  if (canvasInfo) {
    this.canvasWidth = canvasInfo.width;
    this.canvasHeight = canvasInfo.height;
  }
  // 保存canvas画布
  this.ctx = ctx;
  // 保存纹理信息
  this.changeTexture(texture, textureInfo);
  // 解析并保存配置信息
  this.changeConfig(config);
  // 创建粒子对象池
  this.createParticlePool();
}

constructor的参数中就能看出是如何设计初始化API的,textureInfo的设计原因在上文说明过。ctx为可选的,这是分情况的,在需要粒子系统完成绘制画布时这是必须的,在只需要粒子系统提供绘制数据时,ctx是没必要传入的。canvasInfo也是可选的,他的作用是粒子系统清空画布时需要的数据,其实也是一个兼容性参数,后面会提到。

对象池

运行粒子系统时会有很多“粒子对象”,创建一个对象池的目的是减少运行过程中粒子创建和销毁的开销。 严格来讲,对象池应该独立于ParticleSystem,但是这里没有复用的需求且懒得去想分离的系统应该怎么设计,所以将对象池写为ParticleSystem自带的功能。 一个简单的对象池有以下三个关键的属性和方法, poolArray<Particle>可用的粒子对象集合 addOneParticle(): 从对象池中取出一个粒子加入渲染粒子集合 removeOneParticle(particle): 从渲染粒子集合去除一个粒子并回收到对象池 particleList: Array<Particle>渲染粒子集合,独立的对象池设计中应该不包含该属性。

addOneParticle

private addOneParticle () {
  let particle: Particle;

  if (this.pool.length) {
    particle = this.pool.pop();
  } else {
    particle = new Particle;
  }

  particle.setTextureInfo(this.texture, {
    width: this.textureWidth,
    height: this.textureHeight
  })
  // 初始化刚取出的粒子
  this.initParticle(particle);

  this.particleList.push(particle);
}

removeOneParticle

private removeOneParticle (particle: Particle) {
  let index: number = this.particleList.indexOf(particle);

  this.particleList.splice(index, 1);
  // 清除纹理引用
  particle.texture = null;

  this.pool.push(particle);
}

粒子状态初始化和更新

为了粒子系统更有表现力,粒子的某些属性应该具有随机性,结合API设计,我们封装一个获取随机数据的函数randRange(range)

function randRange (range: number): number {
  range = Math.abs(range);
  return Math.random() * range * 2 - range;
}

粒子状态初始化和更新会用到简单的物理知识,主要是计算粒子速度和移动距离。

initParticle(particle)

粒子状态初始化方法设定粒子的初始状态,其中用到上述randRange方法来表现各个粒子的随机不同

private initParticle (particle: Particle): Particle {
  /* 省略了其他参数初始化 */

  let angle = this.angle + randRange(this.angleVariance);

  // 速度分解
  particle.velocityX = this.speed * Math.cos(angle);
  particle.velocityY = this.speed * Math.sin(angle);

  particle.startSize = this.startSize + randRange(this.startSizeVariance);

  // 缩放比例,后面的计算会用到
  particle.scale = particle.startSize / this.startSize;
}

updateParticle(particle)

public updateParticle (particle: Particle, dt: number) {
    // 上传更新状态到本次更新的时间间隔
    dt = dt / 1000;

    // 速度和位置更新
    particle.velocityX += this.gravityX * particle.scale * dt;
    particle.velocityY += this.gravityY * particle.scale * dt;
    particle.x += particle.velocityX * dt;
    particle.y += particle.velocityY * dt;
  }

更新方法

定义一个update方法控制粒子系统中的粒子是否应该被添加、删除、更新。

public update (dt: number) {
  // 是否需要新增粒子
  if (!this.$stopping) {
    this.frameTime += dt;

    // this.frameTime记录上次发射粒子到现在的时间与粒子发射间隔的差
    while (this.frameTime > 0) {
      if (this.particleList.length < this.maxParticles) {
        this.addOneParticle()
      }

      this.frameTime -= this.emissionRate;
    }
  }

  // 更新粒子状态或移除粒子
  let temp: Array<Particle> = [...this.particleList];

  temp.forEach((particle: Particle) => {
    // 如果粒子的生命周期未结束,更新该粒子的状态
    // 如果粒子的生命周期已经结束,移除该粒子
    if (particle.currentTime < particle.lifespan) {
      this.updateParticle(particle, dt);
      particle.currentTime += dt;
    } else {
      this.removeOneParticle(particle);

      if (this.$stopping && this.particleList.length === 0) {
        this.$stopped = true;

        // 粒子系统完全停止后的回调
        // 后期增加的功能,首次开发时可以不考虑
        this.onstopped && this.onstopped();
      }
    }
  })
}

update方法只涉及数据更新,并且该方法为public,这样设计是为了开发者能够通过update更新绘制数据后,自行控制粒子的绘制过程,从而将粒子系统嵌入到已有的程序中。

渲染和重绘

这里指的渲染指将粒子系统中的数据“画”到canvas画布上的过程,用到的canvas API也不多,如果你要涉及纹理的旋转,那就需要先理解一下canvas画布的transform是怎么回事了,传送门

public render (dt: number) {
  this.update(dt);
  this.draw();
  // 兼容小程序
  (<any>this.ctx).draw && (<any>this.ctx).draw();
}

private draw () {
  this.particleList.forEach((particle: Particle) => {
    let {
      texture,
      x,
      y,
      width,
      height,
      alpha,
      rotation
    } = particle;

    let halfWidth = width / 2,
      halfHeight = height /2;

    // 保存画布状态
    this.ctx.save();

    // 将画布的右上角移动到纹理的中心位置
    this.ctx.translate(x + halfWidth, y + halfHeight);

    // 旋转画布
    this.ctx.rotate(rotation);

    if (alpha !== 1) {
      this.ctx.globalAlpha = alpha;
      this.ctx.drawImage(texture, -halfWidth, -halfHeight, width, height);
    } else {
      this.ctx.drawImage(texture, -halfWidth, -halfHeight, width, height);
    }

    // 还原画布状态
    this.ctx.restore();
  })
}

重绘画布包含两个步骤,时间控制和画布重绘,画布重绘又包含清除画布和调用render

// dt表示循环调用的时间差
private circleDraw (dt: number) {
  if (this.$stopped) {
    return;
  }

  // 这里的处理也是为了兼容小程序(回看上面的constructor的参数)
  let width: number, height: number;
  if (this.canvasWidth) {
    width = this.canvasWidth;
    height = this.canvasHeight;
  } else if (this.ctx.canvas) {
    width  = this.ctx.canvas.width;
    height = this.ctx.canvas.width;
  }

  // 画布重绘
  this.ctx.clearRect(0, 0, width, height);
  this.render(dt);

  // 时间控制
  // 简单的兼容处理,requestAnimationFrame有更好的性能优势,
  // 当不支持时使用setTimeout代替
  if (typeof requestAnimationFrame !== 'undefined') {
    requestAnimationFrame(() => {
      let now = Date.now();
      // 计算时间差
      this.circleDraw(now - this.lastTime);
      this.lastTime = now;
    })
  } else {
    // setTimeout的缺点是程序进入后台回调依然会被执行
    setTimeout(() => {
      let now = Date.now();
      this.circleDraw(now - this.lastTime);
      this.lastTime = now;
    }, 17)
  }
}

开始和停止

启动非常简单,只要调用circleDraw就可以启动了。render方法是需要传入时间差的,所以这里需要一个this.lastTime来保存开始和上次重绘时间戳。

public start () {
  this.$stopping = false;

  if (!this.$stopped) {
    return;
  }

  this.lastTime = Date.now();

  this.$stopped = false;
  this.circleDraw(0);
}

public stop () {
  this.$stopping = true;
}

用法举例

如果你按着上述步骤或者看过项目源码或者自己写过一遍,用法部分基本没有难点了,下面是基础的用法举例。

import ParticleSystem from '../src/ParticleSystem'

// 创建canvas
const canvas: HTMLCanvasElement = document.createElement('canvas');
canvas.width = (<Window>window).innerWidth;
canvas.height = (<Window>window).innerHeight;
document.body.appendChild(canvas);

// 获取画布上下文
const ctx: CanvasRenderingContext2D = canvas.getContext('2d');

// 加载纹理
const img: HTMLImageElement = document.createElement('img');
img.src = './test/texture.png';
img.onload = () => {
  // 创建粒子系统
  const particle = new ParticleSystem(
    // 纹理资源
    img,
    // 纹理尺寸
    {
      width: img.width,
      height: img.height
    },
    // 粒子系统参数
    {
      gravity: {
        x: 10,
        y: 80
      },
      emitterX: 200,
      emitterY: -10,
      emitterXVariance: 200,
      emitterYVariance: 10,
      maxParticles: 1,
      endRotation: 2,
      endRotationVariance: 50,
      speed: 50,
      angle: Math.PI / 2,
      angleVariance: Math.PI / 2,
      startSize: 15,
      startSizeVariance: 5,
      lifespan: 5000
    },
    // 画布上下文
    ctx
  )

  particle.start();
}

在小程序平台上,有可能存在性能问题,导致粒子系统运行时FPS在15-60 之间波动很大。我们可以采用计算和渲染分离的方式实现。大致的思路是,将粒子系统运行到子线程worker中,粒子系统只负责粒子位置的计算,将计算好的数据发送给主线程,主线程调用canvas相关API,完成画布的绘制。你可以尝试实现该功能。目前项目中已用该思路实现,小程序运行粒子系统时FPS在45-60。 可以打开以下链接查看用这个粒子实现的demo

TODO

在粒子系统中加入“引力体”和”斥力体“,它们分别可以对粒子产生吸引力和排斥力,并且可以随时改变位置,这可以让粒子系统更具交互性。有兴趣的小伙伴可以自己尝试实现一下。

作者简介:叶茂,芦苇科技web前端开发工程师,代表作品:口红挑战网红小游戏、服务端渲染官网。擅长网站建设、公众号开发、微信小程序开发、小游戏、公众号开发,专注于前端领域框架、交互设计、图像绘制、数据分析等研究。 一起并肩作战: yemao@talkmoney.cn 访问 www.talkmoney.cn 了解更多