年三十前的那个晚上我本来只是想找个现成的烟花模拟器放到家庭群里热闹一下结果搜出来的Demo不是稀稀拉拉几颗光点就是一放进手机浏览器立刻卡成幻灯片。折腾到半夜我索性关掉所有标签页从零手写了一个Canvas版本。说真的烟花模拟器听起来像个简单的视觉玩具但要把满屏流光炸开这种过年质感做出来背后涉及的粒子系统、运动学、颜色混合和性能调优每一项都能单独写篇文章。这篇文章我就把整个实现思路、踩过的坑和优化手段完整梳理一遍。如果你对Canvas有一定了解可以对照着代码直接做一个属于自己的版本如果你是刚接触前端的小白只要照着文中的核心逻辑逐步拼装也能跑出一个效果出彩的烟花页面。我的目标很明确让它在老电脑和老手机上都能跑得动打开就是满屏的流光、爆炸和拖尾过年气氛一秒到位而不是只能截图发朋友圈。1. 别人家的烟花Demo为什么总差口气先说结论网上的开源烟花项目并不少随手一搜就是一堆但真正能满足过年氛围这个场景的并不多。问题不是别人写得不好而是大多数Demo的目标形态和你想要的效果根本不一样。1.1 逛了一圈常见问题集中在三处第一是粒子数量太少。很多示例为了追求性能或代码简洁一朵烟花只炸三十到六十个粒子每颗粒子又细又小放在手机屏幕上几乎看不清更谈不上满屏流光。过年场景需要的是密集视觉轰炸一朵花至少要有百级粒子屏幕同时存在五六朵这个量级和简单Demo完全是两种世界观。第二是缺少物理感。真实烟花爆炸后碎片并不是匀速向外飞散的它们会先被爆炸推力带出去然后在重力作用下逐渐减速、下落、画出长长的弧线。很多Demo只做了圆周散开线性消失粒子爆炸后直挺挺地飘走看起来像一堆荧光虫在飞不像烟花。尤其是炸开后垂落这个动作是模拟真实感的关键。第三是颜色处理太粗糙。直接用rgb(255,0,0)、rgb(0,255,0)这种死板的纯色会让画面显得假、脏。烟花的美感很大程度来自颜色渐变和随机偏差同一朵花里亮度从高到低、色相有细微偏移才会产生流光的质感。这一点后面我会专门展开讲。1.2 想清楚要什么效果再决定写不写我给自己列了一个需求清单想清楚后再动手自动连续发射不需要用户操作就能看到持续不断的烟花爆炸粒子带重力炸开后要有下落拖尾画面要有辉光感光点重叠处亮起来而不是一个个独立的圆点兼容移动端至少保证老款手机不崩溃全部代码尽量控制在单文件内方便直接分享对照这份清单我确定自己写比改造别人的Demo更快。因为要把这些需求全塞进一个别人的项目里改造成本接近重写还不如从头来。2. 烟花模拟器的骨架粒子系统与爆炸运动学烟花看似复杂拆到最底层就一个概念粒子。一堆粒子沿着各自的轨迹运动、变色、消亡组合起来就是一场视觉盛宴。理解了粒子系统烟花模拟器就完成了一半。2.1 粒子的四段生命周期一个完整的烟花过程其实就是四种状态的接力烟花弹上升、到达高点爆炸、碎片四散、碎片下落消失。把这个过程落到代码上核心是一个粒子对象它至少需要这几个字段x、y当前位置vx、vy当前速度age、life已生存时间和总寿命color颜色size大小每次动画帧里粒子做的事情只有两件更新自身位置和速度然后把位置画出来。生命结束就销毁或者回收。用一句话概括粒子就是一段从出生到死亡、位置上不断受外力影响的光点。2.2 爆炸散开的向量计算以及重力如何把流光拉成弧形爆炸那一瞬间碎片是朝四面八方飞出去的。在二维平面上这就是一个绕圆心均匀分布的向量场。基础代码长这样function burst(x, y, color) { const count 100; for (let i 0; i count; i) { const angle (Math.PI * 2 * i) / count Math.random() * 0.1; const speed Math.random() * 2.5 1.0; const vx Math.cos(angle) * speed; const vy Math.sin(angle) * speed; particles.push(createParticle(x, y, vx, vy, color)); } }这里的核心思路是把360度均匀切成count份每个粒子按对应角度射出再给速度加一点随机量让飞散不那么机械。如果你直接把这个函数跑起来会发现粒子会成一个完美的圆形向外散开但很快全部飘走、消失在屏幕边缘——因为少了重力。重力的加入方式非常简单每帧给粒子的vy加上一个固定值p.vy 0.028 * dt;这个0.028不是拍脑袋定的它需要在爆炸后能充分展开和回落速度自然之间找平衡。太小烟花会一直往上飘像没有重量太大烟花还没展开就被拽落变成一闪而过的光点。我实测下来0.025到0.035是一个比较安全的范围。有了重力之后粒子的轨迹从直线变成抛物线就会出现我们熟悉的弧形流光下落效果这是烟花模拟器里最能打动人的画面之一。2.3 用HSL取色让同一朵花有渐变而不是一片死白颜色是很容易被新手忽略、但对观感影响极大的细节。我的建议是不要用RGB硬编码颜色而是用HSL。const hue Math.random() * 360; particles.push({ color: hsl(${hue}, 90%, 60%) });为什么HSL好用因为色相、饱和度、亮度是三个独立维度你可以让一朵烟花内所有粒子的色相保持在相近范围比如主色相20每颗粒子在±10内浮动这样整朵花既有统一感又有细微变化看起来是活的。我还习惯用双色甚至三色方案一朵烟花里一部分粒子用主色一部分粒子用偏移180度的补色。比如主色是金色补色是偏蓝的冷色爆炸后两种颜色交织视觉效果会立刻提升一个档次。单纯的大红或大绿很容易显得廉价色彩有对比才有流光溢彩的味道。3. 手把手把第一版跑起来发射、炸开、落下原理讲完直接上手。我先给一个精简到极致但能完整运行的最小版本你可以把它存成一个HTML文件双击打开就能看到烟花。3.1 最小可运行版本canvas idsky styleposition:fixed; inset:0; background:#020617;/canvas script const cv document.getElementById(sky); const ctx cv.getContext(2d); let W cv.width innerWidth; let H cv.height innerHeight; const ps []; function spawnBomb() { const x Math.random() * W; const y 80 Math.random() * H * 0.45; const hue Math.random() * 360; for (let i 0; i 100; i) { const a Math.PI * 2 * i / 100 Math.random() * 0.3; const sp Math.random() * 2.8 0.8; ps.push({ x, y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp, age: 0, life: 40 Math.random() * 40, c: hsl(${hue},90%,60%), sz: Math.random() * 2 0.6 }); } } function tick(now) { const dt Math.min(2, (now - (tick.last || 0)) / 16.7); tick.last now; ctx.fillStyle rgba(2,6,23,0.25); ctx.fillRect(0, 0, W, H); if (Math.random() 0.08) spawnBomb(); ctx.globalCompositeOperation lighter; for (let i ps.length - 1; i 0; i--) { const p ps[i]; p.vy 0.028 * dt; p.vx * 0.99; p.x p.vx * dt; p.y p.vy * dt; p.age dt; if (p.age p.life) { ps.splice(i, 1); continue; } ctx.globalAlpha Math.max(0, 1 - p.age / p.life); ctx.fillStyle p.c; ctx.beginPath(); ctx.arc(p.x, p.y, p.sz, 0, 6.283); ctx.fill(); } ctx.globalAlpha 1; ctx.globalCompositeOperation source-over; requestAnimationFrame(tick); } requestAnimationFrame(tick); /script这段代码不算长但它已经包含了一个烟花模拟器的所有基础要素随机发射点、均匀散开、重力下落、颜色HSL、寿命衰减、辉光混合模式。如果你只是想在朋友圈发个效果它已经够用了。3.2 逐行拆解几个关键设计dt是什么它是帧间隔时间把帧率波动的影响降到最低。requestAnimationFrame在60Hz屏幕上基本是16.7毫秒一次但如果设备掉到30Hzdt会是2左右这样同一个毫秒数下粒子移动的距离会更大视觉上就不会因为掉帧而变慢。我用Math.min(2, ...)做上限防止切后台再回来时粒子瞬间飞光。为什么用半透明蒙版而不是清屏这是尾迹效果的核心。clearRect会把上一帧内容完全擦掉粒子就是一帧一个光点追不出流光。我用rgba(2,6,23,0.25)的半透明背景覆盖全屏上一帧的画面不会完全消失而是留下一层残影新粒子画上去后会因为叠加产生拖尾。这个技巧是Canvas烟花里最值得记的一行代码。alpha值越低拖尾越长但太低会让整个屏幕脏我通常在0.2到0.35之间调试。为什么把globalCompositeOperation设为lighter这是辉光感的核心。默认的source-over模式下后画的像素会覆盖先画的像素而lighter模式会把颜色做加法混合重叠的地方亮度叠加光点密集区域就自然亮起来模拟出真实烟花的辉光效果。不过要注意画完所有粒子后一定要设回source-over否则背景蒙版也会被一起加亮画面会发白。3.3 第一版实测效果有但满屏远不够把这段代码跑起来你会看到画面里已经有了烟花的基本形态一圈光点炸开、降落、消失循环反复。但离满屏流光炸开还有明显差距——粒子太均匀每一朵花长得几乎一样没有花型层次发射位置虽然随机但缺乏节奏感而且粒子一多就开始掉帧。接下来要解决的就是这三件事视觉升级、造型设计和性能优化。4. 满屏流光的视觉升级辉光、花型和二次爆炸第一版是能看要做到惊艳还得在视觉效果上做三件事强化辉光层次、把单调的圆形炸开变成多种花型、加入二次爆炸。4.1 用参数控制花型而不是永远画一个圆真实烟花不是只有一种菊花形有牡丹形、柳絮形、双层环、交叉环等。用代码其实很好模拟差别主要在速度分布、粒子数量和重力系数上。我通常会维护一张花型表花型粒子数速度范围重力系数特征菊花80-1201.0-3.00.028均匀圆散经典款牡丹150-2200.6-1.80.020粒子密集、慢速膨胀像一团光球柳絮60-900.8-2.20.045重力更大拖尾垂落像金丝柳条双环160内外两圈0.028两个半径不同的环形嵌套把这几个参数做成配置爆炸函数就可以统一处理。比如柳絮花型的粒子速度低、重力高爆炸后还没来得及充分散开就开始下落视觉上就会形成一条条垂下的光丝而牡丹花型粒子多、速度慢会先膨胀成一团光晕再缓缓消失质感完全不同。这里有一个实操细节双环烟花不需要真的画两个圆只需要生成粒子时一部分用半径R1另一部分用半径R2其他逻辑完全不变。爆炸函数里加一步随机分组即可成本极低但效果非常出彩。4.2 二次爆炸让一朵烟花自己会开花真正的烟花有时候会在半空二次爆裂——先是主弹炸开中间某个碎片延迟引爆形成第二波小范围爆炸。这种爆了又爆的效果是瞬间提升画面丰富度的利器。实现方式并不复杂给粒子增加一个可选字段childBoomAt当粒子的age到达这个时间点时调用一次burst(p.x, p.y, 第二波颜色)生成一小簇新粒子。if (p.childBoomAt p.age p.childBoomAt) { burst(p.x, p.y, secondaryColor, 40); p.childBoomAt null; // 避免重复触发 }我把主爆炸的延迟引爆点设在粒子寿命的35%到70%之间随机这样二次爆炸会发生在主花型还在空中膨胀的时候两波粒子叠加在一起画面瞬间有了层次。你甚至可以给二次爆炸的粒子再挂一个childBoomAt形成三次爆炸——但实际做下来两层就足够了层数太多反而会变成一团乱光。4.3 颜色搭配让每一朵花都有自己的性格春季过年场景最稳妥的主色是金红两系。但纯用金色系时间长了会腻我建议在随机逻辑里加权重60%概率用暖色系金黄、橙红30%用冷色系青蓝、紫红10%用极端随机色。冷色烟花在暖色背景衬托下特别好看能形成满屏里突然炸一朵蓝花的惊喜感。颜色方案确定后记得让每朵花内粒子的色相在±10内抖动。别小看这10度它决定了画面是一块一块的颜色还是流动的光。前者像简笔画后者才有流光的质感。5. 性能优化从能跑到老手机也不卡如果你只在电脑上跑前面的优化基本够用了。但过年场景里你可能要把这个页面分享到家庭群在老掉牙的安卓机上打开。那性能就是生死线不过关直接白费。5.1 粒子上限给画面装一个安全阀Canvas 2D在同一时刻能够流畅渲染的粒子数和设备的GPU、屏幕分辨率强相关。我给自己定的安全线是1500个粒子。超过这个数优先淘汰寿命最短的粒子而不是无限制地往数组里塞。最简单的做法是在spawnBomb时判断总粒子数如果超过上限就跳过新爆炸const MAX_PARTICLES 1500; if (ps.length MAX_PARTICLES) return;但这个做法在连续爆炸时会有问题容易出现前半秒还有烟花后半秒突然停了的断档。更好的方案是新爆炸照常生成但在粒子数组里按life - age剩余寿命从低到高排序淘汰最弱的一批保证总量稳定。5.2 对象池与生命周期管理频繁创建和销毁对象会触发垃圾回收让人感觉到卡顿。一个简单的优化是用死亡标记数组压缩代替splice。splice在数组中间删除元素会导致后面的元素整体搬移粒子一多开销不小。我通常这样管理let aliveCount 0; for (let i 0; i ps.length; i) { const p ps[i]; if (p.dead) continue; // 更新和绘制 ps[aliveCount] p; } ps.length aliveCount;这段代码的本质是把存活的粒子全部往前搬运一次性截断数组。它避免了循环内多次splice带来的数组搬移开销。当程序里有上千个粒子时这个差别非常明显。5.3 设备像素比DPR与移动端适配不做DPR适配画面在Retina屏和手机上会发糊但如果你直接按window.devicePixelRatio放大画布又会把粒子渲染量提升好几倍。我的习惯是取一个上限const DPR Math.min(window.devicePixelRatio || 1, 2); cv.width clientWidth * DPR; cv.height clientHeight * DPR; ctx.scale(DPR, DPR);上限设为2既能保证画面足够清晰又不会让3倍屏的设备无谓地承受超过9倍的像素渲染压力。窗口尺寸变化时记得重新设置画布大小并ctx.scale否则会出现模糊或拉伸。5.4 自动降级连续掉帧时自动减负这个技巧来自我做游戏的经验不要只在开局时判断一次设备性能而是持续监控帧耗时。如果连续几十帧的平均耗时超过20毫秒就自动降低粒子上限和发射频率让体验平滑下来。我用一个简单的降级计数器和档位表档位粒子上限发射概率场景高16000.08新手机、电脑中9000.055老手机、高负载低5000.035备用机、多标签页降级应该是单调递减且不反弹避免在临界点来回跳动。这个自动降级逻辑是我觉得整个项目里最实用的一段代码过年现场总会有长辈掏出各种型号的手机有了它至少不会出现打开页面就黑屏的惨剧。6. 过年场景的最后一公里点击互动、背景氛围与一键分享画面效果有了性能兜底了接下来要做的是让这个烟花模拟器真正变成一个有节日仪式感的东西而不是一个纯技术演示。6.1 点哪炸哪让全家都能上手放烟花自动发射适合当背景板但真正让人玩得开心的是点击互动。监听点击事件把烟花弹发射到鼠标点击的位置形成一个亲手放烟花的体验。代码很简单cv.addEventListener(pointerdown, (e) { const x e.clientX; const y e.clientY; spawnBombAt(x, y); });pointerdown比click好在移动端触发延迟更低而且不会因为手指滑动而被浏览器误判为滚动。这里要注意移动端长按可能会触发系统手势或者文本选择最好给画布加上touch-action: none和user-select: none把这些干扰全部禁掉。有了点击反馈这个页面就不再是看的而是一个家庭互动道具。我实测时家里的长辈每人点了十几下比小孩还起劲。6.2 背景氛围星空、屋顶剪影和偶尔的落灰烟花如果直接画在黑板上会显得有点空。我加了一层不影响性能的静态背景用离屏Canvas画好星点、远处屋顶剪影、灯笼轮廓然后主循环里每次直接drawImage贴上去。这样星空有几百颗星但性能成本几乎为零因为不需要每帧重新生成星点坐标。落灰效果可以加在爆炸逻辑里大约5%的粒子不参与爆炸而是在爆炸点附近的水平方向生成极低速度、重力偏大的小粒子它会快速掉落像烟花燃尽的余烬。这个颗粒极细远看不明显但放大看会发现画面里多了一层灰烬落下的细节氛围感立刻不一样。6.3 打包成单文件一条链接发进家庭群我的最终交付形态是一个单HTML文件里面内联所有CSS和JavaScript不依赖任何外部库、图片和字体。这样做的好处是部署和分享非常方便扔到任意静态服务器或者直接用浏览器打开本地文件效果完全一致。部署上我用得最多的是把HTML丢到任何支持静态页面托管的平台生成一个链接后往家庭群一丢。没有域名也无所谓哪怕是临时生成的二级域名在微信内置浏览器里打开也完全没有问题。这里有一个容易忽略的细节音频。如果想加烟花爆炸声不要依赖外部音频文件直接用Web Audio API现场合成一段噪声爆破音。因为浏览器有自动播放限制AudioContext需要在用户首次点击后初始化正好可以和点击互动结合起来。没有声音的烟花不是不行但加上那种短暂的低频轰鸣过年的感觉会浓很多。注意控制音量别让亲戚打开页面时被吓一跳。6.4 彩蛋设计让页面在午夜切换祝福语最后我还在页面里埋了一个小彩蛋检测系统时间如果落在除夕夜0点前后就在烟花的间隙用Canvas在屏幕中央绘制一行大字——万事顺遂平安喜乐每个字像粒子一样闪烁浮现。这个功能不复杂但家庭群第一次看到时通话里的惊呼声证明了一切。绘制字的思路是先用离屏Canvas把文字画出来然后读取像素数据把不透明像素转换成粒子排布再让这些粒子按烟花逻辑飞出。听上去高级但代码量不大属于一个非常划算的春节限定功能。做完这个项目我最大的感受是视觉效果上的高级感绝大多数来自对细节的较真而不是什么复杂的算法。粒子加重力、HSL渐变、半透明蒙版、lighter混合每一样都是老生常谈但把它们组合起来再针对实际场景做取舍就能得到一个又流畅又惊艳的作品。如果你也想在春节前给家里制造点小惊喜我建议你直接把这个页面做成单文件趁年三十之前多调试几遍尤其是老手机上的帧率。等到年夜饭前把屏幕投到电视上满屏流光炸开的那一刻你会觉得所有折腾都值了。