有段时间没打开这个文件夹了年前翻出来跑了一下满屏流光炸开的时候办公室几个人都凑过来看。就是一个这样的东西一个纯前端写的“烟花模拟器”不需要装任何工具一个HTML文件丢到浏览器里就能跑。自动升空、爆炸、拖尾还能点击屏幕任意位置手动引爆过年气氛一秒拉满。如果你正愁节日页面没有氛围或者想在聚会上给大屏加个暖场背景直接照着做就行。想练Canvas粒子系统的这篇也能当一份带完整解析的实战代码笔记来读。1. 烟花模拟器的整体设计与选型思路1.1 它到底是个什么东西能解决什么问题先把这个项目说清楚。它本质上是一个基于Canvas 2D的粒子特效系统里面的每一个光点、每一道拖尾都是JS代码实时算出来的不是什么GIF图或者视频。运行时画面大概是这样的屏幕底部随机位置有一颗“火箭”拖着细长的尾巴飞上去飞到大半屏高度时“嘭”一下爆开变成几十上百个粒子向四周飞散粒子带着余晖缓缓下落然后变淡消失。整个过程循环往复背景是深蓝色的夜空颜色以红、橙、金、粉为主特别有春节那味儿。这个项目适合几类人第一种是公司年会、店铺大屏、家庭投影需要节日背景的挂个页面在那里循环播放就行第二种是前端初学者想知道粒子系统、动画循环、Canvas绘图到底怎么回事第三种是爱折腾的想在个人博客、活动页面里嵌入一段“有点东西”的动态效果。它最大的价值在于门槛极低不依赖任何框架也没有复杂的构建流程复制一个HTML文件就能用这才叫真·开箱即用。1.2 为什么用Canvas而不是CSS动画或WebGL经常有人问这种效果能不能用CSS做能做但会非常吃力。CSS动画擅长的是位移、旋转、缩放这些单点变化你很难让几十个粒子彼此独立、有不同速度和方向地飞出去。真要硬做CSS代码量会爆炸而且浏览器对大量动画节点的合成压力远大于单一Canvas的绘制压力。所以这种“一屏幕几百上千个动态对象”的场景Canvas 2D是性价比最高的选择。那为什么不用WebGLWebGL确实能做到更炫的效果尤其是大量粒子和复杂着色器场景那属于高级玩法。但WebGL需要写着色器语言还要处理上下文、缓冲区、纹理映射对大多数想快速出效果的人来说太重了。烟花粒子系统的核心其实是“数量大、逻辑简单”Canvas 2D的绘制速度完全够用。我实测在普通笔记本上同时存在8000个粒子帧率仍然能稳定在50到60帧。1.3 整套代码的核心架构火箭加粒子池写这个项目前先想清楚数据模型。整件事里出现的东西只有两类一类是飞行中的火箭一类是爆炸后产生的粒子。火箭负责上升到指定高度爆炸后消失粒子负责从爆炸点飞散、减速、下落、淡出。对应到代码里就是两个对象结构。火箭对象里最关键的是x、y坐标和上升速度vy。粒子对象稍微多一点除了坐标和速度还需要记录生命周期life、衰减速度decay、粒子大小size、颜色hue。每次动画帧要做的事也很有规律先更新火箭和粒子的位置、速度、生命周期然后把整个画布清理一遍重新绘制所有存活对象最后把已经“死掉”的对象从数组里删掉。这个循环一旦跑起来就是动画。整个文件核心逻辑大约100多行剩下的是交互和特效细节。2. 核心实现火箭升空、粒子爆开与流光拖尾2.1 火箭怎么升空才不显得假火箭升空看似简单但很多新手写的火箭速度是固定的看起来就像一个匀速移动的小球很呆。真实火箭是不断加速的至少是“渐快”的但因为升空过程只有几十帧匀速其实也够用只要别出现“慢悠悠飘上去”就行。我习惯的做法是给火箭一个较大的初始向上速度同时每帧叠加一点重力。注意这里的重力方向是向下的但一开始向上的初速度远比重力大所以火箭会先冲上去速度逐渐减小但因为它飞行时间很短不会中途掉头。代码大致长这样function createRocket(canvasW, canvasH) { const targetY canvasH * (0.18 Math.random() * 0.25); const flyTime 45 Math.random() * 25; return { x: canvasW * (0.15 Math.random() * 0.7), y: canvasH, vx: (Math.random() - 0.5) * 0.5, vy: -((canvasH - targetY) / flyTime), targetY: targetY, trail: [] }; }这里的关键是我用“目标高度除以飞行时间”来反推初速度这样不管发射位置在哪个屏幕尺寸上火箭都能在合理时间内飞到指定高度附近爆炸。vx加了一点小随机值是为了让火箭轨迹有轻微的斜度不是每炮都直直向上模拟器看起来才自然。火箭本身也带一个小的拖尾数组记录最近几帧的位置把这些历史点连起来画就能形成一条细细的光带。2.2 爆炸粒子的运动方程角度、初速度、重力和阻尼爆炸是整个效果的高潮一切都围绕“粒子怎么飞”展开。一个标准的圆形爆炸做法是把360度分成N等份每个粒子沿一个角度方向飞出去速度随机分布在一个区间内。这个思路很简单但写出来有味道的爆炸需要调三个参数粒子数量、速度范围、阻尼系数。我用的爆炸逻辑是每炮生成70到150个粒子角度按均匀分布速度取2到6之间的随机值。注意速度的单位是“像素每帧”不是“像素每秒”因为动画循环是按帧迭代的每帧更新一次坐标。计算方式如下function explode(x, y, hue) { const count 70 Math.floor(Math.random() * 80); for (let i 0; i count; i) { const angle (Math.PI * 2 * i) / count Math.random() * 0.3; const speed 2 Math.random() * 5; particles.push({ x: x, y: y, vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed, life: 1, decay: 0.008 Math.random() * 0.012, size: 1 Math.random() * 2, hue: hue (Math.random() - 0.5) * 40 }); } }粒子生成后每一帧都要做两件事按当前速度移动然后更新速度。更新速度时有三个力在起作用空气阻力让粒子慢慢变慢重力让粒子向下加速阻尼让粒子的速度按比例衰减。合并起来就是这样p.vx * 0.985; p.vy * 0.985; p.vy 0.03; p.x p.vx; p.y p.vy; p.life - p.decay;这里的0.985就是阻尼系数每帧乘以这个数速度会越来越小。0.03是重力加速度它让粒子飞出去之后逐渐往下坠。这两个值必须配合得当阻尼太大会显得粒子像被胶水粘住太小则粒子飞得太远不聚拢重力太大粒子马上掉地上太小又像在太空里飘。我自己测下来阻尼0.98到0.99之间、重力0.025到0.04之间是比较舒服的区间不同屏幕尺寸下观感差异不大。2.3 流光拖尾的秘密半透明覆盖层与混合模式接下来是“流光”效果。很多人第一次写粒子系统会发现粒子只是一个个圆点飞过去就没了没有那种拖着尾巴闪烁的感觉。原因很简单你在清屏的时候用了不透明的背景色把上一帧完全盖掉了。做到拖尾效果的原理是不清干净画布每次只在上面盖一层半透明背景上一帧的粒子残影就会留在画布上经过多次叠加后渐渐消失。具体做法是在每帧绘制粒子之前先画一个全屏的半透明矩形ctx.fillStyle rgba(10, 12, 40, 0.18); ctx.fillRect(0, 0, canvas.width, canvas.height);这个矩形的透明度决定了拖尾的长度。alpha值越小上一帧的残影保留得越久拖尾越长alpha值越大拖尾越短。我用0.15到0.2之间的值既能保留两三帧的残影又不会让画面糊成一团。这里有个坑如果你直接用纯黑色半透明矩形比如rgba(0,0,0,0.18)在粒子密集的区域反复叠加后画面会慢慢发灰发暗整体观感变得脏。解决方法是把覆盖层的颜色调成你想要的夜空背景色比如深蓝紫色残影在褪去时会自然融进夜空看起来通透得多。真正让粒子“发光靠的”还有一个操作绘制粒子时把混合模式设为lighter也就是加法混合。两个光点叠在一起时亮度会相加于是粒子重叠区域会出现刺眼的白亮光晕这就是模拟爆炸强光的关键。绘制完粒子后再把混合模式改回source-over避免影响后续的半透明覆盖层。ctx.globalCompositeOperation lighter; // 在这里循环绘制所有粒子 ctx.globalCompositeOperation source-over;2.4 春节配色的具体方案红金粉的正确打开方式烟花颜色不能胡乱给。过年场景下荧光蓝、荧光绿不是不能用但用得太多会让气氛很冷。主色调一定要偏暖。我定义了三个主色系金色、中国红、粉色洋红副色是紫色和青色只做点缀。具体用HSL来管理颜色很方便色相hue控制在0到60度之间是黄到红330到360度之间是粉到红这两个区间正好是暖色主区。饱和度S固定在90到100之间明度L在55到70之间这样颜色既鲜艳又不会太暗。每次爆炸时的具体做法是先随机决定这一炮的主色调然后让每个粒子的hue在主色上下浮动20度左右这样同一炮里会有从橙红到金黄的渐变比单一颜色丰富得多。“如何让粒子有白色内芯”也值得说。粒子的绘制可以用两层圆叠加第一层尺寸大、透明度低用主色第二层尺寸小、颜色偏白画在同一个位置。这样粒子看起来就是“外面有颜色、中心发白”非常接近真实烟花的光感。代价是绘制次数翻倍但如果单次粒子数控制在合理范围性能压力完全可以接受。3. 交互玩法和过年气氛的细节设计3.1 点击屏幕放烟花支持鼠标和触摸事件自动播放看多了会腻所以做交互按键是必须的。用户单击页面任意位置就会以鼠标坐标为中心产生一次爆炸效果和自动发射火箭在顶部爆炸一样。实现上就是监听click事件把坐标传入explode函数。但这里有一个体验细节如果每点击一次只爆一发手速快的人会觉得反应不过来。我实测连续快速点击时事件触发的频率很高如果每次都生成150个粒子屏幕会瞬间塞满几千个粒子低端设备可能会有短暂卡顿。我的处理方式是加一个简单的节流阀规定同一时刻最多只允许同时存在2000个粒子超过时新的爆炸直接丢弃。另外用mousedown触发爆炸比click更跟手因为click要在鼠标松开后才触发。触摸端则监听touchstart事件注意取坐标时要兼容移动端事件对象里的targetTouches。3.2 心形、星形和文字烟花怎么生成圆形爆炸是最基础的实际玩起来心形和文字烟花才是全场的焦点。心形的实现其实不需要复杂的物理仿真只要把粒子的初始位置按心形参数方程布点然后让它们朝外飞出去就行。心形参数方程是这样的// t从0到2PI const x 16 * Math.pow(Math.sin(t), 3); const y 13 * Math.cos(t) - 5 * Math.cos(2*t) - 2 * Math.cos(3*t) - Math.cos(4*t);把心形坐标缩放成合适的像素值再作为粒子的初始偏移量叠加到爆炸中心每个粒子不需要额外的初速度因为初始位置已经有形状了。为了让效果更像烟花可以给每帧位置加一点小的随机抖动形状就会有一种“正在点亮”的感觉。文字烟花稍微复杂一点原理是用离屏canvas把文字画出来然后通过getImageData读取像素信息把有颜色的像素点坐标提取出来。比如汉字“福”笔画密集的地方像素点多笔画稀疏的地方像素点少把这些点作为粒子初始位置投放到画布上字形自然就出来了。实际做的时候要注意抽样步长不能把所有像素点都拿来生成粒子否则一个字可能有几千个点直接卡死。我在200乘200的离屏画布上按4像素步长抽样每个字大约生成200到300个粒子效果清晰而且性能完全无压力。function textParticles(text, offCanvas, offCtx) { offCanvas.width 200; offCanvas.height 200; offCtx.font bold 120px sans-serif; offCtx.textAlign center; offCtx.textBaseline middle; offCtx.fillText(text, 100, 100); const imgData offCtx.getImageData(0, 0, 200, 200).data; const points []; const step 4; for (let y 0; y 200; y step) { for (let x 0; x 200; x step) { if (imgData[(y * 200 x) * 4 3] 128) { points.push({ x: x - 100, y: y - 100 }); } } } return points; }3.3 要不要加声音和环境光很多人在做完烟花后总会想加个音效噼里啪啦的爆炸声确实能增强气氛。但我的建议是不要一开始就加因为用户浏览器会自动拦截未经过点击事件的音频播放。如果你想加必须做一个“点击开启声音”的按钮不能用自动播放。关于声音素材的来源最简单的方式是用Web Audio API里的振荡器生成一个短促的噪声爆破声就不用引入外部音频文件了。做法大概是用一段白噪声和一个指数衰减的增益节点噪声持续0.3秒音量从0.8急速降到0就能听出“砰”的感觉。有两个爆炸同时发生时会合成成密集的噼啪声效果还不错。但我个人实际使用时常常把声音关掉因为年会上大屏配音响系统自带的蜂鸣声反而显得廉价干脆陪伴乐和视频一起放。4. 性能优化与不同设备的适配方案4.1 粒子数量爆炸式增长必须设置上限和对象池写粒子系统的第一个坑就是粒子数量失控。如果自动发射定时器设置得太快比如每300毫秒发射一枚火箭每枚火箭爆出150个粒子粒子的生命周期大约100帧那屏幕上同时存在的粒子数少则几百多则两三千。看起来好像不多但如果你绘制粒子时用了两次填充圆再开个shadowBlur性能立刻崩。我的方案是先设一个粒子总数上限超过上限时把数组里最老的一批粒子直接杀掉。这里的“最老”是数组头部元素所以用splice(0, length - MAX)就行。上限值我按设备分档性能较好的设备设8000普通手机设4000低端机设2000。自动发射的频率也要错开不要所有火箭都在同几帧爆炸给定时器加一个随机延迟让爆炸事件均匀分散在时间轴上。4.2 高清屏下Canvas模糊的解决办法凡是搞Canvas的人都会遇到一个问题明明画得很精细放大到有Retina屏的设备上就变模糊。原因很简单Canvas的绘图缓冲区尺寸默认用的是CSS像素而物理像素比CSS像素多好几倍。解决方式是手动把画布缓冲区尺寸设为窗口物理像素大小然后用scale把坐标系缩放回CSS尺寸。const dpr Math.max(1, window.devicePixelRatio || 1); canvas.width window.innerWidth * dpr; canvas.height window.innerHeight * dpr; canvas.style.width window.innerWidth px; canvas.style.height window.innerHeight px; ctx.scale(dpr, dpr);注意窗口大小变化时需要重新执行这段代码并且在重新设置canvas.width后画布内容会被清空所以resize事件里要做防抖处理不然用户拖动窗口时画面会闪烁甚至卡顿。4.3 低端机的降级策略动态检测帧率性能优化不能只在手机上主观感受最好有个客观标准。我习惯在前30帧内记录每次requestAnimationFrame的时间戳计算出平均帧间隔。如果帧间隔超过20毫秒也就是帧率低于50帧就自动把粒子数量上限减半并且关闭拖尾效果。这里的逻辑是拖尾效果虽然好看但半透明大矩形全屏填充也有开销在低端机上可以先牺牲一部分视觉效果来保流畅度。如果帧率进一步掉到30帧以下就彻底停掉自动发射只保留用户点击触发。因为自动发射是持续消耗性能的元凶停掉后页面至少还能交互。这套降级机制我用一个简单的状态对象管理不会因为持续的帧率波动而频繁切换设定为“连续15帧都低于阈值才触发”。5. 常见问题排查与避坑实录5.1 拖尾越积越厚最后花成一团最常见的反馈是粒子拖尾太长整个画面像雾霾天一样越到后期越看不清。问题出在覆盖层的透明度设置上。rgba(10, 12, 40, 0.05)这样的低透明值会让残影保留超过十帧大量粒子叠加后亮度越来越高完全变成白蒙蒙一片。解决方法是提高覆盖层的alpha值比如0.2让残影只保留三四帧拖尾依然存在但不会积灰。另一个连带问题是粒子生命周期太长寿命超过120帧的话即使覆盖层能清掉残影粒子本体还是在屏幕上飘来飘去。我建议把粒子的life初始值设为1decay设置在0.008到0.02之间这样粒子从出现到消失大约是50到120帧两秒以内视觉节奏正好。5.2 页面切出去再切回来动画变成慢动作这个问题几乎每个做Canvas动画的人都会遇到。原因是浏览器的requestAnimationFrame在页面切换后台时会暂停切回时突然补跑或者帧间隔巨大粒子位置瞬间跳变。慢动作的具体表现是粒子像在飘但时间流速明显不对。解决办法是在动画循环里获取当前时间戳计算与上一帧的时间差delta超过50毫秒就当成50毫秒处理然后把位移和生命周期都按delta的比值缩放。不过对于烟花这种帧率敏感的项目更简单的办法是直接忽略异常帧当检测到帧间隔超过100毫秒时就把整个场景里的粒子全都打上“半透明状态”让它们快速淡出下一帧重新开始随机按时序触发火箭。这样观感上不会有跳变反而像是一次效果切换。5.3 文字烟花乱得像鬼画符文字烟花的常见失败案例是字形全部糊在一起或者只有零星的几个字亮点。前者通常是抽样间距太小比如step设为1或2同一个笔画里的粒子重叠太多后者是抽样间距太大像step设为10笔画细的地方直接没取到点。我在不同字体大小下测试过step取3到6是稳定区间。另外要注意fillStyle的像素判定阈值如果大于128则认为是有效像素建议阈值就设在128附近太严笔画残缺太松背景噪点都会被当成文字。还有一个容易忽略的细节离屏canvas尺寸和文字大小必须保持固定比例比如200乘200画布配120像素字体。如果你改了画布大小但没改字体大小文字会偏移到画布外提取出来的像素点就不完整甚至完全空白。5.4 页面没操作时CPU占用过高有些场景下用户只是想让它安静地当背景但动画仍然火力全开地自动放烟花CPU占用率居高不下笔记本风扇呼呼响。后台运行的烟花页面如果一直保持60帧对电池续航和整机温度都很不友好。所以降低CPU消耗的关键是在页面不可见时暂停动画循环而不是继续用RAF空转。用document.visibilitychange事件监听页面可见性隐藏时就调用cancelAnimationFrame并停止定时器显示时重新启动。这个操作可以让后台标签页的CPU占用立刻降到接近0。还有一个偏门但有效的技巧如果页面长时间没有交互可以自动把发射频率降低到每3秒一次让效果“安静”一些在展台大屏上长时间运行也没那么大压力。到这一步整个烟花模拟器从构思到实现再到优化基本讲完了。我自己做完后把它打包成单HTML文件一共不到8KB手机上打开也一样流畅。如果让我说一个最想让你记住的点就是“先控制粒子总量再谈视觉效果”所有好看的烟花效果都是建立在稳定帧率之上的卡成幻灯片的画质毫无气氛可言。把这个项目放到投影仪上配合房间暗下来的环境效果远超预期。过年时用一个页面带来的热闹感真的是前端技术最好的浪漫。
