最近在開發一個小程序,其中涉及動效需求,咱們原先的計劃是使用gif圖實現該動效,可是gif圖有以下三個缺點:前端
因而筆者開始着手利用canvas實現動效,首先第一步也是最重要的一步:打開github,搜索particle
。在長達5分鐘的搜索瀏覽後,發現實現一個粒子系統絕對是一個前無古人的創舉。 em....,容我想幾個理由解釋一下這種重複造輪子的心態:
git
repository
實現的並非咱們想要的效果。repository
看demo好像能實現咱們想要的效果,可是除了demo就沒有其餘了;好歹說一下怎麼用吧。DOM API
或者只支持webgl
模式。開始咱們重複造輪子工做以前,說明一下這個輪子的由來。 在開發下述版本的粒子系統以前,其實筆者已經完成了一個JavaScript
版本,可是回看代碼時以爲API
設計不合理、靈活性不夠,因此決定用TypeScript
從新寫一個,期間也拜讀了egret-libs的代碼後,優化了API
設計和粒子發射控制。項目地址github
簡要說一下咱們須要抽象的兩個東西,粒子系統ParticleSystem
和Particle
。ParticleSystem
借用物理引擎中的world
概念,就是粒子存在的空間,假設空間中有兩個屬性,有縱向的重力加速度,有橫向的加速度(橫向的風)。Particle
就是空間中存在的物體,物體有大小、質量、速度、位置、旋轉角度等屬性。web
先從簡單的開始吧,構建一個Particle
類canvas
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
類都不對外開發,使用時也不須要手動實例化這個類,由於整個系統設計爲Particle
和ParticleSystem
是高度耦合的。 粒子類中有一個成員方法setTextureInfo
,設置粒子的紋理和寬高信息,texture
即ctx.drawImage(..)
時的第一個參數,後面會再次提到。須要手動設置寬高是基於兼容性的考慮,雖然這裏能夠把全部兼容狀況都寫出來,可是最後仍是決定整個粒子系統中儘可能不包含DOM API
,選擇將獲取圖片屬性的操做留給開發者,而只須要傳入寬高信息,聚焦核心功能,不實現有兼容問題的功能就是最好的兼容:)。小程序
ParticleSystem
類無疑是粒子系統的核心,下面一步步剖析他的重要功能。微信小程序
由傳入的參數初始化粒子系統微信
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
自帶的功能。 一個簡單的對象池有如下三個關鍵的屬性和方法, pool
: Array<Particle>
可用的粒子對象集合 addOneParticle()
: 從對象池中取出一個粒子加入渲染粒子集合 removeOneParticle(particle)
: 從渲染粒子集合去除一個粒子並回收到對象池 particleList
: Array<Particle>
渲染粒子集合,獨立的對象池設計中應該不包含該屬性。框架
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); }
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; }
粒子狀態初始化和更新會用到簡單的物理知識,主要是計算粒子速度和移動距離。
粒子狀態初始化方法設定粒子的初始狀態,其中用到上述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; }
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
在粒子系統中加入「引力體」和」斥力體「,它們分別能夠對粒子產生吸引力和排斥力,而且能夠隨時改變位置,這可讓粒子系統更具交互性。有興趣的小夥伴能夠本身嘗試實現一下。
【做者簡介】:葉茂,蘆葦科技web前端開發工程師,表明做品:口紅挑戰網紅小遊戲、服務端渲染官網。擅長網站建設、公衆號開發、微信小程序開發、小遊戲、公衆號開發,專一於前端領域框架、交互設計、圖像繪製、數據分析等研究。 一塊兒並肩做戰: yemao@talkmoney.cn 訪問 www.talkmoney.cn 瞭解更多