☰
纯前端Canvas烟花模拟器实战:粒子系统与性能优化全解
2026/9/25 15:12:05 网站建设 项目流程

有段时间没打开这个文件夹了,年前翻出来跑了一下,满屏流光炸开的时候,办公室几个人都凑过来看。就是一个这样的东西:一个纯前端写的“烟花模拟器”,不需要装任何工具,一个HTML文件丢到浏览器里就能跑。自动升空、爆炸、拖尾,还能点击屏幕任意位置手动引爆,过年气氛一秒拉满。如果你正愁节日页面没有氛围,或者想在聚会上给大屏加个暖场背景,直接照着做就行。想练Canvas粒子系统的,这篇也能当一份带完整解析的实战代码笔记来读。

1. 烟花模拟器的整体设计与选型思路

1.1 它到底是个什么东西,能解决什么问题

先把这个项目说清楚。它本质上是一个基于Canvas 2D的粒子特效系统,里面的每一个光点、每一道拖尾,都是JS代码实时算出来的,不是什么GIF图或者视频。运行时画面大概是这样的:屏幕底部随机位置有一颗“火箭”拖着细长的尾巴飞上去,飞到大半屏高度时“嘭”一下爆开,变成几十上百个粒子向四周飞散,粒子带着余晖缓缓下落,然后变淡消失。整个过程循环往复,背景是深蓝色的夜空,颜色以红、橙、金、粉为主,特别有春节那味儿。

这个项目适合几类人:第一种是公司年会、店铺大屏、家庭投影需要节日背景的,挂个页面在那里循环播放就行;第二种是前端初学者,想知道粒子系统、动画循环、Canvas绘图到底怎么回事;第三种是爱折腾的,想在个人博客、活动页面里嵌入一段“有点东西”的动态效果。它最大的价值在于门槛极低,不依赖任何框架,也没有复杂的构建流程,复制一个HTML文件就能用,这才叫真·开箱即用。

1.2 为什么用Canvas而不是CSS动画或WebGL

经常有人问,这种效果能不能用CSS做?能做,但会非常吃力。CSS动画擅长的是位移、旋转、缩放这些单点变化,你很难让几十个粒子彼此独立、有不同速度和方向地飞出去。真要硬做,CSS代码量会爆炸,而且浏览器对大量动画节点的合成压力远大于单一Canvas的绘制压力。所以这种“一屏幕几百上千个动态对象”的场景,Canvas 2D是性价比最高的选择。

那为什么不用WebGL?WebGL确实能做到更炫的效果,尤其是大量粒子和复杂着色器场景,那属于高级玩法。但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初始值设为1,decay设置在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,手机上打开也一样流畅。如果让我说一个最想让你记住的点,就是“先控制粒子总量,再谈视觉效果”,所有好看的烟花效果,都是建立在稳定帧率之上的,卡成幻灯片的画质毫无气氛可言。把这个项目放到投影仪上,配合房间暗下来的环境,效果远超预期。过年时用一个页面带来的热闹感,真的是前端技术最好的浪漫。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询