Three.js 打造炫酷烟花绽放效果

101 阅读4分钟

概述

本文将详细介绍如何使用 Three.js 和自定义着色器来创建一个炫酷的烟花绽放效果。我们将通过编写顶点着色器和片元着色器来实现烟花从发射到爆炸的完整动画过程,这能帮助你深入理解粒子系统和着色器的高级应用。

screenshot_2026-01-27_22-19-51.gif

准备工作

首先,我们需要引入必要的 Three.js 库和相关工具:

import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";
import gsap from "gsap";
import * as dat from "dat.gui";
import vertexShader from "../shaders/flylight/vertex.glsl";
import fragmentShader from "../shaders/flylight/fragment.glsl";
import { RGBELoader } from "three/examples/jsm/loaders/RGBELoader";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader";

import Fireworks from "./firework";

// 导入水模块
import { Water } from "three/examples/jsm/objects/Water2";

场景初始化

首先,我们需要创建一个基本的 Three.js 场景:

// 初始化场景
const scene = new THREE.Scene();

// 创建透视相机
const camera = new THREE.PerspectiveCamera(
  90,
  window.innerHeight / window.innerHeight,
  0.1,
  1000
);

// 设置相机位置
camera.position.set(0, 0, 20);
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
scene.add(camera);

// 加入辅助轴,帮助我们查看3维坐标轴
// const axesHelper = new THREE.AxesHelper(5);
// scene.add(axesHelper);

环境设置

为了营造更真实的夜空效果,我们需要加载 HDR 环境贴图:

// 加载纹理
// 创建纹理加载器对象
const rgbeLoader = new RGBELoader();
rgbeLoader.loadAsync("./assets/2k.hdr").then((texture) => {
  texture.mapping = THREE.EquirectangularReflectionMapping;
  scene.background = texture;
  scene.environment = texture;
});

飞行动画着色器材质

我们先创建一个用于天空中飞行物体的着色器材质:

// 创建着色器材质
const shaderMaterial = new THREE.ShaderMaterial({
  vertexShader: vertexShader,
  fragmentShader: fragmentShader,
  uniforms: {},
  side: THREE.DoubleSide,
  // transparent: true,
});

模型加载与场景布置

加载烟花模型和场景中的其他元素:

const gltfLoader = new GLTFLoader();
let LightBox = null;

// 加载新年场景模型
gltfLoader.load("./assets/model/newyears_min.glb", (gltf) => {
  console.log(gltf);
  scene.add(gltf.scene);

  // 创建水面
  const waterGeometry = new THREE.PlaneBufferGeometry(100, 100);
  let water = new Water(waterGeometry, {
    scale: 4,
    textureHeight: 1024,
    textureWidth: 1024,
  });
  water.position.y = 1;
  water.rotation.x = -Math.PI / 2;
  scene.add(water);
});

// 加载飞行光点模型
gltfLoader.load("./assets/model/flyLight.glb", (gltf) => {
  console.log(gltf);

  LightBox = gltf.scene.children[0];
  LightBox.material = shaderMaterial;

  // 创建150个随机分布的飞行光点
  for (let i = 0; i < 150; i++) {
    let flyLight = gltf.scene.clone(true);
    let x = (Math.random() - 0.5) * 300;
    let z = (Math.random() - 0.5) * 300;
    let y = Math.random() * 60 + 5;
    flyLight.position.set(x, y, z);
    
    // 使用 GSAP 添加旋转动画
    gsap.to(flyLight.rotation, {
      y: 2 * Math.PI,
      duration: 10 + Math.random() * 30,
      repeat: -1,
    });
    
    // 使用 GSAP 添加位置动画
    gsap.to(flyLight.position, {
      x: "+=" + Math.random() * 5,
      y: "+=" + Math.random() * 20,
      yoyo: true,
      duration: 5 + Math.random() * 10,
      repeat: -1,
    });
    scene.add(flyLight);
  }
});

烟花管理

创建烟花管理数组,用于管理所有的烟花实例:

// 管理烟花
let fireworks = [];

function animate(t) {
  controls.update();
  const elapsedTime = clock.getElapsedTime();
  
  // 更新每个烟花的状态
  fireworks.forEach((item, i) => {
    const type = item.update();
    if (type == "remove") {
      fireworks.splice(i, 1);
    }
  });

  requestAnimationFrame(animate);
  // 使用渲染器渲染相机看这个场景的内容渲染出来
  renderer.render(scene, camera);
}

animate();

点击创建烟花

添加点击事件监听器,允许用户在任意位置创建烟花:

// 设置创建烟花函数
let createFireworks = () => {
  let color = `hsl(${Math.floor(Math.random() * 360)},100%,80%)`;
  let position = {
    x: (Math.random() - 0.5) * 40,
    z: -(Math.random() - 0.5) * 40,
    y: 3 + Math.random() * 15,
  };

  // 随机生成颜色和烟花放的位置
  let firework = new Fireworks(color, position);
  firework.addScene(scene, camera);
  fireworks.push(firework);
};

// 监听点击事件
window.addEventListener("click", createFireworks);

烟花类详解

烟花类是整个效果的核心,它包含三个阶段:发射、爆炸和消散。

构造函数

export default class Fireworks {
  constructor(color, to, from = { x: 0, y: 0, z: 0 }) {
    this.color = new THREE.Color(color);

    // 创建烟花发射的球点
    this.startGeometry = new THREE.BufferGeometry();
    const startPositionArray = new Float32Array(3);
    startPositionArray[0] = from.x;
    startPositionArray[1] = from.y;
    startPositionArray[2] = from.z;
    this.startGeometry.setAttribute(
      "position",
      new THREE.BufferAttribute(startPositionArray, 3)
    );

    const astepArray = new Float32Array(3);
    astepArray[0] = to.x - from.x;
    astepArray[1] = to.y - from.y;
    astepArray[2] = to.z - from.x;
    this.startGeometry.setAttribute(
      "aStep",
      new THREE.BufferAttribute(astepArray, 3)
    );

    // 设置着色器材质
    this.startMaterial = new THREE.ShaderMaterial({
      vertexShader: startPointVertex,
      fragmentShader: startPointFragment,
      transparent: true,
      blending: THREE.AdditiveBlending,
      depthWrite: false,
      uniforms: {
        uTime: {
          value: 0,
        },
        uSize: {
          value: 20,
        },
        uColor: { value: this.color },
      },
    });

    // 创建烟花点球
    this.startPoint = new THREE.Points(this.startGeometry, this.startMaterial);

    // 开始计时
    this.clock = new THREE.Clock();

    // 创建爆炸的烟花
    this.fireworkGeometry = new THREE.BufferGeometry();
    this.FireworksCount = 180 + Math.floor(Math.random() * 180);
    const positionFireworksArray = new Float32Array(this.FireworksCount * 3);
    const scaleFireArray = new Float32Array(this.FireworksCount);
    const directionArray = new Float32Array(this.FireworksCount * 3);
    
    for (let i = 0; i < this.FireworksCount; i++) {
      // 一开始烟花位置
      positionFireworksArray[i * 3 + 0] = to.x;
      positionFireworksArray[i * 3 + 1] = to.y;
      positionFireworksArray[i * 3 + 2] = to.z;
      
      // 设置烟花所有粒子初始化大小
      scaleFireArray[i] = Math.random();
      
      // 设置四周发射的角度
      let theta = Math.random() * 2 * Math.PI;
      let beta = Math.random() * 2 * Math.PI;
      let r = Math.random();

      directionArray[i * 3 + 0] = r * Math.sin(theta) + r * Math.sin(beta);
      directionArray[i * 3 + 1] = r * Math.cos(theta) + r * Math.cos(beta);
      directionArray[i * 3 + 2] = r * Math.sin(theta) + r * Math.cos(beta);
    }
    
    this.fireworkGeometry.setAttribute(
      "position",
      new THREE.BufferAttribute(positionFireworksArray, 3)
    );
    this.fireworkGeometry.setAttribute(
      "aScale",
      new THREE.BufferAttribute(scaleFireArray, 1)
    );
    this.fireworkGeometry.setAttribute(
      "aRandom",
      new THREE.BufferAttribute(directionArray, 3)
    );

    this.fireworksMaterial = new THREE.ShaderMaterial({
      uniforms: {
        uTime: {
          value: 0,
        },
        uSize: {
          value: 0,
        },
        uColor: { value: this.color },
      },
      transparent: true,
      blending: THREE.AdditiveBlending,
      depthWrite: false,
      vertexShader: fireworksVertex,
      fragmentShader: fireworksFragment,
    });

    this.fireworks = new THREE.Points(
      this.fireworkGeometry,
      this.fireworksMaterial
    );

    // 创建音频
    this.linstener = new THREE.AudioListener();
    this.linstener1 = new THREE.AudioListener();
    this.sound = new THREE.Audio(this.linstener);
    this.sendSound = new THREE.Audio(this.linstener1);

    // 创建音频加载器
    const audioLoader = new THREE.AudioLoader();
    audioLoader.load(
      `./assets/audio/pow${Math.floor(Math.random() * 4) + 1}.ogg`,
      (buffer) => {
        this.sound.setBuffer(buffer);
        this.sound.setLoop(false);
        this.sound.setVolume(1);
      }
    );

    audioLoader.load(`./assets/audio/send.mp3`, (buffer) => {
      this.sendSound.setBuffer(buffer);
      this.sendSound.setLoop(false);
      this.sendSound.setVolume(1);
    });
  }
  
  // 添加到场景
  addScene(scene, camera) {
    scene.add(this.startPoint);
    scene.add(this.fireworks);
    this.scene = scene;
  }
  
  // 更新变量
  update() {
    const elapsedTime = this.clock.getElapsedTime();
    
    if (elapsedTime > 0.2 && elapsedTime < 1) {
      if (!this.sendSound.isPlaying && !this.sendSoundplay) {
        this.sendSound.play();
        this.sendSoundplay = true;
      }
      this.startMaterial.uniforms.uTime.value = elapsedTime;
      this.startMaterial.uniforms.uSize.value = 20;
    } else if (elapsedTime > 0.2) {
      const time = elapsedTime - 1;
      // 让点元素消失
      this.startMaterial.uniforms.uSize.value = 0;
      this.startPoint.clear();
      this.startGeometry.dispose();
      this.startMaterial.dispose();
      if (!this.sound.isPlaying && !this.play) {
        this.sound.play();
        this.play = true;
      }
      //设置烟花显示
      this.fireworksMaterial.uniforms.uSize.value = 20;
      this.fireworksMaterial.uniforms.uTime.value = time;

      if (time > 5) {
        this.fireworksMaterial.uniforms.uSize.value = 0;
        this.fireworks.clear();
        this.fireworkGeometry.dispose();
        this.fireworksMaterial.dispose();
        this.scene.remove(this.fireworks);
        this.scene.remove(this.startPoint);
        return "remove";
      }
    }
  }
}

着色器详解

发射阶段着色器

发射顶点着色器 (startpoint/vertex.glsl):

attribute vec3 aStep;

uniform float uTime;
uniform float uSize;

void main(){
    vec4 modelPosition = modelMatrix * vec4( position, 1.0 );

    modelPosition.xyz += (aStep*uTime);

    vec4 viewPosition = viewMatrix * modelPosition;

    gl_Position =  projectionMatrix * viewPosition;

    // 设置顶点大小
    gl_PointSize =uSize;
}

发射片元着色器 (startpoint/fragment.glsl):

uniform vec3 uColor;

void main(){
    float distanceToCenter = distance(gl_PointCoord,vec2(0.5));
    float strength = distanceToCenter*2.0;
    strength = 1.0-strength;
    strength = pow(strength,1.5);
    gl_FragColor = vec4(uColor,strength);
}

爆炸阶段着色器

爆炸顶点着色器 (fireworks/vertex.glsl):

attribute float aScale;
attribute vec3 aRandom;

uniform float uTime;
uniform float uSize;

void main(){
    vec4 modelPosition = modelMatrix * vec4( position, 1.0 );

    modelPosition.xyz+=aRandom*uTime*10.0;

    vec4 viewPosition = viewMatrix * modelPosition;

    gl_Position =  projectionMatrix * viewPosition;

    // 设置顶点大小
    gl_PointSize =uSize*aScale-(uTime*20.0);
}

爆炸片元着色器 (fireworks/fragment.glsl):

uniform vec3 uColor;

void main(){
    float distanceToCenter = distance(gl_PointCoord,vec2(0.5));
    float strength = distanceToCenter*2.0;
    strength = 1.0-strength;
    strength = pow(strength,1.5);
    gl_FragColor = vec4(uColor,strength);
}

渲染器和控制器设置

最后,我们需要设置渲染器和控制器:

// 初始化渲染器
const renderer = new THREE.WebGLRenderer({ alpha: true });
// renderer.shadowMap.enabled = true;
// renderer.shadowMap.type = THREE.BasicShadowMap;
// renderer.shadowMap.type = THREE.VSMShadowMap;
renderer.outputEncoding = THREE.sRGBEncoding;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
// renderer.toneMapping = THREE.LinearToneMapping;
// renderer.toneMapping = THREE.ReinhardToneMapping;
// renderer.toneMapping = THREE.CineonToneMapping;
renderer.toneMappingExposure = 0.1;

// 设置渲染尺寸大小
renderer.setSize(window.innerWidth, window.innerHeight);

// 监听屏幕大小改变的变化,设置渲染的尺寸
window.addEventListener("resize", () => {
  // 更新摄像头
  camera.aspect = window.innerWidth / window.innerHeight;
  // 更新摄像机的投影矩阵
  camera.updateProjectionMatrix();

  // 更新渲染器
  renderer.setSize(window.innerWidth, window.innerHeight);
  // 设置渲染器的像素比例
  renderer.setPixelRatio(window.devicePixelRatio);
});

// 将渲染器添加到body
document.body.appendChild(renderer.domElement);

// 初始化控制器
const controls = new OrbitControls(camera, renderer.domElement);
// 设置控制器阻尼
controls.enableDamping = true;
// 设置自动旋转
controls.autoRotate = true;
controls.autoRotateSpeed = 0.1;

总结

通过这个项目,我们学习了如何使用 Three.js 创建一个完整的烟花效果系统。关键在于:

  1. 利用粒子系统模拟烟花的发射和爆炸效果
  2. 使用自定义着色器实现动态的视觉效果
  3. 通过时间控制实现烟花的三个阶段:发射、爆炸和消散
  4. 添加音效增强用户体验
  5. 使用缓冲几何体高效地处理大量粒子

这种技术可以应用于各种特效制作,如爆炸、流星雨、粒子系统等。掌握了这套技术,你就能创建出更加丰富和生动的三维场景,为用户带来震撼的视觉体验。