在 Canvas 开发中,经常会提到粒子系统,使用粒子系统可以模拟出火、雾、云、雪等抽象视觉效果。它是HTML5新增的为页面添加多样化效果的绝佳实践。
正巧最近做的一个天气类微信小程序紧接尾声,寻思着首页展示温度等信息的地方似乎少了点什么。到Android自带的天气预报上看了下,恍然大悟。于是结合前一段时间钻研的canvas为其添加了雨雪粒子效果。
小程序的绘图 API 虽然也叫canvas,但跟 HTML5 的 Canvas 本质上有很大区别的,其原因是小程序的绘图(Canvas)是客户端实现的 Native UI 组件,而不是普通的 H5 组件,所以在使用上跟普通的 H5 组件用法略有不同。
IOS和Android实现方式还不一样,据说它是基于 Ejecta 实现的。
其主要体现在以下方式上:
小程序绘图 API 的 canvasContext 获取方式是通过 的 canvas-id 来获取的,即:
<canvas canvas-id="test">canvas>
//获取canvas
let ctx = wx.createCanvasContext('test')
这里有一点:它并不同于“获取元素”!
曾经的小程序的绘图 API 在用法上区别于绝大部分的 HTML5 Canvas 属性写法,它有自己的小程序写法,例如:
const ctx = wx.createCanvasContext('myCanvas')
ctx.setFillStyle('red')
ctx.fillRect(10, 10, 150, 75)
ctx.draw()
不过值得一提的是,在 1.9.0 基础库以上,类似 fillStyle、lineWidth 这类的,可以直接跟 H5 的写法一样,不需要使用 setXxxx 的方式了。
ctx.draw()
使用在小程序的绘图使用中,对 context 进行绘制之后,并不会立即绘制到画布上,而是通过执行 ctx.draw()
的方式,将之前在绘图上下文中的描述(路径、变形、样式)画到 canvas 中。但 ctx.draw()
方法比较消耗性能,因此不建议在一个绘制周期内多次调用!
在这个小程序中,笔者使用粒子系统做了雨雪效果,采用es6-Class写法:粒子系统由基类和子类组成。Particle 是基类,定义了子类统一的方法,如 run()
、stop()
、clear()
等。基类负责整个粒子系统动画周期和流程的维护,子类负责具体实现的粒子效果,比如下雨下雪的效果是子类实现的,而下雨下雪的开关和公共处理流程是基类控制的。
基类由如下几个方法组成:
其大致流程如下:
根据如上解释,整个流程就很明了了:
//同级的effect.js文件
// 两个状态
const STATUS_STOP = 'stop'
const STATUS_RUNNING = 'running'
//“基”类-这里就直接当“下雨”类了
class Particle {
constructor(ctx, width, height, opts) {
this._timer = null
this._options = opts || {
}
// canvas 上下文
this.ctx = ctx
this.status = STATUS_STOP
this.w = width
this.h = height
this._init()
}
_init() {
}
_draw() {
}
_update(){
}
run() {
if (this.status !== STATUS_RUNNING) {
// 更改状态
this.status = STATUS_RUNNING
// 绘制循环
this._timer = setInterval(() => {
this._draw()
}, 30)
}
return this
}
stop() {
// 清理定时器,状态修改
this.status = STATUS_STOP
clearInterval(this._timer)
return this
}
clear(){
this.stop()
this.ctx.clearRect(0, 0, this.w, this.h)
this.ctx.draw()
return this
}
}
exports.Particle=Particle
根据上面的提示,在 _init()
中,根据需要生成的粒子个数 amount 循环随机生成每个粒子,放入 this.particles
数组:
// _init
let h = this.h
let w = this.w
// 数量,根据不同雨大小,数量可调
let amount = this._options.amount || 100
// 速度参数,调节下落速度
let speedFactor = this._options.speedFactor || 0.03
let speed = speedFactor * h
let ps = (this.particles = [])
for (let i = 0; i < amount; i++) {
let p = {
x: Math.random() * w,
y: Math.random() * h,
l: 2 * Math.random(),
xs: -1,
ys: 10 * Math.random() + speed,
color: 'rgba(0, 0, 0, 0.15)'
}
ps.push(p)
}
其中:
而 _draw()
的方法,是先将画布清空,然后遍历 this.particles
数组取出单个雨滴并进行绘制,最后调用一个单独实现的 _update()
重新计算单个雨滴的位置:
// _draw
let ps = this.particles
let ctx = this.ctx
// 清空画布
ctx.clearRect(0, 0, this.w, this.h)
// 遍历绘制雨滴
for (let i = 0; i < ps.length; i++) {
let s = ps[i]
ctx.beginPath()
ctx.moveTo(s.x, s.y)
// 画线绘制雨点效果
ctx.lineTo(s.x + s.l * s.xs, s.y + s.l * s.ys)
ctx.setStrokeStyle(s.color)
ctx.stroke()
}
ctx.draw()
return this._update()
而 _update()
中,我们要做的就是判断“下一时刻每一个雨点的位置”以及“是否超出了“画布”的范围”:
// _update
let {
w, h} = this // 获取画布大小
for (let ps = this.particles, i = 0; i < ps.length; i++) {
// 开始下一个周期的位置计算
let s = ps[i]
s.x += s.xs
s.y += s.ys
// 超出范围,重新回收,重复利用
if (s.x > w || s.y > h) {
s.x = Math.random() * w
s.y = -10
}
}
我们大致一看会发现,除了调用的名称有的不一样之外,似乎和原生js中的canvas API1没什么区别。
上面是控制“下雨”的,其实下雪的例子类和下雨的唯一区别就是“粒子的形状”了:
class Snow extends Particle {
_init() {
let {
w, h} = this
let colors = this._options._colors || ['#ccc', '#eee', '#fff', '#ddd']
// 雪的大小用数量来计算
let amount = this._options.amount || 100
let speedFactor = this._options.speedFactor || 0.03
// 速度
let speed = speedFactor * h * 0.15
let radius = this._options.radius || 2
let ps = (this.particles = [])
for (let i = 0; i < amount; i++) {
let x = Math.random() * w
let y = Math.random() * h
ps.push({
x,
y,
// 原始 x 坐标,后面计算随机雪摆动是以此为基础
ox: x,
// 向下运动动能变量
ys: Math.random() + speed,
// 雪的半径大小
r: Math.floor(Math.random() * (radius + 0.5) + 0.5),
// 颜色随机取
color: colors[Math.floor(Math.random() * colors.length)],
rs: Math.random() * 80
})
}
}
_draw() {
let ps = this.particles
let ctx = this.ctx
ctx.clearRect(0, 0, this.w, this.h)
for (let i = 0; i < ps.length; i++) {
let {
x, y, r, color} = ps[i]
ctx.beginPath()
// 绘制下雪的效果
ctx.arc(x, y, r, 0, Math.PI * 2, false)
ctx.setFillStyle(color)
ctx.fill()
ctx.closePath()
}
ctx.draw()
this._update()
}
_update() {
let {
w, h} = this
let v = this._options.speedFactor / 10
for (let ps = this.particles, i = 0; i < ps.length; i++) {
let p = ps[i]
let {
ox, ys} = p
p.rs += v
// 这里使用了 cos,做成随机左右摆动的效果
p.x = ox + Math.cos(p.rs) * w / 2
p.y += ys
// console.log(ys)
// 重复利用
if (p.x > w || p.y > h) {
p.x = Math.random() * w
p.y = -10
}
}
}
}
首先,在 WXML 代码中,给实时天气模块增加 id 为 effect 的 Canvas 组件:
<canvas canvas-id="effect" id="effect">canvas>
而后引入上面的js文件:
import Particle from './effect'
let Rain=Particle.Particle
重点: 在微信小程序内,绘图 API(Canvas)内的长宽单位为 px,而我们页面布局用的是 rpx,虽然我们在 CSS 内已经使用 rpx 设置了 Canvas 的大小,但是由于内部单位的缘故,在实例化 Rain/Snow 粒子系统的时候,传入的 width 和 height 参数应该是实际的 px 大小。
rpx 转 px 是根据不同的设备屏幕尺寸转换的。虽然切图可以按照 1rpx=2px 这样标准的 iPhone 6 视觉稿做页面,而且微信似乎帮我们做了兼容处理,但是涉及实际 px 计算时,仍不能简单采用 1rpx=2px 的方式来解决,需要我们按照实际的 rpx 对应 px 的比例进行转换。如何获取 rpx 和 px 的实际比例呢?我们知道微信小程序中默认规定了屏幕宽度为 750rpx,根据这个设计,我们可以通过 wx.getSystemInfo
获取到的信息,找到手机屏幕的宽度大小 windowWidth
即可算出对应的比例,代码如下:
// 在 onload 内
wx.getSystemInfo({
success: (res) => {
let width = res.windowWidth
this.setData({
width,
scale: width / 375
})
}
})
这样,上面的 width就是屏幕的实际 px 宽度,而每个元素的实际 px 高度则由 元素 rpx 高度 / 2 * scale
得到。
最后,我们在页面代码中,实际使用时的代码是下面这样的:
const ctx = wx.createCanvasContext('effect')
let {
width, scale} = this.data
// 768 为 CSS 中设置的 rpx 值
let height = 768 / 2 * scale
let rain = new Rain(ctx, width, height, {
amount: 100,
speedFactor: 0.03
})
// 跑起来
rain.run()
在切换城市或者检测到没有雨雪天气时调用clear去除效果:
rain.clear()
感兴趣的朋友可以参见这篇文章:HTML5 canvas基础与「生成名片」应用程序 ↩︎