最近在开发一个小程序,其中涉及动效需求,咱们原先的计划是使用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 了解更多