尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

用Canvas手写烟花模拟器:粒子系统与性能优化实战

发布时间:2026/9/26 8:25:43

资讯中心
01
ARTICLE

用Canvas手写烟花模拟器:粒子系统与性能优化实战

用Canvas手写烟花模拟器:粒子系统与性能优化实战
一说到过年很多人第一反应就是找个现成的 GIF 烟花图、视频素材塞到页面上但那种东西动起来是扁平的、卡顿的、也不够“活”。我今年要分享的是自己手写的一个烟花模拟器用 Canvas 从零实现满屏流光炸裂的效果支持自动巡航、鼠标点击引爆、手机触摸放花也支持粒子数量调节和音效反馈。打开页面那一刻年味来得特别直接——夜空中升起光点炸成一大朵扇形流光拖尾慢慢飘落紧接着下一发又升起来了那种感觉是静态图片完全比不了的。这个项目适合所有前端开发者、想要美化个人站点或活动页的朋友。即使你之前没深入玩过 Canvas只要照着下面的思路和代码搭一遍也能在自己的页面上复现出同样的效果甚至在此基础上加自己的创意比如倒计时、祝福语、聚宝盆那种“大字烟花”。我尽量把实现中的设计取舍、物理参数怎么定、性能瓶颈在哪里、真实踩过的坑都讲透不是简单地撒一颗粒子数组上去就完事。1. 整体设计与技术方案选型1.1 为什么选 Canvas 而不是 WebGL 或 CSS 动画做烟花特效可选的技术路线其实就三类CSS 动画、Canvas 2D、WebGL。我直接说结论CSS 动画做不了大规模粒子闪烁和拖尾WebGL 对这种小场景属于杀鸡用牛刀Canvas 2D 是最平衡的选择。CSS 动画适合做单个元素的位移动画比如几朵大烟花在固定位置循环 fadeIn、scale想生成几十个随机粒子的散射、重力下落、颜色渐变、相互交叠CSS 的复杂度会爆炸。尤其当粒子数量超过一百个浏览器会对每个元素的 transform 和 opacity 做独立合成动画一多必然掉帧。WebGL 能在一帧内绘制上万个粒子性能最强但需要写着色器、管理缓冲区和纹理绑定对于一个小型娱乐项目来说学习成本太高了而且部署体积也不划算。Canvas 2D 的fillRect、arc、globalCompositeOperation和线性渐变可以覆盖烟花所需的一切视觉表达。它只在一块画布上画画浏览器不用维护几百个 DOM 节点同一时刻画多少粒子只取决于我们的绘制代码数量。现代浏览器对 Canvas 有 GPU 加速支持大部分绘制操作会交给合成器处理所以粒子数量在两三百时电脑和手机都能稳定跑满帧。1.2 把项目拆成发射器、粒子、渲染层三大模块整个烟花模拟器的架构我拆成了三大模块发射器Launcher、粒子系统ParticleSystem、渲染与交互层Renderer Controller。这样拆的原因很简单升空、爆炸、飘落是三种不同状态的粒子不能把它们全塞进一个数组里靠一个逻辑死磕。发射器负责两个任务一是定时投放上升的“信号弹”粒子二是把鼠标点击或屏幕触摸的坐标转换成一次爆炸的触发点。粒子系统内部维护两个数组一个存未激活的空闲粒子一个存活跃粒子。每帧遍历活跃粒子更新位置、速度、透明度和生命周期当粒子的生命值归零就回收。渲染层负责把粒子系统的状态画到画布上同时监听窗口尺寸变化、DPR 变化保证画面在高清屏上不模糊。这种模块化设计最大的好处是如果你想给烟花加“螺旋轨迹”或“环形爆炸”只需要在发射器或粒子系统里新增一个函数不影响其他部分。我测试过程中经常想临时验证某个花样改完发射器存好草稿再也不用去翻渲染层的代码。1.3 春节氛围的加分项音效、祝语与交互动效烟花只有视觉还不够我额外做了三个提升氛围的设计。第一是提示音效烟花升空时有“咻”的一声短音爆炸时伴随低沉的爆响。音效我用 Web Audio API 实时合成没有用一个音频文件这样加载体积基本为零。第二是祝福文字页面中央每隔几秒淡入一个金色毛笔字风格的祝福语比如“新春快乐”“万事如意”这些词轮换出现和烟花形成视觉焦点。第三是一键全屏模式点击按钮后页面进入全屏让烟花覆盖整个显示器适合投屏到电视或投影仪上用作活动现场背景。不要小看这三点一个模拟器如果只有光效看三十秒就会腻。有了声音和文字层观众会感受到“这是设计过的场景”而不是“又一个 canvas demo”。尤其当你在家庭聚会时把它投到客厅电视上气氛会完全不同。2. 粒子系统的核心实现让“炸开”这件事有细节2.1 粒子的数据结构与生命周期每个烟花粒子的本质是一个非常普通的数据结构。我用 TypeScript 写的话大概长这样interface Particle { x: number; // 当前位置 x y: number; // 当前位置 y vx: number; // 水平速度 vy: number; // 垂直速度 life: number; // 剩余寿命单位帧 maxLife: number; // 初始寿命用于计算透明度渐变 color: string; // 粒子颜色 size: number; // 粒子半径 type: spark | glow | trail; // 粒子类型 targetY?: number; // 部分粒子下落的目标高度 }生命周期分为三个阶段出生spawn、活跃update和回收recycle。出生时从一个随机位置产生初始速度由发射方向决定每帧调用update更新位置把速度应用到坐标上再更新剩余存活时间当life 0时从活跃数组移到空闲池。这里有个经验不要把new Particle()写在每帧循环里。频繁的创建和回收会触发垃圾回收器当粒子数量几百上千时GC 造成的卡顿会非常明显。我的做法是常驻一个粒子池初始就分配好数组空间只维护一个activeCount标记。比如:const poolSize 2000; const particles: Particle[] Array.from({ length: poolSize }, () ({ x: 0, y: 0, vx: 0, vy: 0, life: 0, maxLife: 1, color: #fff, size: 1, type: spark })); let activeCount 0;需要生成新粒子时从池子里取一个未激活的对象重置它的属性粒子死亡时把activeCount减一。这样做在整个页面生命周期中几乎不会产生新的对象内存平稳帧率也就稳定了。2.2 重力和阻力模型参数怎么定才像真烟花烟花在空中的运动不是简单的匀速直线它要同时受重力和空气阻力影响。以现实世界为参照烟花弹升空后会有一个竖直向上的初速度之后重力逐渐把它拉低最高点出现短暂的滞留感爆炸后的小火星抛射出去速度越快、半径越大同时因为空气阻力不断减速最后变成慢速旋转下落的余光。我用两套参数分别模拟这两段运动。升空段竖直方向初速度vy0取屏幕高度的-0.01 ~ -0.015倍意思是在一秒钟内要从底部附近升到中上部。每帧给vy加上一个重力加速度g我取0.18像素/帧²。上升过程中还掺了一个小随机横向漂移vx让弹道形成微弱的弯曲而不是笔杆一样的直线。爆炸段爆炸瞬间给每个粒子赋一个方向角angle360 度随机初速度speed在4 ~ 9像素/帧之间。速度向量拆成vx Math.cos(angle) * speed、vy Math.sin(angle) * speed。同时每帧都把速度乘以一个小于 1 的阻力系数drag 0.98速度会呈指数趋势下降。重力依然加在vy上。为什么阻力要用乘法而不是简单减因为空气阻力的物理模型是与速度平方成正比的但平方模型计算量稍大在像素模拟里直接用指数衰减已然足够速度快时衰减迅猛速度慢时衰减变缓效果与真实空气阻力近似。经过几次调试后drag0.98、g0.18能出现非常经典的“大菊花”烟花形态爆炸瞬间火星飞得远到末端时又能拉出细长拖尾。2.3 颜色的学问怎么生成春节高级感配色烟花的美感一大半靠颜色。直接随机rgb(0, 255, 128)会得到又脏又刺眼的颜色。我的方案是准备一个春节主题调色板然后从调色板里取主色并允许粒子在主色附近做小范围色偏。调色板我是这样定义的const palette [ #FF3333, // 中国红 #FFD700, // 金色 #FF6A00, // 橙火 #00FFFF, // 荧光青 #FF69B4, // 粉玫瑰 #7FFF00, // 草绿火花 #FFFFFF, // 白闪 ];粒子爆炸时以主色为中心在 HSV 空间微调色相、明度和饱和度。Canvas 直接写 hex 不方便微调我先把主色转成 HSV然后在色相正负 12 度、明度正负 5% 范围波动再转回字符串。这样每一朵花都有主色调但花瓣颜色之间又会自然过渡不呆板。还有个小技巧在爆炸中心额外生成 10 到 15 个纯白高光粒子它们不参与颜色偏移只负责一瞬间的爆发闪光。人眼对烟花印象最深的部分就是爆炸中心那一下白光加上之后主色的扩散感才更有层次。globalAlpha也是颜色的一部分。粒子刚出生时透明度高生命末期衰减到 0。为了让拖尾看起来绵长粒子还带着一条“尾迹线”描述上次坐标到当前坐标的连线线条透明度略低于粒子本体。这样即使单帧只画了 200 个粒子视觉上却像有上千个拖尾在闪烁。2.4 轨迹与形态从“单球爆裂”到“双层彩虹”仅仅一个圆形爆炸太单薄我做了三种爆炸形态随机轮换第一种是最常规的球形爆炸随机方向 360 度均匀散射第二种是双层花环内圈粒子的初速度为外层的一半形成中心亮、外围虚的立体花形第三种是环形闪光粒子被限制在一个狭窄的环带方向上形成像天使光环一样的圆环然后逐渐雾化。为了让烟花更像现实中的春节烟花我还加入了一种“柳树”效果上升粒子到达最高点后爆炸但纵向速度受重力主导横向速度较小结果形成一束像垂柳一样的长条下落火星。行话叫“千轮柳”很多南方朋友过年应该见过。实现上只需让爆炸粒子的方向角集中在 75 到 105 度偏向垂直方向并且初始速度偏大重力和阻力共同作用下粒子先快速上升、随后再缓慢拐弯下落轨迹曲线特别醒目非常适合作为压轴节目。3. 实操教程从零搭一个轻量高帧率的烟花 Demo3.1 初始化画布、像素密度与全屏适配第一步是初始化 Canvas 并解决高清屏模糊问题。很多人第一步就会踩坑直接用 CSS 宽度占满屏幕然后用canvas.width window.innerWidth画在 2K 或 Retina 屏上会发虚。原因是页面的 CSS 像素和物理像素不是一比一我们有 DPRDevice Pixel Ratio。我建议的初始化逻辑const canvas document.getElementById(fireworks-canvas); const ctx canvas.getContext(2d); let dpr Math.min(window.devicePixelRatio || 1, 2); function resizeCanvas() { canvas.style.width window.innerWidth px; canvas.style.height window.innerHeight px; canvas.width Math.floor(window.innerWidth * dpr); canvas.height Math.floor(window.innerHeight * dpr); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); } window.addEventListener(resize, resizeCanvas); resizeCanvas();注意我把 DPR 上限限制到 2。如果你在 3 倍屏上用 3 倍画布尺寸帧缓冲会非常大Canvas 绘制量剧增手机发热明显。上限 2 之后人眼几乎分辨不出清晰度差异性能却省了一大截。绘制坐标统一使用 CSS 像素尺寸所以窗口缩放后无需修改粒子逻辑只需调用resizeCanvas即可。爆炸发射点坐标也以 CSS 像素为单位坐标映射不会乱。接下来要写主循环推荐用requestAnimationFrame而不是setInterval。requestAnimationFrame会跟随显示器刷新率通常 60Hz页面切到后台时自动暂停不会白白耗电。主循环体内依次执行生成新粒子、更新活跃粒子、绘制背景、绘制粒子然后请求下一帧。3.2 发射器与人机交互支持点击和触摸一个默认的自动发射循环很必要不然打开页面没有声音用户不知道要点击。我设计了两类发射方式自动巡航模式和手动点击模式。自动巡航模式下每 600 到 1200 毫秒发射一枚升空弹爆炸位置在屏幕上半部分随机。为了不显得呆板发射位置我做了偏正态分布让烟花更集中在画面偏上方偏中心偶尔会“失误”发射到左侧边缘反而更有真实感。手动模式监听pointerdown事件——我建议用pointerdown而不是click或touchstart因为前者同时覆盖鼠标、触摸笔和手指而且能在mousedown后立即响应延迟更小。监听后取事件坐标直接从该坐标触发一次爆炸不需要升空过程。很多人问为什么不做成“点击位置发射升空弹再爆炸”我解释一下手指点击的瞬间触摸事件触发坐标已经确定如果先升空再爆炸会有几百毫秒的滞后感且用户手指可能已经移开。对模拟器而言直接爆炸更能即时满足视觉刺激。在移动端还要做一件事防止 Canvas 上的触摸默认行为比如滚动、双指缩放。我给canvas加上了touch-action: none样式并在pointerdown事件里调用preventDefault确保触点只会被当作烟花触发点不会干扰页面滑动。3.3 粒子管理对象池与数量上限前面提过对象池这里写一下具体上限控制。当同时有 3 发大烟火在屏幕上绽放时粒子数很容易瞬间冲到 800 到 1200 颗。如果算上残影和拖尾实际绘制对象可能翻一倍。这么多粒子的逐帧计算对移动端压力很大所以必须做一个软上限。我提供一个参数叫maxParticles默认设 800用户可以在控制面板里调节范围为 200 到 2500。每当新增粒子的数量超过配额就把超出的部分丢弃而不是裁剪掉已有粒子。因为爆炸效果是叠加的偶尔丢几颗新粒子对视觉影响不大但如果削砍旧粒子会看到烟花突然消失反而更奇怪。另外每一帧我都会检查activeCount如果它超过maxParticles * 0.9就让发射器跳过本次新烟花发射休息一下等上一轮粒子消退后再发。这样可以保持画面始终高潮但粒子总量始终在一个安全阈值内。3.4 装饰与控制面板音效、祝语和手感调节控制面板我放在页面右下角共三个开关声音开关、文字开关、粒子密度滑杆。所有设置项存入localStorage下次访问时自动恢复。这个细节别省很多用户希望每次打开都是熟悉的配置。音效实现用 Web Audio API 的OscillatorNode加GainNode升空音用频率从 800Hz 快速滑升到 2400Hz 的三角波持续 130 毫秒爆炸音用白噪声经过快速衰减的 Gain 包络模拟“砰”。每次触发爆炸前先创建一个新的AudioBufferSourceNode播完立刻断开。祝语模块也很简单用一个div固定在屏幕中央下方JS 里用定时器切换文案。比较建议做一个canvas 内文字版即把祝福词渲染在画布上沿金色烟花拖尾变化。但 canvas 文字做动画容易像素化所以我选择了 DOM 文字加 CSS 过渡视觉稳定代码也更简单。当全屏模式启用时DOM 元素会跟随fullscreenchange事件调整样式保证不丢失。4. 性能优化与体验打磨4.1 高帧率的关键内存稳定与绘制批次本项目的性能瓶颈不在于算法复杂度而在绘制调用次数和内存抖动。每画一个粒子都要调一次ctx.arc和fill属于比较重的操作因为arc内部会做路径解析。优化策略是能不用arc就不用小粒子直接画fillRect大而模糊的光晕再用arc加径向渐变。在我的实现里普通火花粒子都是fillRectctx.fillStyle color; ctx.globalAlpha life / maxLife; ctx.fillRect(x - size / 2, y - size / 2, size, size);这样省掉了路径创建的损耗。测试下来fillRect绘制 2000 个粒子的耗时要远低于arc绘制 1000 个。亮度和闪烁效果通过globalAlpha控制。因为渲染不是物理世界我们只需要视觉上像光斑即可fillRect小方块和arc圆斑在人眼识别上没有本质差异。对于拖尾效果我并没有真正去做残影层而是用一个半透明盖板技巧每帧先用globalAlpha0.22的纯色矩形填充整个画布再绘制新粒子。旧帧的图像会残留在画布上形成自然尾迹。需要注意的是这个半透明盖板颜色需要和页面背景一致否则画面会越积越脏。4.2 粒子降级策略帧率自适应不同配置的手机性能差距很大。我做了个帧率监测如果最近 30 帧的平均渲染时间超过 22ms约 45fps 以下就自动把粒子密度调低 20%并通知发射器减少同时发生的烟花数量。如果连续 60 帧都低于 30fps则把拖尾残留从 0.22 提升到 0.3缩短尾迹长度同时把maxParticles压缩到 400。这套自适应机制不需要用户干预是纯运行时判断。判断逻辑我写在主循环外部用采样队列统计过去 30 帧的时间戳差。降级后如果帧率回复正常也不会立刻恢复而是等 10 秒后逐步恢复防止反复横跳。4.3 移动端适配与电量友好移动端有几个常见痛点一是全屏触摸手势被误判二是长时动画导致手机发烫三是刘海屏和键盘弹起引发视口混乱。第一个已经用touch-action解决第二个靠帧率自适应和 DPR 限制基本缓解。第三个我会在resize事件里重新读取window.innerHeight和document.documentElement.clientHeight并把画布高度对齐文档高度。还有一个细节当页面进入后台比如切到别的 ApprequestAnimationFrame会自动停止。我在visibilitychange事件里记录“离开页面前的状态”回来后强制清空画布并重新初始化发射器避免用户看到一堆残留的半透明粒子。4.4 代码体积与部署优化整个模拟器我把它做成了一个单文件模块核心代码加上控制面板在内约 400 行 JavaScript没有任何第三方依赖压缩后大概 12KB。这意味着它可以被方便地嵌入任何 HTML 页面也可以直接放到静态站点服务器上。部署优化方面我做了两件事一是给 Canvas 元素加了will-change: transform暗示合成器把它提升到一个独立图层减少重绘时与 DOM 树的交互二是给背景星空层使用单独的慢速粒子循环与烟花主循环解耦。星空用来增加夜晚氛围但我把它的粒子数限制在 150 以内绘制频率每 5 帧才更新一次几乎不产生额外的性能压力。5. 常见问题与调试实录5.1 帧率掉到 20fps一次真实排查有一次我把粒子上限调到 3000然后在笔记本上跑帧率直接掉到 20fps 出头。查了一会儿发现死结并不在粒子更新逻辑而是ctx.arc调用次数太多而且每个粒子我都在画一个径向渐变。大尺寸径向渐变属于昂贵操作尤其在爆炸那几帧三千粒子同时生存单帧绘制时间超过 30ms。我解决问题分三步先移除所有普通粒子的径向渐变只保留中心高光和少量大粒子的渐变再用fillRect替换小粒子最后把粒子上限调回 1200。三步做完20fps 回升到 55fps 左右。这件事让我总结出一个准则先自查绘制调用、再查算法最后一招才是降粒子数。5.2 烟花爆炸后消失在屏幕外边界处理爆炸粒子偶尔会飞出屏幕边缘这在“柳树”形态和高速散射中格外常见。视觉上表现为烟花“不完整”半边直接切断。我不是简单粗暴地把粒子 clamp 在边界内而是为粒子增加一个removeOutside标记如果超出边界超过 50 像素就提前结束生命周期回收到池中。但如果只超出 10 像素则继续显示保证爆炸边缘不会被硬生生裁成直线。这样做的好处是烟花在边缘爆炸时即使被裁切也会因为溢出粒子的存在而自然淡出看起来更像真实的视口取景。5.3 粒子颜色越画越脏混合模式踩坑有一阵子我在拖尾区域用globalCompositeOperation lighter希望获得更华丽的光影叠加效果结果颜色越来越白甚至发灰。原因是lighter混合会造成颜色高亮叠加如果同时存在大量同色粒子就会变成一片惨白。后来我改成了把尾迹颜色做一次 HSL 明度下降衰减把核心高光之外的粒子从lighter模式改为普通source-over只在爆炸中心保留一个亮度渐变的叠加层。这样效果立刻干净了画面能看出“红就是红、金就是金”。5.4 随机数陷阱每次刷新动画都一样初版我使用Math.random()直接生成随机数按理说每次都不一样。但在某些设备上如果页面使用了数据库缓存或快照恢复动画序列会被记录下来造成每次打开出现一模一样的“假随机”。其实问题不在随机而在我的种子生成逻辑我用的是固定时间单位做动画时长传入相同的起点导致相同序列。解决办法是把动画起点对齐到performance.now()并以此作为随机种子每一个粒子都用两个独立随机种子生成坐标和属性避免伪随机序列早衰。如果你希望用户能分享固定的烟花组合反而可以反过来手动建立一个seed参数相同的种子生成完全相同的烟花做成“烟花大赛”。5.5 常见问题速查现象可能原因解决办法手机上画面模糊没有适配 DPR画布尺寸乘 devicePixelRatio一放大就卡粒子数量过高调低 maxParticles 或启用帧率自适应拖尾消失得很快盖板透明度太高把盖板 alpha 从 0.3 降回 0.18~0.22文字祝福看不见DOM 层级低于画布给文字容器设置更高的 z-index点击没反应pointerdown没有 preventDefault检查 canvas 的 CSStouch-action声音爆音同时多个爆炸音叠加每 50ms 最多允许播放一个新爆炸音画面残影发灰盖板颜色和背景不同将盖板颜色设置为背景的纯色我用的#0b10265.6 从 Demo 到活动页后续可以扩展的方向做完了基础版你能再往哪走我列三个我认为最有价值的节后扩展方向。第一个是祝福语定制化接入一个输入框和 URL 参数比如?message万事如意typerainbow让用户生成带自己名字的专属烟花页发到群里或作为弹幕墙的候补方案。第二个是多场景主题按“除夕”“元宵”“婚礼”“跨年”切换调色板烟花颜色跟随主题变化。第三个是音乐联动分析用 Web Audio 的AnalyserNode分析背景音乐的频率能量低频触发大爆炸、高频触发小烟花实现“自动卡点”。我自己的建议是先把基础交互打磨好特别是移动端的手感和帧率稳定性这两个过关后换皮和集成活动页的效率都会非常高。这个烟花模拟器目前还在我 GitHub 仓库里每次过年我都要打开点几下自己先过一把瘾。说实话调出满意的“柳树”效果那一刻比很多大型项目上线还开心。如果你搭的时候踩到坑又不想折腾可以直接把这里的核心思路拿过去改一版。代码量不大耐心加一点随机调参你也能做出让人“哇”一下的过年利器。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。