Bootstrap
博客内容搜索
Kaysama's Blog

想给自己的网站写一个404、500之类的错误页,光放仨数字太丑,想着有什么办法漂亮地动起来,于是想起来以前做过一个破碎的粒子合并成文字的效果,就拿来使了。但是当初做的时候没有考虑性能上的问题,于是这次重构了一遍,所以本篇博客的重点不是动画效果,而是性能优化。

动画的原理很简单,相信大家都能想到:
① 就是先在一个临时的画布上绘制出文字
② 然后遍历画布中的每个像素
③ 如果不是透明的则记录下来作为例子结束的位置
④ 然后随机生成例子的初始位置和颜色,设置初始大小为0
⑤ 创建动画循环,粒子放大并移动到结束位置

核心的API是getImageData方法。如果按照这种方式实现,当你生成的粒子较多,你会发现动画过程中会明显卡顿的时候。主要的原因是getImageData和遍历像素是一个十分耗时的操作,会阻塞页面的绘制,这时可以使用Web Worker和离屏渲染OffscreenCanvas一定程度上避免该问题。

主线程代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
<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)代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
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