1. 项目概述与核心思路1.1 这玩意儿到底能干嘛烟花模拟器说白了就是用代码在屏幕上画烟花。但别小看这四个字真要做出来满屏流光炸开的效果牵扯到的东西比想象中多得多粒子系统、随机算法、缓动曲线、颜色叠加、碰撞检测甚至还有性能优化和事件调度。过年期间把它挂在网页头图、会议室投屏、或者发到家庭群里当彩蛋效果比静态祝福语强太多了。我最早做这个项目的动机很朴素——那年除夕夜楼下鞭炮被禁了家里电视机里放着春晚但总觉得少了点气氛。后来想在电脑屏幕上放点烟花找了几个现成库要么是卡成PPT的老古董要么是纯3D重引擎杀鸡用牛刀。干脆自己写一个轻量的目标就三条浏览器打开就能跑、满屏粒子不卡顿、爆炸效果足够以假乱真。最终成品是一个单HTML文件零依赖不加载任何外部资源纯Canvas 2D渲染。代码总共不到600行塞进一个HTML里双击就能看。放在本地服务器上也无所谓反正没有跨域问题。你要是愿意甚至能直接内嵌到主页的header区域当背景特效。这项目的核心受众很明确前端新人想练手Canvas动画的过年前想搞点气氛但又没时间从零研究的或者就是单纯想在朋友圈晒一晒自己会写烟花的。无论你属于哪种这篇文章都能让你照着敲出来。1.2 技术选型为什么是Canvas而不是WebGL或CSS先说结论Canvas 2D是这个场景的最优解。WebGL确实能做出更炫的粒子效果但代价是学习曲线陡峭还要处理着色器编译、缓冲区管理等一堆底层细节。CSS动画倒是简单但烟花的核心是无数个粒子各自飞散CSS对大量独立运动元素的控制力太弱做几十个星星点点还行做几百上千个粒子就明显吃力了。Canvas 2D的定位恰好卡在中间API简单到一把梭drawCircle就能画粒子原生性能在普通设备上跑几千个粒子没啥压力。最妙的是它天然支持像素级别的alpha混合烟花重叠时的通透感能直接做出来这也是HTML DOM方案永远追不上的。还有个细节值得说一下requestAnimationFrame。很多人写动画喜欢用setInterval但浏览器对setInterval有最小间隔限制刷新率不匹配还会出现卡顿或跳帧。requestAnimationFrame是跟着显示器刷新率走的60Hz屏幕就是每秒60帧每帧回调里的时间戳是精确到微秒的做物理计算时用它卡时间不会出现不同电脑上速度快慢不同的经典bug。选型的时候就一个原则用最少的依赖解决最多的问题。Canvas 2D加原生JavaScript没有任何第三方库连jQuery都不用——不是装清高而是这种纯动画项目库的体积和API开销反而成了累赘。你想想烟花模拟器本身就是一个展示型项目加载一个300KB的库就为了画圆那就完全背离初衷了。2. 核心细节解析与实操要点2.1 粒子系统的设计哲学烟花爆炸的本质就是一个父粒子裂变成几百个子粒子的过程。每个子粒子都有自己的位置、速度、加速度、生命周期和颜色。用面向对象的思路写一个Particle类然后维护一个全局数组这就是粒子系统的雏形。但这里有个坑如果每个粒子都是独立对象且频繁创建销毁JavaScript的垃圾回收机制会在某些时刻阻塞渲染线程表现就是烟花炸到一半突然卡一下。解决办法有两个第一是对象池复用。预先创建一批粒子对象使用一个存活标记来区分当前帧是否有效。死亡的粒子不是delete而是把存活标记置为false下一帧新烟花爆炸时直接复用这些空闲对象大幅减少内存分配和GC压力。第二是限制粒子总数。我实测过普通PC上Canvas 2D绘制5000个半径为2像素的圆每帧耗时大约8-12毫秒也就是每帧还有富余。但如果粒子数超过10000帧时间会飙升到25毫秒甚至更高直接掉到40帧以下。所以设计上定了一个硬上限粒子上限3000个。这个数字是有讲究的。每颗烟花爆炸产生80到120个粒子同时烟花弹飞行上升的那颗也算一个粒子满屏同时有30发烟花在绽放就是大约3000个粒子的量级刚好卡在性能和视觉的平衡点上。粒子类的核心属性就这几个千万别贪多class Particle { constructor() { this.x 0; // 水平坐标 this.y 0; // 垂直坐标 this.vx 0; // 水平速度 this.vy 0; // 垂直速度 this.gravity 0; // 重力加速度 this.alpha 1; // 透明度 this.decay 0; // 透明度衰减系数 this.color ; // 颜色 this.size 0; // 半径 this.active false; // 是否存活 } }每家写的粒子系统字段名略有差异但我强烈建议把gravity作为粒子级属性而不是全局常量。原因后面讲这直接关系到烟花形态的多样化。2.2 轨迹算法的取舍重力、摩擦和空气阻力要让烟花看起来真实速度变化必须遵循物理直觉。烟花弹上升阶段是匀速减速爆炸瞬间获得一个朝各个方向的初速度然后受重力影响逐渐下落同时空气阻力让水平速度逐渐衰减。三个变量就够了重力加速度约0.05像素/帧²。注意这是按帧计算的不是按秒。如果换算成秒是0.05 x 60² 180像素/秒²。这个值不需要物理课本那么严谨让它最终看起来像就行。阻尼摩擦系数0.98。每一帧粒子速度乘以0.98就是逐渐减速的效果。0.98意味着每帧保留98%的速度60帧后速度约为原来的30%肉眼可见地在飘落但不会瞬间停下。初始速度爆炸时粒子向四面八方飞散速度方向是随机的大小范围在2到8像素/帧之间。关键技巧爆炸粒子方向要按角度均匀分布再加随机扰动。如果用完全随机角度爆炸形状会是一团不规则的云没有烟花的球形爆开感。// 爆炸方向计算 const baseAngle (Math.PI * 2 * index) / particleCount; const randomAngle (Math.random() - 0.5) * 0.5; // 扰动 const angle baseAngle randomAngle; const speed 2 Math.random() * 6; particle.vx Math.cos(angle) * speed; particle.vy Math.sin(angle) * speed;这个先均分再加扰动的思路在粒子动画里是黄金法则。不只在烟花里做星空闪烁、雪花飘落、碎纸飞舞都能用上。2.3 颜色方案从五毛特效到视觉盛宴我之前翻了市面上很多烟花模拟器最大的通病就是配色死板。一片大红色、金黄色乍一看很喜庆但混在一起就糊成一团像颜料桶打翻了。真实烟花颜色的核心秘密是色彩叠加。爆炸时单个粒子用较浅的颜色粒子与粒子重叠的区域因为alpha叠加颜色自然加深产生外层亮、内层艳的立体感。如果你每个粒子都画成深色没有透明度差异那炸开就是一大块色斑。我的配色策略分三层第一层是底色粒子。整体数量多、透明度较高alpha 0.6到0.9负责构成烟花的主体形状颜色选用饱和度高的亮色。第二层是核心亮点。数量少透明度低alpha 0.3尺寸更小但颜色更亮接近白色或淡黄出现在烟花中心附近模拟爆炸核心高温区域的光芒。第三层是渐隐拖尾。透明度极低alpha 0.1但运动方向与主粒子相同、速度略慢一点点产生尾迹的幻觉。这一层数量可以多因为透明度低对整体视觉干扰小但能极大增强流光感。颜色选暖色系为主但不要全程大红大紫。我维护了一个色板数组每种烟花随机取一套const palettes [ [#FF6A00, #FFD700, #FF4444], // 橙金红 [#00D2FF, #7FFF00, #00FFAA], // 青绿 [#FF3E96, #FF6EA8, #FFD1E0], // 粉红 ];这个阶段你会体验到代码调色的乐趣烟花颜色的微妙变化不是靠复杂的混色算法而是几个十六进制字符串和一个Math.random()。3. 实操过程与核心环节实现3.1 初始化Canvas与动画主循环我直接把所有代码放在一个HTML文件里便于分发和演示。初始化Canvas时有几个细节值得注意首先是高清屏适配。移动端很多浏览器默认CSS像素和物理像素不一致如果不处理画出来的粒子会发虚像近视眼没戴眼镜看烟花。解决方法是通过devicePixelRatio缩放Canvas的实际尺寸const dpr window.devicePixelRatio || 1; canvas.width canvas.clientWidth * dpr; canvas.height canvas.clientHeight * dpr; ctx.scale(dpr, dpr);画布的CSS尺寸设100vw和100vh监听window.resize事件实时更新。这个方案对桌面浏览器、手机浏览器、甚至投屏到大屏幕都有效。我投到过一块55英寸的会议屏上效果依然清晰不会有任何锯齿或模糊。3.2 粒子渲染从圆形到多形态最朴素的粒子是圆形用arc()画一个实心圆这是最省性能的渲染方式。但真正让烟花好看的是粒子的多层绘制。我用了一个三层绘制法。每帧对每个存活粒子执行第一层画一个半径稍大的低透明度圆模拟光晕。第二层画一个半径稍小但透明度更高的圆模拟核心。第三层在粒子当前位置画一条短线拖尾线段方向是粒子当前的运动方向长度与当前速度成正比。这条线是关键它能模拟出烟花绽放时的流光扫过天空的动感。因为Canvas天然支持线段抗锯齿同样的透明度下短线比圆的视觉效果更柔和光与尾的衔接更自然。关于性能优化还有一个大杀器关闭Canvas的阴影功能。很多新手教程喜欢用ctx.shadowBlur来发光看起来确实炫但这玩意儿是性能黑洞粒子一多帧率直接崩。替代方案就是我上面说的三层绘制法效果类似但性能开销小一个数量级。3.3 自动发射算法营造持续绽放的氛围单纯手动点击发射烟花体验感不足。最终我加入了一套自动发射逻辑让烟花每隔1.5到3秒随机绽放一次。但这个随机不是等概率而是动态调权的。这背后的逻辑是过年气氛的节奏感很重要。如果烟花平均每2秒一发连续10发都是均匀间隔看久了会疲惫。真实的烟花秀是有起伏的——有时候连珠炮一样接连升空有时候又有一段安静。自动发射的算法很简单维护一个nextLaunchTime变量每次发射后从正态分布中取样得到下一个间隔。我简化成一个概率数组其中短间隔0.8秒概率30%中间隔2秒概率40%长间隔3.5秒概率30%。效果比固定间隔好很多视觉上有了呼吸感。烟花弹发射的位置也是重点。我限制了烟花弹的落点范围水平方向是画面的40%到60%区域垂直方向从底部发射。如果随机到屏幕边缘烟花爆炸会被裁掉一半看起来不完整。这个限制看似简单但能让整体构图一直保持视觉中心稳定。另外一个细节发射高度要随机。有的烟花炸得高画面的25%高度处有的炸得低画面40%高度处。高处的烟花爆炸半径小但持续时间长低处的爆炸半径大但明显更亮。这种高低搭配让画面有层次感不会全程一个模式。3.4 声音与交互要不要加鞭炮声说到过年的仪式感声音是不可或缺的一部分。我试过用Web Audio API写一段合成烟花音效白噪音的爆发加低频衰减能模拟出砰的一声。但实际测试发现效果尴尬合成音效和真实烟花差距太大而且同时播放时CPU占用明显上升影响到粒子渲染的流畅度。最终我把音频功能做成可选用一个playSound开关控制默认关闭想要音效的自己去找个wav并填入路径就行。交互上保留了点击屏幕任意位置发射烟花的功能。这个逻辑用监听pointerdown事件实现计算点击坐标直接从该点生成一颗烟花弹升空。但这里有个细微的地方点击发射的烟花爆炸效果要比自动发射的明显一个档次。我是通过给点击发射的粒子加大初始速度和爆炸数量粒子数翻倍让用户的交互有爽感——这算是个心理学小技巧类似游戏里爆击的感觉。4. 常见问题与排查技巧实录4.1 帧率过低粒子太多还是代码太烂如果你跑起来只有20帧先别怀疑电脑配置。90%的情况是你每一帧里做了太多次状态更新或绘制。用DevTools的Performance面板录一段10秒的火焰山图看主线程的占用。如果长时间为红色说明JavaScript计算本身卡了如果持续绿色但帧率低那是浏览器渲染层瓶颈。我遇到过一个典型问题初始化时给每个粒子都调用了ctx.createRadialGradient()创建渐变。650个粒子每帧都创建结果从30帧掉到12帧。后来改成预绘制在初始化时用一个离屏Canvas画好粒子贴图一张光晕PNG的DataURL每帧直接drawImage贴图。帧率瞬间回升到55以上。经验Canvas动画中避免在循环内创建任何对象渐变、路径、甚至字符串拼接全部提前做好。这是优化Canvas最核心的一条法则没有之一。4.2 粒子阵列中的一闪一闪现象这种情况表现为烟花明明该持续拖尾但轨迹却断裂成一截一截的像接触不良的灯带。这多半是因为粒子生命周期结束得太快或者透明度衰减速度太快。排查方法是检查decay值如果每帧透明度减少0.05那一个粒子20帧约0.3秒后就完全消失。对烟花来说这个衰减速度太快正常应该在60帧到90帧之间才完全消失。透明的衰减应该与粒子速度联动快粒子衰减慢慢粒子衰减快。原理很朴素——快速运动的粒子如果衰减快会留下一大片段尾而它们速度慢下来后如果还在高透明度状态就会像悬停的亮点特别不自然。4.3 屏幕尺寸适配的坑我最开始没处理resize事件只在初始化时读取一次窗口尺寸。结果浏览器窗口放大后烟花依然在原来那个尺寸的范围内绘制剩下大片空白。后来加上了resize监听并重新计算画布尺寸但忘记调整发射区域。窗口变得特别宽的时候烟花还是集中在中间40%区域两边空荡荡的。最终改成相对坐标落点范围用画布宽度的百分比表示而不是固定像素。这个改动彻底解决了不同分辨率下的构图问题。移动端的另一坑是设备方向变化。横竖屏切换时会触发resize必须做防抖300毫秒延时再重置画布否则切换瞬间画面会闪一下。4.4 设备兼容性的最后一道防线这里特别提醒requestAnimationFrame的兼容性。新版浏览器没问题但老版本尤其是一些微信内置浏览器可能不支持传入时间戳参数。我写了个降级方案用Date.now()代替每个时间帧的参数并在不支持requestAnimationFrame的旧环境退回setInterval用1000/60毫秒的间隔。这个保护逻辑很少被触发但如果用户用的是老手机加上微信内置浏览器就全靠它兜底。function startAnimation() { if (window.requestAnimationFrame) { requestAnimationFrame(loop); } else { setInterval(loop, 16.7); } }还有一个容易被忽略的点Canvas元素的CSS属性。我见过有人把Canvas放在了有transform属性的父容器里导致坐标偏移点击位置和实际烟花落点对不上。排查半天才反应过来是CSS变换在作怪。解决方法是始终用canvas.getBoundingClientRect()计算点击坐标而不是直接用event.clientX。5. 后续拓展与个人体会5.1 让烟花模拟器不只是过年工具这个项目做完后我发现它天然具备很强的场景扩展性。你可以把配色和发射频率换一换做成演唱会背景屏效果把粒子替换成文字形状的贴图就会变成文字烟花甚至结合麦克风输入做音频可视化——根据音量大小调整烟花的密集程度那又是另一种玩法了。结构上最大的扩展点是粒子的纹理贴图。目前是圆形和短线如果你有精力可以做一个离屏Canvas绘制星形、爱心、甚至福字轮廓的贴图就能炸出福字烟花。原理一样只是把drawImage的贴图换成其他图案。这个能力对春节定制场景特别有用。5.2 经验沉淀一次代码重构的启发这项目我重构过一次最大的领悟是不要一上来就做复杂抽象。最初版本我写了一个发射器类、一个爆炸类、三个特效类过度设计让代码很难调整。后来全部推倒重来只保留了Particle类和全局的数组加上各种标记位逻辑反而清晰了。烟花模拟器的核心就一句话每帧更新所有粒子的位置状态绘制它们然后清理掉该消失的。任何超出这个范围的抽象都是自找麻烦。对于想深入学习前端动画的人这个项目是极好的练手教材。粒子系统是游戏开发和前端可视化最底层的通用技术学会了烟花模拟器颗粒飘散、星空背景、破碎特效、雨雪天气全都能顺手写出来。这个链路比照搬任何教程都有价值。5.3 最后说几个让效果更上一层楼的小技巧我在实际测试中发现以下几个细节对最终观感影响极大第一烟花弹上升阶段画一条淡淡的拖尾线透明度0.2长度随速度变化会极大增强升空时的期待感。真实烟花升空时也是有一条光柱的不是凭空出现在天空中的。第二每次爆炸后在爆炸中心处额外生成了一个短暂的闪光粒子——半径大、持续6帧、透明度极速降低。玩家看烟花时眼角的余光能感觉到这个闪视觉体验一下子就上去一个台阶。第三烟花爆炸的时间差。真正好看的烟花秀不是所有烟花同时炸而是一发还没完全消失、下一发已在半空。所以我的自动发射逻辑特意让间隔短于单个烟花的完整生命周期约2.5秒这样才能形成叠加绽放的效果。说到底烟花模拟器就是用代码制造一秒钟的感动。它让一个物理上不可复现的场景在屏幕上变成可以随时重播、随时自定义的记忆。今年过年如果有家人问起屏幕上的烟花是谁放的你就可以拍拍胸脯说是我代码里放的。