1. 项目整体设计与思路拆解
1.1 为什么用Three.js做烟花,而不是Canvas 2D或纯CSS
先回答一个大家可能都想过的问题:做烟花特效,Canvas 2D不香吗?CSS动画不香吗?干嘛非得折腾Three.js?
坦白说,如果只是做一个“能看”的烟花,Canvas 2D确实够了。但如果你想让烟花有真正的体积感、空间感——烟花炸开时能围绕一个立体球面飞散、镜头能绕着烟花转、近大远小的透视效果真实,Canvas 2D做起来会非常痛苦。CSS动画就更不用说了,手写几十个div粒子,性能上限摆在那里,粒子数量一多就卡。
Three.js的核心价值在于:它帮你封装了WebGL的底层细节,把顶点数据、着色器编译、矩阵变换、绘制调用这些脏活累活都包了,你只需要用“场景、相机、物体”这套对象模型去思考问题。做烟花,本质上就是在三维空间里模拟大量粒子的运动轨迹,这正是Three.js的强项。
这个项目的目标很具体:用Three.js实现一个粒子烟花系统,支持多次发射、多彩爆炸、粒子自然下落、镜头自动旋转。你可以把它跑在本地浏览器里,也可以嵌入到自己的个人网站或活动页面里当背景特效。
需要的基础也不高——了解JavaScript语法,知道npm怎么用,对Three.js的Scene、Camera、Renderer、BufferGeometry这些核心概念有基本认识就够了。如果你连Three.js都没摸过,这篇文章里的代码也能直接跑,我会把每一步都拆开讲清楚。
1.2 粒子系统的三种实现方案对比
先想清楚一个核心问题:烟花炸开后的碎屑,在Three.js里应该用什么来渲染?
我实际对比过三种常见方案,各有优劣,这里直接给出结论和参数对比:
| 方案 | 实现方式 | 粒子上限 | 性能表现 | 灵活度 |
|---|---|---|---|---|
| 独立Mesh小球 | 每个粒子new一个SphereGeometry+Mesh | 500左右 | 较差,Draw Call爆炸 | 高,可单独控制每个球 |
| THREE.Points | 使用Points+PointsMaterial | 5000左右 | 优秀,单次Draw Call | 中等,靠材质属性控制 |
| ShaderMaterial+BufferAttribute | Points配合自定义着色器和attribute | 20000+ | 最佳,GPU端计算 | 最高,可自定义一切 |
先说独立Mesh小球方案。这个方案最直观,每个粒子就是一个小球体,想怎么控制就怎么控制。但问题是:一个烟花炸开后可能有200~500个粒子,同时放5发烟花就是2000多个Mesh,浏览器光是排序和绘制调用就能把帧率拖垮。我实测过,超过1000个独立Mesh后帧率就明显下降,切场景时还会卡顿。这个方案,说实话,只适合教学演示,不适合实际项目。
再看THREE.Points方案。这是官方内置的粒子系统,底层用BufferGeometry存粒子的位置、颜色、大小,渲染时一次性提交给GPU,性能非常好。PointsMaterial支持贴图、大小衰减、顶点颜色,能满足70%的粒子场景需求。做烟花的话,可以用它配合一个圆形渐变贴图,伪装成圆形粒子。这个方案是目前最常用、性能与开发效率最平衡的。
最后是自定义ShaderMaterial方案。这需要写GLSL着色器代码,把粒子的位置、大小、透明度、颜色变化全部交给GPU计算。好处是性能天花板最高,可以同时渲染几万甚至几十万个粒子,而且能实现PointsMaterial做不了的效果——比如粒子大小随距离衰减的平滑控制、颜色渐变、透明度随寿命变化等。坏处是要自己写着色器,调试成本高。
我的选择是:用THREE.Points作为渲染载体,配合BufferGeometry管理粒子的位置和颜色属性,但在材质上直接写ShaderMaterial。这样既拿到了Points的渲染性能优势,又能通过GLSL着色器精准控制烟花粒子的视觉效果。一句话总结:用Points的身,穿ShaderMaterial的衣,这套组合拳在性能和效果之间找到了最佳平衡点。
1.3 烟花系统的核心架构规划
整个系统我拆成了四个模块,每个模块各司其职,方便后续扩展和调试。
第一个模块是粒子池。烟花的粒子数量是动态变化的——发射瞬间粒子数激增,爆炸后粒子逐渐消失。如果每次发射都重新创建BufferAttribute,内存一直在分配和释放,很容易造成卡顿。粒子池的做法是:预分配一个最大容量(比如5000个粒子),所有烟花共用这一块缓冲区,发射和回收只是修改粒子的“存活状态”,不涉及内存分配。
第二个模块是发射控制器。它负责按预设的时间间隔发射一发或几发烟花,设定每一发的爆炸中心点坐标、颜色、粒子数量、初始速度等参数。发射控制器就像是一个“炮兵指挥官”,它不关心粒子怎么运动,只负责决定“什么时候、在哪里、用什么颜色炸一发”。
第三个模块是粒子动力学与生命周期。每颗粒子从爆炸中心获得一个方向随机的初速度,之后受重力影响做抛物线运动,同时记录自己的剩余存活时间。当寿命耗尽,粒子从活跃列表里移除并归还给粒子池。
第四个模块是渲染管理器。负责把粒子位置数据上传到GPU、更新着色器中的uniform变量(比如时间、屏幕比例)、控制相机自动旋转。
模块之间通过一个简单的“发射事件”来通信:发射控制器调fire(x, y, z, color),粒子池分配粒子,粒子动力学更新每一帧的位置和寿命,渲染管理器负责把最终数据显示到屏幕上。这种解耦方式让我后来想加音效、加摄像机震动、加拖尾特效时,基本不需要改动核心逻辑。
2. 核心技术原理与Shader实现细节
2.1 粒子纹理:圆点软边界的生成技巧
烟花粒子如果直接用THREE.PointsMaterial渲染,默认是方形的像素点,效果非常生硬。要做出那种边缘柔和的圆形光点,最关键的一步是给粒子一个“软边纹理”。
我试过两种做法。第一种是用Photoshop或在线工具生成一张白底黑边的圆点PNG贴图,然后加载进来。这个方案可用,但有个问题——加载是异步的,需要等纹理加载完成才能开始渲染,而且项目里多了一个外部资源文件,迁移时容易漏。
第二种做法我强烈推荐:直接用Canvas画出来。三行代码的事,还不用发网络请求:
function createParticleTexture() { const canvas = document.createElement('canvas'); canvas.width = 32; canvas.height = 32; const ctx = canvas.getContext('2d'); // 画一个从中心白到边缘透明的径向渐变圆 const gradient = ctx.createRadialGradient(16, 16, 0, 16, 16, 16); gradient.addColorStop(0, 'rgba(255, 255, 255, 1)'); gradient.addColorStop(0.5, 'rgba(255, 255, 255, 0.8)'); gradient.addColorStop(1, 'rgba(255, 255, 255, 0)'); ctx.fillStyle = gradient; ctx.fillRect(0, 0, 32, 32); const texture = new THREE.CanvasTexture(canvas); return texture; }这个纹理创建一次,之后所有粒子共用它。在Shader里,粒子的颜色可以通过顶点颜色属性单独控制,纹理的白色部分会跟顶点颜色相乘,最终显示成带渐变边缘的彩色光点。边缘的Alpha渐变在着色器里处理成透明,叠加在一起时会自然融合,不会出现方块轮廓。
2.2 ShaderMaterial的核心Uniform与Attribute
写ShaderMaterial之前,先搞清楚哪些数据要随每颗粒子变化、哪些数据是全局统一的。两类数据在GPU里走不同的通道:
- Attribute(顶点属性):每颗粒子都有自己的值。位置(position)、颜色(aColor)、大小(aSize)、寿命进度(aLife)这些都是attribute。
- Uniform(全局变量):每一帧所有粒子共享的值。时间(uTime)、重力(uGravity)、屏幕比例(uPixelRatio)、纹理(uTexture)、粒子透明度控制(uOpacity)这些是uniform。
顶点着色器的主要工作是:把每颗粒子的世界坐标换算成裁剪空间坐标,决定粒子在屏幕上的像素大小,并且把颜色、寿命进度这些数据传递给片元着色器。
核心代码如下:
// 顶点着色器 attribute float aSize; attribute vec3 aColor; attribute float aLife; uniform float uTime; uniform float uPixelRatio; varying vec3 vColor; varying float vLife; void main() { vColor = aColor; vLife = aLife; vec4 mvPosition = modelViewMatrix * vec4(position, 1.0); // 距离越远粒子越小,距离越近粒子越大,这是透视效果的基础 gl_PointSize = aSize * uPixelRatio * (300.0 / -mvPosition.z); gl_Position = projectionMatrix * mvPosition; }片元着色器的逻辑:
// 片元着色器 uniform sampler2D uTexture; uniform float uOpacity; varying vec3 vColor; varying float vLife; void main() { // 从纹理采样,拿到当前片元的颜色和透明度 vec4 texColor = texture2D(uTexture, gl_PointCoord); // 粒子逐渐消失:根据寿命进度淡出 float alpha = texColor.a * uOpacity * (1.0 - vLife); // 靠近粒子边缘时透明度平滑过渡到0 if (alpha < 0.01) discard; gl_FragColor = vec4(vColor * texColor.rgb, alpha); }这里有两个点值得展开说明。第一个是gl_PointSize的衰减公式。300.0 / -mvPosition.z是一个经验公式,其中-mvPosition.z是粒子到相机的距离。当距离为300时,粒子大小刚好是aSize * uPixelRatio的原始值;距离翻倍则大小减半。这个300是个可调参数,值越小粒子整体越大,视觉效果越炸裂。第二个是片元着色器里的discard操作。当粒子边缘的Alpha低于0.01时直接丢弃该片元,这能避免GPU对完全透明的像素做无意义的混合计算,对性能有实实在在的提升。
2.3 粒子运动:初速度分布、重力衰减与阻力
烟花爆炸的物理过程,可以简化为一个三维空间内的抛体运动。每颗粒子在爆炸瞬间获得一个随机的三维速度方向,之后受重力影响向下加速,同时空气阻力让速度逐渐衰减。
初速度的方向需要均匀分布在球面上。这看起来简单,但有个经典的坑:如果直接在[-1, 1]之间取三个随机数再归一化,得到的粒子分布会偏向立方体的角方向,球面会出现不均匀的密集块。正确做法是用球坐标生成——随机取极角φ在[0, 2π]均匀分布,取方位角θ的余弦值在[-1, 1]均匀分布,再换算回笛卡尔坐标:
function randomDirection() { const theta = Math.random() * Math.PI * 2; const cosPhi = Math.random() * 2 - 1; const sinPhi = Math.sqrt(1 - cosPhi * cosPhi); return new THREE.Vector3( sinPhi * Math.cos(theta), sinPhi * Math.sin(theta), cosPhi ); }速度大小怎么定?我经过多次调参,发现5到9的区间比较合适。小于5,烟花碎片刚刚从中心弹出就往下掉,炸不开;大于9,粒子飞得太快,肉眼还没来得及看清形状就已经飞出屏幕了。还有一个细节:不同粒子的速度差距要尽量大,这样爆炸时才能形成“中心密集、外围稀疏”的自然层次感,否则所有的粒子都以同样的速度飞行,又均匀又假。
每帧更新位置时,两个力同时作用:重力和阻力。重力是一个固定的向下加速度值,我设为-9.8 * 0.25,这个缩放系数是为了让效果在三维世界里看起来更自然——完全不缩放的话,粒子掉得太快,烟花很快就“谢了”。阻力用速度的指数衰减来模拟:每帧把速度乘以0.985左右,模拟空气摩擦。阻力值不能太大,否则粒子飞到一半就“定住”了,失去那种炸开后自由飞散的感觉。
位置更新的核心逻辑:
const positions = geometry.attributes.position.array; const velocities = []; // 保存每颗粒子的速度向量 const gravities = []; // 保存每颗粒子独立的重力衰减系数 // 每帧更新所有活跃粒子 function updateParticles(delta) { for (let i = 0; i < particleCount; i++) { if (!active[i]) continue; velocities[i].multiplyScalar(0.985); // 空气阻力 velocities[i].y += GRAVITY * gravities[i] * delta; // 重力 positions[i * 3] += velocities[i].x * delta; positions[i * 3 + 1] += velocities[i].y * delta; positions[i * 3 + 2] += velocities[i].z * delta; } geometry.attributes.position.needsUpdate = true; }注意这里用的是needsUpdate = true告诉WebGL缓冲区需要重新上传数据。如果忘了这一行,你会发现舞台上一片漆黑,粒子纹丝不动。
3. 实操过程与核心环节实现
3.1 环境初始化与基础场景搭建
进入实操环节。先初始化项目:新建一个文件夹,执行npm init -y,再安装依赖。我用的是Vite作为开发服务器,启动速度快、HMR体验好。也可以用webpack,但Vite对Three.js这类纯前端库的调试最省心。
npm install three vite -D然后在项目根目录建一个index.html、一个main.js。入口文件里先搭好最基础的三件套:场景、相机、渲染器。
import * as THREE from 'three'; import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; const scene = new THREE.Scene(); scene.background = new THREE.Color(0x050510); // 深蓝黑色天空背景 const camera = new THREE.PerspectiveCamera( 60, window.innerWidth / window.innerHeight, 0.1, 1000 ); camera.position.set(0, 8, 24); camera.lookAt(0, 8, 0); const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(window.innerWidth, window.innerHeight); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); // 限制像素比,防止高DPI设备性能崩溃 document.body.appendChild(renderer.domElement); // 加入轨道控制器,方便调试视角 const controls = new OrbitControls(camera, renderer.domElement); controls.enableDamping = true; controls.dampingFactor = 0.05; controls.autoRotate = true; controls.autoRotateSpeed = 0.8;像素比这个细节值得多说一句。现在的手机和高端显示器,devicePixelRatio动不动就是2或3,如果不做限制,渲染器的实际分辨率会暴涨到原始分辨率的4到9倍。粒子特效是纯GPU密集型任务,分辨率翻倍意味着片元着色器的工作量翻四倍。限制到2是对画质和性能的折中,肉眼几乎看不出差别,帧率却能稳定不少。
OrbitControls的autoRotate是我强烈推荐开的功能——镜头自动绕着场景缓慢旋转,烟花的三维立体感直接拉满。如果镜头固定不动,那么无论粒子怎么飞,观众看到的始终是一个平面,三维空间的纵深感完全体现不出来。
还要加一点调节气氛的光效。Three.js自带的点光源和聚光灯在这类纯粒子项目里用处不大,真正的立体感来自自发光粒子在暗背景下的对比。所以不需要额外的Light对象,Scene的background颜色统一用深色即可。
3.2 粒子池与烟花发射器的完整实现
粒子池的设计目标很明确:在一开始就分配好最大容量的缓冲区,游戏运行时不增不减。
我预分配了MAX_PARTICLES = 8000个粒子。每个粒子占用的内存包括:3个float存位置、3个float存颜色、1个float存大小、1个float存寿命进度。用Float32Array打包存储,每颗粒子12个float。
BufferGeometry创建如下:
const MAX_PARTICLES = 8000; const positions = new Float32Array(MAX_PARTICLES * 3); const colors = new Float32Array(MAX_PARTICLES * 3); const sizes = new Float32Array(MAX_PARTICLES); const life = new Float32Array(MAX_PARTICLES); const geometry = new THREE.BufferGeometry(); geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); geometry.setAttribute('aColor', new THREE.BufferAttribute(colors, 3)); geometry.setAttribute('aSize', new THREE.BufferAttribute(sizes, 1)); geometry.setAttribute('aLife', new THREE.BufferAttribute(life, 1));新增粒子时,从数组尾部取一个空槽位写入数据。发射烟花的核心函数:
function launchFirework(x, y, z, baseColor) { // 从粒子池申请200到300个粒子 const count = 200 + Math.floor(Math.random() * 100); let assigned = 0; while (assigned < count && poolTail < MAX_PARTICLES) { const i = poolTail++; const speed = 5 + Math.random() * 4; const dir = randomDirection(); positions[i * 3] = x; positions[i * 3 + 1] = y; positions[i * 3 + 2] = z; velocities[i] = new THREE.Vector3( dir.x * speed, dir.y * speed, dir.z * speed ); // 在基础颜色上做随机偏移,让同一发烟花有丰富的色阶 const color = new THREE.Color(baseColor); color.offsetHSL(Math.random() * 0.05 - 0.025, 0, Math.random() * 0.1 - 0.05); colors[i * 3] = color.r; colors[i * 3 + 1] = color.g; colors[i * 3 + 2] = color.b; sizes[i] = 0.3 + Math.random() * 0.4; life[i] = 0; assigned++; } geometry.attributes.aColor.needsUpdate = true; geometry.attributes.position.needsUpdate = true; geometry.attributes.aSize.needsUpdate = true; geometry.attributes.aLife.needsUpdate = true; }这里有个经验参数:最大粒子数不是越大越好。8000个粒子配合ShaderMaterial,在主流集显上都能流畅跑60帧。我试过把上限拉到20000,画质确实更细腻,但帧率降到了30帧上下,得不偿失。如果你的机器配置好,想加粒子数,优先考虑减少粒子的绘制面积,而不是盲目增加数量。
发射位置怎么选?固定在同一位置炸会显得单调,全部随机又会让画面失去重心。我的做法是:核心发射点在(0, 6, 0)附近做小范围随机偏移,偏移半径1到3。这样观众视线的落点基本稳定在画面中央偏上,视觉冲击力最强。
3.3 帧循环、响应式适配与销毁处理
帧循环是整个应用的发动机,每一帧要做的事情包括:更新粒子物理位置、更新粒子的寿命进度、清理死粒子、让相机控制器自动旋转、渲染画面。
const clock = new THREE.Clock(); let lastFireTime = 0; const FIRE_INTERVAL = 800; // 毫秒 function animate() { requestAnimationFrame(animate); const delta = clock.getDelta(); const elapsed = clock.getElapsedTime(); // 定时发射烟花 if (elapsed * 1000 - lastFireTime > FIRE_INTERVAL) { lastFireTime = elapsed * 1000; launchFirework( (Math.random() - 0.5) * 6, 4 + Math.random() * 4, (Math.random() - 0.5) * 6, randomColor() ); } updateParticles(delta); controls.update(); renderer.render(scene, camera); } function randomColor() { const hue = Math.random(); return new THREE.Color().setHSL(hue, 0.9, 0.7); } animate();窗口尺寸变化时的适配,是个很容易被忽略但必须处理的点:
window.addEventListener('resize', () => { camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth, window.innerHeight); });如果不加这段,浏览器窗口拉大后画面会被拉伸,粒子不再是圆形而是椭圆,视觉效果直接崩塌。
最后别忘了销毁逻辑。如果这个烟花效果是加在某个页面里的,用户切换路由时需要销毁渲染器释放GPU资源:
function disposeFirework() { cancelAnimationFrame(animationId); geometry.dispose(); material.dispose(); texture.dispose(); renderer.dispose(); }dispose是WebGL里经常被忽略的一环。BufferGeometry、Texture、Material都占用了GPU内存,不释放的话会发生显存泄漏,页面长时间运行后会越来越卡。如果你是在Vue或React里用它,组件卸载钩子里一定要调用dispose。
4. 常见问题与排查技巧实录
4.1 粒子不显示或全黑:最容易踩的四个坑
我调试这个项目时,粒子不显示是遇到最多的状况,而且几乎每次原因都不一样。这里把高频原因和排查方法整理成表,方便你对照检查:
| 现象 | 原因 | 排查与解决 |
|---|---|---|
| 画面全黑,控制台无报错 | 相机朝向错了,对着天空或地面 | 在animate里打印camera.position,确认相机位置是否在粒子周围 |
| 粒子显示为方点 | 纹理没有正确传给ShaderMaterial | 检查uniform里的uTexture是否赋值,CanvasTexture需要needsUpdate |
| 粒子大小异常 | uPixelRatio未正确设置 | 检查renderer.getPixelRatio()返回值,与uniform里传入的值对比 |
| 粒子忽隐忽现 | z-fighting或深度测试干扰 | 在ShaderMaterial里设置depthWrite: false,粒子不做深度写入 |
第四个问题值得展开。粒子的默认材质是支持深度测试的,但粒子之间的scene位置关系是动态变化的,经常出现后面的粒子被前面粒子遮挡、或者粒子嵌到别的物体中的情况。对不需要被遮挡的发光特效,depthWrite: false是标准解法——关闭深度写入后,所有粒子只是相互混色,不会被后面的粒子覆盖掉,视觉上会柔和很多。
还有一个坑在ShaderMaterial的transparent属性。忘加transparent: true会导致粒子的Alpha通道被忽略,片元着色器里算出的透明度完全不生效,粒子边缘的渐变消失,全部变成不透明色块。
4.2 性能卡顿:先查粒子数量,再查Pixel Ratio
性能问题优先级最高。我踩过一个坑:在4K屏上跑这个烟花,帧率仅有15帧,代码怎么优化都没用,最后发现是renderer.setPixelRatio(window.devicePixelRatio)返回了3,渲染分辨率达到了8K级别。
排查性能问题时,我建议按以下顺序来:
第一,先确认当前的渲染分辨率。在控制台面板里查看renderer.getDrawingBufferSize(),如果输出远大于窗口的CSS尺寸,就是像素比的问题,把它强制限制到2。
第二,检查粒子数。通过geometry.attributes.position.count查看当前粒子总数,如果长期维持在8000以上且帧率低,考虑降低单发烟花的粒子数量。
第三,检查Draw Call。打开Chrome DevTools的Performance面板录制几秒,查看GPU进程的耗时占比。如果Render命令耗时异常高,优先检查着色器里是否有高开销的循环或纹理采样。
第四,最容易被忽略的:不要每帧都新建Vector3、Color对象。Three.js的对象创建有内存开销,在粒子更新循环里频繁new会导致垃圾回收频繁触发,帧率曲线会像锯齿一样。好的做法是预先创建临时变量,循环里复用。
4.3 视觉不自然:让烟花更像烟花的调参经验
烟花最怕的就是看着假,主要原因通常是颜色太单调、速度太均匀、轨迹太直。
颜色方面,不要用纯色。纯红色、纯蓝色烟花在暗背景下看着像色块,缺少真实烟花那种从中心向外颜色逐渐变淡的层次感。我的做法是给每颗粒子的颜色加一个小的随机偏移:色相偏移±0.025,亮度偏移±0.05。这样同一发烟花内能看到细微的颜色变化,整体又不失主色调。
速度方面,粒子初速度要拉开差距。如果所有粒子速度都在5到6之间,爆炸后你会看到一圈整齐的“花瓣”,规则得像数学题答案,极不自然。速度范围扩大到5到9后,快速粒子冲到外围,慢速粒子留在中心,形成了自然的密度梯度,才像真正的爆炸。
轨迹方面,纯抛物线的弹道太“干净”了。现实的烟花碎屑在飞行过程中会因为尾迹、空气湍流产生微小抖动。我加了一个非常轻的随机扰动,每帧给速度增加一个微小的随机向量,幅度在0.02以内。这个扰动肉眼几乎察觉不到,但粒子轨迹从“数学曲线”变成了“有生命感的轨迹”,整体感觉立刻不一样。
最后一个点是颜色偏暗问题。ShaderMaterial里的颜色计算基于线性空间,而浏览器里的颜色是基于sRGB空间输出的。粒子颜色在sRGB下看着正常,到线性空间会暗一截。解决方法是把色彩空间设置对齐:
renderer.outputColorSpace = THREE.SRGBColorSpace;这一行看起来不起眼,实际效果立竿见影——粒子会变得鲜艳透亮,而不是灰蒙蒙的。
5. 结合热点:从烟花看WebGL岗位的实际需求
写完代码,顺便聊聊热词里大家关心的问题:WebGL/Three.js这种岗位需求多吗?值不值得投入时间学?
我的看法比较直接:WebGL/Three.js不是大众岗位,但它是前端的“高价值边缘技能”,它的需求量远不如普通React/Vue开发岗位,但拥有这项技能的候选人数量更少,供需比反而对求职者更友好。
从我的观察看,Three.js的需求集中在几个领域:营销互动页面(618、双11的3D活动页)、数据可视化大屏、在线教育(3D模型展示)、智慧城市(3D地图)、电商(3D商品展示)、游戏公司(H5游戏)。这些领域不会像内容管理系统那样遍地开花,但每个城市的大厂、广告公司、垂直行业头部公司都会养一两个能写Three.js的人。
想走这条路线,我的建议是:先把基础打牢,WebGL原生的着色器编程概念一定要懂,不要只会调Three.js的API。招聘方问得最多的几个点:一是有没有做过性能优化,不懂着色器程序就只能回答“用InstancedMesh”,很难有深度;二是有没有做过复杂交互,比如3D场景中的拖拽、点击、动画序列控制;三是有没有解决过兼容性问题,不同浏览器和移动端的WebGL表现差异很大。
从学习路径上看,从Three.js入手是对的,但一定要在学API之外理解背后的原理。比如你今天做完这个烟花项目,应该能说出:为什么粒子用BufferGeometry性能好?顶点着色器和片元着色器各做了什么?内存泄漏是怎么产生的?这些都是面试官真正想听到的东西。
这个项目做完,你可以顺着几个方向继续扩展:给粒子加拖尾效果(用贝塞尔曲线插值);做成一个烟花编辑器,让用户自定义颜色、爆炸位置、粒子数量;把粒子系统升级成火焰、喷泉、星空等通用特效库。每一步扩展都在加深你对Three.js和WebGL原理的掌握。
我个人在这个项目里最深刻的体会是:做视觉类特效,效果调优比逻辑编码更花时间。粒子数量、速度范围、颜色偏移、重力系数、阻力系数——每个参数单独看都很小,但它们之间相互影响,调参是在做一个多维优化。建议你每次只改一个变量,观察效果变化,多做记录。等总结出一套自己的“手感”参数组合,再做任何粒子特效都会得心应手。