学习Three.js--烟花
2026年2月2日 10:16
学习Three.js--烟花
前置核心说明
开发目标
基于Three.js实现带拖尾渐隐效果的3D烟花,核心能力包括:
- 鼠标点击任意位置发射烟花,同时支持自动定时发射;
- 烟花粒子具备物理运动特性(重力+空气阻力),模拟真实爆炸扩散;
- 粒子带拖尾效果,拖尾从亮到暗渐隐,烟花整体采用发光叠加效果;
- 夜空雾效氛围营造,适配全屏黑色背景,视觉效果更逼真。
![]()
核心技术栈
| 技术点 | 作用 |
|---|---|
THREE.BufferGeometry |
手动构建顶点/颜色缓冲区,高效管理大量粒子(性能优于普通几何体) |
THREE.LineSegments |
基于顶点数据绘制线段,实现粒子拖尾效果(每段拖尾由多条短线组成) |
| 粒子物理系统 | 自定义粒子位置、速度、生命周期,模拟重力、空气阻力等物理现象 |
顶点颜色(vertexColors: true) |
为每个粒子顶点单独设置颜色,实现拖尾渐隐、粒子发光效果 |
加法混合(AdditiveBlending) |
粒子颜色叠加发光,模拟烟花的明亮光晕效果 |
THREE.Fog |
营造夜空雾效,远处粒子渐隐于深色背景,提升空间层次感 |
| 屏幕坐标→世界坐标转换 | 实现鼠标点击位置与3D场景坐标的映射,点击哪里发射哪里 |
| HSL颜色模式 | 统一烟花色调,生成协调且鲜艳的烟花颜色,避免色彩杂乱 |
分步开发详解
步骤1:基础环境搭建(场景/相机/渲染器/雾效)
1.1 核心代码
// 1. 场景初始化(添加雾效营造夜空氛围)
const scene = new THREE.Scene();
// 雾效:颜色#000022(深夜空蓝),近裁切面100,远裁切面800
scene.fog = new THREE.Fog(0x000022, 100, 800);
// 2. 透视相机(模拟人眼视角,适配3D场景)
const camera = new THREE.PerspectiveCamera(
60, // 视角(FOV)
window.innerWidth / window.innerHeight, // 宽高比
0.1, // 近裁切面
2000 // 远裁切面
);
camera.position.set(0, 50, 300); // 高位俯视视角,清晰观察烟花爆炸
// 3. 渲染器(抗锯齿+透明背景)
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio); // 高清适配
document.body.appendChild(renderer.domElement);
// 4. 环境光(微弱补光,避免纯黑,不影响烟花视觉)
scene.add(new THREE.AmbientLight(0x222222));
1.2 关键说明
-
雾效配置:
THREE.Fog是线性雾,参数分别为「雾颜色」「开始生效距离」「完全遮蔽距离」,这里用深夜空蓝,让远处烟花粒子自然融入背景; -
渲染器
alpha: true:开启透明背景,配合HTML的body { background: #000; },实现纯黑夜空效果,避免渲染器默认白色背景; -
相机位置:
(0, 50, 300)采用高位俯视,既可以看到烟花的3D扩散效果,又不会让视角过于陡峭,符合人眼观察烟花的习惯。
步骤2:核心粒子系统初始化
这是烟花效果的核心,需要手动构建粒子的顶点、颜色缓冲区,以及存储粒子物理状态的数据结构。
2.1 核心代码
// 粒子系统核心参数(集中管理,方便调整)
const MAX_PARTICLES = 8000; // 最大粒子数(限制性能开销)
const TRAIL_LENGTH = 4; // 每个粒子的拖尾长度(4个顶点=3段短线)
const totalVertices = MAX_PARTICLES * TRAIL_LENGTH; // 总顶点数
// 1. 初始化缓冲区数据(Float32Array存储顶点/颜色数据,高效)
const positions = new Float32Array(totalVertices * 3); // 顶点坐标:每个顶点3个值(x,y,z)
const colors = new Float32Array(totalVertices * 3); // 顶点颜色:每个顶点3个值(r,g,b)
const alives = new Uint8Array(MAX_PARTICLES); // 粒子存活状态(0=空闲,1=活跃)
const particleData = []; // 存储每个粒子的完整物理状态(自定义数据结构)
// 2. 初始化每个粒子的物理状态数据
for (let i = 0; i < MAX_PARTICLES; i++) {
particleData.push({
pos: new THREE.Vector3(), // 当前位置
vel: new THREE.Vector3(), // 当前速度
color: new THREE.Color(), // 粒子颜色
size: 0, // 粒子尺寸(此处用于后续扩展,当前拖尾效果暂未用到)
life: 0, // 粒子生命周期(0~1,1=消亡)
history: [ // 拖尾历史位置(FIFO队列,存储最近TRAIL_LENGTH个位置)
new THREE.Vector3(),
new THREE.Vector3(),
new THREE.Vector3(),
new THREE.Vector3()
]
});
}
// 3. 构建BufferGeometry(手动绑定缓冲区数据)
const geometry = new THREE.BufferGeometry();
// 绑定顶点位置缓冲区
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
// 绑定顶点颜色缓冲区
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
// 4. 自定义材质(支持顶点颜色+拖尾渐隐+发光叠加)
const material = new THREE.LineBasicMaterial({
vertexColors: true, // 启用顶点颜色(优先使用缓冲区的color数据,而非材质统一颜色)
transparent: true, // 启用透明(支持渐隐效果)
depthWrite: false, // 关闭深度写入(避免粒子之间互相遮挡,提升叠加效果)
blending: THREE.AdditiveBlending // 加法混合(颜色叠加,实现烟花发光效果)
});
// 5. 创建LineSegments(基于顶点数据绘制拖尾线段)
const lines = new THREE.LineSegments(geometry, material);
scene.add(lines);
2.2 关键技术点解析
-
缓冲区数据(
Float32Array):BufferGeometry是Three.js中高效的几何体类型,直接操作二进制数组存储顶点数据,比普通Geometry性能更高,适合大量粒子场景; -
拖尾实现原理:每个粒子存储
TRAIL_LENGTH个历史位置(这里是4个),通过LineSegments连接相邻位置,形成3段短线,视觉上就是拖尾; -
alives数组:标记粒子是否空闲,避免重复创建/销毁粒子(对象池模式),提升性能,Uint8Array占用内存小,适合存储二值状态; -
材质核心参数:
-
AdditiveBlending:加法混合,每个粒子的颜色会与背景和其他粒子颜色叠加,越密集的地方越亮,完美模拟烟花的发光光晕; -
depthWrite: false:关闭深度写入,粒子之间不会互相遮挡,所有粒子都能正常叠加发光,避免拖尾被遮挡的问题; -
vertexColors: true:启用后,材质会忽略自身的color参数,转而使用BufferGeometry中的color缓冲区数据,实现每个顶点的独立颜色。
-
步骤3:烟花发射函数(分配粒子+初始化物理状态)
3.1 核心代码
/**
* 发射烟花函数
* @param {Number} x - 发射位置X
* @param {Number} y - 发射位置Y
* @param {Number} z - 发射位置Z
* @param {Boolean} isChild - 是否为子粒子(用于二次爆炸,当前暂为单级爆炸)
*/
function launchFirework(x, y, z, isChild = false) {
// 1. 确定粒子数量(子粒子少,主烟花粒子多,模拟真实爆炸)
const count = isChild ? (30 + Math.random() * 60) : (300 + Math.random() * 400);
// 2. 确定烟花色调(HSL模式,保证同批次烟花颜色协调)
const hue = isChild ? (Math.random() * 0.1 + 0.95) : (Math.random() * 0.3);
for (let p = 0; p < count; p++) {
// 3. 查找空闲粒子(对象池模式,复用空闲粒子,提升性能)
let idx = -1;
for (let i = 0; i < MAX_PARTICLES; i++) {
if (!alives[i]) {
idx = i;
break;
}
}
if (idx === -1) break; // 无空闲粒子,终止本次发射
const particle = particleData[idx];
// 4. 初始化粒子初始位置
particle.pos.set(x, y, z);
// 5. 初始化粒子爆炸速度(极坐标转换,360°扩散)
const baseSpeed = isChild ? (10 + Math.random() * 15) : (30 + Math.random() * 30);
const theta = Math.random() * Math.PI * 2; // 水平方向角度(0~360°)
const phi = Math.random() * Math.PI; // 垂直方向角度(0~180°)
particle.vel.set(
baseSpeed * Math.sin(phi) * Math.cos(theta),
baseSpeed * Math.sin(phi) * Math.sin(theta),
baseSpeed * Math.cos(phi)
).add(new THREE.Vector3().random().subScalar(0.5).multiplyScalar(8)); // 加入微小扰动,避免扩散过于规则
// 6. 初始化粒子颜色和尺寸(HSL模式,鲜艳且协调)
particle.color.setHSL(hue, 0.85, isChild ? 0.9 : 0.65); // 色相统一,亮度/饱和度微调
particle.size = isChild ? (0.3 + Math.random() * 0.7) : (0.4 + Math.random() * 0.9);
particle.life = 0; // 重置生命周期
// 7. 初始化粒子拖尾历史位置(所有历史位置与当前位置一致,避免初始拖尾偏移)
for (let h = 0; h < TRAIL_LENGTH; h++) {
particle.history[h].copy(particle.pos);
}
// 8. 标记粒子为活跃状态
alives[idx] = 1;
}
}
3.2 关键技术点解析
-
对象池模式:通过
alives数组查找空闲粒子,复用已有粒子对象,避免频繁创建/销毁对象带来的性能开销,这是大量粒子场景的最佳实践; -
极坐标转换:使用
theta(水平角度)和phi(垂直角度)生成3D空间中的扩散速度,实现烟花向四面八方均匀爆炸的效果; -
HSL颜色模式:
setHSL(hue, saturation, lightness)中,hue(色相)决定烟花的主颜色,同批次烟花使用相同/相近的hue,保证颜色协调,避免杂乱;saturation(饱和度)设为0.85,保证颜色鲜艳;lightness(亮度)微调,实现粒子间的细微颜色差异; -
速度扰动:
new THREE.Vector3().random().subScalar(0.5).multiplyScalar(8)生成一个微小的随机向量,叠加到基础速度上,避免烟花扩散过于规则,更贴近真实效果。
步骤4:交互绑定(鼠标点击+自动发射)
4.1 核心代码
// 1. 鼠标点击事件:点击屏幕任意位置发射烟花
window.addEventListener('click', (e) => {
// 步骤1:屏幕坐标转换为NDC坐标(归一化设备坐标,-1~1)
const x = (e.clientX / window.innerWidth) * 2 - 1;
const y = -(e.clientY / window.innerHeight) * 2 + 1;
// 步骤2:NDC坐标转换为世界坐标(通过相机反投影)
const vector = new THREE.Vector3(x, y, 0.5); // z=0.5 取视口中间深度
vector.unproject(camera); // 反投影:NDC → 世界坐标
// 步骤3:计算射线方向,确定3D场景中的发射位置
const dir = vector.sub(camera.position).normalize(); // 相机到点击点的方向向量
const distance = (200 - camera.position.z) / dir.z; // 固定深度距离,避免发射位置过远/过近
const pos = camera.position.clone().add(dir.multiplyScalar(distance)); // 最终发射位置
// 步骤4:发射烟花
launchFirework(pos.x, pos.y, pos.z);
});
// 2. 自动发射:每隔1秒随机发射一次烟花(增加场景活力)
setInterval(() => {
if (Math.random() > 0.8) { // 20%概率发射,避免过于密集
launchFirework(
(Math.random() - 0.5) * 600, // X轴随机范围(-300~300)
100 + Math.random() * 200, // Y轴随机范围(100~300)
200 + Math.random() * 300 // Z轴随机范围(200~500)
);
}
}, 1000);
4.2 关键技术点解析
-
屏幕坐标→世界坐标转换:这是Three.js中实现「点击3D场景」的核心逻辑,步骤为「屏幕坐标→NDC坐标→世界坐标」:
- 屏幕坐标(
clientX/clientY)是像素值,范围为(0,0)到(window.innerWidth, window.innerHeight),转换为NDC坐标后范围为(-1,-1)到(1,1); -
vector.unproject(camera):将NDC坐标转换为世界坐标,需要指定一个z值(此处为0.5),表示视口的中间深度; - 计算射线方向并确定距离,最终得到3D场景中的发射位置,避免烟花发射到相机后方或过远的位置;
- 屏幕坐标(
-
自动发射逻辑:使用
setInterval定时执行,配合Math.random() > 0.8实现20%的发射概率,避免烟花过于密集,平衡视觉效果和性能。
步骤5:动画循环(粒子更新+拖尾渲染+场景渲染)
这是烟花动起来的核心,每帧更新粒子的物理状态、拖尾历史位置,并更新缓冲区数据,实现流畅的动画效果。
5.1 核心代码
const clock = new THREE.Clock(); // 时钟,用于获取每帧时间增量
function animate() {
requestAnimationFrame(animate); // 绑定浏览器刷新率,实现流畅动画
const delta = Math.min(clock.getDelta(), 0.05); // 获取时间增量,限制最大为0.05(防止帧率波动导致动画跳变)
// 1. 遍历所有粒子,更新活跃粒子的状态
for (let i = 0; i < MAX_PARTICLES; i++) {
if (!alives[i]) continue; // 跳过空闲粒子
const p = particleData[i];
// 步骤1:更新粒子生命周期,判断是否消亡
p.life += delta * 0.8; // 生命周期增速(0.8为调节系数,越大消亡越快)
if (p.life > 1.0) { // 生命周期超过1,标记为空闲
alives[i] = 0;
continue;
}
// 步骤2:更新粒子物理状态(重力+空气阻力)
p.vel.y -= 45 * delta; // 重力:Y轴速度递减(模拟地球重力,向下拉)
p.vel.multiplyScalar(0.985); // 空气阻力:速度整体衰减(模拟空气阻力,粒子逐渐减速)
p.pos.add(p.vel.clone().multiplyScalar(delta)); // 根据速度更新当前位置
// 步骤3:更新粒子拖尾历史位置(FIFO先进先出,实现拖尾移动)
for (let h = TRAIL_LENGTH - 1; h > 0; h--) {
p.history[h].copy(p.history[h - 1]); // 后一个位置复制前一个位置的数据
}
p.history[0].copy(p.pos); // 最新位置写入历史队列的第一个位置
// 步骤4:更新缓冲区数据(顶点位置+顶点颜色,实现拖尾渲染+渐隐)
const baseIdx = i * TRAIL_LENGTH; // 当前粒子的顶点起始索引
for (let h = 0; h < TRAIL_LENGTH; h++) {
const posIdx = (baseIdx + h) * 3; // 当前顶点的位置索引
const colIdx = (baseIdx + h) * 3; // 当前顶点的颜色索引
// 更新顶点位置
const histPos = p.history[h];
positions[posIdx] = histPos.x;
positions[posIdx + 1] = histPos.y;
positions[posIdx + 2] = histPos.z;
// 更新顶点颜色(拖尾渐隐+生命周期渐隐)
const fade = 1.0 - (h / (TRAIL_LENGTH - 1)) * 0.7; // 拖尾渐隐:越旧的位置越暗
const alphaFactor = 1.0 - p.life; // 生命周期渐隐:粒子越接近消亡越暗
colors[colIdx] = p.color.r * fade * alphaFactor;
colors[colIdx + 1] = p.color.g * fade * alphaFactor;
colors[colIdx + 2] = p.color.b * fade * alphaFactor;
}
}
// 2. 标记缓冲区数据需要更新(Three.js才会重新渲染)
geometry.attributes.position.needsUpdate = true;
geometry.attributes.color.needsUpdate = true;
// 3. 渲染场景
renderer.render(scene, camera);
}
// 启动动画循环
animate();
5.2 关键技术点解析
-
clock.getDelta():获取上一帧到当前帧的时间增量(单位:秒),使用时间增量更新动画,保证动画速度与帧率无关,无论高帧率还是低帧率,烟花行进速度一致; -
物理模拟逻辑:
- 重力:
p.vel.y -= 45 * delta,只在Y轴施加重力,模拟地球重力,让粒子逐渐下落,更贴近真实烟花; - 空气阻力:
p.vel.multiplyScalar(0.985),每帧让速度乘以一个小于1的系数,实现速度衰减,粒子逐渐减速,拖尾也会随之变短;
- 重力:
- 拖尾FIFO队列:拖尾历史位置数组采用「先进先出」模式,每帧将前一个位置的数据复制到后一个位置,最新位置写入数组头部,实现拖尾的移动效果,视觉上就是粒子带着尾巴前进;
-
双重渐隐逻辑:
- 拖尾渐隐(
fade):1.0 - (h / (TRAIL_LENGTH - 1)) * 0.7,拖尾中越旧的位置(索引h越大),fade值越小,颜色越暗,实现拖尾从亮到暗的渐变; - 生命周期渐隐(
alphaFactor):1.0 - p.life,粒子越接近消亡(life越接近1),alphaFactor值越小,颜色越暗,实现粒子从亮到暗的消亡效果;
- 拖尾渐隐(
-
needsUpdate = true:BufferGeometry的缓冲区数据更新后,必须将对应的needsUpdate设为true,告诉Three.js缓冲区数据已变更,需要重新渲染,否则修改不会生效。
步骤6:窗口适配(响应式调整)
6.1 核心代码
window.addEventListener('resize', () => {
// 1. 更新相机宽高比
camera.aspect = window.innerWidth / window.innerHeight;
// 2. 更新相机投影矩阵(必须调用,否则宽高比修改不生效)
camera.updateProjectionMatrix();
// 3. 更新渲染器尺寸
renderer.setSize(window.innerWidth, window.innerHeight);
});
6.2 关键说明
- 窗口大小变化时,需要同步更新相机的宽高比和渲染器的尺寸,保证烟花效果在不同屏幕尺寸下都能全屏显示;
-
camera.updateProjectionMatrix():相机参数修改后,必须调用该方法更新投影矩阵,否则相机的宽高比修改不会生效,场景会出现拉伸变形。
核心参数速查表(快速调整效果)
| 参数名 | 取值 | 作用 | 修改建议 |
|---|---|---|---|
MAX_PARTICLES |
8000 | 最大粒子数,限制性能开销 | 配置低的设备可改为4000,减少卡顿;高性能设备可改为16000,提升烟花密集度 |
TRAIL_LENGTH |
4 | 每个粒子的拖尾长度(顶点数) | 改为2,拖尾变短更锐利;改为6,拖尾变长更柔和(注意:会增加顶点数,影响性能) |
baseSpeed(主烟花) |
30~60 | 烟花爆炸初始速度 | 改为20 |
p.vel.y -= 45 * delta |
45 | 重力系数 | 改为20,重力更弱,烟花停留时间更长;改为60,重力更强,烟花下落更快 |
p.vel.multiplyScalar(0.985) |
0.985 | 空气阻力系数 | 改为0.97,阻力更大,粒子减速更快;改为0.995,阻力更小,粒子飞行更远 |
hue(主烟花) |
0~0.3 | 烟花主色调(HSL) | 改为0.3 |
AdditiveBlending |
混合模式 | 粒子发光叠加 | 改为NormalBlending,关闭发光效果,呈现普通粒子;改为MultiplyBlending,呈现暗色调叠加效果 |
完整代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>Three.js 烟花带拖尾效果</title>
<style>
body { margin: 0; overflow: hidden; background: #000; }
#info {
position: absolute;
top: 20px;
width: 100%;
text-align: center;
color: white;
font-family: Arial, sans-serif;
pointer-events: none;
text-shadow: 0 0 8px rgba(255,255,255,0.7);
}
</style>
</head>
<body>
<div id="info">点击任意位置发射烟花(带拖尾)</div>
<script type="module">
import * as THREE from 'https://esm.sh/three@0.174.0';
// ========== 1. 基础环境初始化(场景/相机/渲染器/雾效) ==========
const scene = new THREE.Scene();
// 夜空雾效:深蓝黑色,远处粒子自然融入背景
scene.fog = new THREE.Fog(0x000022, 100, 800);
// 透视相机:高位俯视,清晰观察烟花爆炸
const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 2000);
camera.position.set(0, 50, 300);
// 渲染器:抗锯齿+透明背景,适配纯黑夜空
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio);
document.body.appendChild(renderer.domElement);
// 微弱环境光:补充暗部,不影响烟花视觉效果
scene.add(new THREE.AmbientLight(0x222222));
// ========== 2. 粒子系统核心参数与缓冲区初始化 ==========
const MAX_PARTICLES = 8000; // 最大粒子数(平衡效果与性能)
const TRAIL_LENGTH = 4; // 拖尾长度(4个顶点=3段短线)
const totalVertices = MAX_PARTICLES * TRAIL_LENGTH; // 总顶点数
// 缓冲区数据:存储顶点坐标和颜色
const positions = new Float32Array(totalVertices * 3);
const colors = new Float32Array(totalVertices * 3);
const alives = new Uint8Array(MAX_PARTICLES); // 粒子存活状态(0=空闲,1=活跃)
const particleData = []; // 粒子物理状态数据池
// 初始化粒子物理状态
for (let i = 0; i < MAX_PARTICLES; i++) {
particleData.push({
pos: new THREE.Vector3(), // 当前位置
vel: new THREE.Vector3(), // 当前速度
color: new THREE.Color(), // 粒子颜色
size: 0, // 粒子尺寸(预留扩展)
life: 0, // 生命周期(0~1)
history: [ // 拖尾历史位置(FIFO队列)
new THREE.Vector3(),
new THREE.Vector3(),
new THREE.Vector3(),
new THREE.Vector3()
]
});
}
// 构建BufferGeometry:高效管理大量粒子顶点数据
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
// 自定义材质:支持顶点颜色+发光叠加+渐隐
const material = new THREE.LineBasicMaterial({
vertexColors: true, // 启用顶点独立颜色
transparent: true, // 启用透明,支持渐隐
depthWrite: false, // 关闭深度写入,粒子正常叠加
blending: THREE.AdditiveBlending // 加法混合,实现烟花发光效果
});
// 创建LineSegments:绘制粒子拖尾线段
const lines = new THREE.LineSegments(geometry, material);
scene.add(lines);
// ========== 3. 烟花发射函数(分配粒子+初始化物理状态) ==========
function launchFirework(x, y, z, isChild = false) {
// 确定粒子数量:主烟花多,子烟花少(预留二次爆炸扩展)
const count = isChild ? (30 + Math.random() * 60) : (300 + Math.random() * 400);
// 确定烟花主色调:HSL模式,保证颜色协调
const hue = isChild ? (Math.random() * 0.1 + 0.95) : (Math.random() * 0.3);
for (let p = 0; p < count; p++) {
// 查找空闲粒子(对象池复用,提升性能)
let idx = -1;
for (let i = 0; i < MAX_PARTICLES; i++) {
if (!alives[i]) {
idx = i;
break;
}
}
if (idx === -1) break; // 无空闲粒子,终止本次发射
const particle = particleData[idx];
// 初始化粒子位置
particle.pos.set(x, y, z);
// 初始化爆炸速度(极坐标转换,360°扩散)
const baseSpeed = isChild ? (10 + Math.random() * 15) : (30 + Math.random() * 30);
const theta = Math.random() * Math.PI * 2; // 水平角度
const phi = Math.random() * Math.PI; // 垂直角度
particle.vel.set(
baseSpeed * Math.sin(phi) * Math.cos(theta),
baseSpeed * Math.sin(phi) * Math.sin(theta),
baseSpeed * Math.cos(phi)
).add(new THREE.Vector3().random().subScalar(0.5).multiplyScalar(8)); // 速度扰动,避免规则扩散
// 初始化粒子颜色(HSL模式,鲜艳协调)
particle.color.setHSL(hue, 0.85, isChild ? 0.9 : 0.65);
particle.size = isChild ? (0.3 + Math.random() * 0.7) : (0.4 + Math.random() * 0.9);
particle.life = 0; // 重置生命周期
// 初始化拖尾历史位置(避免初始偏移)
for (let h = 0; h < TRAIL_LENGTH; h++) {
particle.history[h].copy(particle.pos);
}
// 标记粒子为活跃状态
alives[idx] = 1;
}
}
// ========== 4. 交互绑定(鼠标点击+自动发射) ==========
// 鼠标点击:屏幕坐标→世界坐标,发射烟花
window.addEventListener('click', (e) => {
// 步骤1:屏幕坐标转NDC坐标(-1~1)
const x = (e.clientX / window.innerWidth) * 2 - 1;
const y = -(e.clientY / window.innerHeight) * 2 + 1;
// 步骤2:NDC坐标转世界坐标
const vector = new THREE.Vector3(x, y, 0.5);
vector.unproject(camera);
// 步骤3:计算3D场景发射位置
const dir = vector.sub(camera.position).normalize();
const distance = (200 - camera.position.z) / dir.z;
const pos = camera.position.clone().add(dir.multiplyScalar(distance));
// 步骤4:发射烟花
launchFirework(pos.x, pos.y, pos.z);
});
// 自动发射:每隔1秒,20%概率发射烟花
setInterval(() => {
if (Math.random() > 0.8) {
launchFirework(
(Math.random() - 0.5) * 600,
100 + Math.random() * 200,
200 + Math.random() * 300
);
}
}, 1000);
// ========== 5. 动画循环(粒子更新+拖尾渲染) ==========
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
const delta = Math.min(clock.getDelta(), 0.05); // 限制最大时间增量,避免动画跳变
// 遍历更新所有活跃粒子
for (let i = 0; i < MAX_PARTICLES; i++) {
if (!alives[i]) continue;
const p = particleData[i];
// 更新生命周期,判断是否消亡
p.life += delta * 0.8;
if (p.life > 1.0) {
alives[i] = 0;
continue;
}
// 更新物理状态(重力+空气阻力)
p.vel.y -= 45 * delta; // 重力:Y轴速度递减
p.vel.multiplyScalar(0.985); // 空气阻力:速度整体衰减
p.pos.add(p.vel.clone().multiplyScalar(delta)); // 更新当前位置
// 更新拖尾历史位置(FIFO先进先出)
for (let h = TRAIL_LENGTH - 1; h > 0; h--) {
p.history[h].copy(p.history[h - 1]);
}
p.history[0].copy(p.pos);
// 更新缓冲区数据(顶点位置+颜色)
const baseIdx = i * TRAIL_LENGTH;
for (let h = 0; h < TRAIL_LENGTH; h++) {
const posIdx = (baseIdx + h) * 3;
const colIdx = (baseIdx + h) * 3;
// 更新顶点位置
const histPos = p.history[h];
positions[posIdx] = histPos.x;
positions[posIdx + 1] = histPos.y;
positions[posIdx + 2] = histPos.z;
// 更新顶点颜色(双重渐隐:拖尾+生命周期)
const fade = 1.0 - (h / (TRAIL_LENGTH - 1)) * 0.7;
const alphaFactor = 1.0 - p.life;
colors[colIdx] = p.color.r * fade * alphaFactor;
colors[colIdx + 1] = p.color.g * fade * alphaFactor;
colors[colIdx + 2] = p.color.b * fade * alphaFactor;
}
}
// 标记缓冲区数据需要更新,Three.js重新渲染
geometry.attributes.position.needsUpdate = true;
geometry.attributes.color.needsUpdate = true;
// 渲染场景
renderer.render(scene, camera);
}
// 启动动画循环
animate();
// ========== 6. 窗口适配(响应式调整) ==========
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
</script>
</body>
</html>
总结与扩展建议
核心总结
-
粒子系统核心:采用
BufferGeometry+对象池模式,高效管理大量粒子,平衡视觉效果与性能,是大量粒子场景的最佳实践; -
拖尾效果实现:通过存储粒子历史位置(FIFO队列),配合
LineSegments绘制线段,再通过顶点颜色实现渐隐,视觉效果流畅自然; - 物理模拟:手动添加重力和空气阻力,让粒子运动更贴近真实,速度扰动避免规则扩散,提升真实感;
-
视觉优化:采用
AdditiveBlending加法混合实现烟花发光效果,THREE.Fog营造夜空氛围,HSL颜色模式保证颜色协调鲜艳; - 交互核心:屏幕坐标→世界坐标转换,实现鼠标点击发射烟花,自动发射增加场景活力。
扩展建议
-
二次爆炸效果:在粒子生命周期接近0.5时,调用
launchFirework发射子粒子,实现烟花爆炸后再分裂的效果; - 声音效果:添加音频文件,在发射烟花时播放爆炸声,提升沉浸感;
- 鼠标跟随:改为鼠标移动时发射烟花,或让烟花跟随鼠标位置爆炸;
-
颜色渐变:粒子生命周期中动态修改
hue值,实现烟花颜色从亮到暗、从暖到冷的渐变; -
粒子尺寸变化:利用
particle.size,在生命周期中动态修改粒子尺寸,实现烟花爆炸后粒子逐渐变大再消亡的效果; - 轨迹优化:增加粒子旋转效果,或让拖尾带有轻微弯曲,更贴近真实烟花轨迹;
-
性能优化:使用
InstancedMesh替代LineSegments,进一步减少DrawCall,支持更多粒子数。