從 0到 1 實現一個 粒子系統

最近在開發一個小程序,其中涉及動效需求,咱們原先的計劃是使用gif圖實現該動效,可是gif圖有以下三個缺點:前端

  1. 高質量的動效表現的gif圖單個大小至少2MB。
  2. 動效細節過多難以實現無縫循環。
  3. 技術b格不高:)。

因而筆者開始着手利用canvas實現動效,首先第一步也是最重要的一步:打開github,搜索particle。在長達5分鐘的搜索瀏覽後,發現實現一個粒子系統絕對是一個前無古人的創舉。 repor em....,容我想幾個理由解釋一下這種重複造輪子的心態:
thinkinggit

  1. 大多數repository實現的並非咱們想要的效果。
  2. 有部分repository看demo好像能實現咱們想要的效果,可是除了demo就沒有其餘了;好歹說一下怎麼用吧。
  3. 沒法兼容小程序:核心代碼包含DOM API或者只支持webgl模式。

開始咱們重複造輪子工做以前,說明一下這個輪子的由來。 在開發下述版本的粒子系統以前,其實筆者已經完成了一個JavaScript版本,可是回看代碼時以爲API設計不合理、靈活性不夠,因此決定用TypeScript從新寫一個,期間也拜讀了egret-libs的代碼後,優化了API設計和粒子發射控制。項目地址github

面向對象

簡要說一下咱們須要抽象的兩個東西,粒子系統ParticleSystemParticleParticleSystem借用物理引擎中的world概念,就是粒子存在的空間,假設空間中有兩個屬性,有縱向的重力加速度,有橫向的加速度(橫向的風)。Particle就是空間中存在的物體,物體有大小、質量、速度、位置、旋轉角度等屬性。web

Particle類

先從簡單的開始吧,構建一個Particlecanvas

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也是可選的,他的做用是粒子系統清空畫布時須要的數據,其實也是一個兼容性參數,後面會提到。app

對象池

運行粒子系統時會有不少「粒子對象」,建立一個對象池的目的是減小運行過程當中粒子建立和銷燬的開銷。 嚴格來說,對象池應該獨立於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

在粒子系統中加入「引力體」和」斥力體「,它們分別能夠對粒子產生吸引力和排斥力,而且能夠隨時改變位置,這可讓粒子系統更具交互性。有興趣的小夥伴能夠本身嘗試實現一下。

image

【做者簡介】:葉茂,蘆葦科技web前端開發工程師,表明做品:口紅挑戰網紅小遊戲、服務端渲染官網。擅長網站建設、公衆號開發、微信小程序開發、小遊戲、公衆號開發,專一於前端領域框架、交互設計、圖像繪製、數據分析等研究。 一塊兒並肩做戰: yemao@talkmoney.cn 訪問 www.talkmoney.cn 瞭解更多

相關文章
相關標籤/搜索