
做互动项目这些年我越来越发现一个规律越是看起来图一乐的东西拆开之后越能学到硬货。这个“烟花模拟器”就是典型例子——表面上是过年气氛组神器满屏流光一炸年味直接拉满实际上背后是粒子系统、随机算法、颜色混合、音效节奏这些老生常谈但很少有人讲透的技术点。这篇我把自己做烟花模拟器的完整思路、关键实现和踩坑记录整理出来不管你是想给网页加个特效、做一个小程序互动页还是单纯想搞明白那些“一炸开就好看”的效果到底怎么来的都可以直接参考。1. 整体设计与思路拆解1.1 烟花模拟器到底在“模拟”什么先说烟花模拟器的本质。它做的是三件事模拟一枚烟花“升空—爆炸—散落—熄灭”的完整生命周期模拟不同颜色和形态的爆裂效果模拟用户交互带来的即时反馈。拆到技术层面就变成了粒子系统控制运动轨迹随机函数控制爆炸形态颜色空间控制视觉层次音频或节奏控制情绪氛围。很多人第一次做会掉进一个误区觉得烟花好看的核心是“粒子够多、够闪”。实际上真正让烟花显得高级的是那几毫秒内粒子从炸开到减速再消失的节奏变化加上颜色由亮变暗的过渡。如果所有粒子永远保持同一个速度和亮度哪怕数量上万看起来也只是一团噪点完全没有“炸裂”的感觉。所以你去看各种烟花模拟器无论网页版还是小游戏核心都在做同一件事用极短时间生成大量粒子让粒子在最初的几十帧内拥有比较高初始速度和亮度随后快速衰减。感官上就是“啪”一下炸开、带着光尾四散、再慢慢熄灭。这个“光尾”也就是拖影是氛围感最重要的来源。1.2 为什么选择实时粒子系统而不是预渲染视频这个问题我每次都会被问到直接录一段烟花视频循环播放不是更简单吗答案是场景不同。你要的是朋友圈装饰素材那录屏确实快但你要的是一个“模拟器”那就必须支持交互和实时变化——用户点击哪里就在哪里放一炮按住拖动能改变发射方向甚至调整颜色、爆炸半径。这些需求预渲染视频完全做不了。更重要的是实时粒子系统的数据量其实很小。每个粒子只需要记录位置、速度、颜色、寿命几个字段一万个粒子也只是几万条数字而已。反而是渲染层要谨慎处理因为每一帧你都要把这些粒子画到屏幕上画法不对就会卡。用熟悉的话说计算不贵画才贵。所以我的选择一直是动态粒子系统。配合上 canvas 或者 WebGL不仅能做到满屏流光还能随手改成鼠标跟随、屏幕震动、文字烟花这些衍生玩法。这个“可扩展性”是录视频没法比的。1.3 平台与引擎的选型逻辑做烟花模拟器选平台和引擎主要取决于你想让它跑在哪。我列一下自己常用的三个方向按上手难度排序网页端纯 JavaScript Canvas 2D最轻量浏览器打开就玩不需要装任何东西。适合做互动页面、H5、个人网站特效。性能中等优化好可以支撑一两千个粒子流畅运行。WebGL / Three.js适合追求大场面比如上万粒子带光晕、模糊、景深效果。代价是代码复杂度上去了需要理解着色器。移动端 App 或小程序通常用原生 Canvas 或者小游戏引擎核心逻辑和网页一致但要多处理触控事件和性能差异。我个人最推荐从“HTML5 Canvas 2D 版本”入手因为它的调试成本最低、所见即所得而且把核心逻辑跑通之后移植到其他平台其实就是换个渲染层的事。这篇后面我会手把手拆一个可复现的 Canvas 版本。2. 核心细节解析与实操要点2.1 粒子生命周期与运动模型一个烟花的生命可以分成四个阶段上升、爆炸、飞散、消亡。不同阶段粒子的行为完全不一样我的做法是用“状态”标记区分不混合处理。上升阶段其实只有一颗“炮弹”粒子从底部向上飞受重力影响减速甚至横向偏移。这颗粒子需要画出一条高亮轨迹通常我会让它每一帧在上一帧位置画个小光点让连续帧之间形成一条亮线。爆炸阶段是整场效果的关键它发生在一瞬间。炮弹到达目标高度后我会一次性生成几十到几百个粒子给每个粒子一个随机的初始速度方向速度大小也随机分布。这里有个细节为了让炸开的形状比较圆粒子角度要均匀分布而不是完全随机。如果是随机角度容易偏到一边看起来像哑炮。做法是先按圆周均匀生成角度再给角度叠加一个很小的随机扰动。飞散阶段每个粒子独立受重力影响速度逐渐衰减同时颜色从明到暗变化。消亡阶段则是透明度降到零后回收粒子对象避免内存持续增长。运动模型的物理公式其实很简单。我用的是每帧更新而不是真实物理时间。因为真实时间单位是“秒”而画布坐标单位是“像素”换算反而麻烦。固定帧更新的写法是// 每帧调用一次 particle.x particle.vx; particle.y particle.vy; particle.vx * 0.98; // 水平阻尼 particle.vy particle.vy * 0.98 0.05; // 垂直阻尼 重力 particle.life - 1;0.98是阻尼系数可以理解成空气阻力把速度一点点吃掉。0.05是重力增量模拟向下的拉扯。这两个数字看起来不起眼但对烟花视觉影响极大阻尼太大会让粒子刚炸开就定住像一坨碎纸重力太强会让整个烟花快速砸向地面没有了在空中停留的华丽感。2.2 颜色、轨迹与流光效果的实现烟花好看不好看颜色占七成。我一开始做的时候全都用纯红、纯绿、纯蓝结果画面又生硬又廉价。后来改用 HSL 颜色模型让色相随机变化饱和度和亮度在爆炸最亮时达到峰值之后逐步降低。HSL 的好处在于它天然符合人眼对颜色的感知。你在火红的烟花里加个黄色小粒子整体就丰富很多而 RGB 直接加减容易混出脏颜色。具体做法是把每个烟花设一个“主色相”爆炸粒子在这个色相附近做小范围随机偏移这样可以保证同一朵烟花整体协调同时又有层次。流光效果也就是烟花拖出的那条渐渐消失的光尾是氛围感的一号功臣。实现上有两种常见思路。第一种最简单用半透明色填充整块画布作为背景而不是完全清除画布。这样上一帧的粒子和下一帧的粒子会在画布上叠加残留的影像就形成了光尾。第二种思路是给每个粒子单独记录一段历史位置然后画一条渐隐的线。第一种性能开销极低缺点是需要处理拖尾过度导致画面糊掉第二种控制更精细但历史数组会占用内存。做网页端我一般先用第一种配合偶尔全屏清一次保证亮度不失控。2.3 爆炸音效与引信视觉设计很多人只盯着粒子看把音效放到最后其实音效才是让“过年气氛一秒拉满”的关键。没有声音的烟花再绚烂也像隔了一层玻璃。做模拟器时如果能加音效体验完全不一样。常用的做法是生成几个短促的噪声片段升空时用渐弱的咻声爆炸时用低频爆炸声。如果不想找现成素材可以用 Web Audio API 实时合成。爆炸声本质上就是白噪声加一个快速衰减的低通滤波代码写起来不超过二十行。要是播放环境不方便出声也可以退一步靠视觉上“爆开瞬间屏幕轻微震动”来替代一个屏幕闪烁的效果顶不少事。所谓“引信视觉设计”指的是爆炸前观众看到的那条上升亮线。这条线处理得好会让人产生期待感处理不好观众还没反应过来就炸了没有节奏。我的做法是让炮弹上升轨迹在起始阶段很暗越接近爆炸点越亮越粗并在即将爆炸时闪烁两下暗示“要来了”。这些细节比粒子数量更影响整体观感。2.4 交互模式与多场景适配烟花模拟器的交互可以很简单也可以做得很有深度。最基础的版本就是“点击屏幕任意位置在该位置上方随机高度炸一发”。更进一步可以支持长按拖拽拖拽方向决定炮弹发射方向松手时爆炸也可以提供颜色选择器让用户指定下一次烟花的主色。自动模式同样重要。很多人只是想放着当屏幕背景懒得一下下点所以最好加一个定时器每隔 1~2 秒自动在随机位置放一发形成连续不断的“满屏流光”效果。我再补充一个体验细节不要把自动模式做得太规律最好在时间间隔上加上随机抖动忽快忽慢才像有人在手动放而不是机器人打固定节拍。场景适配方面要注意不同屏幕的宽高比。手机竖屏和电脑横屏的爆炸高度不一样直接硬编码“从底部飞到距离顶部 200 像素处爆炸”在横屏上就出问题。我的习惯是根据屏幕高度动态计算爆炸最高点再在最高点以下随机取实际爆炸高度。3. 实操过程用 HTML5 Canvas 手写一个能跑的版本3.1 基础结构与初始化下面这个版本我尽可能精简但保留了完整核心逻辑。打开任意支持 Canvas 的浏览器就能直接跑。先把基本骨架搭出来设置画布全屏、监听点击事件、启动动画循环。!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0, user-scalableno title烟花模拟器/title style body { margin: 0; overflow: hidden; background: #0a0a1a; } canvas { display: block; } /style /head body canvas idfireworks/canvas script const canvas document.getElementById(fireworks); const ctx canvas.getContext(2d); let width canvas.width window.innerWidth; let height canvas.height window.innerHeight; window.addEventListener(resize, () { width canvas.width window.innerWidth; height canvas.height window.innerHeight; }); const particles []; const rockets []; const colors [0, 40, 120, 190, 280, 330]; // 主色相采样列表 /script /body /html这里把画面背景设为极深的蓝色色相偏紫方便后面用叠加模式提亮。主色相列表里的数字都是 HSL 的色相角度覆盖红、黄、蓝、青、紫、粉几个烟花常见色域。3.2 粒子类与运动更新接下来定义两个核心结构一个用于上升阶段的炮弹rocket一个用于爆炸后的碎片粒子particle。我把它们分开写因为生命周期不同需要关注的属性也不同。function createRocket(x) { return { x: x, y: height, targetY: height * 0.15 Math.random() * height * 0.35, vy: -(Math.random() * 4 8), color: colors[Math.floor(Math.random() * colors.length)], trail: [] }; } function createParticles(bx, by, color, count) { const list []; const baseAngle Math.random() * Math.PI * 2; for (let i 0; i count; i) { const angle baseAngle (Math.random() - 0.5) * 0.8; const speed Math.random() * 5 2; const hueVariation Math.random() * 40 - 20; list.push({ x: bx, y: by, vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed, life: 60 Math.random() * 40, maxLife: 100, hue: (color hueVariation 360) % 360, light: 1 }); } return list; }createRocket 里的 targetY 就是爆炸点范围在屏幕高度 15% 到 50% 之间这样烟花不会每次都在正中间爆有高有低才自然。vy 初始取负值表示向上随机范围控制在 -8 到 -12保证上升速度差异感。createParticles 里的 baseAngle 随机生成一个基准角度粒子在基准角度附近小范围抖动既保证爆炸基本圆形又有不规则感。运动更新时炮弹先上升后爆炸粒子受重力和阻尼影响。这一步逻辑很直观function update() { for (let i rockets.length - 1; i 0; i--) { const r rockets[i]; r.trail.push({ x: r.x, y: r.y }); if (r.trail.length 8) r.trail.shift(); r.vy 0.05; r.y r.vy; if (r.y r.targetY) { const count 60 Math.floor(Math.random() * 60); particles.push(...createParticles(r.x, r.y, r.color, count)); rockets.splice(i, 1); } } for (let i particles.length - 1; i 0; i--) { const p particles[i]; p.x p.vx; p.y p.vy; p.vy 0.03; p.vx * 0.985; p.vy * 0.985; p.life--; p.light Math.max(0, p.life / p.maxLife); if (p.life 0 || p.y height 20) { particles.splice(i, 1); } } }炮弹的轨道记录我取最近 8 帧的位置用来画拖尾。粒子到达爆炸点后一次性生成 60 到 120 个碎片。爆炸数量和颜色是影响视觉效果的核心参数太少显得冷清太多会让整个屏幕发白。实际运行时可以先用我给的数字再根据性能微调。3.3 渲染与颜色混合渲染层是烟花效果最见功力的一步。我先用半透明黑色擦除画布让上一帧残留形成光尾再把每个粒子用 additive 混合模式画出来。function draw() { ctx.globalCompositeOperation source-over; ctx.fillStyle rgba(10, 10, 26, 0.2); ctx.fillRect(0, 0, width, height); ctx.globalCompositeOperation lighter; for (const r of rockets) { for (let i 0; i r.trail.length; i) { const t r.trail[i]; const alpha (i 1) / r.trail.length; ctx.beginPath(); ctx.arc(t.x, t.y, 2, 0, Math.PI * 2); ctx.fillStyle hsla(${r.color}, 90%, 60%, ${alpha}); ctx.fill(); } } for (const p of particles) { const alpha p.light * 0.9; const radius 1.5 p.light * 1.5; ctx.beginPath(); ctx.arc(p.x, p.y, radius, 0, Math.PI * 2); ctx.fillStyle hsla(${p.hue}, 95%, ${55 p.light * 20}%, ${alpha}); ctx.fill(); } requestAnimationFrame(loop); } function loop() { update(); draw(); } loop();这里有两个关键点值得单独说。第一个是globalCompositeOperation lighter它让颜色叠加变亮红色叠红色会变成更亮的红色甚至趋向白色。这正是烟花“炸开那一瞬间光芒四射”的来源。如果用默认的 source-over粒子叠在一起颜色只会互相遮挡画面会发脏。第二个是亮度的动态计算粒子的半径和透明度都随生命周期逐渐下降视觉上才有一个慢慢熄灭的过程。你可以试着去掉这层渐变立刻会发现画面变成一堆没有灵魂的光点。3.4 点击交互与自动发射交互部分很简单监听鼠标和触摸事件把屏幕坐标转成画布坐标然后生成炮弹。自动发射则通过定时器实现为了让节奏更接近真实烟花表演我加了一点随机延迟。function launchFromScreen(screenX) { rockets.push(createRocket(screenX)); } canvas.addEventListener(mousedown, (e) launchFromScreen(e.clientX)); canvas.addEventListener(touchstart, (e) { e.preventDefault(); const touch e.touches[0]; launchFromScreen(touch.clientX); }); function autoLaunch() { launchFromScreen(Math.random() * width); const delay 800 Math.random() * 1200; setTimeout(autoLaunch, delay); } autoLaunch();为什么我只用屏幕横坐标来决定发射位置而不是点哪里就在哪里爆炸因为真实烟花是从地面垂直升空的你点击的位置决定了发射起点爆炸高度是随机的。这样更贴近直觉点击偏左烟花从左上方炸开点击偏右从右上方炸开。如果点击位置直接决定爆炸中心反而少了那种“升空再炸”的期待感。3.5 参数调优建议下面这组参数是我反复试下来比较稳的组合但你在不同场景里可能需要微调。我列成一张对照表方便你按需调整。场景粒子数量/发阻尼系数重力增量说明手机竖屏50-800.9850.03屏幕小粒子太多会看不清单朵造型电脑横屏80-1500.980.04兼顾性能和视觉丰富度大型投影/大屏150-2400.9750.03粒子多更显大气但需控制拖尾残留低配设备35-500.990.04减少粒子保帧率优先我自己的习惯是先固定“最多 1500 个粒子同时存在”的全局预算每次爆炸生成的粒子数 预算 / 当前场上烟花数量。这样无论发射多频繁总粒子数不会失控性能抖动也小。这个方法其实是个通用思路与其定死每个效果的上限不如为整个场景设一个动态预算。4. 常见问题与排查技巧实录4.1 粒子一多就卡顿这是被问得最多的问题。Canvas 2D 的瓶颈通常不在计算而在fillStyle拼接和arc调用次数上。每帧画 1500 个圆每个圆都要设置 fillStyle、beginPath、arc、fill这四个操作累加起来就会拖慢帧率。排查思路是先降低粒子数量看是否流畅如果流畅就是渲染次数的问题。解决办法有几个一是把同色粒子合并成一次路径绘制也就是先 beginPath然后不停地 moveTo 和 arc最后统一 fill 一次能把 fillStyle 的切换开支省下来二是用ctx.fillRect画 1~2 像素的小方块代替arc视觉上差别不大但性能提升非常明显三是实在要上万粒子就直接上 WebGL用三角形精灵渲染。4.2 画面偏暗、不够“炸”如果烟花炸开后颜色发闷最可能是没开lighter混合模式。另一个原因是背景擦除的透明度设得太小比如rgba(10,10,26,0.95)意味着上一帧画面几乎不残留光尾就很短视觉能量感也弱。调回 0.15~0.25 的透明度会明显更亮。还有一个容易被忽略的点色相饱和度。HSL 里如果用hsla(h, 100%, 30%, a)颜色就很暗烟花核心爆点应该用高亮度比如 50%-70% 的亮度。我通常让粒子亮度随生命期从 70% 衰减到 35%这样炸开瞬间是刺眼的白亮几帧后恢复成彩色层次感就出来了。4.3 手机端适配和触控问题手机端的坑主要在两点。第一是 canvas 绘制模糊那是因为没有考虑物理分辨率。需要把 canvas 的实际像素尺寸乘以devicePixelRatio再用 CSS 显示尺寸兜底否则在 Retina 屏上画面会糊。第二是 touch 事件里e.preventDefault()的手势冲突如果页面还能滚动触摸时页面会跟着滚烟花反而放不出来。我调试过程中发现真实触摸的坐标和 click 事件坐标在移动端有偏差所以移动端不要监听 click要直接监听 touchstart 并且用e.touches[0].clientX。响应速度完全不一样。4.4 想做成多端或升级效果怎么走做完 Canvas 2D 版本之后你想移植到其他平台其实不复杂因为粒子运动逻辑是纯数据计算。在 Unity 里可以用 Particle System 配置一个 Burst 发射模式在 Lottie 里可以用关键帧动画替代。但如果你想保留交互和随机性我更建议把 Canvas 版本直接嵌到原生应用 WebView 里跑零成本还能复用代码。升级视觉效果最值得做的是加光晕和预渲染精灵。常见做法是离线画一张带渐变发光的软圆点图也就是 radial gradient 的圆形贴图然后每个粒子用 drawImage 画这张图而不是画实心圆。一大片软光点叠加起来就是那种广告级的大面积发光效果比纯硬边缘圆点高级得多。这个改动在 Canvas 2D 里性能依然可控因为 drawImage 比 arc 快不少。另外建议调一下屏幕震动。爆炸瞬间把整个 canvas 的 CSS transform 位移几像素再在 100ms 内恢复到原位几行代码的事爽感能提升一个档次。对短视频传播和现场大屏来说这种“震一下”的反馈比单纯变亮更抓眼球。写在最后最后再分享一个小技巧是我每次做类似粒子项目都会先做的事先把粒子数量调到很大感受一下上限在哪再往回收敛。很多人一上来就想着优化写了半天才发现视觉上限根本没摸到。先放开数字把特效做夸张再逐步加限制你才知道每个参数到底在控制什么。烟花模拟器这个东西初看是氛围感往深了看其实是把这个逻辑吃透以后做爆炸物、流星雨、星星拖尾、甚至满屏星光光效都能直接复用同一套骨架。过年图个热闹顺便把技术也给练了何乐而不为。