尧图精选

手把手教你用Canvas粒子系统实现烟花模拟器

🕒 发布时间:2026/10/2 15:38:49 📁 来源:尧图网络
离除夕还有几天的时候我决定给自己写一个烟花模拟器。起因很简单今年回不了老家朋友圈里全是视频烟花但隔着屏幕总觉得少了点参与感。与其看别人放不如自己做一场满屏流光炸开、鼠标一点就原地过年的特效页面。我把这个想法落地成了一款纯前端实现的网页烟花模拟器写完后分享到技术群里结果当天晚上就有十几个人来问代码。这篇文章就把整个项目的设计思路、核心算法、实现细节和踩坑记录完整复盘一遍想自己写烟花页面的朋友可以直接照抄。这个项目说穿了是一个基于 Canvas 的粒子特效系统鼠标点击或者点击按钮后一个光球拖着尾巴升到空中在最高点炸开化作几十上百个彩色粒子受重力影响下落、衰减、最终消失。听起来不复杂但因为涉及粒子运动、颜色管理、性能优化、设备适配完整做下来还是有不少值得讲的细节。它也完全不需要后端一个 HTML 文件就能跑适合作为新年礼物页面、个人主页彩蛋或者纯粹用来练手 Canvas 特效。1. 整体设计与技术选型一秒钟的惊艳背后有什么1.1 这个项目到底在做什么很多人第一次看到烟花模拟器会觉得它只是个“画点颜色的小玩意”。但真正动手做你就会发现要模拟一场让人“哇”出来的烟花至少得解决四个问题视觉效果要有“真实感”。烟花之所以好看是因为它有一个从亮到暗、从聚到散的过程不是一堆圆点杵在屏幕上。运动轨迹要符合直觉。光球上升要带一点轻微的摆动爆炸后粒子要受重力影响往下飘不能像塑料袋一样直直浮在半空。性能必须稳。一个画面里同时存在几百上千个粒子如果代码写得糙帧率会掉到个位数气氛瞬间归零。交互要自然。点击屏幕就要有烟花在点击位置炸开而不是等一个延时或者只在固定位置播放。这四个问题指向的答案是同一个用 Canvas 2D requestAnimationFrame 写一个粒子系统。这个组合的好处在于Canvas 2D 有极好的兼容性和足够高的绘制效率几乎不需要任何外部依赖requestAnimationFrame 会自动适配屏幕刷新率还能在标签页切到后台时自动暂停省电又省心。1.2 为什么不用 WebGL、CSS 动画或者现成库有人可能会问WebGL 做粒子特效不是更专业吗Three.js 里也有烟花 demo。我的观点是杀鸡别用牛刀。WebGL 虽然性能天花板更高但学习成本、代码复杂度、兼容性调试成本都上来了为了一个装饰性效果完全不值得。Canvas 2D 在几千个粒子的规模下完全能跑到稳定 60 帧已经足够了。CSS 动画也不适合。倒不是说 CSS 做不出火花飞溅的效果而是烟花粒子有复杂的随机性、衰减规则、相互叠加用 CSS 管理成百上千个独立动画会变成一场灾难DOM 节点爆炸、动画时间线难以同步、性能直线下降。粒子系统本质上是“数据驱动渲染”用 CSS 来表达这件事等于拿勺子舀大海。现成的粒子库比如 particles.js确实能用但要定制烟花特有的“升空-爆炸-衰减”状态机反而更麻烦因为通用粒子库不会为“烟花”这个具体场景做优化代码体积还不小。自己写一个粒子类结构完全可控后续加文字烟花、心形烟花、音乐联动都更容易这也是做项目最值钱的部分。2. 粒子系统原理把一场烟花拆成数学公式2.1 一粒火花的一生我习惯用一个“状态机”来理解一个烟火花火粒子的生命周期它大概经历四个阶段状态 A升空阶段一个明亮的弹体从画布底部出发向上飞行。此时它整体是一个光点尾部拖一条逐渐淡出的轨迹速度会随着时间略微降低。状态 B爆炸点弹体到达设定高度通常是画布高度的一定比例比如 60%-85%速度降到一个阈值附近触发爆炸。状态 C飞散阶段爆炸会生成几十个体积更小的次级粒子继承爆炸点的位置但速度方向随机分布在一个圆形或其他形状的范围内。状态 D衰减阶段次级粒子受到重力影响抛物线式落地同时透明度随寿命衰减直到完全消失。我用一个粒子位置、速度、寿命循环核心思路是“每一帧都先更新粒子状态再一次性把全部粒子画到画布上”。粒子对象大致长这样class Particle { constructor(x, y, vx, vy, hue, life, size) { this.x x; // 当前 x 坐标 this.y y; // 当前 y 坐标 this.vx vx; // 水平方向速度单位是 像素/帧 this.vy vy; // 垂直方向速度负值代表向上 this.hue hue; // HSL 色相值0-360 this.life life; // 剩余寿命单位是帧 this.maxLife life; // 最大寿命用于计算透明度比例 this.size size; // 粒子半径单位是像素 } update(gravity, drag) { this.vy gravity; // 每帧施加一个向下的加速度 this.vx * drag; // 空气阻力让粒子慢慢减速 this.vy * drag; this.x this.vx; this.y this.vy; this.life - 1; // 寿命递减 } draw(ctx) { const alpha Math.max(this.life / this.maxLife, 0); ctx.globalAlpha alpha; ctx.fillStyle hsla(${this.hue}, 90%, 60%, ${alpha}); ctx.beginPath(); ctx.arc(this.x, this.y, this.size * alpha, 0, Math.PI * 2); ctx.fill(); } }alpha life / maxLife这个比例非常关键。粒子刚爆炸时最亮随着寿命减少逐渐透明最后消失视觉上就像火花在夜空中燃尽真实感立刻上来了。2.2 爆炸形态设计圆形、心形还是文字粒子爆炸时的“形状”本质上是通过给粒子赋予不同的初始速度方向来实现的。圆形爆炸最简单随机角度取 0 到 2π随机一个速度值然后分解成 vx、vyfor (let i 0; i particleCount; i) { const angle Math.random() * Math.PI * 2; const speed Math.random() * 4 2; const vx Math.cos(angle) * speed; const vy Math.sin(angle) * speed; }想做出“光环”效果就让所有粒子的速度大小固定去掉Math.random() * 4只剩角度随机效果就是一个匀速扩张的圆环。想做出心形需要用心形参数方程// 心形参数方程t 取 0 到 2π 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);把 x、y 当作粒子的初始速度向量再乘一个展开系数就能得到一颗心形炸开的烟花。我实际试过心形烟花特别受女同事欢迎适合做成“给女朋友的跨年页面”。文字烟花则稍微特殊一点先用一个离屏 canvas 绘制要显示的文字然后逐像素读取把所有有颜色的像素点坐标存进数组爆炸时让粒子以这些坐标为初速度四散开来。这个原理并不复杂但视觉效果相当惊艳后面扩展部分我还会细讲。2.3 颜色体系为什么你看到的烟花五颜六色颜色是最容易被忽略、却最影响观感的部分。我最初用rgb()随机取色结果画面特别脏红一块紫一块像颜料盘打翻了。后来我全部切换到 HSL 色相体系把每个粒子绑定一个 hue 值颜色统一用hsla(hue, saturation, lightness, alpha)来表达。真实烟花的颜色是有规律的通常是同一色调往旁边轻微偏移形成“主色 渐变”而不是毫无章法的彩虹混搭。我常用的做法是爆炸时取一个随机 hue比如 0 到 360再让每个粒子在hue ± 20的范围内波动这样整个爆炸区域的颜色既丰富又统一。金色效果更好把色相固定在 45 到 55 之间亮度和饱和度都调高放出来的烟花特别有“过年鎏金”质感。银白火焰则是低饱和高亮度hue 在 180 附近适合做“雪花蓝银烟花”。3. 核心代码逐段拆解可抄作业的完整方案3.1 基础框架画布、画刷与主循环开头先初始化 Canvas 画布并适配窗口大小。这里有一个容易踩的坑直接设置canvas.width window.innerWidth在小屏手机上会特别模糊因为物理像素和 CSS 像素不是一回事。正确做法是读取window.devicePixelRatio把画布的实际分辨率放大再用 CSS 把画布撑满屏幕这样在 Retina 屏上依然锐利。const canvas document.getElementById(fireworksCanvas); const ctx canvas.getContext(2d); function resizeCanvas() { const dpr window.devicePixelRatio || 1; const width window.innerWidth; const height window.innerHeight; canvas.style.width width px; canvas.style.height height px; canvas.width width * dpr; canvas.height height * dpr; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); } window.addEventListener(resize, resizeCanvas); resizeCanvas();主循环需要同时管理“烟花弹体”和“爆炸粒子”两个数组。每一帧做这些事清屏。注意不是真的清空而是用半透明黑色覆盖一层形成拖尾效果。更新所有弹体的位置检查弹体是否到达爆炸高度是则生成粒子并从数组移除。更新所有粒子的速度和位置移除寿命归零的粒子。统一绘制所有物体。function createFirework(targetX, targetY) { rockets.push(new Rocket(targetX, targetY)); } function loop() { // 半透明黑覆盖形成渐变拖尾注意固定 alpha 值不能太大 ctx.fillStyle rgba(0, 0, 0, 0.15); ctx.fillRect(0, 0, canvas.width, canvas.height); // 更新弹体 for (let i rockets.length - 1; i 0; i--) { rockets[i].update(); if (rockets[i].shouldExplode()) { explode(rockets[i]); rockets.splice(i, 1); } else { rockets[i].draw(ctx); } } // 更新粒子 for (let i particles.length - 1; i 0; i--) { particles[i].update(0.03, 0.98); if (particles[i].life 0) { particles.splice(i, 1); } else { particles[i].draw(ctx); } } requestAnimationFrame(loop); } requestAnimationFrame(loop);拖尾效果里这个rgba(0,0,0,0.15)很有讲究。数值太大粒子刚画出来就糊掉数值太小拖尾变得又长又鬼影。我反复对比后觉得 0.12 到 0.2 之间最合适。另外要注意这里覆盖的范围应该用画布的坐标空间如果在高 DPI 屏上直接用了像素坐标容易出现只擦掉一半的问题所以我在 resize 时用setTransform统一处理。3.2 升空弹与爆炸粒子的实现弹体的实现核心是从底部出发向目标点通常是鼠标点击位置或随机点移动。为了真实我不会让它直线飞而是给一个额外的水平漂移量配合轻微的摩擦衰减看起来就像一枚真正的烟花弹在空中一边升空一边微摆。class Rocket { constructor(targetX, targetY) { this.x canvas.width / 2; // 默认从画布中下部发射 this.y canvas.height; this.targetX targetX; this.targetY targetY; const angle Math.atan2(targetY - this.y, targetX - this.x); const speed 10; // 固定初速度 this.vx Math.cos(angle) * speed; this.vy Math.sin(angle) * speed; } update() { // 弹体只受到轻微阻力保留摆动感 this.vx * 0.99; this.vy * 0.995; this.x this.vx; this.y this.vy; } shouldExplode() { const dx this.x - this.targetX; const dy this.y - this.targetY; return dx * dx dy * dy 100; // 距离小于 10 像素时就爆炸 } draw(ctx) { ctx.globalAlpha 1; ctx.fillStyle #fff; ctx.beginPath(); ctx.arc(this.x, this.y, 2, 0, Math.PI * 2); ctx.fill(); } }爆炸函数负责在一瞬间生成大量粒子这也是整屏流光效果的主要来源。我给一个弹体的默认粒子数设在 70 到 90 之间太多会卡太少不够丰满。粒子数有个体差异可以让大烟花更壮观function explode(rocket) { const particleCount Math.floor(Math.random() * 20) 70; const baseHue Math.random() * 360; for (let i 0; i particleCount; i) { const angle Math.random() * Math.PI * 2; const speed Math.random() * 4 2; const hue baseHue (Math.random() * 40 - 20); particles.push({ x: rocket.x, y: rocket.y, vx: Math.cos(angle) * speed * 0.5, vy: Math.sin(angle) * speed * 0.5, hue: hue, size: Math.random() * 2 1, life: Math.floor(Math.random() * 30) 50, }); } }3.3 颜色与光晕怎么调出高级感很多初学 Canvas 的伙伴画粒子就是一个小小的实心圆效果干巴巴的。想让粒子有“发光”的感觉最简单有效的方法是叠画用shadowBlur给粒子加光晕或者用两层圆叠出渐变感。前者代码少但性能开销大粒子一多就掉帧后者性能更好是自己画一个中心亮边缘淡的渐变圆。我推荐把性能放在第一位所以采用“双层绘制”方案。第一层画略微大一点的半透明圆第二层画高亮的小圆视觉上自然形成光晕而且成本可控function drawGlowParticle(ctx, p) { const alpha Math.max(p.life / 60, 0); ctx.globalAlpha alpha * 0.4; ctx.fillStyle hsla(${p.hue}, 90%, 60%, 1); ctx.beginPath(); ctx.arc(p.x, p.y, p.size * 2.5, 0, Math.PI * 2); ctx.fill(); ctx.globalAlpha alpha; ctx.fillStyle hsla(${p.hue}, 90%, 75%, 1); ctx.beginPath(); ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2); ctx.fill(); }刚才那个粒子队列里还应该给每个粒子标记一下它属于哪一场爆炸这样可以在同一帧里对不同爆炸区做差异化透明度处理。不过初期版本这样已经够用后面性能优化时再调整。4. 性能优化与设备适配过年不要卡成PPT4.1 粒子数量限制与对象池烟花特效最大的性能敌人不是“绘制”本身而是频繁地创建和销毁粒子对象。每次爆炸生成七八十个对象一秒放十次就是七百八十个垃圾回收机制会被频繁触发产生肉眼可见的卡顿。解决方案至少有两条路。第一条是限制粒子总量开一个上限比如 1500 个粒子超过时优先让旧粒子快速衰减或者直接丢弃多余的新粒子。第二条更优雅用对象池事先创建一批粒子对象爆炸时从池里取粒子消失后归还。虽然对象池在纯前端的小项目里稍显啰嗦但对体验提升非常明显特别是在低端安卓机上。const MAX_PARTICLES 1200; const particlePool []; function getParticle() { return particlePool.pop() || {}; } function recycleParticle(p) { if (particlePool.length MAX_PARTICLES * 2) { particlePool.push(p); } }使用对象池后每帧不再有大量对象被创建销毁帧率曲线会平滑很多。我在自测时用 Chrome DevTools 的 Performance 面板盯过对象池方案让 GC 次数减少了大概一半以上。4.2 设备像素比与低端机降级前面已经提到devicePixelRatio带来的清晰度问题。高 DPR 机型会把画布分辨率放大到物理像素级别但如果你的手机是 2 倍 DPR 同时又是一台中端机720p 的实际分辨率放大 2 倍可能会让 GPU 压力翻好几倍。我的处理方式是做一层降级检测设备型号或者粗略估算 DPR 值超过 2 就强制把绘制分辨率锁定在 2这样可以兼顾清晰度和性能。另外一个常被忽略的问题是“后台标签页”的处理。requestAnimationFrame 在后台会自动暂停这本来是好事但如果用户切到别的标签页再切回来你会发现烟花静止了很久然后再一下子暴走式更新因为之前积压的帧被追帧执行了。解决方案也很简单记录lastTime计算帧间隔deltaTime超过 200 毫秒就直接丢弃这一帧的时间重置而不是让粒子跳变。let lastTime 0; function loop(timestamp) { const deltaTime timestamp - lastTime; lastTime timestamp; if (deltaTime 200) { requestAnimationFrame(loop); return; } // 其余更新逻辑同一帧进行 // 如果追求更精确可以把 deltaTime 归一化后乘到粒子速度上 requestAnimationFrame(loop); }移动端的另一个隐藏坑是浏览器地址栏收缩和屏幕旋转会触发 resize 事件导致画布尺寸变化。我加了一个 200ms 的防抖resize 触发后 200ms 内只做一次真实重置避免频繁重新分配画布内存。4.3 用帧率监控验证效果不要凭感觉判断卡不卡写一个简单的帧率监控很有必要。我加了 FPS 计数每帧让计数器加一每秒钟更新一次 UIlet fps 0; let frames 0; let lastFpsTime 0; function loop(timestamp) { frames; if (timestamp - lastFpsTime 1000) { fps frames; frames 0; lastFpsTime timestamp; document.getElementById(fps).textContent fps; } // ... }调试阶段把帧率显示在小角落发布时隐藏。我测试的场景是连续自动放烟花五分钟后iPhone 12 保持 60fps三年前的千元安卓机在粒子上限下调到 700 后也能稳定在 40fps 左右肉眼基本可接受。5. 常见问题与排查技巧我踩过的坑5.1 问题速查表我把开发过程中遇到的高频问题整理成了表格每一个都是实际踩过坑后才总结出来的现象原因解决方案全屏一片黑没有烟花canvas 没有设置高度默认是 150 像素在 resizeCanvas 中重新读取 window.innerHeight烟花效果模糊不清没有处理 devicePixelRatio按 DPR 放大画布物理分辨率再用 setTransform 恢复坐标拖尾很淡或出不来每帧覆盖的黑色 alpha 值太小调整rgba(0,0,0,0.15)过小拖尾几乎不可见过大糊成雾粒子一瞬间全部消失life 初始值过小或循环里 splice 方向错误确认 life 至少 30 帧数组倒序遍历后 splice内存不断上涨每帧 new 大量对象没有对象池引入对象池或限制粒子总数手机上发热严重粒子数量过高 DPR 过大降低粒子上限、锁 DPR 最大 2、减少 shadowBlur 使用切回标签页后烟花抽风帧间隔 deltaTime 过大计算 deltaTime超过 200ms 时重置时间点击没反应事件绑定失败或 canvas 被其他元素遮挡检查监听事件绑在 canvas 上还是 window 上确认 z-index5.2 经验技巧与调试工具调试 Canvas 粒子效果有一个特别好使的手段暂停按钮加单步执行。我通常在代码里留一个debugFlag打开开发者工具后在 Console 里手动执行debugFlag true再配合debugStep()函数每帧只运行一次。这样可以精确观察一个弹体在升空哪个位置触发爆炸、粒子的飞行状态是否符合预期比盯着动画盲猜高效十倍。另一个技巧是把“随机种子”固定住。烟花效果是随机生成的调颜色时如果每次刷新都不一样根本没法对比。我写了一个简单的伪随机函数替换Math.random()调试时固定种子上线时再换回原生随机。这样我能精确复现同一个“配色方案”去做微调。如果你的开发时间足够甚至可以做一个小的控制面板实时调整粒子数、重力、光晕强度所见即所得省去一遍遍刷新页面的痛苦。我还推荐用 Chrome DevTools 的 Rendering 面板把 “FPS Meter” 和 “Paint flashing” 同时打开前者看整体帧率后者看每次绘制区域。如果 Paint flashing 显示大面积重绘多半是你的fillRect清屏用错了坐标系或者 canvas 尺寸没正确限制。6. 让气氛更到位个性化扩展玩法6.1 音频联动让烟花跟着音乐节奏炸开做过一版纯视觉烟花后我开始琢磨怎么加音乐。在 Web 端做音频联动有个免费好用的工具Web Audio API。它可以对音频信号做频谱分析拿到每个频段的能量值。思路是把一首歌的低频能量和一个鼓点检测算法结合起来低频能量超过阈值时就触发一次爆炸这样烟花就能跟随音乐节拍炸开。const audioCtx new AudioContext(); const analyser audioCtx.createAnalyser(); analyser.fftSize 256; const frequencyData new Uint8Array(analyser.frequencyBinCount); function checkBeat() { analyser.getByteFrequencyData(frequencyData); const bassEnergy frequencyData.slice(0, 8).reduce((sum, v) sum v, 0) / 8; if (bassEnergy 140) { createFirework( Math.random() * window.innerWidth, window.innerHeight * (0.15 Math.random() * 0.45) ); } }这个方案的好处是音频解码、播放、分析全都在浏览器里完成不用额外引第三方包。注意 AudioContext 必须由用户交互行为触发才能启动否则会被浏览器拦截所以页面里要加一个“开启音乐”按钮点击后再播放音乐并初始化音频链路。我第一次做的时候没注意这个限制页面加载后自动初始化音频结果 Safari 上怎么都没反应排查了半天才发现是自动播放策略在卡脖子。6.2 文字烟花把跨年祝福炸到天上去文字烟花是整屏烟花最出效果的变体。前面粗略提过原理这里把步骤展开在内存里创建一个隐藏 canvas大小和爆炸区域对应用大号字体写“新年快乐”这样的文字调用getImageData读取像素数据把有颜色的像素筛选出来。记录它们相对于文字左上角的位置。爆炸时每个粒子先落到文字像素点所在的位置再向四外飞散。这里有两段路要走第一段是“聚拢”过程粒子朝各自目标点加速移动第二段是“散开”过程到位后立刻赋予随机速度形成烟花碎片效果。整体看起来就像文字在空中炸开视觉冲击力极强。function loadTextPixels(text) { const offCanvas document.createElement(canvas); const offCtx offCanvas.getContext(2d); offCanvas.width 300; offCanvas.height 100; offCtx.font bold 72px Microsoft YaHei; offCtx.fillStyle #fff; offCtx.fillText(text, 10, 80); const imageData offCtx.getImageData(0, 0, 300, 100); const points []; for (let i 0; i imageData.data.length; i 4) { if (imageData.data[i 3] 128) { const x i / 4 % 300; const y Math.floor(i / 4 / 300); points.push({ x, y }); } } return points; }文字像素点数量可能非常多一个 300×100 的区域几百上千个点全部生成粒子会直接卡死。所以要“抽稀”每隔几个点取一个或者随机采样把单次爆炸的粒子数控制在 200 到 300 之间。这是我试过之后的安全阈值既可以清晰辨认文字内容又不至于让帧率崩掉。6.3 部署与小彩蛋从本地 HTML 到朋友圈扫码即玩这个项目最终形态可以简单到“一个 HTML 文件”所有 CSS 和 JavaScript 都内联在里面。分享时有两个方向一个是本地直接打开适合发给少数人另一个是部署到静态托管平台比如 GitHub Pages、Vercel、Cloudflare Pages得到一个链接后生成二维码别人扫码就能体验。我建议部署到静态平台因为 HTML 文件里没有任何后端依赖整个项目就是零成本托管还能绑定自定义域名。页面里加一段 metadata 保证移动端缩放正常meta nameviewport contentwidthdevice-width, initial-scale1, maximum-scale1, user-scalableno部署后有几个细节不要忽略页面标题和分享卡片要设置好标题可以是“满屏流光炸开今年过年气氛一秒拉满”为了兼容微信内置浏览器的分享缩略图最好再放一个 300×400 的预览图。我做完这步后把链接发到家族群长辈用微信点开也能顺畅跑起来气氛直接到位。再加一个小彩蛋如果愿意折腾可以加一个“倒计时”层在除夕夜 235959 自动触发连放模式每秒钟连放十场烟花。配合音乐和文字烟花效果堪比小型灯光秀。这种“时间驱动 视觉反馈”的结合最能让用户感受到页面背后的巧思。我自己在写这个项目时最大的感受是烟花模拟器虽然只是个“小玩具”但它几乎涵盖了前端动画开发的所有关键点——性能、状态管理、随机性设计、跨设备适配、用户交互。它不需要多少代码量却能把 Canvas 的威力展示得很透彻。做这个页面的过程中我也逐渐理解了一件事很多看似简单的效果真正做好看、做流畅靠的是背后大量细微的计算和取舍。如果你也想复刻一个建议先不要急着加花哨功能把最基础的一个烟花跑通再逐步叠加颜色渐变、拖尾、心形爆炸、音频联动。每加一个功能都回头看看帧率有没有变化这样学到的东西最多也最不容易被一堆复杂代码劝退。等最后一版上线看到家人朋友因为满屏流光发出惊叹时你会觉得这些调试的夜晚特别值得。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →