文字粒子效果

想給自己的網站寫一個404、500之類的錯誤頁,光放仨數字太醜,想着有什麼辦法漂亮地動起來,於是想起來以前做過一個破碎的粒子合併成文字的效果,就拿來使了。但是當初做的時候沒有考慮性能上的問題,於是這次重構了一遍,所以本篇博客的重點不是動畫效果,而是性能優化。

動畫的原理很簡單,相信大家都能想到:

① 就是先在一個臨時的畫布上繪製出文字

② 然後遍歷畫布中的每個像素

③ 如果不是透明的則記錄下來作爲例子結束的位置

④ 然後隨機生成例子的初始位置和顏色,設置初始大小爲0

⑤ 創建動畫循環,粒子放大並移動到結束位置

核心的API是getImageData方法。如果按照這種方式實現,當你生成的粒子較多,你會發現動畫過程中會明顯卡頓的時候。主要的原因是getImageData和遍歷像素是一個十分耗時的操作,會阻塞頁面的繪製,這時可以使用Web Worker和離屏渲染OffscreenCanvas一定程度上避免該問題。

主線程代碼:

<html lang="zh-cn">
<head>
  <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
  <meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>粒子</title>
  <style>
    * {
      padding: 0;
      margin: 0;
    }

    html, body {
      width: 100%;
      height: 100%;
    }

    #particle-canvas {
      display: block;
      width: 100%;
      height: 100%;
    }

    #input-word {
      font-family: Tahoma, sans-serif;
      position: absolute;
      bottom: 10px;
      left: 10px;
      height: 40px;
      padding: 10px;
      z-index: 1;
      width: 200px;
      border: 1px solid #aaa;
      line-height: 30px;
      box-sizing: border-box;
    }

    #btn-generate {
      font-family: Tahoma, sans-serif;
      position: absolute;
      height: 40px;
      bottom: 10px;
      left: 240px;
      padding: 0 10px;
      box-sizing: border-box;
    }
  </style>
</head>
<body>
<canvas id="particle-canvas" width="1600" height="849"></canvas>
<input type="text" placeholder="please input words" id="input-word">
<button id="btn-generate">Generate</button>
<script>
  (function () {

    // const blob = new Blob([document.querySelector('#worker').textContent], { type: 'text/javascript' })
    // const url = window.URL.createObjectURL(blob)
    const worker = new Worker('./worker.js')

    let pageWidth, pageHeight
    let particleList = []
    let particleSize = 0
    let timer = -1

    let canvas = document.getElementById('particle-canvas')

    let ctx = canvas.getContext('2d')

    let unDoneCount = 0

    worker.onmessage = function (e) {
      if (e.data.signal === 'initialized') {
        // 倒計時十秒,每秒繪製一次
        let count = 10
        timer = setInterval(function () {
          if (count <= 0) {
            clearInterval(timer)
          }
          worker.postMessage({ signal: 'generate', word: String(count), fontSize: pageHeight * 0.8, })
          count--
        }, 1000)
      }
      else if (e.data.signal === 'generated') {
        particleList = e.data.particleList
        particleSize = e.data.particleSize
        unDoneCount = particleList.length
        tick()
      }
      // else if (e.data.signal === 'resized') {
      //   particleList = e.data.data
      //   tick()
      // }
    }

    let tick = function () {
      ctx.clearRect(0, 0, pageWidth, pageHeight)
      let particle
      for (let i = 0; i < particleList.length; i++) {
        particle = particleList[i]
        if (!particle.done) {
          particle.size = particle.size + (particleSize - particle.size) * particle.speed
          particle.from.x = particle.from.x + (particle.to.x - particle.from.x) * particle.speed
          particle.from.y = particle.from.y + (particle.to.y - particle.from.y) * particle.speed
          if (Math.abs(particle.to.x - particle.from.x) < 0.5 && Math.abs(particle.to.y - particle.from.y) < 0.5 && particleSize - particle.size < 0.1) {
            particle.done = true
            particle.from.x = particle.to.x
            particle.from.y = particle.to.y
            unDoneCount--
          }
        }
        ctx.fillStyle = particle.color
        ctx.fillRect(particle.from.x, particle.from.y, particle.size, particle.size)
      }
      if (unDoneCount > 0) {
        requestAnimationFrame(tick)
      }
      else {
        console.log('繪製結束')
      }
    }

    pageWidth = document.documentElement.clientWidth
    pageHeight = document.documentElement.clientHeight
    canvas.width = pageWidth
    canvas.height = pageHeight

    worker.postMessage({
      signal: 'init',
      width: pageWidth,
      height: pageHeight,
      particleSpacing: 4,
    })

    const $inputWord = document.getElementById('input-word')
    const $btnGenerate = document.getElementById('btn-generate')
    $btnGenerate.onclick = function () {
      const word = $inputWord.value
      clearInterval(timer)
      worker.postMessage({ signal: 'generate', word, fontSize: 'auto' })
    }
  })()
</script>

</body>
</html>

worker線程(worker.js)代碼:

let offscreen
let ctx

const colors = [
  '#a09d1d',
  '#84b826',
  '#168a30',
  '#155fbf',
  '#40148c',
  '#5f168b',
  '#93148c',
  '#970c0d',
  '#af2e15',
  '#ab4913',
  '#a45a12',
  '#514e0e',
]
let cvsWidth, cvsHeight
let particleSize
let particleSpacing
let particleList
let wordLeft
let wordTop
let generating

addEventListener('message', function (e) {

  if (e.data.signal === 'init') {
    cvsWidth = e.data.width
    cvsHeight = e.data.height
    particleSpacing = e.data.particleSpacing
    offscreen = new OffscreenCanvas(cvsWidth, cvsHeight)
    ctx = offscreen.getContext('2d')
    postMessage({ signal: 'initialized' })
  }
  else if (e.data.signal === 'generate') {
    generate(e.data.word, e.data.fontSize)
  }
}, false)

function generate (word, fontSize) {
  generating = true
  offscreen = new OffscreenCanvas(cvsWidth, cvsHeight)
  // 自動計算字體大小
  if (fontSize === 'auto') {
    fontSize = cvsWidth / word.length
    fontSize = Math.min(cvsHeight * 0.8, fontSize)
  }

  ctx.textAlign = 'left'
  ctx.textBaseline = 'middle'
  ctx.fillStyle = '#000'
  ctx.font = 'bold ' + fontSize + 'px Tahoma'
  const wordWidth = ctx.measureText(word).width
  particleSize = Math.max(wordWidth / 140, 5)
  wordLeft = (cvsWidth - wordWidth) / 2
  wordTop = cvsHeight / 2
  ctx.clearRect(0, 0, cvsWidth, cvsHeight)
  ctx.fillText(word, (cvsWidth - ctx.measureText(word).width) / 2, cvsHeight / 2) // 輕微調整繪製字符位置

  particleList = []
  let imageData = ctx.getImageData(0, 0, cvsWidth, cvsHeight).data
  let i, j // 採樣的座標
  const sampleOffset = Math.floor(particleSize + particleSpacing)
  for (i = 0; i < cvsWidth; i += sampleOffset) {
    for (j = 0; j < cvsHeight; j += sampleOffset) {
      // 若採樣點alpha通道的值不是0
      if (imageData[4 * (j * cvsWidth + i) + 3]) {
        particleList.push({
          from: { x: cvsWidth * Math.random(), y: cvsHeight * Math.random() }, // 動畫隨機起始位置
          to: { x: i, y: j },
          color: colors[Math.floor(Math.random() * colors.length)], // 隨機選取顏色
          speed: 0.08 + 0.04 * Math.random(),
          size: 0, // 初始大小爲0
          done: false, // 是否完成動畫
        })
      }
    }
  }
  postMessage({ signal: 'generated', particleList, particleSize })
  generating = false
}

最終效果如下:

 完整代碼戳這裏

在線演示 1 、在線演示 2

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章