Canvas 2D粒子系统实战:打造满屏流光烟花模拟器
发布时间:2026/9/26 2:07:18来源:尧图网络
每年除夕最头疼的不是买菜是让一屋子亲戚家的小孩安静下来。今年我索性写了一个烟花模拟器网页丢到大屏上打开就是满屏流光炸开鼠标点哪儿哪儿放花夜里有城市剪影、圆月和星星小孩们围着屏幕看了将近半小时客厅气氛比窗外的真烟花还热闹。这个项目本身不算复杂核心就是一套粒子系统加一条Canvas渲染管线但很多细节做得对不对决定了它是看起来像放烟花还是一眼假。这篇文章把完整实现思路、关键参数和踩过的坑都摊开讲既适合当年味彩蛋也很适合拿来练手Canvas和粒子动画。1. 为什么一个烟花模拟器比视频更有年味选型与整体架构1.1 这个项目要解决的四个问题我在动手前先列了几个刚需不然很容易写着写着就变成会动的粒子而已。第一是气氛要够足。年味这东西视觉占了很大比重要满屏、持续、有层次感而不是孤零零隔几秒放一朵。所以我会做成自动连发模式每隔一小段时间就在屏幕上随机位置炸一朵同时允许手动点击追加保证热闹不断档。第二是交互要直观。小孩和长辈不会看代码也不会看说明书他们能做的动作就是点一下、划一下。哪怕没有点击屏幕自己也要不停地放。所以交互逻辑必须是最简单的点击触发自动连发兜底。第三是运行要轻量。这台演示机器可能是一台普通配置的笔记本也可能是一块安卓平板不能为了画面把帧率拖到20帧。这意味着粒子系统必须严格控制数量并且做好对象复用不能随手new一堆对象然后又等着垃圾回收去清理。第四是画面要有情绪。单纯彩色圆点乱飞是烟花吗不是更像弹幕。真正让画面有烟花感的地方在于爆炸前的升空轨迹、爆炸瞬间的明亮爆点、炸开后的拖尾光痕、以及最终缓缓消散的下坠感。这四段情绪缺一段味道就少一半。这四个问题决定了技术选型不可能太花哨但也不能太简陋。1.2 技术方案对比Canvas 2D、WebGL还是CSS特效市面上常见的方案有三类我简单对比一下方案优点缺点我的结论CSS动画 Div粒子实现快单个粒子样式好做粒子一多DOM节点爆炸卡得厉害只适合几十个粒子的轻量场景WebGL / Shader性能最强可以做复杂光效开发成本高调试麻烦手机上兼容性要额外处理犯不上杀鸡用牛刀Canvas 2D requestAnimationFrame性能足够支撑数千粒子代码直观调试方便需要自己管理绘制顺序和粒子状态最优解我最终选它从实际效果看Canvas 2D在1000到3000个粒子的规模下完全能稳定跑60帧对烟花这种爆完即散的场景已经非常宽裕。再往上堆粒子观感并不会线性提升反而会让画面变成一片光雾失去烟花该有的轮廓。所以在参数设计上我会把粒子数控制在合理范围而不是单纯追求多。1.3 三层结构背景层、火箭层、爆炸层我最终用了一个Canvas没有拆成多个叠加画布。拆多个画布在分层绘制上更干净比如背景星星不用每帧重绘但多Canvas会增加内存占用而且半透明填充做拖尾时各层之间的混合关系反而不好控制。我选择在同一个画布上用绘制顺序来实现分层先画背景层渐变夜空、星星、月亮、底部城市楼群剪影。这些元素一部分是静态的比如夜空渐变我只要在初始化时画一次星星和月亮会在拖尾填充时被逐渐覆盖但下一帧draw背景又会把它们补回来。再画静态装饰之上的粒子层火箭上升尾迹、爆炸碎屑、闪烁光点。最后画UI层祝福文字横幅、调试信息这类永远置顶的内容。这个顺序很重要因为带alpha的背景填充会把上一帧的粒子残留压暗相当于自动实现轨迹渐隐。如果先画粒子再刷背景就完全达不到拖尾效果了。2. 粒子系统拆解把一次绽放拆成发射、升空、爆炸、消散2.1 粒子对象需要哪些字段一次完整的烟花在视觉上至少要经历四个阶段发射火箭从地面窜出、升空带着尾迹飞向目标高度、爆炸瞬间迸出大量碎屑、消散碎屑带着重力缓缓落下并淡出。如果直接写一个粒子走完全程你会发现升空和爆炸阶段需要的物理参数完全不同所以拆成两类对象更好处理火箭粒子负责升空带尾迹速度向上到达目标高度后触发爆炸。碎片粒子爆炸瞬间产生四散飞出受重力和阻力影响逐渐消亡。我给粒子设计了这样一组字段简单但够用class Particle { constructor() { this.x 0; // 屏幕坐标原点在左上角 this.y 0; this.vx 0; // x方向速度 this.vy 0; // y方向速度注意canvas里y向下升空是负值 this.life 1; // 当前剩余寿命1表示刚出生0表示死亡 this.maxLife 1; // 最大寿命用于计算归一化进度 this.size 2; // 绘制半径 this.color #fff; // 颜色 this.drag 0.98; // 每帧速度衰减系数 this.gravity 0; // 每帧施加到vy上的重力 } }这套字段的核心思路是物理表现靠速度变化视觉表现靠颜色和尺寸生死靠life判断。只要每帧执行一次位置 速度, 速度 * 阻力, 速度 重力, 寿命 - 1/maxLife的逻辑粒子自己就会动起来不需要额外记录复杂的运动状态。2.2 火箭升空与尾迹粒子的配合火箭粒子生成后我的目标是让它飞到屏幕上半部分的某个随机高度然后爆炸。为了让火箭有嗖一下窜出去的感觉初速度不能给得太小。我用的方法是先随机一个目标高度再根据目标高度反推初速度。// 火箭从底部随机横向位置起飞 rocket.x Math.random() * canvasWidth; rocket.y canvasHeight 10; // 目标高度屏幕高度10%到50%之间随机取一个 const targetY canvasHeight * (0.1 Math.random() * 0.4); // 初速度上升距离越大初速度越大再加一个随机波动 rocket.vy -(rocket.y - targetY) * 0.006 - 3; rocket.vx (Math.random() - 0.5) * 0.5;这里有个细节如果初速度用固定值比如一律-8那么矮烟花和高烟花看起来节奏完全一样视觉上会非常呆板。反过来用距离决定速度的公式烟花会呈现出飞得高的更快、飞得低的更慢的物理真实感。虽然观众说不清为什么觉得舒服但就是会更像真的。火箭升空过程中每一帧我还会从火箭当前位置生成一颗小尾迹粒子加入尾迹流。尾迹粒子不仅要在位置上落后于火箭还要逐渐变暗变小。这样在视觉上火箭就不是一个孤零零的点而是一道拖着光尾的光柱。// 在火箭位置附近生成尾迹粒子 if (Math.random() 0.6) { const spark acquireParticle(); spark.x rocket.x (Math.random() - 0.5) * 4; spark.y rocket.y 2; spark.vx (Math.random() - 0.5) * 0.3; spark.vy (Math.random() - 0.5) * 0.3; spark.life spark.maxLife 0.3 Math.random() * 0.3; spark.size 1.5 Math.random() * 1.5; spark.color #ffcc66; }2.3 爆炸瞬间的碎片分布算法爆炸是整个烟花的高潮也是观众视线最集中的瞬间。碎片分布如果只是均匀散开一圈看几个后就腻了。我做了几种爆炸形态随机切换圆形炸基础款速度均匀分布在360度方向上适合做经典菊花型。双层炸内圈慢速、外圈快速炸开后会出现两圈交错的光环层次感更强。尾花炸在圆形分布基础上把速度幅度加一点随机但保证整体轮廓仍然是圆边缘会有绒毛感。二次闪爆爆炸后延迟几十毫秒在中心再闪一次形成砰、砰两段观感非常提气。圆形炸的代码逻辑// 在爆炸点生成碎片 const count 80 Math.floor(Math.random() * 40); for (let i 0; i count; i) { const spark acquireParticle(); spark.x centerX; spark.y centerY; // 随机方向 const angle Math.random() * Math.PI * 2; // 基础速度加随机差额 const speed 2 Math.random() * 4.5; spark.vx Math.cos(angle) * speed; spark.vy Math.sin(angle) * speed; spark.gravity 0.04 Math.random() * 0.02; spark.drag 0.98; spark.life spark.maxLife 1.5 Math.random() * 1.2; spark.size 1.5 Math.random() * 1.5; // 颜色从预设色卡里选保证同组烟花颜色和谐 spark.color pickColor(colorFamily); }颜色是爆炸视觉里非常容易被忽略但又极影响观感的环节。我准备了几组色卡比如中国红金色、蓝紫青白、翠绿金色每次爆炸随机选一组整朵烟花内部用同一组颜色衍生出细微差异。这比满屏随机颜色高级得多不会让画面变成五颜六色的杂乱噪点。二次闪爆的实现也不复杂爆炸时给中心区域额外保存一个延迟触发器在T60ms时再次生成一圈小碎片。肉眼效果就是中心突然又亮了一下。2.4 重力、阻力与寿命为什么最后都缓缓落地碎片炸开之后如果运动状态一直保持匀速画面会非常假。现实中烟花碎屑会被空气摩擦减速同时受重力影响不断下坠最后带着余光慢慢熄灭。所以我给碎片加了两组参数drag阻力系数和gravity重力值。每帧更新逻辑// 速度衰减 spark.vx * spark.drag; spark.vy * spark.drag; // 重力加速让粒子逐渐向下坠落 spark.vy spark.gravity; // 位置更新 spark.x spark.vx; spark.y spark.vy;drag取0.98时粒子每秒大约会衰减48%的速度视觉上正好是炸开后快速外扩、随后慢下来的感觉。gravity取0.04到0.06粒子在一两秒内会从高点缓缓下落形成烟花的余烬。还有一点粒子寿命结束前我会让它在最后20%的寿命窗口内逐步降低透明度同时缩小size模拟余光熄灭。这比突然消失平滑得多也是画面质感的来源之一。3. 主循环与渲染管线尾迹流光感的秘诀3.1 requestAnimationFrame的节奏控制与时间步长整个动画的驱动器是requestAnimationFrame它会在浏览器每一帧绘制前回调一次。最理想的节奏是60帧每秒但实际运行中刷新率可能是120Hz也可能是老设备的60Hz甚至后台切一下再回来会跳动。我不依赖固定的帧数计数而是用时间戳计算帧间隔。let lastTime 0; function frame(timestamp) { // clamp到33ms防止切后台回来时粒子瞬间飞光 const delta Math.min((timestamp - lastTime) / 1000, 0.033); lastTime timestamp; update(delta); render(); requestAnimationFrame(frame); } requestAnimationFrame(frame);这里最关键的坑是标签页切回来浏览器后台时requestAnimationFrame会暂停切回来时timestamp会突然跳一大截。如果直接用这个delta去更新粒子位置粒子会一瞬间跑出十万八千里整个画面直接崩掉。所以我始终把delta限制在33毫秒以内超过就当没发生只保留最近一帧的运动。这是实机演示前必须做好的保护。3.2 带alpha的背景填充代替clearRect这是让拖尾有流光感的第一个关键诀窍。很多初学者画动画每帧第一件事就是ctx.clearRect(0, 0, w, h)。但对烟花来说清屏会让粒子失去拖尾每一帧都像硬生生剪出来的一张静态图完全没有绽放的余韵。我的做法是不调clearRect而是用半透明黑色填充整块画布ctx.fillStyle rgba(0, 0, 0, 0.14); ctx.fillRect(0, 0, canvasWidth, canvasHeight);这行代码的效果是上一帧画上去的粒子在这一帧被蒙上一层透明度很高的黑色颜色会留一部分、暗一部分下一帧再暗一点最终逐渐消失在背景里。粒子不断被更新和绘制就形成了自然的拖尾渐隐。透明度取多少很讲究太大会导致拖尾消失太快流光感不足太小则残影拖太久画面会越来越脏。我试下来0.12到0.18之间效果最好推荐从0.14起步微调。3.3 合成模式lighter补出光晕半透明填充把暗部拖尾做好了但烟花核心爆炸点那一圈的亮度还是不够。真正烟花炸开的瞬间中心是有一团刺眼白光的。这里借助Canvas的合成模式做加法混合ctx.globalCompositeOperation lighter; // 绘制所有粒子 ctx.globalCompositeOperation source-over;lighter合成模式会把像素颜色相加重叠越多的区域就越亮。同一位置的粒子叠加后显示屏会出现类似真实发光的过曝效果。爆炸中心因为粒子高度密集会自然形成一团明亮的白色光芯炸开后向四周扩散成彩色光雾非常接近真实烟花的观感。但这招不能全域乱用必须只在绘制粒子层时开启画背景和UI时恢复成source-over否则整个画面会被提亮得发灰。3.4 背景层里的城市楼群、月亮和星星一个完整的过年场景除了烟花本身背景氛围也很重要。纯黑背景上放烟花会显得单调我补了三个氛围元素城市楼群画在画面底部随机生成一组矩形高楼填充深蓝色或蓝黑色部分窗户随机点亮为暖黄色小方块。楼群只生成一次之后每帧都要重新绘制因为带alpha的背景填充也会把楼群盖暗。月亮左上角画一个圆外圈叠加一圈径向渐变模拟光晕。月亮不需要每帧更新位置固定即可。星星初始化时生成60到100个随机点每帧以小幅度透明度波动闪烁。这部分代码要在粒子之前绘制因为它们属于背景层。楼群和星星被拖尾渐隐蒙上了一层阴影后再被下一帧重绘补回来视觉上就是楼群和星星稳定不动烟花在它们前面飞。4. 性能优化从卡顿到粒子池再到自适应预算4.1 卡顿的根源频繁GC与过量绘制烟花项目如果没有性能约束写起来特别放飞一次爆炸生成200个对象每秒放10次就是2000个对象。掉帧的根源通常不是计算量而是垃圾回收。JavaScript引擎每分配一批对象隔一段时间就要暂停一下去回收那些不再被引用的对象表现为画面突然卡一下、又恢复流畅循环往复。另外就是绘制量粒子越多Canvas的填充指令越多。即使单个粒子只是画一个圆形指令数量也会让渲染管线不堪重负。所以性能优化的核心就两条减少对象分配控制粒子上限。4.2 对象池的核心实现对象池的思路非常简单不随手new Particle()而是先从一个池子里取用完再还回去。池子空了才真正新建对象池子回收来的对象直接复用字段。const particlePool []; function acquire() { if (particlePool.length 0) { return particlePool.pop(); } return new Particle(); } function release(particle) { particlePool.push(particle); }每帧遍历所有粒子时遇到life 0的粒子就调用release归还池子。这样整个运行期间对象实例总数基本稳定在峰值时最大存活粒子数不会因为频繁创建销毁触发大量GC。尾迹粒子因为很短命一秒钟要产生几十上百个如果不走池子GC压力立刻就会上来。给火箭生成尾迹和爆炸碎片时全部统一走acquire/release这是整个项目最值得做的一步优化。4.3 动态粒子上限与FPS自适应即便有了对象池也不能无限放大粒子数量。我给整个系统设定了一个总粒子上限比如2000个。生成新粒子前先检查当前存活的粒子数如果已经超过上限这次爆炸就少生成一些碎片或者干脆跳过尾迹生成。同时我会主动检测帧率做自适应降级let currentFps 60; let frameCount 0; let fpsTimer 0; function trackFps(delta) { fpsTimer delta; frameCount; if (fpsTimer 1) { currentFps frameCount / fpsTimer; frameCount 0; fpsTimer 0; } }如果当前帧率跌到40以下我会把后续爆炸的单次碎片数量下调30%恢复稳定后再逐步升回来。这叫体面降级让低端设备玩家看到的是烟花数量少一点而不是满屏掉帧。4.4 移动端高分屏适配的隐藏细节在手机上跑不能不处理设备像素比。Canvas的CSS尺寸和实际像素尺寸是两回事如果不设置canvas.width 逻辑宽 * dpr在高分屏上画面会模糊像隔着一层雾。const dpr window.devicePixelRatio || 1; canvas.style.width cssWidth px; canvas.style.height cssHeight px; canvas.width Math.floor(cssWidth * dpr); canvas.height Math.floor(cssHeight * dpr); ctx.scale(dpr, dpr);这里有个坑缩放上下文之后所有粒子坐标仍然按CSS像素计算绘制指令会自动放大到物理像素。但半透明填充rgba(0,0,0,0.14)会因为dpr放大导致拖尾渐隐速度变化实际效果比桌面端更容易变脏。所以如果检测到dpr大于1我会把透明度微调到0.1或0.12拖尾会干净很多。5. 年味装饰与实战避坑从能放到愿意玩5.1 点击放花与自动连发交互设计的层次我把交互分成两个层次主动和被动。主动是用户点击屏幕立刻在点击位置生成一朵烟花被动是页面自己定时随机放保证没有互动时画面照样热闹。点击事件用pointerdown而不是click。click在移动端有300毫秒左右的延迟派发看起来像慢半拍pointerdown是按下瞬间就会触发。另外移动端用pointerdown兼容鼠标和触摸代码只需要一套canvas.addEventListener(pointerdown, (e) { const rect canvas.getBoundingClientRect(); const x e.clientX - rect.left; const y e.clientY - rect.top; spawnRocketAt(x, y); });点击位置生成的火箭爆炸中心应该尽量落在点击位置偏上方所以我给点击生成的火箭设置了目标高度等于点击点的y值到达后立即爆炸形成我在哪里点、哪里开花的即时反馈。自动连发我用的是一段简单的定时调度器每700到1500毫秒随机延迟从屏幕底部不同位置发射一发火箭。之所以不用setInterval而用延迟随机是为了避免所有烟花节奏完全相同看起来像节拍器。let nextAutoFireTime 0; function scheduleAutoFire(timestamp) { if (timestamp nextAutoFireTime) { spawnRocketAt(Math.random() * canvasWidth, canvasHeight); nextAutoFireTime timestamp 600 Math.random() * 900; } }这样每朵烟花的间隔都在600到1500毫秒之间抖动铺满整个屏幕的同时节奏仍然松散不会有一瞬间全部塞满的压迫感。5.2 祝福语叠加过年的仪式感过年页面没有字总觉得缺口气。我在画面顶部做了一个渐变半透明横幅上面滚动显示新年快乐万事如意这类祝福。做法是叠加一条横向渐变背景文字层不用Canvas每帧重绘而是用一个绝对定位覆盖在Canvas上方的HTML元素用CSS动画做横向滚动这样既省渲染文字清晰度也高。如果想更花哨一点也可以把祝福做成文字粒子雨每个文字作为一个粒子从顶部飘落并旋转消散。但实测下来文字用粒子渲染中文字体在Canvas里如果字号小会很糊而且每帧绘制大量文字对性能压力很大。我的建议是普通场景用CSS横幅就够了干净又便宜。5.3 音效用WebAudio生成的爆炸声只有画面没有声音房间里的氛围会弱一半。项目没有找音频素材而是直接用WebAudio合成const ctx new AudioContext(); function playBoom(intensity 1) { const oscillator ctx.createOscillator(); const gain ctx.createGain(); oscillator.type sine; oscillator.frequency.setValueAtTime(160, ctx.currentTime); oscillator.frequency.exponentialRampToValueAtTime(30, ctx.currentTime 0.4); gain.gain.setValueAtTime(0.4 * intensity, ctx.currentTime); gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime 0.6); oscillator.connect(gain); gain.connect(ctx.destination); oscillator.start(); oscillator.stop(ctx.currentTime 0.65); }合成出来的这个低频下坠听起来很像闷响爆竹比真音频文件更灵活强度也能和烟花大小关联。最关键的是用户体验必须在用户第一次点击后创建并恢复AudioContext因为浏览器的自动播放限制不允许页面加载后就有声音。我是在pointerdown事件里调用一次ctx.resume()之后所有boom就都能正常播放了。5.4 我在实机演示时踩过的四个坑这个项目从能跑到真正敢当着人打开我前后修过几个不算复杂但很影响演示印象的问题列出来给你参考。第一个是切后台时间戳跳变。前面说过requestAnimationFrame在后台会暂停切回来时delta会突然变得非常大。如果不用clamp限制屏幕上的烟花会在瞬间全变成几条直线飞出画布场面非常尴尬。我后来不但在时间步长上做了34毫秒上限还在页面重新可见时主动重置lastTime双保险。第二个是半透明填充累积残影变脏。alpha值调大拖尾确实更漂亮但连放几十秒后屏幕上会残留一层淡淡的灰雾星月楼群都不干净了。我的处理是alpha取中值0.14并且每10秒做一次完全的clearRect相当于给画面洗一次脸让背景重新干净起来。视觉上几乎发现不了但画面会长期保持通透。第三个是移动端点击放花的坐标错位。如果页面里有滚动或缩放e.clientX拿到的坐标和Canvas内部坐标可能不在同一套坐标系。我使用getBoundingClientRect做了相对偏移并确保Canvas外层容器没有margin否则点击位置和绽放心位置会差一截。第四个是颜色过曝。lighter合成模式在粒子密集度很高时中心区域会直接变成纯白亮点反而丢失了内部细节。我的解决方法是爆炸中心额外加一层淡金色→透明的径向渐变圆让它更像真实烟花的核心光团同时把碎片粒子size控制在1.5到3之间不要画太大的圆点组合起来就自然多了。项目做下来最大的体会是烟花模拟器最难的地方不在算法而在克制。粒子数量要有上限、拖尾残留要控制、颜色要成组、节奏要有间隔每一样都往真实烟花靠近一点点整体效果就会非常惊艳。小孩问我为什么能放那么多烟花我说因为这里不用等过年。
网站建设高端定制企业官网