普通视图

发现新文章,点击刷新页面。
今天 — 2026年2月2日首页

学习Three.js--星环粒子(ShaderMaterial)

2026年2月2日 17:22

学习Three.js--星环粒子(ShaderMaterial)

前置核心说明

开发目标

基于Three.js的ShaderMaterial实现高性能10万粒子纯净白色星环效果,核心能力包括:

  1. 生成环形分布的大量粒子(10万级),形成内外半径固定的星环,粒子分布均匀协调;
  2. 借助GPU着色器实现粒子3D脉动动画,兼顾流畅效果与高性能,低配设备无明显卡顿;
  3. 实现纯净白色粒子,保证加法混合下明亮不返白、柔和有光晕,避免生硬刺眼;
  4. 实现圆形抗锯齿粒子,避免默认方形粒子的生硬边缘,提升视觉细腻度;
  5. 支持轨道交互(拖拽旋转、滚轮缩放),全方位查看3D星环的脉动与光晕效果。

dc73c358-2461-4480-bd95-eea934b70677.png

核心技术栈

技术点 作用
THREE.ShaderMaterial 自定义顶点/片元着色器,逻辑运行在GPU上并行处理,高效支撑10万级粒子(性能远超普通材质如PointsMaterial
自定义attributesizes/shift 向着色器传递每个粒子的独立数据(尺寸、脉动参数),实现粒子差异化效果(不同大小、不同脉动节奏)
自定义uniformuTime 向着色器传递全局统一数据(时间),驱动所有粒子的动画同步更新,保证脉动效果协调统一
圆柱坐标系(setFromCylindricalCoords 快速生成环形分布的粒子坐标,无需手动计算sin/cos三角函数,简洁高效且不易出错
模型视图/投影矩阵(modelViewMatrix/projectionMatrix 着色器中完成3D顶点的透视变换,将粒子的局部坐标转换为屏幕可显示的2D坐标,是3D渲染的必备步骤
透视缩放点大小 实现粒子大小随相机距离变化,模拟「近大远小」的真实透视效果,避免远处粒子过大/过小导致视觉失真
片元着色器圆形粒子+smoothstep 绘制圆形粒子,并用抗锯齿算法实现边缘渐隐,同时为白色粒子营造柔和光晕,提升视觉质感
THREE.AdditiveBlending 粒子白色亮度叠加发光,让星环呈现更明亮、更有层次感的朦胧光晕,同时通过亮度控制避免返白
白色亮度安全控制(基底<1.0+尺寸限制) 加法混合下的核心避坑点,保证白色粒子明亮通透且不出现过曝返白,是纯净白色星环的关键优化

分步开发详解

步骤1:基础环境搭建(场景/相机/渲染器/控制器)

1.1 核心代码
// 1. 场景初始化(纯黑背景,最大化衬托白色星环的光晕效果)
const scene = new THREE.Scene();

// 2. 透视相机(适配3D场景,兼顾星环整体查看与细节观察)
const camera = new THREE.PerspectiveCamera(
  60, // 视角(FOV):60°视野适中,无场景变形
  innerWidth / innerHeight, // 宽高比:适配浏览器窗口
  1, // 近裁切面:过滤过近无效对象,提升性能
  1000 // 远裁切面:保证星环完整处于可见范围
);
camera.position.set(0, 6, 100); // 高位侧视:既完整查看环形形态,又体现3D脉动层次感

// 3. 渲染器(抗锯齿,提升白色粒子边缘细腻度,避免光晕锯齿感)
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio); // 高清适配:Retina屏幕无模糊
document.body.appendChild(renderer.domElement);

// 4. 轨道控制器(支持拖拽旋转/滚轮缩放,便捷查看3D白色星环)
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true; // 启用阻尼:拖拽旋转有惯性,交互更顺滑自然
controls.dampingFactor = 0.05; // 阻尼系数:惯性适中,兼顾精准度与流畅度
1.2 关键说明
  • 相机位置(0, 6, 100) 采用「高位+稍远」视角,既可以完整捕捉白色星环的环形形态,又能清晰观察粒子3D脉动的光晕变化,避免视角过近导致星环变形、光晕过曝。
  • 渲染器antialias: true:开启抗锯齿,配合片元着色器的smoothstep抗锯齿逻辑,让白色粒子的边缘和光晕更细腻,减少锯齿感,这对明亮的白色星环尤为重要。
  • 控制器阻尼:启用阻尼后,交互体验更贴近真实3D场景,适合长时间查看星环的脉动效果,避免拖拽后瞬间停止的生硬感。

步骤2:粒子数据生成(环形坐标+自定义属性)

这是星环的基础,需要生成10万粒子的环形坐标,以及每个粒子的独立尺寸、脉动参数,为后续着色器提供数据支撑,同时保证粒子分布均匀,为白色光晕叠加打下基础。

2.1 核心代码
// 粒子系统核心参数(集中管理,方便调整,适配白色星环效果)
const count = 100000; // 总粒子数(10万,ShaderMaterial可高效处理,无明显卡顿)
const innerRadius = 10, outerRadius = 40; // 星环内外半径:决定环形大小与宽度
const pointsArr = []; // 粒子顶点坐标数组
const sizes = []; // 粒子尺寸数组(每个粒子独立尺寸,避免白色星环单调)
const shift = []; // 粒子脉动参数数组(每个粒子4个独立参数,实现差异化脉动)
const radii = []; // 粒子环形半径数组(备用,方便后续扩展)

// 辅助函数:向shift数组添加单个粒子的4个脉动参数
const pushShift = () => {
  shift.push(
    Math.random() * Math.PI, // 脉动参数1:初始相位(控制脉动起始位置,避免所有粒子同步脉动)
    Math.random() * Math.PI * 2, // 脉动参数2:水平相位(控制水平方向脉动,增加3D层次感)
    (Math.random() * 0.9 + 0.1) * Math.PI * 0.1, // 脉动参数3:脉动频率(控制脉动快慢,0.1倍PI保证舒缓流畅)
    Math.random() * 0.7 + 0.05 // 脉动参数4:脉动幅度(控制脉动距离,0.05~0.75避免粒子跑出星环)
  );
};

// 循环生成10万粒子数据
for (let i = 0; i < count; i++) {
  // 幂次采样(Math.pow(Math.random(), 1.5)):让粒子更均匀分布在环形区域
  // 避免直接使用Math.random()导致内侧粒子密集、外侧稀疏的问题
  const rand = Math.pow(Math.random(), 1.5);
  const radius = Math.sqrt(outerRadius * outerRadius * rand + (1 - rand) * innerRadius * innerRadius);
  radii.push(radius); // 存储粒子环形半径(备用,方便后续扩展白色星环的密度变化)

  // 从圆柱坐标系转换为直角坐标系,快速生成环形粒子坐标
  pointsArr.push(
    new THREE.Vector3().setFromCylindricalCoords(
      radius, // 圆柱坐标系半径(对应星环环形半径,决定粒子在星环中的位置)
      Math.random() * 2 * Math.PI, // 圆柱坐标系角度(0~2π,实现环形均匀分布)
      (Math.random() - 0.5) * 2 // 圆柱坐标系高度(Z轴,-1~1):让星环有轻微厚度,提升3D立体感
    )
  );

  // 生成粒子独立尺寸(0.3~1.5之间随机):实现白色粒子大小差异化,光晕叠加更自然
  sizes.push(Math.random() * 1.2 + 0.3);

  // 生成粒子独立脉动参数:每个粒子脉动节奏不同,避免白色星环脉动过于规则
  pushShift();
}
2.2 关键技术点解析
  • 10万粒子的高性能支撑:普通材质(如PointsMaterial)处理10万粒子时,动画逻辑运行在CPU上,会出现明显卡顿;而ShaderMaterial的逻辑运行在GPU上,具备强大的并行处理能力,可轻松应对10万级甚至百万级粒子,这是实现高性能白色星环的核心基础。
  • 圆柱坐标系(setFromCylindricalCoords:Three.js内置的坐标转换方法,参数为「半径、角度、高度」,无需手动计算sin/cos来生成环形坐标,简洁高效且不易出错,是实现星环、圆柱等环形结构的最佳实践。
  • 幂次采样(Math.pow(Math.random(), 1.5):如果直接使用Math.random(),粒子会在「内半径到外半径」的区间内均匀分布,导致星环内侧粒子密集、外侧稀疏,白色光晕叠加后会出现内侧过亮返白的问题;幂次采样后,粒子会更均匀地分布在环形区域,白色光晕叠加更协调,不易出现局部过曝。
  • 自定义属性数组(sizes/shift
    • sizes:存储每个粒子的独立尺寸,实现白色粒子大小的差异化,避免星环过于单调,同时让光晕叠加呈现自然的明暗变化;
    • shift:每个粒子存储4个脉动参数,后续在着色器中用于计算3D脉动动画,让每个粒子的脉动效果不同,白色星环的脉动更贴近真实星尘的效果。

步骤3:构建BufferGeometry(绑定顶点+自定义attribute)

将步骤2生成的粒子数据绑定到BufferGeometry,并将自定义属性(sizes/shift/radii)添加到几何体中,让着色器能够访问这些数据,为实现差异化粒子效果和白色星环优化提供数据支撑。

3.1 核心代码
// 1. 构建BufferGeometry,绑定粒子顶点坐标(环形粒子的基础位置数据)
const pointsGeometry = new THREE.BufferGeometry().setFromPoints(pointsArr);

// 2. 添加自定义attribute:sizes(粒子尺寸,每个粒子1个值)
// 第二个参数「1」表示每个顶点的分量数为1,与sizes数组的每个元素一一对应
pointsGeometry.setAttribute(
  'sizes', 
  new THREE.Float32BufferAttribute(sizes, 1)
);

// 3. 添加自定义attribute:shift(粒子脉动参数,每个粒子4个值)
// 第二个参数「4」表示每个顶点的分量数为4,与shift数组的每4个元素对应一个粒子
pointsGeometry.setAttribute(
  'shift', 
  new THREE.Float32BufferAttribute(shift, 4)
);

// 4. 添加自定义attribute:radii(粒子环形半径,每个粒子1个值,备用扩展)
pointsGeometry.setAttribute(
  'radii', 
  new THREE.Float32BufferAttribute(radii, 1)
);
3.2 关键技术点解析
  • BufferGeometry:高效的几何体类型,直接操作二进制数组存储数据,渲染时减少CPU与GPU之间的数据传输开销,适合大量粒子场景,性能远优于已被废弃的普通Geometry,是Three.js推荐的几何体类型。
  • 自定义attribute:这是向着色器传递「每个顶点/粒子独立数据」的核心方式,语法为geometry.setAttribute(属性名, BufferAttribute实例)
    • 着色器中需要声明同名attribute变量(如attribute float sizes;),才能访问对应数据;
    • 对于白色星环而言,通过attribute传递的size参数,是实现粒子大小差异化、避免光晕均匀过曝的关键。
  • Float32BufferAttribute:最常用的BufferAttribute类型,存储32位浮点型数据,兼顾精度与性能,适合传递粒子尺寸、脉动参数等数据。

步骤4:创建ShaderMaterial(核心!纯净白色星环的灵魂)

ShaderMaterial是本次实战的核心,通过自定义顶点着色器片元着色器,实现粒子的纯净白色、3D脉动、圆形抗锯齿和柔和光晕,同时保证加法混合下不返白,所有逻辑在GPU上运行,保证高性能。

4.1 核心代码
// 构建ShaderMaterial,配置全局uniforms和着色器,实现纯净白色星环
const pointsMaterial = new THREE.ShaderMaterial({
  // 1. 全局uniforms(向着色器传递全局统一数据,此处为时间和星环半径)
  uniforms: {
    uTime: { value: 0 }, // 全局时间:驱动所有粒子的动画同步更新
    uInnerRadius: { value: innerRadius }, // 星环内半径(备用扩展)
    uOuterRadius: { value: outerRadius } // 星环外半径(备用扩展)
  },

  // 2. 顶点着色器(处理粒子位置、颜色、大小,运行在每个顶点/粒子上)
  vertexShader: `
    uniform float uTime;
    uniform float uInnerRadius;
    uniform float uOuterRadius;
    attribute float sizes; // 粒子尺寸(自定义attribute,每个粒子独立)
    attribute vec4 shift; // 粒子脉动参数(自定义attribute,每个粒子4个值)
    attribute float radii; // 粒子环形半径(备用扩展)
    varying vec3 vColor; // 传递给片元着色器的白色(varying变量,实现平滑插值)

    void main() {
      // 步骤1:获取粒子原始位置
      vec3 pos = position;

      // 步骤2:设置纯净白色(核心避坑:不返白,留光晕叠加空间)
      vColor = vec3(0.9, 0.9, 0.9); // 白色基底:0.9(<1.0),避免加法混合过曝返白
      vColor *= 0.99; // 全局亮度微调:0.99(安全最大值),明亮且不返白,保留光晕感

      // 步骤3:3D脉动动画(球面扰动,让粒子沿球面方向脉动,白色光晕更有层次)
      float t = uTime;
      // 计算粒子脉动相位(结合初始相位和时间,实现每个粒子不同的脉动节奏)
      // 6.28318530718 = 2π,取模保证相位始终在0~2π之间,实现循环脉动
      float moveT = mod(shift.x + shift.z * t, 6.28318530718);
      float moveS = mod(shift.y + shift.z * t, 6.28318530718);
      // 计算脉动偏移量(球面坐标转换,实现3D方向脉动,避免平面化)
      vec3 offset = vec3(
        cos(moveS) * sin(moveT),
        cos(moveT),
        sin(moveS) * sin(moveT)
      ) * shift.w; // 乘以脉动幅度,控制粒子脉动距离,避免跑出星环
      pos += offset; // 叠加偏移量,更新粒子位置,实现脉动效果

      // 步骤4:透视变换(将3D粒子坐标转换为2D屏幕坐标,3D渲染必备)
      vec4 mvPosition = modelViewMatrix * vec4(pos, 1.0); // 模型视图矩阵:局部坐标→相机视角坐标
      gl_Position = projectionMatrix * mvPosition; // 投影矩阵:相机视角坐标→屏幕裁剪坐标

      // 步骤5:粒子大小计算(透视缩放+最大尺寸限制,避免白色光晕过曝)
      gl_PointSize = 0.18 * sizes * (200.0 / -mvPosition.z); // 透视缩放:近大远小,视觉更真实
      gl_PointSize = min(gl_PointSize, 5.0); // 限制最大尺寸:5.0,避免粒子过大导致光晕叠加返白
    }
  `,

  // 3. 片元着色器(处理粒子像素颜色、形状,运行在每个像素上,实现圆形抗锯齿与柔和光晕)
  fragmentShader: `
    varying vec3 vColor; // 从顶点着色器传递过来的纯净白色

    void main() {
      // 步骤1:绘制圆形粒子(基于点坐标的UV计算,替代默认方形粒子)
      vec2 uv = gl_PointCoord - 0.5; // 将点坐标从(0,0)~(1,1)转换为(-0.5,-0.5)~(0.5,0.5)
      float d = length(uv); // 计算当前像素到粒子中心的距离

      // 步骤2:圆形裁剪(丢弃超出圆心0.5范围的像素,形成圆形粒子)
      if (d > 0.5) discard; // discard:丢弃当前像素,不渲染,实现圆形轮廓

      // 步骤3:抗锯齿+柔和光晕(smoothstep实现渐隐,避免圆形边缘锯齿,提升白色光晕质感)
      float alpha = smoothstep(0.5, 0.05, d); // 从0.5到0.05,alpha从0渐变到1,边缘渐隐

      // 步骤4:设置最终像素颜色(纯净白色+渐变Alpha,实现柔和光晕)
      gl_FragColor = vec4(vColor, alpha);
    }
  `,

  // 4. 材质附加配置(提升白色星环视觉效果,核心是加法混合与透明设置)
  transparent: true, // 启用透明:支持粒子边缘渐隐,实现柔和光晕效果
  depthTest: false, // 关闭深度测试:允许白色粒子叠加,营造光晕层次感,避免粒子互相遮挡
  blending: THREE.AdditiveBlending, // 加法混合:粒子白色亮度叠加,呈现朦胧光晕,提升星环质感
  premultipliedAlpha: false // 关闭预乘Alpha:避免白色发灰,保持纯净通透,适配加法混合
});

// 5. 创建Points粒子对象,添加到场景(将几何体与材质结合,形成最终的白色星环)
const points = new THREE.Points(pointsGeometry, pointsMaterial);
scene.add(points);
4.2 关键技术点解析
(1)全局uniforms与着色器变量声明
  • uniform float uTime:全局时间变量,从JS端每帧更新,驱动所有粒子的动画同步,所有粒子共享该值,保证白色星环的脉动效果协调统一;
  • attribute变量:声明自定义属性(sizes/shift/radii),每个粒子有独立的值,对应BufferGeometry中绑定的数据,是实现粒子差异化效果的核心;
  • varying vec3 vColor:插值变量,用于在顶点着色器和片元着色器之间传递白色数据,Three.js会自动在顶点之间进行平滑插值,保证白色光晕的过渡自然,无明显断层。
(2)顶点着色器核心逻辑(纯净白色+3D脉动+透视优化)
  1. 纯净白色设置(核心避坑:不返白)

    • 直接将vColor赋值为vec3(0.9, 0.9, 0.9),白色基底值设为0.9而非1.0,为加法混合预留光晕叠加空间,避免10万粒子密集叠加后亮度溢出(>1.0)导致返白;
    • 保留vColor *= 0.99的全局亮度微调,让白色更通透柔和,带有自然的光晕感,不会显得生硬刺眼,同时0.99是安全最大值,不会触发过曝返白;
    • 若觉得白色偏暗,可将基底值调整为0.95,全局增益调整为0.99切勿将任一值设为1.0及以上,否则会出现明显返白。
  2. 3D脉动动画(球面扰动,提升光晕层次感)

    • 利用mod函数计算脉动相位,保证相位始终在0~2π之间,实现循环流畅的脉动效果,避免粒子脉动出现断层;
    • 通过球面坐标转换(cos/sin)计算偏移量offset,让粒子沿球面方向脉动,而非平面方向,白色星环更具3D立体感,光晕叠加也更有层次;
    • 乘以shift.w(脉动幅度),控制每个粒子的脉动距离,实现差异化脉动效果,避免白色星环脉动过于规则,更贴近真实星尘的效果。
  3. 透视变换与点大小缩放(视觉真实+避坑优化)

    • modelViewMatrix + projectionMatrix:Three.js内置的矩阵,完成3D顶点坐标到2D屏幕坐标的转换,是3D渲染的必备步骤,保证白色星环能够正确显示在屏幕上;
    • gl_PointSize:设置粒子的屏幕尺寸,200.0 / -mvPosition.z实现「近大远小」的透视效果,让白色星环更具真实感,0.18为整体缩放系数,控制粒子的整体大小;
    • min(gl_PointSize, 5.0):限制粒子的最大尺寸,避免远处粒子因透视缩放过大,导致白色光晕叠加过曝返白,这是白色星环的关键避坑点之一。
(3)片元着色器核心逻辑(圆形抗锯齿+柔和光晕)
  1. 圆形粒子绘制(替代默认方形,提升视觉质感)

    • gl_PointCoord:Three.js内置变量,代表当前像素在粒子中的坐标,范围为(0,0)(1,1)
    • 转换为(-0.5,-0.5)(0.5,0.5)的坐标,计算到粒子中心的距离d,通过if (d > 0.5) discard丢弃超出圆心0.5范围的像素,形成圆形粒子,替代默认的方形粒子,让白色星环更细腻。
  2. 抗锯齿+柔和光晕(smoothstep核心优化)

    • smoothstep(a, b, x):GLSL内置平滑插值函数,当x <= a时返回0,x >= b时返回1,中间为平滑渐变;
    • 此处smoothstep(0.5, 0.05, d),当d=0.5时返回0(完全透明),d=0.05时返回1(完全不透明),中间为平滑渐隐过渡,既实现了圆形粒子的抗锯齿,又为白色粒子营造了柔和的光晕,避免边缘生硬刺眼;
    • 若想让光晕更强,可将第二个参数调整为0.0,若想让粒子边缘更锐利,可调整为0.1,根据需求灵活适配。
  3. 加法混合(AdditiveBlending

    • 粒子的白色亮度会与背景和其他粒子的亮度叠加,越密集的地方越亮,形成自然的朦胧光晕,提升白色星环的层次感和视觉冲击力;
    • 配合transparent: truedepthTest: false,保证白色粒子之间能够正常叠加,不会互相遮挡,光晕效果更连贯,同时避免白色发灰,保持纯净通透。

步骤5:动画循环(驱动动画+更新渲染)

每帧更新全局时间uTime,驱动粒子脉动动画,同时更新星环整体旋转和控制器阻尼,实现流畅的白色星环动画效果,保证视觉体验的连贯性。

5.1 核心代码
const clock = new THREE.Clock(); // 时钟:用于获取累计运行时间,不受帧率影响,避免动画累积误差

function animate() {
  requestAnimationFrame(animate); // 绑定浏览器刷新率(通常60帧/秒),实现流畅无卡顿的动画

  // 1. 获取累计运行时间,驱动着色器脉动动画(减慢速度,让白色星环脉动更舒缓)
  const t = clock.getElapsedTime() * 0.5; // 乘以0.5:减慢时间流速,提升观察体验
  pointsMaterial.uniforms.uTime.value = t * Math.PI; // 乘以PI:放大相位变化,让脉动更流畅

  // 2. 星环整体旋转,增加场景活力(白色星环缓慢旋转,光晕效果更丰富)
  points.rotation.y = t * 0.05;

  // 3. 更新轨道控制器阻尼(必须在动画循环中调用,保证阻尼效果生效)
  controls.update();

  // 4. 渲染场景(将场景和相机的3D信息渲染为2D画布,呈现最终的白色星环效果)
  renderer.render(scene, camera);
}

// 启动动画循环(开始运行白色星环的脉动与渲染)
animate();
5.2 关键说明
  • clock.getElapsedTime():获取从时钟启动到当前的累计运行时间(单位:秒),相比getDelta()(获取两帧之间的时间差)更适合驱动全局循环动画,避免动画因帧率波动出现累积误差,保证白色星环的脉动效果在不同设备上一致。
  • 动画速度调节:乘以0.5减慢时间流速,乘以Math.PI放大相位变化,让白色星环的脉动更舒缓、更易观察,可根据需求调整系数(如0.3更慢,1.0更快)。
  • 星环整体旋转points.rotation.y让白色星环绕Y轴缓慢旋转,配合粒子的3D脉动,白色光晕的变化更丰富,避免场景过于静态,提升视觉体验。

步骤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():相机参数(如宽高比)修改后,必须调用该方法更新投影矩阵,否则宽高比的修改不会生效,场景会出现明显的拉伸变形,影响白色星环的视觉效果。

核心参数速查表(快速调整白色星环效果)

参数名 当前取值 作用 修改建议
count 100000 总粒子数,决定白色星环的密集程度与光晕细腻度 低配设备改为50000~80000,减少卡顿;高配设备改为200000,提升光晕细腻度
innerRadius/outerRadius 10/40 星环内/外半径,决定白色星环的大小和环形宽度 改为5/30:星环更小更窄,光晕更集中;改为20/60:星环更大更宽,光晕更分散
shift.w(生成时) 0.05~0.75 粒子脉动幅度,决定白色粒子的移动距离与光晕变化 改为0.050.5:脉动更柔和,光晕变化更平缓;改为0.51.0:脉动更剧烈,光晕变化更明显(避免>1.0,粒子易跑出星环)
白色基底值 vec3(0.9, 0.9, 0.9) 0.9 白色基础亮度,决定星环的整体明亮度,核心避坑点 改为0.8~0.95:亮度适中,不易返白;切勿≥1.0,否则加法混合会过曝返白
全局亮度增益 * 0.99 0.99 白色整体增益,微调星环明亮度,保留光晕空间 改为0.9~0.99:安全范围,明亮且不返白;切勿>1.0,触发过曝返白
粒子整体缩放 0.18gl_PointSize 0.18 白色粒子的整体尺寸缩放系数,决定粒子基础大小 改为0.15:粒子更小,光晕更细腻;改为0.25:粒子更大,光晕更明显(避免>0.3,易返白)
粒子最大尺寸 5.0min限制) 5.0 白色粒子的最大尺寸限制,核心避坑点,防止光晕过曝 改为3.04.0:更不易返白,光晕更柔和;改为6.08.0:粒子更大,光晕更亮(需降低白色基底值,避免返白)
smoothstep(0.5, 0.05, d) 0.05 白色粒子边缘渐隐起始值,决定光晕强弱与边缘细腻度 改为0.0:光晕更强,边缘更柔和;改为0.1:光晕更弱,边缘更锐利
clock.getElapsedTime() * 0.5 0.5 动画时间流速,决定白色星环的脉动与旋转速度 改为0.3:动画更舒缓,便于观察光晕细节;改为1.0:动画更快速,光晕变化更活跃

完整优化代码

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>星环粒子 - ShaderMaterial 纯净白色版</title>
  <style>body { margin: 0; overflow: hidden; background: #000; }</style>
</head>
<body>
  <script type="module">
  // 导入Three.js核心库和轨道控制器
  import * as THREE from 'https://esm.sh/three@0.174.0';
  import { OrbitControls } from 'https://esm.sh/three@0.174.0/examples/jsm/controls/OrbitControls.js';

  // ========== 1. 基础环境初始化(场景/相机/渲染器/控制器) ==========
  const scene = new THREE.Scene();

  // 透视相机:高位侧视,清晰观察白色星环的环形形态与3D脉动
  const camera = new THREE.PerspectiveCamera(60, innerWidth / innerHeight, 1, 1000);
  camera.position.set(0, 6, 100);

  // 渲染器:抗锯齿,提升白色粒子边缘与光晕的细腻度,适配高清屏幕
  const renderer = new THREE.WebGLRenderer({ antialias: true });
  renderer.setSize(window.innerWidth, window.innerHeight);
  renderer.setPixelRatio(window.devicePixelRatio);
  document.body.appendChild(renderer.domElement);

  // 轨道控制器:启用阻尼,实现顺滑的3D交互体验
  const controls = new OrbitControls(camera, renderer.domElement);
  controls.enableDamping = true;
  controls.dampingFactor = 0.05;

  // ========== 2. 粒子数据生成(环形坐标+自定义属性,适配白色星环) ==========
  const count = 100000; // 总粒子数:10万,ShaderMaterial高效支撑,无明显卡顿
  const innerRadius = 10, outerRadius = 40; // 星环内外半径:决定环形大小与宽度
  const pointsArr = []; // 粒子顶点坐标数组
  const sizes = []; // 粒子尺寸数组:每个粒子独立,实现差异化光晕
  const shift = []; // 粒子脉动参数数组:每个粒子4个值,实现差异化脉动
  const radii = []; // 粒子环形半径数组:备用,方便后续扩展

  // 辅助函数:生成单个粒子的4个脉动参数,控制脉动节奏与距离
  const pushShift = () => {
    shift.push(
      Math.random() * Math.PI, // 脉动初始相位1:控制起始位置
      Math.random() * Math.PI * 2, // 脉动初始相位2:控制水平方向脉动
      (Math.random() * 0.9 + 0.1) * Math.PI * 0.1, // 脉动频率:控制快慢,舒缓流畅
      Math.random() * 0.7 + 0.05 // 脉动幅度:控制距离,避免粒子跑出星环
    );
  };

  // 循环生成10万粒子数据,保证环形分布均匀,为白色光晕叠加打基础
  for (let i = 0; i < count; i++) {
    // 幂次采样:让粒子均匀分布在环形区域,避免内侧密集、外侧稀疏导致光晕过曝
    const rand = Math.pow(Math.random(), 1.5);
    const radius = Math.sqrt(outerRadius * outerRadius * rand + (1 - rand) * innerRadius * innerRadius);
    radii.push(radius);

    // 圆柱坐标系→直角坐标系:快速生成环形粒子坐标,提升开发效率
    pointsArr.push(
      new THREE.Vector3().setFromCylindricalCoords(
        radius,
        Math.random() * 2 * Math.PI,
        (Math.random() - 0.5) * 2 // 轻微高度:让星环有3D立体感,光晕更丰富
      )
    );

    // 生成粒子独立尺寸:0.3~1.5,实现差异化大小,光晕叠加更自然
    sizes.push(Math.random() * 1.2 + 0.3);

    // 生成粒子独立脉动参数:实现差异化脉动,避免星环脉动过于规则
    pushShift();
  }

  // ========== 3. 构建BufferGeometry(绑定顶点+自定义attribute,传递粒子数据) ==========
  const pointsGeometry = new THREE.BufferGeometry().setFromPoints(pointsArr);

  // 添加自定义attribute:sizes(粒子尺寸),着色器中实现差异化大小
  pointsGeometry.setAttribute('sizes', new THREE.Float32BufferAttribute(sizes, 1));

  // 添加自定义attribute:shift(粒子脉动参数),着色器中实现差异化脉动
  pointsGeometry.setAttribute('shift', new THREE.Float32BufferAttribute(shift, 4));

  // 添加自定义attribute:radii(粒子环形半径),备用扩展
  pointsGeometry.setAttribute('radii', new THREE.Float32BufferAttribute(radii, 1));

  // ========== 4. 创建ShaderMaterial(核心:实现纯净白色、3D脉动、柔和光晕) ==========
  const pointsMaterial = new THREE.ShaderMaterial({
    // 全局uniforms:传递时间与星环半径,驱动全局动画与扩展
    uniforms: {
      uTime: { value: 0 },
      uInnerRadius: { value: innerRadius },
      uOuterRadius: { value: outerRadius }
    },

    // 顶点着色器:处理粒子位置、白色、大小,实现3D脉动与透视优化
    vertexShader: `
      uniform float uTime;
      uniform float uInnerRadius;
      uniform float uOuterRadius;
      attribute float sizes;
      attribute vec4 shift;
      attribute float radii;
      varying vec3 vColor;

      void main() {
        vec3 pos = position;

        // 纯净白色设置:基底0.9+增益0.99,明亮不返白,保留光晕叠加空间
        vColor = vec3(0.9, 0.9, 0.9);
        vColor *= 0.99;

        // 3D脉动动画:球面扰动,实现流畅循环的差异化脉动,提升光晕层次感
        float t = uTime;
        float moveT = mod(shift.x + shift.z * t, 6.28318530718);
        float moveS = mod(shift.y + shift.z * t, 6.28318530718);
        vec3 offset = vec3(
          cos(moveS) * sin(moveT),
          cos(moveT),
          sin(moveS) * sin(moveT)
        ) * shift.w;
        pos += offset;

        // 透视变换:3D坐标→2D屏幕坐标,保证白色星环正确显示
        vec4 mvPosition = modelViewMatrix * vec4(pos, 1.0);
        gl_Position = projectionMatrix * mvPosition;

        // 透视缩放+最大尺寸限制:近大远小更真实,避免粒子过大导致光晕过曝返白
        gl_PointSize = 0.18 * sizes * (200.0 / -mvPosition.z);
        gl_PointSize = min(gl_PointSize, 5.0);
      }
    `,

    // 片元着色器:处理粒子形状、抗锯齿、柔和光晕,实现纯净白色圆形粒子
    fragmentShader: `
      varying vec3 vColor;

      void main() {
        // 圆形粒子绘制:转换UV坐标,计算到粒子中心的距离
        vec2 uv = gl_PointCoord - 0.5;
        float d = length(uv);

        // 圆形裁剪:丢弃超出圆心的像素,形成圆形轮廓,替代默认方形
        if (d > 0.5) discard;

        // 抗锯齿+柔和光晕:smoothstep实现边缘渐隐,提升白色光晕质感
        float alpha = smoothstep(0.5, 0.05, d);

        // 最终像素颜色:纯净白色+渐变Alpha,实现明亮柔和的光晕效果
        gl_FragColor = vec4(vColor, alpha);
      }
    `,

    // 材质配置:提升白色星环视觉效果,核心是加法混合与透明设置
    transparent: true, // 启用透明,支持边缘渐隐与光晕叠加
    depthTest: false, // 关闭深度测试,允许粒子叠加,光晕更连贯
    blending: THREE.AdditiveBlending, // 加法混合,白色亮度叠加,呈现朦胧光晕
    premultipliedAlpha: false // 关闭预乘Alpha,避免白色发灰,保持纯净通透
  });

  // 创建Points粒子对象,添加到场景,形成最终的纯净白色星环
  const points = new THREE.Points(pointsGeometry, pointsMaterial);
  scene.add(points);

  // ========== 5. 动画循环(驱动脉动+更新渲染,实现流畅白色星环效果) ==========
  const clock = new THREE.Clock();

  function animate() {
    requestAnimationFrame(animate);

    // 更新全局时间,驱动着色器脉动动画,减慢速度提升观察体验
    const t = clock.getElapsedTime() * 0.5;
    pointsMaterial.uniforms.uTime.value = t * Math.PI;

    // 星环整体旋转,增加场景活力,白色光晕变化更丰富
    points.rotation.y = t * 0.05;

    // 更新轨道控制器阻尼,保证顺滑交互
    controls.update();

    // 渲染场景,呈现最终的纯净白色星环效果
    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. 高性能核心ShaderMaterial将动画与渲染逻辑移至GPU并行处理,可轻松应对10万级甚至百万级粒子,性能远超普通JS驱动的粒子系统,这是实现流畅白色星环的基础。
  2. 数据传递核心:自定义attribute传递粒子独立数据,uniform传递全局统一数据,varying实现着色器间数据平滑插值,这是Three.js着色器开发的核心范式,适用于所有复杂粒子场景。
  3. 白色星环核心避坑
    • 白色基底值设为<1.0,全局增益设为≤0.99,为加法混合预留光晕叠加空间,避免过曝返白;
    • 限制粒子最大尺寸,避免远处粒子过大导致光晕叠加过曝;
    • 幂次采样保证粒子均匀分布,避免局部密集导致光晕过亮。
  4. 视觉效果核心
    • 圆柱坐标系快速生成环形粒子,幂次采样保证分布均匀,为白色光晕叠加打下基础;
    • 顶点着色器实现3D脉动,片元着色器实现圆形抗锯齿与柔和光晕;
    • AdditiveBlending加法混合实现白色亮度叠加,营造朦胧光晕,提升星环层次感。
  5. 透视优化核心:粒子大小随相机距离缩放,实现「近大远小」的真实透视效果,避免场景失真,提升白色星环的视觉真实感。

扩展建议

  1. 白色星环效果扩展
    • 动态调整白色亮度:通过uniform传递白色增益值,让星环随时间实现「明暗呼吸」效果,提升场景张力;
    • 调整光晕强弱:通过修改smoothstep的渐隐参数,实现光晕的「浓淡变化」,适配不同视觉风格;
    • 多环叠加:创建多个不同半径、不同脉动速度的白色星环,形成星系效果,提升场景复杂度。
  2. 功能扩展
    • 交互增强:绑定鼠标位置,让白色星环跟随鼠标旋转、脉动,提升交互体验;
    • 参数控制面板:提供可视化面板,允许用户调整星环半径、粒子数、脉动速度、白色亮度等参数,实时预览效果;
    • 响应式优化:根据设备性能自动调整粒子数,低配设备减少粒子数,保证流畅性,高配设备增加粒子数,提升细腻度。
  3. 性能优化
    • 使用InstancedBufferGeometry替代BufferGeometry,进一步减少DrawCall,支持更多粒子(百万级);
    • 开启渲染器的powerPreference: "high-performance",优先使用高性能GPU,提升渲染效率;
    • 剔除不可见粒子:通过视锥体裁剪,剔除屏幕外的粒子,减少GPU渲染开销。
  4. 视觉风格扩展
    • 添加轻微蓝色/银色调:在白色基底中加入少量蓝色(如vec3(0.9, 0.9, 1.0)),营造冷色调科技感星环;
    • 添加辉光效果:结合THREE.UnrealBloomPass后期处理,增强白色星环的辉光感,提升视觉冲击力。

学习Three.js--烟花

2026年2月2日 10:16

学习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,支持更多粒子数。
❌
❌