最近在开发一个小程序,其中涉及动效需求,我们原先的计划是使用gif图实现该动效,但是gif图有如下三个缺点:
- 高质量的动效表现的gif图单个大小至少2MB。
- 动效细节过多难以实现无缝循环。
- 技术b格不高:)。
- 源码地址 欢迎提issue
于是笔者开始着手利用canvas实现动效,首先第一步也是最重要的一步:打开github,搜索particle
。在长达5分钟的搜索浏览后,发现实现一个粒子系统绝对是一个前无古人的创举。
em....,容我想几个理由解释一下这种重复造轮子的心态:
- 大多数
repository
实现的并不是我们想要的效果。 - 有部分
repository
看demo好像能实现我们想要的效果,但是除了demo就没有其他了;好歹说一下怎么用吧。 - 无法兼容小程序:核心代码包含
DOM API
或者只支持webgl
模式。
开始我们重复造轮子工作之前,说明一下这个轮子的由来。
在开发下述版本的粒子系统之前,其实笔者已经完成了一个JavaScript
版本,但是回看代码时觉得API
设计不合理、灵活性不够,所以决定用TypeScript
重新写一个,期间也拜读了egret-libs的代码后,优化了API
设计和粒子发射控制。项目地址
面向对象
简要说一下我们需要抽象的两个东西,粒子系统ParticleSystem
和Particle
。ParticleSystem
借用物理引擎中的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
类都不对外开发,使用时也不需要手动实例化这个类,因为整个系统设计为Particle
和ParticleSystem
是高度耦合的。
粒子类中有一个成员方法setTextureInfo
,设置粒子的纹理和宽高信息,texture
即ctx.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
自带的功能。
一个简单的对象池有以下三个关键的属性和方法,
pool
: Array
可用的粒子对象集合
addOneParticle()
: 从对象池中取出一个粒子加入渲染粒子集合
removeOneParticle(particle)
: 从渲染粒子集合去除一个粒子并回收到对象池
particleList
: Array
渲染粒子集合,独立的对象池设计中应该不包含该属性。
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 = [...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();
// 兼容小程序
(this.ctx).draw && (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).innerWidth;
canvas.height = (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前端开发工程师,代表作品:口红挑战网红小游戏、服务端渲染官网。擅长网站建设、公众号开发、微信小程序开发、小游戏、公众号开发,专注于前端领域框架、交互设计、图像绘制、数据分析等研究。 一起并肩作战: [email protected] 访问 www.talkmoney.cn 了解更多