文字粒子动画效果


文字粒子动画效果

前言

对动画特效比较有兴趣,之前逛网站时看到的效果,之前看到了实现的文章,稍微学了一下,记录一下。shape-shifter

流程

获取文字的像素点信息

首先,利用配置的文字信息,绘制文字到虚拟的 canvas 中。

把虚拟的 canvas 也添加到 dom 树上看效果。

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
const options = {
width: 400,
height: 400,
time: 20,
};

const textOptions = {
word: "赤蓝紫",
font: "200px Arial",
};

function initCanvas() {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");

options.width = document.body.clientWidth;
options.height = document.body.clientHeight;

canvas.width = options.width;
canvas.height = options.height;

return { canvas, ctx };
}
const { canvas, ctx } = initCanvas();

getPointsInfo(options);

function getPointsInfo({ width, height }) {
const virtualCvs = document.createElement("canvas");
virtualCvs.width = width;
virtualCvs.height = height;
document.body.appendChild(virtualCvs);

const virtualCtx = virtualCvs.getContext("2d");
drawText(virtualCtx, options, textOptions);
}

function drawText(virtualCtx, { width, height }, { font, word }) {
virtualCtx.font = font;
const measure = virtualCtx.measureText(word); // 获取文字的宽高

virtualCtx.fillText(word, (width - measure.width) / 2, height / 2); // (width - measure.width) / 2是为了让文字居中
}

把文字绘制到虚拟画布上之后,利用getImageData获取文字的像素信息。

getImageData简单使用:

1
2
3
4
5
6
7
8
9
10
11
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");

canvas.width = 2;
canvas.height = 2;

ctx.fillStyle = "black";
ctx.fillRect(0, 0, 2, 2);

const { data } = ctx.getImageData(0, 0, 2, 2);
console.log(data);

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function getWordPxInfo(virtualCtx, { width, height, speed }) {
const imageData = virtualCtx.getImageData(0, 0, width, height).data;

const gap = 6;
const particles = [];

for (let y = 0; y < height; y += gap) {
for (let x = 0; x < width; x += gap) {
const position = (width * y + x) * 4; // 获取点阵信息
const r = imageData[position];
const g = imageData[position + 1];
const b = imageData[position + 2];
const a = imageData[position + 3];

// 判断当前像素是否有文字
if (r + g + b + a > 0) {
// 粒子实例化
particles.push(new Particle({ x, y }));
}
}
}

return particles;
}

粒子类

随机生成点的位置 x、y,并根据参数targetXtargetY设置速度。

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
class Particle {
constructor(point) {
this.targetX = point.x;
this.targetY = point.y;

this.x = Math.round(Math.random() * canvas.width);
this.y = Math.round(Math.random() * canvas.height);

this.radius = 3;
this.color = `purple`;
}

draw() {
ctx.beginPath();
ctx.fillStyle = this.color;
ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
ctx.fill();

ctx.closePath();
this.update();
}

update() {
const mx = this.targetX - this.x;
const my = this.targetY - this.y;
this.vx = mx / options.time;
this.vy = my / options.time;

this.x += this.vx;
this.y += this.vy;
}
}

绘制粒子文字

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// ...
const points = getPointsInfo(options);
draw(points, options);

function draw(points, { width, height }) {
ctx.clearRect(0, 0, width, height);

points.forEach((point) => {
point.draw();
});

window.requestAnimationFrame(function () {
draw(points, options);
});
}

input 监听

文字粒子数量调整,打乱已有粒子顺序。

规则:

  1. 遍历新点集,如果在旧点集中存在,则调用change方法更新旧点集的位置。如果不存在(即新点集比旧点集大),新增粒子。
  2. 遍历完新点集,如果新点集比旧点集小,则删除旧点集多的部分。
  3. 最后随机打乱点
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
class Particle {
// ...
change(targetX, targetY) {
this.targetX = targetX;
this.targetY = targetY;
}
}

const inputEle = document.querySelector("input");
inputEle.addEventListener("keyup", (e) => {
if (e.key === "Enter") {
textOptions.word = inputEle.value;

const newPoints = getPointsInfo(options);
changeFont(points, newPoints);
}
});

function shuffle(oldPoints) {
// 随机打算顺序,让切换更自然
let len = oldPoints.length;
while (len) {
const randomIndex = Math.round(Math.random() * len--);
const randomPoint = oldPoints[randomIndex];

const { targetX, targetY } = randomPoint;

randomPoint.targetX = oldPoints[len].targetX;
randomPoint.targetY = oldPoints[len].targetY;

oldPoints[len].targetX = targetX;
oldPoints[len].targetY = targetY;
}
}

function changeFont(oldPoints, newPoints) {
const oldLen = oldPoints.length;
const newLen = newPoints.length;

for (let i = 0; i < newPoints.length; i++) {
const { targetX, targetY } = newPoints[i];

// 如果在旧的点集里存在,则调用change改变位置。否则,新增
if (oldPoints[i]) {
oldPoints[i].change(targetX, targetY);
} else {
oldPoints[i] = new Particle({ x: targetX, y: targetY });
}
}

// 新点集比旧点集小
if (newLen < oldLen) {
oldPoints.splice(newLen, oldLen);
}

shuffle(oldPoints);
}

效果:

添加鼠标互斥交互

Particle类的update方法中根据鼠标到粒子的距离,确定一个力度比例。(即距离越小,收到的力越大)。将力度转换为速度的减量,从而实现斥力的效果。

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
const mousePosition = {
mouseX: undefined,
mouseY: undefined,
};

/** 中心影响的半径 */
const Radius = 100;
/** 排斥/吸引 力度 */
const Power = 0.8;

class Particle {
update() {
// ...
const { mouseX, mouseY } = mousePosition;
if (mouseX && mouseY) {
// 计算粒子与鼠标的距离
let dx = mouseX - this.x;
let dy = mouseY - this.y;
let distance = Math.sqrt(dx ** 2 + dy ** 2);

// 粒子相对鼠标距离的比例,来确定收到的力度比例
let disPercent = Radius / distance;

// 设置阈值
disPercent = disPercent > 7 ? 7 : disPercent;

const angle = Math.atan2(dy, dx);
const cos = Math.cos(angle);
const sin = Math.sin(angle);

const repX = cos * Power * disPercent;
const repY = sin * Power * disPercent;

this.vx -= repX; // 将力度转换为x、y方向上的速度。如果是斥力,则速度减去得到的值。否则,速度加上得到的值。
this.vy -= repY;
}

// ...
}
}

canvas.addEventListener("mousemove", (e) => {
const { left, top } = canvas.getBoundingClientRect();
const { clientX, clientY } = e;

mousePosition.mouseX = clientX - left;
mousePosition.mouseY = clientY - top;
});

canvas.addEventListener("mouseleave", () => {
mousePosition.mouseX = undefined;
mousePosition.mouseY = undefined;
});

效果:

完整代码

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
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>粒子文字</title>
<style>
html,
body {
height: 100%;
background-color: #000;
overflow: hidden;
}

#container {
position: relative;
height: 100%;
}

input {
position: absolute;
bottom: 0;
left: 50%;
transform: translate(-50%, -40px);

width: 400px;
color: #fff;
font-size: 24px;
text-align: center;
background-color: transparent;
border: none;
border-bottom: 1px solid #fff;
}

input:focus-visible {
outline: none;
}
</style>
</head>
<body>
<div id="container">
<canvas id="particle"></canvas>
<input type="text" />
</div>

<script src="./text-particle.js"></script>
</body>
</html>
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
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
const options = {
width: 400,
height: 400,
time: 20,
};

const textOptions = {
word: "赤蓝紫",
font: "200px Arial",
};

const mousePosition = {
mouseX: undefined,
mouseY: undefined,
};

/** 中心影响的半径 */
const Radius = 100;
/** 排斥/吸引 力度 */
const Power = 0.8;

function initCanvas() {
const canvas = document.getElementById("particle");
const ctx = canvas.getContext("2d");

options.width = document.body.clientWidth;
options.height = document.body.clientHeight;

canvas.width = options.width;
canvas.height = options.height;

return { canvas, ctx };
}
const { canvas, ctx } = initCanvas();

class Particle {
constructor(point) {
this.targetX = point.x;
this.targetY = point.y;

this.x = Math.round(Math.random() * canvas.width);
this.y = Math.round(Math.random() * canvas.height);

this.radius = 3;
this.color = `purple`;
}

draw() {
ctx.beginPath();
ctx.fillStyle = this.color;
ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
ctx.fill();

ctx.closePath();
this.update();
}

update() {
const mx = this.targetX - this.x;
const my = this.targetY - this.y;
this.vx = mx / options.time;
this.vy = my / options.time;

const { mouseX, mouseY } = mousePosition;
if (mouseX && mouseY) {
// 计算粒子与鼠标的距离
let dx = mouseX - this.x;
let dy = mouseY - this.y;
let distance = Math.sqrt(dx ** 2 + dy ** 2);

// 粒子相对鼠标距离的比例,来确定收到的力度比例
let disPercent = Radius / distance;

// 设置阈值
disPercent = disPercent > 7 ? 7 : disPercent;

const angle = Math.atan2(dy, dx);
const cos = Math.cos(angle);
const sin = Math.sin(angle);

const repX = cos * Power * disPercent;
const repY = sin * Power * disPercent;

this.vx -= repX; // 将力度转换为x、y方向上的速度。如果是斥力,则速度减去得到的值。否则,速度加上得到的值。
this.vy -= repY;
}

this.x += this.vx;
this.y += this.vy;
}

change(targetX, targetY) {
this.targetX = targetX;
this.targetY = targetY;
}
}

const points = getPointsInfo(options);
draw(points, options);

function getPointsInfo({ width, height }) {
const virtualCvs = document.createElement("canvas");
virtualCvs.width = width;
virtualCvs.height = height;

const virtualCtx = virtualCvs.getContext("2d");

drawText(virtualCtx, options, textOptions);
return getWordPxInfo(virtualCtx, options);
}

function drawText(virtualCtx, { width, height }, { font, word }) {
virtualCtx.font = font;
const measure = virtualCtx.measureText(word); // 获取文字的宽高

virtualCtx.fillText(word, (width - measure.width) / 2, height / 2); // (width - measure.width) / 2是为了让文字居中
}

function getWordPxInfo(virtualCtx, { width, height, speed }) {
const imageData = virtualCtx.getImageData(0, 0, width, height).data;

const gap = 6;
const particles = [];

for (let y = 0; y < height; y += gap) {
for (let x = 0; x < width; x += gap) {
const position = (width * y + x) * 4; // 获取点阵信息
const r = imageData[position];
const g = imageData[position + 1];
const b = imageData[position + 2];
const a = imageData[position + 3];

// 判断当前像素是否有文字
if (r + g + b + a > 0) {
// 粒子实例化
particles.push(new Particle({ x, y }));
}
}
}

return particles;
}

function draw(points, { width, height }) {
ctx.clearRect(0, 0, width, height);

points.forEach((point) => {
point.draw();
});

window.requestAnimationFrame(function () {
draw(points, options);
});
}

const inputEle = document.querySelector("input");
inputEle.addEventListener("keyup", (e) => {
if (e.key === "Enter") {
textOptions.word = inputEle.value;

const newPoints = getPointsInfo(options);
changeFont(points, newPoints);
}
});

function shuffle(oldPoints) {
// 随机打算顺序,让切换更自然
let len = oldPoints.length;
while (len) {
const randomIndex = Math.round(Math.random() * len--);
const randomPoint = oldPoints[randomIndex];

const { targetX, targetY } = randomPoint;

randomPoint.targetX = oldPoints[len].targetX;
randomPoint.targetY = oldPoints[len].targetY;

oldPoints[len].targetX = targetX;
oldPoints[len].targetY = targetY;
}
}

function changeFont(oldPoints, newPoints) {
const oldLen = oldPoints.length;
const newLen = newPoints.length;

for (let i = 0; i < newPoints.length; i++) {
const { targetX, targetY } = newPoints[i];

// 如果在旧的点集里存在,则调用change改变位置。否则,新增
if (oldPoints[i]) {
oldPoints[i].change(targetX, targetY);
} else {
oldPoints[i] = new Particle({ x: targetX, y: targetY });
}
}

// 新点集比旧点击小
if (newLen < oldLen) {
oldPoints.splice(newLen, oldLen);
}

shuffle(oldPoints);
}

canvas.addEventListener("mousemove", (e) => {
const { left, top } = canvas.getBoundingClientRect();
const { clientX, clientY } = e;

mousePosition.mouseX = clientX - left;
mousePosition.mouseY = clientY - top;
});

canvas.addEventListener("mouseleave", () => {
mousePosition.mouseX = undefined;
mousePosition.mouseY = undefined;
});

参考

https://www.kennethcachia.com/shape-shifter/
https://juejin.cn/post/7052152858518487053


文章作者: 赤蓝紫
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 赤蓝紫 !
评论
  目录