本文目标是实现如下文字雨垂直降落的效果,如下动图:

代码仓库:
1. 功能概览
- 实现
文字雨
垂直下落,下降速度不同
,有远近
的效果 - 底部有
同心圆
,同心圆会发生旋转
- 插入
文字标签
,并跟随同心圆一起旋转 双击暂停
动画,再次双击继续开始动画
2. 基础架构搭建
-
在vite或者vue项目中开发都可以,我是在vite项目中开发
-
必须安装好three.js的npm包
-
main.js中引入
three
包
js
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import GUI from 'three/examples/jsm/libs/lil-gui.module.min.js';
- 场景、摄像机、渲染器、轨道控制器
js
// 创建场景
const scene = new THREE.Scene();
scene.background = new THREE.Color(0xdec9aa)
// 创建相机
const camera = new THREE.PerspectiveCamera(
45, // 视角
window.innerWidth / window.innerHeight, // 宽高比
0.01, // 近平面
100 // 远平面
);
// 设置相机位置
camera.position.z = 50;
camera.position.y = 2.5;
camera.position.x = 3;
camera.lookAt(0, 1.2, 0);
// 创建渲染器
const renderer = new THREE.WebGLRenderer({
antialias: true, // 开启抗锯齿
});
renderer.shadowMap.enabled = true;
renderer.toneMapping = THREE.ReinhardToneMapping
renderer.toneMappingExposure = 1
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
// 添加轨道控制器,注意轨道控制器不需要加入到场景里面去
const controls = new OrbitControls(camera, renderer.domElement);
// 设置带阻尼的惯性
controls.enableDamping = true;
// 设置阻尼系数
controls.dampingFactor = 0.05;
// 设置旋转速度
// controls.autoRotate = true;
controls.zoom = 20
3. 文字雨效果
- 准备数据源,要下降的文字,这个数据一般要从接口拿到
js
const groupsTexts = [
"生而不有",
"是以圣人处无为之事",
"难易想成",
"皆知善之为善",
"功成而弗居",
"是以不去",
"长短相较",
"行不言之教",
"天下皆知美之为美",
"长短相教",
"万物作焉而不辞",
"生而不有",
"是以圣人处无为之事",
"难易想成",
"皆知善之为善",
"功成而弗居",
"是以不去",
"长短相较",
"行不言之教",
"天下皆知美之为美",
"长短相教",
"万物作焉而不辞",
"生而不有",
"是以圣人处无为之事",
"难易想成",
"皆知善之为善",
"功成而弗居",
"是以不去",
"长短相较",
"行不言之教",
"天下皆知美之为美",
"长短相教",
"万物作焉而不辞",
"生而不有",
"是以圣人处无为之事",
"难易想成",
"皆知善之为善",
"功成而弗居",
"是以不去",
"长短相较",
"行不言之教",
"天下皆知美之为美",
"长短相教",
"万物作焉而不辞",
"生而不有",
"是以圣人处无为之事",
"难易想成",
"皆知善之为善",
"功成而弗居",
"是以不去",
"长短相较",
"行不言之教",
"天下皆知美之为美",
"长短相教",
"万物作焉而不辞"
]
createTextSprite
创建一个精灵字体
js
// 创建文字材质函数,基于canvas画布生成纹理
function createTextSprite(text, color='white', opacity = 1) {
const canvas = document.createElement('canvas');
const size = 256;
canvas.width = size; // 画布宽高
canvas.height = size;
const ctx = canvas.getContext('2d'); // 获取画布的2d绘图上下文
ctx.clearRect(0, 0, size, size); // 清空整个画布区域
ctx.fillStyle = color; // 绘制文本的颜色
ctx.globalAlpha = opacity; // 绘制文本的透明度
ctx.font = 'bold 64px Arial'; // 字体
ctx.textAlign = 'center'; // 文本水平对其
ctx.textBaseline = 'middle'; // 文本垂直对其
ctx.fillText(text, size / 2, size / 2); // 画布中心绘制这个文字
const texture = new THREE.CanvasTexture(canvas); // 用画布生成THREE.JS纹理,用于后续贴图使用
texture.minFilter = THREE.LinearFilter; // 设置为线性过滤,避免缩放时产生模糊或者锯齿
const material = new THREE.SpriteMaterial({ // 精灵材质
map: texture,
transparent: true,
opacity: opacity, // 控制字体透明度,产生远近的效果
depthWrite: false, // 关闭深度写入,能够减少透明物体的冲突
depthTest: true,
});
// 用精灵物体更加快捷
const sprite = new THREE.Sprite(material);
sprite.scale.set(4, 4, 1); // 缩放文字大小
return sprite;
}
- 借助
canvas标签
绘制文字,设置文字的大小 颜色 宽高 透明度 对齐效果
canvas
标签借助CanvasTexture
转化为一个贴图texture
- 在
SpriteMaterial
精灵材质中,将texture
赋值给map属性,同时注意开启透明transparent
,设置透明度;关闭深度写入(这个很关键,不关闭的话,会出现抖动的情况) - 最后再创建
精灵物体
createTextGroup
创建一个字体组合
js
function createTextGroup(chars, opacity=1) {
const group = new THREE.Group();
for (let i = 0; i < chars.length; i++) {
const sprite = createTextSprite(chars[i], '#5a3814', opacity);
// 竖直排列的关键点
sprite.position.set(0, -i * 1, 0); // 竖直排列,间隔5单位
group.add(sprite);
}
return group;
}
- 这个方法的作用是,将
教学相长
这样的一个组合拆分,每个字单独调用createTextSprite
方法, - 得到的
sprite
物体添加到group
组合中 sprite.position.set(0, -i * 1, 0)
这行代码是设置文字垂直排列的关键,教学相长
的学
在教
的下面,所以他的y设置的是-i * 1
,y轴越往上,值越大
- 遍历所有数据,创建文字组
js
const allGroups = [];
groupsTexts.forEach((chars, idx) => {
const group = createTextGroup(chars, Math.random());
group.position.set(
(Math.random() - 0.5) * 40, // x随机散布
Math.random() * 50 + 20, // y开始在顶部附近
(idx % 2) * 2 // z位置稍微有差异,制造远近
); // 每个字体组合的下降位置不尽相同
allGroups.push({
group,
speed: Math.random() * Math.random() * 0.1 + 0.1 // 下降的速度
});
scene.add(group);
});
animate动画中:
js
function animate() {
requestAnimationFrame(animate);
// 文字组下落
allGroups.forEach(item => {
item.group.position.y -= item.speed; // 下落的关键
if (item.group.position.y < -3) { // 下面越界后重置到顶部随机位置
item.group.position.y = 50 + Math.random() * 20;
item.group.position.x = (Math.random() - 0.5) * 40;
}
});
renderer.render(scene, camera);
}
animate();
这样以后,能够实现如下效果:

4. 底部旋转同心圆
- 要创建的同心圆有5层,
js
const circleList = [
{
radius: 6,
color: '#5a3814'
},
{
radius: 12,
color: '#5a3814'
},
{
radius: 18,
color: '#5a3814'
},
{
radius: 24,
color: '#5a3814'
},
{
radius: 30,
color: '#5a3814'
}
]
let circleMeshs = []
circleList.forEach(item => {
createCircle(item)
})
createCircle
方法,创建单个圆
js
function createCircle (item) {
// 创建渐变画布
const size = 256;
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
// 设置渐变:createRadialGradient(x0, y0, r0, x1, y1, r1) => (x0, y0)起始圆心坐标,r0是半径;
// (x1, y1)结束圆圆心坐标,r1是结束圆半径,表示从中心到外围的镜像渐变
const gradient = ctx.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size/2);
gradient.addColorStop(0, 'white');
gradient.addColorStop(1, item.color); // 从外到里渐变,最里面是白色
// 设置画笔
ctx.fillStyle = gradient;
// 填充的范围是整个画布
ctx.fillRect(0, 0, size, size);
// 纹理
const texture = new THREE.CanvasTexture(canvas);
// 圆形平面
const geometry = new THREE.CircleGeometry(item.radius, 32); // 半径1,32段细分
const material = new THREE.MeshBasicMaterial({
color: 0xe4d6c3,
map: texture,
side: THREE.DoubleSide,
transparent: true,
depthWrite: false, //深度写入
depthTest: true, // 深度检测
opacity: 0.1
});
const circle = new THREE.Mesh(geometry, material);
circle.rotation.x = Math.PI / 2 // 沿着x轴旋转90°
circle.position.y = -3
scene.add(circle);
// 将同心圆添加到数组中
circleMeshs.push(circle)
}
- 圆用的是
CircleGeometry
- 注意,必须关闭
deepWrite
深度写入,否则可能只能看到最外层的圆
animate
动画
diff
function animate() {
requestAnimationFrame(animate);
// 文字组下落
allGroups.forEach(item => {
item.group.position.y -= item.speed;
if (item.group.position.y < -3) { // 下面越界后重置到顶部随机位置
item.group.position.y = 50 + Math.random() * 20;
item.group.position.x = (Math.random() - 0.5) * 40;
}
});
// 让同心圆转起来
+ circleMeshs.forEach(circle => {
+ circle.rotation.z += 0.01 // 这里我有点困惑,实际上看起来是绕着y轴旋转的,但是设置必须设+置为z才行
+ })
renderer.render(scene, camera);
}
animate();
这样以后的效果:
5. 文字标签
想要创建这样的文字标签,并且跟随同心圆一起移动:

- 创建标签的方法,注意标签有背景色,还有文字
js
function createLabel (text) {
// 用canvas写文字
const width = 128
const height = 128
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
const ctx = canvas.getContext('2d')
const gradient = ctx.createRadialGradient(width / 2, height / 2, 0, width / 2, height / 2, width / 2);
gradient.addColorStop(0, '#ffffff'); // 边缘白色
gradient.addColorStop(1, 'pink'); // 中心深棕色
ctx.fillStyle = gradient // 画笔设置为渐变
ctx.fillRect(0, 0, width, height) // 修改画布颜色
ctx.font = '24px Arial'
ctx.fillStyle = '#ae7c47' // 修改画笔的颜色来绘制文字
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText(text, width / 2, height / 2) // 填充文字
// canvas当做纹理贴图
const texture = new THREE.CanvasTexture(canvas)
texture.minFilter = THREE.LinearFilter // 防止模糊
// 平面几何体,稍宽,适合文字
const geometry = new THREE.PlaneGeometry(3.5, 1.75); // 可以调整比例,使更扁宽贴圆
const material = new THREE.MeshBasicMaterial({
map: texture,
transparent: true,
side: THREE.DoubleSide,
depthWrite: false,
depthTest: true,
});
const mesh = new THREE.Mesh(geometry, material);
return mesh
}
- 上面的步骤和之前的类似,唯一要注意的是canvas画布的背景和文字颜色不同,所以要记得修改画笔的颜色后再去绘制
js
const labelGroup = new THREE.Group()
scene.add(labelGroup)
const radius4 = circleList[circleList.length - 2].radius
const radius5 = circleList[circleList.length - 1].radius
// 让标签的位置处于第四和第五个圆之间
const labelRadius = (radius4 + radius5) / 2
// 标签内容
const labels = ['AG致用', '二元辩证', '原点规律', '历史背景', '成长案例'];
const labelCount = labels.length
for (let i = 0; i < labels.length; i++) {
const angle = (i / labelCount) * Math.PI * 2
const labelMesh = createLabel(labels[i])
// 位置坐标转化
labelMesh.position.set(
labelRadius * Math.cos(angle),
-3,
labelRadius * Math.sin(angle),
)
labelMesh.lookAt(0, 0, 0)
labelMesh.rotateY(Math.PI)
labelGroup.add(labelMesh)
}
上面的坐标转化见下图:

这行代码是让标签均匀分布在圆上:
js
const angle = (i / labelCount) * Math.PI * 2

最终在animate函数中更新标签的位置
diff
function animate() {
if (!isAnimate) return
requestAnimationFrame(animate);
// 文字组下落
allGroups.forEach(item => {
item.group.position.y -= item.speed;
if (item.group.position.y < -3) { // 下面越界后重置到顶部随机位置
item.group.position.y = 50 + Math.random() * 20;
item.group.position.x = (Math.random() - 0.5) * 40;
}
});
// 让同心圆转起来
circleMeshs.forEach(circle => {
circle.rotation.z += 0.01
})
// 让标签转起来
+ labelGroup.rotation.y += 0.01
renderer.render(scene, camera);
}