Skip to content

实战篇 5:为天气页面制作雨雪效果的粒子系统

经过前两节的内容,基本天气预报页面的布局和数据交互都已经完成了,本节来介绍使用小程序的绘图 API 在「实时天气」模块上做一个雨雪效果。

小程序的绘图 API

小程序的绘图 API 跟 HTML5 的 Canvas 本质上有很大区别的,造成用法区别的原因是:

小程序的绘图(Canvas)是客户端实现的 Native UI 组件,而不是普通的 H5 组件,所以在使用上跟普通的 H5 组件用法略有不同。

Tips: 微信的 Canvas 在 iOS 上是 Ejecta 实现的。

上下文获取方式不同

小程序绘图 API 的 canvasContext 获取方式是通过 <canvas>canvas-id 来获取的,即

<canvas canvas-id="test"></canvas>

获取 Context:

let ctx = wx.createCanvasContext('test')

API 写法不同

小程序的绘图 API 跟 HTML5 的 Canvas 在用法上主要是绝大部分的 HTML5 Canvas 属性写法,变成了小程序的方法写法,例如:

const ctx = wx.createCanvasContext('myCanvas')
ctx.setFillStyle('red')
ctx.fillRect(10, 10, 150, 75)
ctx.draw()

不过值得一提的是,在 1.9.0 基础库以上,类似 fillStylelineWidth 这类的,可以直接跟 H5 的写法一样,不需要使用 setXxxx 的方式了。

想要显示绘制效果,需要 ctx.draw() 使用

在小程序的绘图使用中,对 context 进行绘制之后,并不会立即绘制到画布上,而是通过执行 ctx.draw() 的方式,将之前在绘图上下文中的描述(路径、变形、样式)画到 canvas 中。ctx.draw() 方法比较消耗性能,因此不建议在一个绘制周期内多次调用。

Tips: 小程序绘图中的内部尺寸单位都是 px,例如 clearRect() 这类方法,所以在使用 rpx 布局的页面中,需要注意 Canvas 内部 rpx 到 px 的转换关系,详细解释见本节粒子系统实现部分。

粒子系统设计思路

在 Canvas 开发中,经常会提到粒子系统,使用粒子系统可以模拟出火、雾、云、雪、尘埃、烟气等抽象视觉效果。

在这个小程序中,笔者使用粒子系统做了雨雪效果,通过雨雪效果的编写,可以让读者学会粒子系统的基础知识,以及在小程序中使用绘图 API 相关的接口。

本小册中的粒子系统由基类和子类组成。Particle 是基类,定义了子类统一的方法,如 run()stop()clear() 等。基类负责整个粒子系统动画周期和流程的维护,子类负责具体实现的粒子效果,比如下雨下雪的效果是子类实现的,而下雨下雪的开关和公共处理流程是基类控制的。

基类由如下几个方法组成:

  • _init():实例化时第一执行的方法;空,由子类具体实现
  • _draw():每个动效周期内画图用的方法;空,由子类具体实现
  • run:设置定时器,定时执行 _draw(),实现动画周期
  • stop:停止动画
  • clear:停止动画,并且清空画板

这些方法之间的关系是:

上面的关系图很清晰地展现了整个粒子系统的设计思路:

  1. 在构造器内调用 _init,随机生成单个粒子,放进数组对象
  2. 在执行实例 run 的时候,设置定时器,定时器回调调用 _draw 绘制粒子,设置单个粒子下一步的属性
  3. _init_draw 是子类具体根据效果实现的

根据这个关系图,基类就很简单实现了:

// lib/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() {}
  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
  }
}

下雨效果的粒子系统

根据上面的内容,具体的子类只需要在 _init 中,根据需要生成的粒子个数 amount 循环随机生成每个粒子,放入 this.particles 数组即可:

// lib/effect.js
// _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(255, 255, 255, 0.1)'
  }
  ps.push(p)
}

其中: * x、y 代表单个粒子的位置,即雨滴开始绘图的位置 * xs、ys 分别代表 x、y 方向上的加速度,即雨滴的下落速度和角度 * l 代表雨滴的长度

_draw的方法,是先将画布清空,然后遍历 this.particles 数组取出单个雨滴并进行绘制,最后调用一个单独实现的 _update 重新计算单个雨滴的位置:

// lib/effect.js
// _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 的具体实现如下:

// lib/effect.js

// _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
  }
}

下雪效果子类实现

下雪的效果跟下雨不同的是,下雨是长条的线,雪花是圆形的雪片,另外为了增加「灵性」做出飘来飘去的效果,在 _update 方法中,使用了 Math.cos 来随机生成下一步 x 轴的位置,这里就直接贴出代码来:

// lib/effect.js
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
      // console.log(x, y)
      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
      }
    }
  }
}

注意,不管是下雨还是下雪,在 _draw 的最开始都是执行 ctx.clearRect 清空画布,最后都是执行 ctx.draw 使 native 对画布进行统一绘制。

使用粒子系统

上面介绍了雨雪的粒子系统 JS 类实现,下面讲解怎样将 Canvas 效果画到网页上,首先看下效果图。

效果图的黄色框内为下雨的效果,这个红色框大小跟顶部「实时天气模块」是等大的。首先,在 WXML 代码中,给实时天气模块增加 ideffect 的 Canvas 组件:

<!-- weather/index.wxml -->
<view class="container" id="canvas-wrapper">
  <!-- 下面是雨雪效果的 Canvas -->
  <canvas canvas-id="effect" id="effect"></canvas>
  <view class="now">
    <view class="location" bindtap="chooseLocation">
      ...
    </view>
    <view class="air-quality" wx:if="{{air.aqi}}">
      ...
    </view>
    <view class="now-weather">
      ...
    </view>
  </view>
  <view class="two-days">
    ....
  </view>
</view>

在样式中,设置 Canvas 的大小跟实时天气模块大小一样,并且绝对定位,完全覆盖到实时天气模块上:

// weather/index.scss
#effect {
  width: 750rpx;
  height: 768rpx;
  position: absolute;
  top: 0;
  right: 0;
}

重点:在微信小程序内,绘图 API(Canvas)内的长宽单位为 px,而我们页面布局用的是 rpx,虽然我们在 CSS 内已经使用 rpx 设置了 Canvas 的大小,但是由于内部单位的缘故,在实例化 Rain/Snow 粒子系统的时候,传入的 widthheight 参数应该是实际的 px 大小。

根据之前章节的介绍,rpx 转 px 是根据不同的设备屏幕尺寸转换的。虽然切图可以按照 1rpx=2px 这样标准的 iPhone 6 视觉稿做页面,但是涉及实际 px 计算时,不能简单采用 1rpx=2px 的方式来解决,需要我们按照实际的 rpx 对应 px 的比例进行转换。如何获取 rpx 和 px 的实际比例呢?我们知道微信小程序中默认规定了屏幕宽度为 750rpx,根据这个设计,我们可以通过 wx.getSystemInfo 获取到的信息,找到手机屏幕的宽度大小 windowWidth 即可算出对应的比例,代码如下:

// weather/index.js
// 在 onload 内
wx.getSystemInfo({
  success: (res) => {
    let width = res.windowWidth
    this.setData({
      width,
      scale: width / 375
    })
  }
})

这样,上面的 width就是屏幕的实际 px 宽度,而每个元素的实际 **px 高度**则由 元素 rpx 高度 / 2 * scale 得到。

最后,我们在页面代码中,实际使用 Rain/Snow 类时的代码是下面这样的:

// weather/index.js
// 下面是 canvas 的 canvas-id
const canvasId = 'effect'
const ctx = wx.createCanvasContext(canvasId)
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()
// 如果切换了城市,不在下雨/雪,则执行
rain.clear()

小结

本节介绍使用小程序绘图 API(Canvas)绘制雨雪效果的粒子系统,给整个天气页面添加动效。

在使用粒子系统中应该注意小程序绘图 API 的写法跟 HTML5 中 Canvas API 的差异。除了 API 的差异,还要在绘图结束后调用 ctx.draw() 使绘制执行。最后介绍了怎样在 rpx 的布局中绘制不定宽高的效果,需要根据屏幕的宽度跟 rpx 的实际比例计算出元素的实际 px 宽高,然后给 Canvas 使用。