年三十前的那个晚上,我本来只是想找个现成的烟花模拟器放到家庭群里热闹一下,结果搜出来的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 id="sky" style="position: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毫秒一次,但如果设备掉到30Hz,dt会是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-120 | 1.0-3.0 | 0.028 | 均匀圆散,经典款 |
| 牡丹 | 150-220 | 0.6-1.8 | 0.020 | 粒子密集、慢速膨胀,像一团光球 |
| 柳絮 | 60-90 | 0.8-2.2 | 0.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毫秒,就自动降低粒子上限和发射频率,让体验平滑下来。
我用一个简单的降级计数器和档位表:
| 档位 | 粒子上限 | 发射概率 | 场景 |
|---|---|---|---|
| 高 | 1600 | 0.08 | 新手机、电脑 |
| 中 | 900 | 0.055 | 老手机、高负载 |
| 低 | 500 | 0.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混合,每一样都是老生常谈,但把它们组合起来,再针对实际场景做取舍,就能得到一个又流畅又惊艳的作品。
如果你也想在春节前给家里制造点小惊喜,我建议你直接把这个页面做成单文件,趁年三十之前多调试几遍,尤其是老手机上的帧率。等到年夜饭前把屏幕投到电视上,满屏流光炸开的那一刻,你会觉得所有折腾都值了。