
一个周末的下午我刷着朋友圈里满屏的拜年海报觉得静态图片实在少了点感觉——鞭炮声听不见烟花也不会动年味全靠文字在撑。想着自己的个人主页马上要换上新春主题索性花一下午做了一个网页版的“烟花模拟器”。打开页面深蓝夜空铺开一发金色烟花冲上天空炸开的瞬间流光四溅、缓缓下坠配上一点合成音效过年的气氛一下子就出来了。这篇文章会把整个烟花模拟器从零到一的实现过程完整拆开从粒子系统的物理模型、Canvas渲染细节到配色、光晕、音效再到我在不同设备和浏览器上实测遇到的性能坑。对于一个想给网站、活动页或者个人主页增加年味的前端开发者、创意编程爱好者来说这份实现记录应该能让你少走不少弯路。1. 为什么我选择用纯Canvas手写粒子系统而不是现成烟花库市面上做粒子效果的库并不少tsParticles、particles.js、还有各种基于Three.js的粒子方案。如果只是为了“能出效果”套一个库确实最快。但我这次的需求很明确要一个能高度定制、轻量、且能跑成独立单文件的过年烟花页。对比下来纯Canvas 2D手写粒子系统的优势比想象中大得多。先说说现成库的劣势。tsParticles本身做得非常成熟内置了大量粒子交互模式但它更像一个通用粒子引擎想做“逼真的烟花”需要翻大量配置项——发射角度、初速度、爆炸半径、重力阻尼、颜色渐变、拖尾轨迹每一项都得在它的配置概念里翻译一遍。等你把这些配置摸透往往会发现自由度和性能控制还是不如自己写来的直接。更别提particles.js这类老牌库定位是背景装饰粒子做烟花需要魔改的地方更多不如不折腾。Three.js和WebGL方案则是另一个极端。烟花本身并不需要真正的3D场景2D粒子配合径向渐变光晕贴图视觉上已经能达到“满屏流光”的效果。引入WebGL除了把问题复杂化还会让整个项目体积翻几倍学习成本也高。如果你之后想往3D数字人、复杂交互场景走那用WebGL没问题但就“过年气氛一秒拉满”这件事来说纯Canvas 2D是性价比最高的方案。我最后采用的是最朴素的单文件架构一个HTML文件内嵌CSS和JavaScript双击就能打开扔给朋友也能直接看不需要npm安装、不需要构建工具、不需要网络请求任何外部资源。整个烟花模拟器的核心JavaScript代码大约400行逻辑完全在掌控之中里面每个参数都是亲手调出来的不存在“库帮你做了但你不懂”的情况。2. 烟花粒子的物理模型从升空到炸开的完整生命周期写任何烟花模拟器第一步都是把物理模型想清楚。一个看起来漂亮的烟花本质上是一群粒子的运动轨迹集合而这些轨迹必须遵守几个简单规则惯性、重力、空气阻力以及最重要的“突发性爆炸”。我把整个生命周期拆成三个阶段弹头发射升空、空中爆炸、碎片燃烧消亡。2.1 弹头发射抛物线上升与随机抖动弹头阶段在页面底部随机一个水平位置生成一个半径很小的“火箭”粒子。它需要具备三个属性初始位置、初始速度、当前速度。为了模拟真实发射的倾斜度我给初始速度一个小的水平分量加上垂直向上的主速度然后每帧对速度做重力累加。这里有个细节真实的烟花升空不是一条完美直线而是会因为发射筒的抖动、气流的影响产生轻微摆动。我处理的方法是在每一帧更新位置时给水平速度加一个极小的随机扰动扰动范围控制在正负0.02左右。这样弹头轨迹会在竖直方向上轻微摇摆看起来像有生命一样而不是一道死板的激光。弹头什么时候炸我的做法是给它一个目标高度参数在初始化时随机取屏幕高度的55%到85%之间。当弹头的y坐标到达目标高度的瞬间就触发爆炸函数。这个随机高度差很重要如果所有烟花都在同一高度炸开画面会显得很机械有一定的高低错落才有“此起彼伏”的节日感。2.2 爆炸扩散角度、速度与二次分布爆炸是整个模拟器的精髓。当弹头到达预定高度我会把它从活动粒子列表中移除然后一次性生成80到160个碎片粒子。每个碎片粒子从爆炸中心点出发速度方向在360度内随机速率则决定烟花炸得开不开。速率分配上我试过均匀随机效果很平没有层次。后来改成了一种接近真实烟花的做法先随机一个基础速率区间比如3到7再用一个随机因子生成少数高速粒子飞出外轮廓和大量中低速粒子填充内部。我管这个叫“二次分布”先用随机决定大方向再用另一层随机决定速度衰减。最后爆炸形状会呈现中间密、边缘疏的效果更像一朵花。碎片粒子的“身份”还需要绑定颜色。我给每发烟花分配一个色相家族比如正红色系色相在350附近、金色系45附近、紫色系280附近。爆炸时每个碎片粒子在家族色相基础上随机偏移正负25度亮度也做上下浮动。这样整朵烟花远看色调统一近看每个点又有细微差别不会显得像色块堆积。2.3 消亡阶段空气阻力、重力下坠与透明渐隐爆炸之后的碎片粒子要经历一个“先快后慢”的过程。如果只靠重力碎片会按照完美的抛物线飞出去但真实烟花炸开后边缘会有一种“被空气兜住”的感觉速度明显衰减。我的做法是每帧更新速度时乘一个阻力系数一般取0.96到0.99之间。阻力系数越大烟花扩散越远、拖尾越长越小则越紧实。考虑到过年烟花“炸得开”的视觉效果我最终锁定了0.98。同时每个粒子都有一个生命周期从1开始线性递减。粒子的透明度直接和剩余生命挂钩生命过半后开始明显变淡最后的五分之一生命周期内还会叠加一个随机闪烁让粒子在熄灭前像真的燃烧殆尽一样。生命结束后粒子从活动列表移除回到粒子池等待复用。3. 代码落地一个可复制的烟花模拟器核心实现直接给出一个可以运行的核心版本。下面的代码不是完整项目而是把“弹头发射爆炸粒子主循环”的关键骨架抽出来你把它套进任何HTML文件就能看到烟花在Canvas上炸开。3.1 初始化与粒子池粒子对象如果频繁用new创建和销毁在几百上千个粒子的场景下会造成明显的GC卡顿。我维护了一个简单的对象池粒子数组里所有对象预先分配好死亡后标记为active false新粒子直接复用。class Particle { constructor() { this.x 0; this.y 0; this.vx 0; this.vy 0; this.life 0; this.maxLife 0; this.color hsla(0, 80%, 60%, 1); this.size 2; this.active false; } update(dt) { if (!this.active) return; this.vy 0.04 * dt; // 重力 this.vx * 0.98; // 阻力 this.vy * 0.98; this.x this.vx * dt; this.y this.vy * dt; this.life - dt; if (this.life 0) this.active false; } draw(ctx) { if (!this.active) return; const alpha Math.max(0, this.life / this.maxLife); ctx.globalAlpha alpha; ctx.fillStyle this.color; ctx.beginPath(); ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2); ctx.fill(); ctx.globalAlpha 1; } }3.2 弹头与爆炸逻辑弹头火箭的更新和普通粒子略有差异它不做阻力衰减只受重力和随机扰动影响。到达炸点后调用burst()函数生成一组碎片粒子。function burst(cx, cy, baseHue, count 120) { for (let i 0; i count; i) { const p objectPool.get(); // 从对象池取 p.active true; p.x cx; p.y cy; const angle Math.random() * Math.PI * 2; const speed 1.5 Math.random() * 6; // 基础速率 // 二次分布少量高速粒子形成外轮廓 const extra Math.pow(Math.random(), 2) * 4; p.vx Math.cos(angle) * (speed extra); p.vy Math.sin(angle) * (speed extra); p.maxLife 60 Math.random() * 40; p.life p.maxLife; const hueOffset (Math.random() - 0.5) * 50; p.color hsl(${baseHue hueOffset}, 85%, ${55 Math.random() * 25}%); p.size 1.5 Math.random() * 2; } }3.3 主循环与半透明残影拖尾烟花拖尾的实现方式不是“记录历史轨迹画线”成本太高。我的做法是每一帧先用半透明黑色填充整个画布再画粒子。上一帧残留下来的图案会被逐渐盖掉形成自然的拖尾光效。这里的填充alpha直接决定拖尾长度alpha越小残影停留越久拖尾越长。function frame() { ctx.globalCompositeOperation source-over; ctx.fillStyle rgba(10, 8, 25, 0.22); // 半透明背景拖尾长度靠这个值调 ctx.fillRect(0, 0, canvas.width, canvas.height); // 粒子加法混合让光晕重叠区域更亮 ctx.globalCompositeOperation lighter; for (const p of particles) { p.update(dt); p.draw(ctx); } requestAnimationFrame(frame); }rgba(10, 8, 25, 0.22)这组值是我反复试出来的。背景基色是深蓝色偏紫alpha取0.22时拖尾的流光感最明显又不会让上一帧的颗粒残留过重导致画面变脏。4. 氛围的关键不是粒子而是颜色、光晕和构图代码跑通之后我用十分钟就看到了“粒子们在天上飞”但画面离“过年气氛拉满”还差得很远。说句实话最初版的效果像一群彩色蚊子在屏幕上乱窜毫无庆祝感。后来我意识到决定一个烟花模拟器观感的并不是粒子物理系统的复杂程度而是三个偏设计向的细节光晕质感、背景层次、颜色策略。4.1 光晕贴图告别shadowBlur性能毒药很多人画发光粒子第一反应是ctx.shadowBlur效果确实梦幻但代价极其惨重——每帧每个粒子都要做一次阴影计算粒子数量超过300以后帧率直接掉到30以下。我的解法是预渲染光晕贴图在离屏Canvas上画一个小的径向渐变圆中心为白色半透明边缘完全透明然后在渲染时用drawImage把这个贴图画到每个粒子位置。这个技巧本质上是把“实时计算的复杂光晕”变成“查表贴图”性能开销从几十倍降到了接近普通图片绘制。配合globalCompositeOperation lighter加法混合多个粒子的光晕叠加处会自然变亮模拟出真实烟花那种中心若隐若现的刺眼光感。4.2 背景层次夜空不是单纯的黑为了让画面不空洞我给了背景三层内容最上层是渐变夜空从深蓝过渡到暗紫再到底部暖灰黑中层是远远的一排深色建筑剪影用来衬托烟花的“远”最下层是两棵光秃秃的树影和几个红色的灯笼剪影贴近新年气氛。建筑和树影我不会真的画精细模型就在Canvas上用arc和lineTo勾勒几个不规则的半圆轮廓然后填充纯深色比夜空最暗处更黑一点。关键点在于烟花的炸点高度要和剪影产生遮挡关系——画面底部建筑剪影的前方偶尔会有低空烟花炸开粒子从剪影的暗色区域飞出瞬间层次感就出来了。这个做法成本极低但效果很值得。4.3 颜色策略色相家族胜过五彩斑斓新手做烟花最容易犯的错是每颗粒子随机一个颜色结果一整发烟花五颜六色画面又脏又乱。真实烟花每一发都有自己的主色调顶多带一点渐变点缀。我实现的策略是按“轮次”分配色相家族自动播放模式下每2到3秒内出现的一组烟花使用相近的色相范围比如第一波是金色系第二波是红色系第三波是紫蓝色系。同波次的烟花用基础色相加上小范围偏移组织爆炸粒子的颜色。偶尔我也会在一发烟花里混合两三种对比色比如紫色烟花里掺少量白色粒子作为爆炸中心的高光点。这些白色粒子速度略慢、寿命偏短位置集中在爆炸核心附近视觉上会产生“炸开的强光一闪而过”的效果非常能提升精致感。注意白色粒子不要太多否则在加法混合下会形成一大片过曝的白斑。4.4 自动与手动两种触发模式页面打开时我做了两个播放模式自动模式下每1.5到2.5秒随机生成本发烟花耐心看一会整个屏幕会持续不断出现此起彼伏的爆炸手动模式下用户点击屏幕任意位置即可在点击处触发烟花。自动模式适合放在大屏展示的角落手动模式则适合自己玩或者嵌入到贺卡交互页里。自动模式的发射顺序也有讲究开局先来一发大而散的金色烟花亮度拉满把整个夜空“点亮”紧接着两发小型的红色和紫色烟花错落在两侧节奏快一些之后就是随机间隔的持续输出。这样前5秒就能抓住人而不是一打开页面就慢吞吞地单发试探。5. 性能优化和实机踩坑记录这部分我想认真聊聊踩过的坑因为烟花模拟器这类粒子项目写逻辑容易调性能真的很折磨人。尤其我的目标是让它在普通笔记本上也能稳定60帧这逼着我做了一系列优化。5.1 坑一shadowBlur让帧率直接腰斩最初光晕效果用的是shadowBlur粒子数大约500时帧率就是30FPS上不去了。后来统计了一下当前屏幕上有超过一千个粒子的时候每帧光阴影计算就要消耗十几毫秒这还只是中等爆炸规模。替换成光晕贴图之后同样的粒子量帧率恢复到60FPS上下限一下拉开了差距。如果你做类似项目光晕这一条一定要记得绕开shadowBlur。5.2 坑二高分辨率屏幕的性能与清晰度权衡Retina屏会让Canvas的实际像素数变成CSS像素数的两倍或三倍。如果直接把canvas.width设置成window.innerWidth * devicePixelRatio虽然画面细腻但像素总量爆炸式增长。在4K显示器上一次全屏烟花加上背景渐变和半透明覆盖填充开销大到掉帧。我的妥协方案是给Canvas设置一个固定的逻辑尺寸上限比如宽1920、高1080然后用CSS把Canvas拉伸到全屏显示。超出1920宽的屏幕画面会略微发虚但换来的是稳定的帧率。考虑到烟花本身是移动速度很快的光点轻微的锯齿和发虚肉眼几乎分辨不出来帧率稳定才是体验的核心。5.3 坑三帧率不同导致烟花速度不一致这是最隐蔽的一个坑。我在60Hz屏幕上调试好的参数是完美的但拿到一台144Hz高刷笔记本上一跑烟花升空速度直接快了一倍多爆炸扩散也快得离谱整个气氛变成了“流星雨”。原因很简单我的物理更新直接放在requestAnimationFrame回调里没有考虑帧间隔。解决方案是引入固定的时间步长。我抽出一个update(dt)函数dt固定为1对应60帧的间隔然后用累积器在requestAnimationFrame里补帧每次回调把实际经过的帧数累加每满1帧就执行一次物理更新不足1帧的部分留到下一帧。这样不管屏幕刷新率是60、90还是144Hz烟花物理永远按60Hz的节奏运行视觉效果完全一致。5.4 坑四对象池与GC卡顿当我第一次把爆炸粒子数拉到150、同时存在3发烟花时画面出现了一秒一次的轻微卡顿。打开DevTools的Performance面板一看JS里大量的垃圾回收时间。罪魁祸首是频繁创建的粒子对象。改造为对象池之后粒子对象从创建一次反复使用GC几乎消失卡顿问题跟着消失。对象池的代码很普通核心就是数组里放一批对象取用时找一个非活跃对象重置字段归还时标记为不活跃仅此而已。问题现象根因解决方案粒子一多帧率骤降shadowBlur逐粒子实时计算阴影离屏Canvas预渲染光晕贴图drawImage复用高DPI屏幕下卡顿Canvas像素量过大填充开销高逻辑分辨率限制在1920x1080CSS拉伸不同刷新率下速度不一致物理更新绑定rAF实际帧率固定时间步长累积器控制物理频率偶发卡顿像掉帧粒子对象频繁创建销毁触发GC对象池复用粒子避免运行时new6. 进阶玩法从单发烟花到互动烟花秀核心跑通之后如果还有余力有几个扩展方向我非常推荐它们能把这个模拟器从“演示Demo”提升成“完整作品”。说白了过年氛围这件事比拼的往往不是谁粒子物理更精确而是谁的互动心跳更懂人。6.1 特殊形状烟花环形、心形与文字烟花让烟花炸成特定形状本质上是改变速度向量的角度与速度分布。环形最简单不让角度均匀随机而是让所有粒子的角度严格均分取模360度速度也统一在一个很小的波动范围内爆炸后就会形成一个规整的圆。心形则要给每个粒子的速度向量乘上一个心形参数方程产生的方向向量再在沿途叠加重力。文字烟花稍微复杂一点先在离屏Canvas上用粗体字把“福”“春”之类的单字画出来然后用getImageData读取像素点得到文字轮廓坐标集合。爆炸时把这些坐标当作粒子的目标方向让粒子沿着轮廓点飞行。实测下来文字烟花在偏大的字上效果才有辨识度小字会糊成一团。6.2 合成音效没有音频文件也能有声有色静音的烟花始终差一口气鞭炮声和爆炸声对气氛的贡献不亚于画面。我不想引外部音频文件于是用了Web Audio API直接合成音效。升空阶段用的是一个快速上升的正弦波频率包络听起来像“咻——”爆炸声则用一段带衰减的白噪声加低频振荡器混合模拟闷响。这种做法的好处是打开页面瞬间就能播放不用等音频资源加载。6.3 组合弹玩法高级一点的烟花秀单发不够看。我在项目里加了两阶段爆炸第一发炸开之后不等粒子死亡在0.2秒后从每个碎片粒子的当前位置再次触发一个小型爆炸。这样会产生“炸了又炸”的连环效果。用得克制的话非常惊艳如果每发都这样搞画面会过曝卡顿我的经验是每隔三四发普通烟花穿插一发双阶段特效观众的目光就会一直被你牵着走。6.4 交互彩蛋输入心愿发射文字烟花最后我做了个隐藏彩蛋键盘输入文字后按回车文字会作为弹头带着亮光升空炸开时以文字形状的粒子群显示在夜空中。虽然技术上只是把文字烟花和输入框做结合但互动感和参与感瞬间提升一个档次。放到朋友圈或公司年会的个人页面上能让人玩上很久。7. 写在最后的调参心得所有代码写完、功能跑通之后我还花了大概一个晚上专门调各种参数。这里分享几个自己总结的小规律希望对做类似粒子项目的你有帮助。参数不要照搬任何教程里的一组固定值包括我上面给出的那些计算值和区间。不同屏幕比例、不同背景色调、不同预期效果甚至不同长度的拖尾都会让最优参数漂移。最笨但最有效的方法是做一个“参数实验室”把重力、阻力、初速度、爆炸粒子数、色相、寿命都暴露成全局变量再绑定一组滑块UI边拖边看效果。这样你不用重开页面就能找到自己项目的“手感”。另外真实烟花的节奏感很难用一套参数覆盖。我观察了许多慢动作烟花视频发现一个容易被忽略的点爆炸后的前十几帧粒子速度极快且带着强烈的光芒随后迅速变慢后半段的视觉几乎完全由拖尾和下坠主导。模拟的时候宁可把起始速度调大一些、把阻力调强一些也不要整体匀速飞行那样的烟花会像水母飘在空中没有爆发力。最后随机性这个东西关键在“让随机看起来有秩序”。每一发烟花的粒子方向都不同但整体是扩散的圆每一颗粒子的颜色都偏离一点但整体在一个色系里每一秒的爆炸高度都不同但总体覆盖屏幕的中上区域。这种“约束里的随机”才是真实感的来源。我调了一整晚最深的体会是代码和粒子本身并不复杂复杂的其实是审美上的那一点点分寸感。