阅读视图

发现新文章,点击刷新页面。

学习Three.js--烟花

学习Three.js--烟花

前置核心说明

开发目标

基于Three.js实现带拖尾渐隐效果的3D烟花,核心能力包括:

  1. 鼠标点击任意位置发射烟花,同时支持自动定时发射;
  2. 烟花粒子具备物理运动特性(重力+空气阻力),模拟真实爆炸扩散;
  3. 粒子带拖尾效果,拖尾从亮到暗渐隐,烟花整体采用发光叠加效果;
  4. 夜空雾效氛围营造,适配全屏黑色背景,视觉效果更逼真。

9529c66d-56b1-47c3-9d71-9736f8ca88ba.png

核心技术栈

技术点 作用
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 关键技术点解析
  • 缓冲区数据(Float32ArrayBufferGeometry 是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坐标→世界坐标」:
    1. 屏幕坐标(clientX/clientY)是像素值,范围为(0,0)(window.innerWidth, window.innerHeight),转换为NDC坐标后范围为(-1,-1)(1,1)
    2. vector.unproject(camera):将NDC坐标转换为世界坐标,需要指定一个z值(此处为0.5),表示视口的中间深度;
    3. 计算射线方向并确定距离,最终得到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 = trueBufferGeometry的缓冲区数据更新后,必须将对应的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 烟花爆炸初始速度 改为2040,爆炸范围变小;改为4080,爆炸范围更大更壮观
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.30.6,呈现绿色/青色系;改为0.60.9,呈现红色/粉色系
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>

总结与扩展建议

核心总结

  1. 粒子系统核心:采用BufferGeometry+对象池模式,高效管理大量粒子,平衡视觉效果与性能,是大量粒子场景的最佳实践;
  2. 拖尾效果实现:通过存储粒子历史位置(FIFO队列),配合LineSegments绘制线段,再通过顶点颜色实现渐隐,视觉效果流畅自然;
  3. 物理模拟:手动添加重力和空气阻力,让粒子运动更贴近真实,速度扰动避免规则扩散,提升真实感;
  4. 视觉优化:采用AdditiveBlending加法混合实现烟花发光效果,THREE.Fog营造夜空氛围,HSL颜色模式保证颜色协调鲜艳;
  5. 交互核心:屏幕坐标→世界坐标转换,实现鼠标点击发射烟花,自动发射增加场景活力。

扩展建议

  1. 二次爆炸效果:在粒子生命周期接近0.5时,调用launchFirework发射子粒子,实现烟花爆炸后再分裂的效果;
  2. 声音效果:添加音频文件,在发射烟花时播放爆炸声,提升沉浸感;
  3. 鼠标跟随:改为鼠标移动时发射烟花,或让烟花跟随鼠标位置爆炸;
  4. 颜色渐变:粒子生命周期中动态修改hue值,实现烟花颜色从亮到暗、从暖到冷的渐变;
  5. 粒子尺寸变化:利用particle.size,在生命周期中动态修改粒子尺寸,实现烟花爆炸后粒子逐渐变大再消亡的效果;
  6. 轨迹优化:增加粒子旋转效果,或让拖尾带有轻微弯曲,更贴近真实烟花轨迹;
  7. 性能优化:使用InstancedMesh替代LineSegments,进一步减少DrawCall,支持更多粒子数。
❌