【下雪特效】2021年的第一场雪下在了首页

877 阅读6分钟

冬天来了,北方人可能不李姐南方人对雪的执念,那执念就像霓凰郡主始终相信梅长苏还活着一样,既然看不到雪,那就自己造一场雪吧,生活需要仪式感,这是技术人的浪漫。

其实是半个月之前的圣诞活动,事情是这样的:

UI:“这个圣诞版本皮肤我在首页想要一个下雪的特效。”

我:“你想要什么样的?”

UI:“下雪的那种!”

我:“那是什么样的?”

...

你倒是给我出一个效果图啊(自然是不能更小姐姐这么说的)。

最后的最后,她说:“太忙了,没时间出,你有时间就做一个吧,没有就算了。”(也是真的太忙了😄)

算了?那就算了吧😅!哈哈哈。

...

后来啊,我就陷入了这场雪的幻想中....

“雪花要洁白的,随风摇曳。“

“雪花的大小要随机,还要有毛茸茸的感觉。“

“对了,下的雪还要堆积起来”

最终现实给我一记暴扣:醒醒!

最终版本也是按照UI的标准,“雪堆积”的效果实在是没精力搞了,想出了一些思路,但是时间不够了。皮肤上线后一直想记录分享一下,奈何挺忙的,到现在才想起来记录,惭愧惭愧😥。

等后面有精力了再完善一下“雪堆积”的效果吧,到时候在更新下文章。

考虑到雪花的数量,如果用css + dom的方式会造成页面的渲染性能,所以使用canvas在来实现。

本篇文章是基于微信小程序的实现方案,其实在不同端用canvas实现的原理都一样,唯一的差别是不同端提供的canvas绘制能力上的细微差别,大同小异。

先来看看效果图(由于转换后的GIF图有压缩的情况看起来卡,但实际不卡的):

ezgif.com-video-to-gif.gif

图片有点大(5M多)

特效分析

  • 雪花的大小,下落的速率,飘落的方向都是随机的。
  • 始终保持一定数量的雪花。

实现思路

1. 创建canvas画布

<!-- index.wxml -->

<canvas class="canvas" type="2d" id="canvas"></canvas>
/* index.wxss */

.canvas {
  width: 100vw;
  height: 300px;
  background: #000;
}

2.实现snow.js

借用面向对象的编程思想,将每个雪花都看做是一个独特的对象。这个对象能做两件事:

  • 将雪花绘制出来
  • 飘落

我们先来描述雪花的一些特征:

// snow.js

class Snow{
  constructor(index,x,y){
    this.index = index // 雪花编号(身份证)
    this.x = x // x轴位置
    this.y = y // y轴位置
    this.offset = (-Math.floor(Math.random() * 3) + 1) * 0.5 // 飘落时水平偏移量
    this.speed = Math.floor(Math.random() * 5 + 5) / 10 // 速度 0.5 - 1 (垂直偏移量)
    this.size = Math.random() * 14 + 4 // 1 - 4 雪花直径尺寸
    this.alpha = Math.floor(Math.random() * 4 + 6) / 10 // 0.7 - 1 雪花的透明度
  }
}

export default Snow;

再来实现一下雪花能做的两件事:

// snow.js

class Snow{
  constructor(index,x,y){
    this.index = index // 雪花编号
    this.x = x // x轴位置
    this.y = y // y轴位置
    this.offset = (-Math.floor(Math.random() * 3) + 1) * 0.5 // 飘落时水平偏移量
    this.speed = Math.floor(Math.random() * 5 + 5) / 10 // 速度 0.5 - 1 (垂直偏移量)
    this.size = Math.random() * 14 + 4 // 1 - 4 雪花直径尺寸
    this.alpha = Math.floor(Math.random() * 4 + 6) / 10 // 0.7 - 1 雪花的透明度
  }
}

/**
 * @description 绘制雪花
 * @param {*} ctx 画笔
 * @param {*} img 雪花图标
 */
Snow.prototype.draw = function (ctx,img){
  ctx.globalAlpha = this.alpha
  ctx.drawImage(img, this.x, this.y, this.size, this.size)
}

/**
 * @description 飘落
 * @param {*} ctx 画笔
 * @param {*} img 雪花图标
 */
Snow.prototype.move = function (ctx,img){
  this.y += (this.speed * 2)
  this.x += this.offset
  this.draw(ctx,img)
  return [this.x , this.y]
}

export default Snow;

move方法返回的坐标大家可以思考一下用来做什么,后面揭晓。

3. 完善js逻辑

// index.js

import Snow from "./snow"

Page({
  /**
   * 页面的初始数据
   */
  data: {
    canvsInfo: {}, // 画布相关信息
  },
  onLoad (){
    const sysInfo = wx.getSystemInfoSync()
    this.data.canvsInfo = {
      dpr: sysInfo.pixelRatio, // 设备像素比
      WIDTH: sysInfo.screenWidth, // 画布宽
      HEIGHT: 300, // 画布高
      COUNT: 60 , // 雪花的数量
      icon: null, // 雪花图标
      list: [] // 队列
    }
    const that = this
    const timer = setTimeout(()=>{
      wx.createSelectorQuery().in(this)
        .select('#canvas')
        .fields({
          node: true,
          size: true
        })
        .exec(that.init.bind(this))
      clearTimeout(timer)
    },100)
  },
  
  /**
   * 初始化canvs,获取图标路径等
   * @param {*} res canvs节点信息
   */
  init (res) {
    const canvas = res[0].node
    const ctx = canvas.getContext('2d')
    const { dpr } = this.data.canvsInfo
    canvas.width = res[0].width * dpr
    canvas.height = res[0].height * dpr
    this.data.canvsInfo.HEIGHT = res[0].height
    ctx.scale(dpr, dpr)
    const img = canvas.createImage()
    img.onload = () => {
      this.data.canvsInfo.icon = img
      this.data.canvsInfo.ctx = ctx
      // 开始绘制
      this.render()
    }
    img.src = './snow.png'
  },
  
  /**
   * 绘制雪花
   * @param {*} num 雪花的数量
   */
  render () {
    const { WIDTH,COUNT, icon,ctx } = this.data.canvsInfo
    for (let i = 0; i < COUNT ; i++){
      // 随机创建下落的坐标点(100是为了大家开发中能看到效果,后面会改)
      const snow = new Snow(i, Math.random() * WIDTH, 100)
      // 将创建的雪花添加进队列
      this.data.canvsInfo.list.push(snow)
      // 绘制
      snow.draw(ctx, icon)
    }
  }
})

以上我们已经将雪花绘制出来了,接着让它动起来。

动画原理:动画是通过把人物的表情、动作、变化等分解后画成许多动作瞬间的画幅,再用摄影机连续拍摄成一系列画面,给视觉造成连续变化的图画。它的基本原理与电影、电视一样,都是视觉暂留原理。医学证明人类具有“视觉暂留”的特性,人的眼睛看到一幅画或一个物体后,在0.34秒内不会消失。利用这一原理,在一幅画还没有消失前播放下一幅画,就会给人造成一种流畅的视觉变化效果。(摘自百度百科

动画就是由一帧一帧的画面组成,我们要让它动来就是要不停的:清除上一次绘制 > 重新绘制 > 清除上一次绘制 > 重新绘制 ...

那要怎么让它“不停”呢?比如可以使用js的方法setInterelsetTimeout。不过这两种都不是最佳方案,写动画的话还是建议使用requestAnimationFrame方法,相比于setInterelsetTimeout有它独有的优势,这也是前端面试的高频考点哦,不清楚的同学可以私下去了解一下,这里我就不多赘述了,我们来继续完善:

// index.js

import Snow from "./snow"

Page({

  /**
   * 页面的初始数据
   */
  data: {
    canvsInfo: {}, // 画布相关信息
    canvas:null // canvas对象
  },
  onLoad (){
    ...
  },
  /**
   * @description 初始化canvs,获取图标路径等
   * @param {*} res canvs节点信息
   */
  init (res) {
    ...
    ctx.scale(dpr, dpr)
    this.data.canvas = canvas
    ...
    img.onload = () => {
      ...
      // 开始绘制
      this.render()
      // 循环绘制
      this.renderLoop()
    }
    ...
  },
  /**
   * @description 绘制雪花
   * @param {*} num 雪花的数量
   */
  render (num = 0) {
    ...
    for (let i = 0; i < COUNT ; i++){
      // 随机创建下落的坐标轴
      const snow = new Snow(i, Math.random() * WIDTH, (-Math.random() * 100))
      ...
    }
  },
  /**
   * @description 循环绘制
   */
  renderLoop(){
    this.animate()
    const { canvas } = this.data
    canvas.requestAnimationFrame(this.renderLoop.bind(this))
  },
  
  /**
   * @description 雪花移动
   */
  animate (){
    const { ctx,icon, WIDTH, HEIGHT, list } = this.data.canvsInfo
    // 清楚之前的绘制
    ctx.clearRect(0, 0, WIDTH, HEIGHT)
    for (let i = 0; i < list.length;i++){
      list[i].move(ctx,icon)
    }
  }
})

这样我们的雪花就可以动起来了,到这里这场雪就“下”了差不多一大半了。

以上动画还有一些问题:

  • 设置的雪花数量一次性下完就没有了,怎么让它循环起来呢?
  • requestAnimationFrame方法总不能一直执行吧,会造成资源浪费。

循环的原理也很简单:

我们上面提到的move方法返回的坐标点,我们在每次雪花移动之后判断雪花的位置是否还在可视区域内,如果在区域内则表示雪花依旧“存活”,不在区域内则表示这些雪花需要重新生成。将“消亡”的雪花再次调用render方法绘制即可。这样就实现了视野内始终只有一定数量的雪花在飘落。

当用户离开当前页面或者销毁的时候我们应该调用cancelAnimationFrame取消requestAnimationFrame的执行。

完整的index.js的代码如下:

// index.js


import Snow from "./snow"

Page({

  /**
   * 页面的初始数据
   */
  data: {
    canvsInfo: {}, // 画布相关信息
    canvas:null, // canvs
    isPause: false, // 是否暂停
  },
  onLoad (){
    const sysInfo = wx.getSystemInfoSync()
    this.data.canvsInfo = {
      dpr: sysInfo.pixelRatio, // 像素比
      WIDTH: sysInfo.screenWidth, // 设备宽度
      HEIGHT: 300,
      COUNT: 60 , // 随机产生雪花的数量
      icon: null, // 雪花
      list: [] // 队列
    }
    const that = this
    const timer = setTimeout(()=>{
      wx.createSelectorQuery().in(this)
        .select('#canvas')
        .fields({
          node: true,
          size: true
        })
        .exec(that.init.bind(this))
      clearTimeout(timer)
    },100)
  },
  hide (){
    this.data.isPause = true
    this.data.canvas.cancelAnimationFrame(this.data.aniFrameId)
  },
  show (){
    if (!this.data.isPause){
      return
    }
    this.data.isPause = false
    this.renderLoop()
  },
  /**
   * 初始化canvs,获取图标路径等
   * @param {*} res canvs节点信息
   */
  init (res) {
    const canvas = res[0].node
    const ctx = canvas.getContext('2d')
    const { dpr } = this.data.canvsInfo
    canvas.width = res[0].width * dpr
    canvas.height = res[0].height * dpr
    this.data.canvsInfo.HEIGHT = res[0].height
    ctx.scale(dpr, dpr)
    this.data.canvas = canvas
    const img = canvas.createImage()
    img.onload = () => {
      this.data.canvsInfo.icon = img
      this.data.canvsInfo.ctx = ctx
      // 开始绘制
      this.render()
      // 循环绘制
      this.renderLoop()
    }
    img.src = './snow.png'
  },
  /**
   * @description 绘制雪花
   * @param {*} num 雪花的数量
   */
  render (num = 0) {
    const { WIDTH,COUNT, icon,ctx } = this.data.canvsInfo
    for (let i = 0; i < (num || COUNT) ; i++){
      // 随机创建下落的坐标轴
      const snow = new Snow(i, Math.random() * WIDTH, num ? -3 : (-Math.random() * 100))
      // 将创建的雪花添加进队列
      this.data.canvsInfo.list.push(snow)
      // 绘制
      snow.draw(ctx, icon)
    }
  },
  /**
   * @description 循环绘制
   */
  renderLoop(){
    this.animate()
    const { canvas,isPause } = this.data
    this.data.aniFrameId = canvas.requestAnimationFrame(this.renderLoop.bind(this))
    if (this.data.canvsInfo.list.length === 0 || isPause){
      canvas.cancelAnimationFrame(this.data.aniFrameId)
    }
  },
  /**
   * @description 雪花移动
   */
  animate (){
    const { ctx,icon, WIDTH, HEIGHT, list } = this.data.canvsInfo
      const alive = [] // 依然存活的雪花
      // 清除之前的绘制
      ctx.clearRect(0, 0, WIDTH, HEIGHT)
      for (let i = 0; i < list.length;i++){
        const [x,y] = list[i].move(ctx,icon)
        // 在视野内(设置一定大小的阈值让动画更生动)
        if (y < HEIGHT && (x > -10 && x < (WIDTH + 10))){
          alive.push(list[i])
        }
      }
      const died = list.length - alive.length
      this.data.canvsInfo.list = alive
      if (died > 0){
        this.render(died)
      }
  }
})

以上就是整个动画的demo版本了,更多细节大家可以随机应变,举一反三,这里作者就不啰嗦了。

最后

这种动画的布局一般都会脱离文档流,进行重叠(尤其是小程序会出现原生组件层级最高的问题,开发时要注意合理的布局)。这样就会影响到事件捕获,导致层级底部文档的事件无法生效,比如点击,手势等。可以使用css pointer-events: none对上层文档进行屏蔽,不针对事件做出反应,对下层文档使用pointer-events: auto进行正常接收。

好了,以上就是文章的全部内容。

最后祝福大家: 2022新年快乐,程序无bug,摸鱼时间多。