☰
Canvas粒子系统打造烟花模拟器:从物理模型到性能优化全解析
2026/9/26 23:48:56 网站建设 项目流程

一到年底,前端群里的热门话题基本就俩:一个是抢票,另一个就是“能不能给我做个烟花”。这个标题“烟花模拟器:满屏流光炸开,过年气氛一秒拉满”,我第一眼看过去就知道它背后要解决的是节日氛围可视化的问题——不仅是过年,各种活动页、大屏、婚礼现场、品牌营销节点都需要这种“一眼就有气氛”的动态效果。

这篇就围绕“烟花模拟器”这个项目,把它的核心思路、物理模型、代码实现、性能优化和场景扩展完整拆开。适合正在做节日H5、活动主视觉、个人博客装饰,或者单纯想用代码造点浪漫的同学参考。我会用Canvas 2D的粒子系统方案来讲,因为这是目前性价比最高、兼容性最好的做法,后面会解释为什么不用WebGL或视频方案。

1. 烟花模拟器的核心思路拆解

1.1 一个标题背后的真实需求

“满屏流光炸开,过年气氛一秒拉满”——这句话本质上描述了三个产品需求:

  • 视觉冲击力:画面要亮、要满、要炸,最好让用户打开页面的第一眼就被抓住。
  • 氛围即时感:不需要用户做复杂操作,最好打开就自动播放,或者轻轻点击一下就能看到效果,即“一秒拉满”。
  • 低成本接入:它通常是一个网页项目里的背景层、弹窗装饰或者活动彩蛋,不能吞噬太多主业务资源。

所以“烟花模拟器”说到底不是单纯的技术Demo,而是一个面向节日场景的轻量级视觉特效组件。你要把它当成一个产品来做,而不是一段好看的代码。理解了这一点,后面所有的设计取舍都有方向了。

市面上很多烟花实现效果看着热闹,但一放到手机浏览器里就掉帧、发烫、变糊。真正要“拉满气氛”,不是粒子越多越好,而是在有限性能预算内让视觉观感最大化工。这是整个项目最核心的设计哲学。

1.2 为什么选Canvas粒子系统而不是GIF、视频、WebGL

我先说结论:在这个场景下,Canvas 2D粒子系统是最优解。不信的话,我们对比一轮:

方案优点缺点适用性
GIF动图制作简单、兼容无敌体积大、没法交互、清晰度差、循环生硬低
视频(MP4/WebM)画质高、特效炫酷、可控性强加载慢、没法交互、透明通道兼容性麻烦中
CSS动画轻量、好调试复杂粒子效果写起来极其痛苦、性能瓶颈明显中低
Canvas 2D粒子轻量、灵活、交互天然、可动态生成任何效果高分辨率大画布有性能压力,需要优化手段高
WebGL/Three.js性能上限高、3D效果炸裂学习成本高、移动端兼容调试复杂、包体积不友好中高

做了几年可视化项目后我的体会是:先问场景复杂度,再选技术栈。烟花虽然是大量粒子运动,但本质是二维平面上的物理模拟+画点渲染。Canvas 2D完全能扛住,而且代码量可控、调试直观、真机表现稳定。WebGL当然能做得更细腻(比如粒子光晕、景深、3D环绕视角),但如果是年底赶活动的项目,用WebGL纯属给自己加风险——没必要。

还有一个容易被忽略的点:Canvas的调试体验极佳。你可以在任何一步把当前帧的粒子坐标、速度打出来看,也可以用浏览器的Performance面板准确分析瓶颈。这种可观测性,对赶时间的项目来说太重要了。

1.3 画出“真实感”的底层逻辑:粒子生命周期

烟花为什么能让人心动?因为它有一个完整的“起承转合”:一颗光点拖着尾巴蹿上高空→在顶端突然炸开→无数碎屑向四面八方飞散→划出抛物线轨迹→在夜空中缓缓熄灭。这个“过程感”才是情绪价值所在。

所以代码里不能只画一堆圆点,而是要模拟出一个粒子的完整生命周期:

  • 上升阶段:一个携带初速度的“母粒子”向上飞行,速度逐渐衰减。
  • 爆炸阶段:母粒子到达指定高度(或者定时引爆),分裂出几十到上百个子粒子。
  • 扩散阶段:子粒子按球面(二维场景下是圆形)随机方向获得初速度,同时受重力影响,划出抛物弧线。
  • 消亡阶段:粒子透明度随生命值降低,最后完全消失,回收复用。

这个生命周期模型是烟花模拟器所有功能的地基。后来我做的文字烟花、连续发射、自动巡航烟花,全部是在这个骨架上做的变体。你只要把生命周期跑通,后面全是可玩的花样。

2. 核心技术细节:运动学模型与视觉渲染

2.1 运动学模型:重力、阻力、速度衰减

烟花在空中飞行的物理过程其实不复杂,主要受两个力影响:重力和空气阻力。为了在代码里模拟得像,我们把每帧粒子状态拆成位置和速度两个矢量:

粒子状态: { x, y, vx, vy, life, maxLife, color, size } 每帧更新: x += vx * dt y += vy * dt vy += GRAVITY * dt // 重力加速度,给下坠的真实感 vx *= DRAG_FACTOR // 空气阻力/能量损耗 vy *= DRAG_FACTOR life -= dt // 生命周期倒数

看代码可能有点抽象,我具体说下参数怎么定义。假设主循环用requestAnimationFrame,帧间隔按16.67ms计算:

  • GRAVITY:建议取值0.02~0.05(基于60FPS的帧尺度)。太小烟花下坠慢,像失重;太大会变成“窜天猴”,还没扩散就掉地上了。
  • DRAG_FACTOR:取0.96~0.99之间。这个值越接近1,阻力越小,粒子飞得越远;越小则粒子越“黏稠”,像在水里炸开。我踩过的一个坑是:第一次用0.9,结果烟花刚炸开就缩成一团,完全没有“花开”的感觉。后来调到0.98,观感就对了。
  • 爆炸初速度:以爆炸点为中心,随机角度生成速度向量。常见写法是:
const angle = Math.random() * Math.PI * 2; const speed = MIN_SPEED + Math.random() * (MAX_SPEED - MIN_SPEED); const vx = Math.cos(angle) * speed; const vy = Math.sin(angle) * speed;

这里的MIN_SPEED和MAX_SPEED决定了爆开的半径范围。通常最大速度在4~8像素/帧,配合阻力衰减,最终爆开半径大约在100到300像素之间。

有读者会问:为什么不直接用真实的物理公式(牛顿第二定律+空气阻力公式)?我的回答是:游戏和视觉特效只需要“看起来对”,不需要“物理上对”。真实物理的常数计算量大、难调参,而且数值也不适合做节奏控制。视觉模拟里,给你足够直觉可控的参数,往往比“精确”更重要。

2.2 粒子视觉:发光效果与颜色心理学

烟花好看,除了运动轨迹,另一个关键在“如何画这个点”。如果只是用fillRect画一个个细小方块,效果会很干瘪;如果每个粒子都加shadowBlur,性能会直接崩掉。

我的做法是预渲染一张光晕精灵图:先用离屏Canvas画一个径向渐变圆点,然后在每一帧把精灵图drawImage到粒子位置。这样既保留了柔和发光感,又避开了实时阴影计算的高昂开销。

// 离屏预渲染光晕 const offscreen = document.createElement('canvas'); offscreen.width = 64; offscreen.height = 64; const octx = offscreen.getContext('2d'); const gradient = octx.createRadialGradient(32, 32, 0, 32, 32, 32); gradient.addColorStop(0, 'rgba(255,255,255,1)'); gradient.addColorStop(0.3, 'rgba(255,255,255,0.8)'); gradient.addColorStop(1, 'rgba(255,255,255,0)'); octx.fillStyle = gradient; octx.fillRect(0, 0, 64, 64);

颜色方面,春节场景有个天然优势:传统烟花配色本身就极适合中国市场审美,金色、红色、紫色、蓝绿色是永远不出错的选择。我在实现颜色随机时用HSL模型,把色相控制在几个锚点附近:

  • 暖金系:色相 40°~50°,饱和度 80% 以上
  • 中国红:色相 0°~10°
  • 梦幻紫:色相 280°~320°
  • 青蓝系:色相 180°~220°

每次爆炸之前随机选一组锚点,再在这个区间内抖动色相值,这样整体色调和谐,又不会单调。别天真地全色域随机,那是“杀马特风”的烟花,不是高级感烟花。

2.3 混合模式:用lighter做出“流光炸开”

Canvas 2D的globalCompositeOperation是一个常常被忽略的宝藏API。默认绘制模式是source-over,新像素直接覆盖旧像素;但烟花这种自发光效果,用lighter模式会刷新你对Canvas的认知——它会把颜色加在一起,模拟光叠加,产生那种“亮到发白”的炸裂感。

ctx.globalCompositeOperation = 'lighter'; // 绘制所有粒子 ctx.globalCompositeOperation = 'source-over';

我实际测试下来的经验是:开启lighter之后,烟火重叠区域的亮度会自然叠加,金色和金色交叠会闪出白金色的高光,观感瞬间从“幼儿园简笔画”升级到“科幻电影特效”。但有个性能折衷:lighter模式下如果粒子数量过大,亮部会饱和成整片白,视觉反而糊掉。所以开启它之后,建议单画面同时存在的粒子总数控制在2000以内,且透明度降一点。

注意:globalCompositeOperation尽量不要在每绘制一个粒子时反复切换,应该在“开始批量绘制粒子前”设置一次,绘制完所有粒子后再改回来。反复切换状态会让Canvas的渲染性能下降非常明显。

3. 实操过程与核心环节实现

3.1 场景搭建:高DPI适配是第一步

很多人的Canvas画出来发虚、模糊,九成是没做高DPI适配。浏览器里canvas.width和 CSS 里canvas的显示宽度是两个概念。手机屏幕的物理像素通常是CSS像素的2~3倍(devicePixelRatio),如果不把画布的实际分辨率放大,系统就会把低分辨率画布拉伸显示,必然糊成一片。

我通常这样初始化:

function initCanvas(canvas) { const dpr = window.devicePixelRatio || 1; const rect = canvas.getBoundingClientRect(); canvas.width = rect.width * dpr; canvas.height = rect.height * dpr; const ctx = canvas.getContext('2d'); ctx.scale(dpr, dpr); return ctx; }

注意这里设置了ctx.scale(dpr, dpr)之后,后面所有代码里的坐标都按CSS像素来写,不用再关心DPR换算,心智负担会轻很多。在窗口尺寸变化时要重新执行这段逻辑,否则画布尺寸和显示区域不一致,要么出现裁剪,要么出现坐标偏移。

3.2 粒子类与烟花类的最小实现

先写一个轻量的粒子类,只记录状态,不管渲染:

class Particle { constructor(options) { this.x = options.x; this.y = options.y; this.vx = options.vx; this.vy = options.vy; this.life = options.life || 100; this.maxLife = this.life; this.color = options.color; this.size = options.size || 2; this.alpha = 1; } update(dt) { const ratio = dt / 16.67; this.vy += GRAVITY * ratio; this.x += this.vx * ratio; this.y += this.vy * ratio; this.vx *= DRAG_FACTOR; this.vy *= DRAG_FACTOR; this.life -= ratio; this.alpha = Math.max(0, this.life / this.maxLife); return this.life > 0; } draw(ctx, sprites) { ctx.globalAlpha = this.alpha; ctx.drawImage(sprites.glow, this.x - 16, this.y - 16, 32, 32); } }

这里有个细节:每个粒子的绘制用精灵图的中心对齐,所以drawImage的起始坐标要减去半宽。直接把精灵图左上角怼到粒子坐标会偏移,肉眼看起来像“粒子们集体往右下角偏了一截”,第一版做的时候很容易忽略。

然后是爆炸逻辑。它负责决定一次烟花在哪里爆、爆出多少个粒子、什么颜色:

class Firework { constructor(x, y, targetY, colorPalette) { this.x = x; this.y = y; this.targetY = targetY; this.colorPalette = colorPalette; this.exploded = false; this.speed = 6 + Math.random() * 4; this.vy = -this.speed; } update(dt) { if (!this.exploded) { this.vy += GRAVITY * 0.3 * (dt / 16.67); this.y += this.vy * (dt / 16.67); if (this.y <= this.targetY) { this.exploded = true; return this.explode(); } return null; } return null; } explode() { const count = 80 + Math.floor(Math.random() * 80); const particles = []; const hue = this.colorPalette[Math.floor(Math.random() * this.colorPalette.length)]; for (let i = 0; i < count; i++) { const angle = Math.random() * Math.PI * 2; const speed = Math.random() * 6 + 2; particles.push(new Particle({ x: this.x, y: this.y, vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed, life: 60 + Math.random() * 40, color: `hsl(${hue}, 85%, 65%)`, size: 1.5 + Math.random() * 1.5 })); } return particles; } }

3.3 主循环与自动发射节奏

主循环是烟花模拟器的心脏。它负责三件事:更新所有烟花和粒子的坐标、过滤掉死亡的粒子、按节奏生成新的上升烟花。这段代码虽然简单,但节奏控制是观感的关键:

const fireworks = []; const particles = []; let canLaunch = true; function loop() { // 用固定时间步长,避免不同屏幕刷新率导致速度不一致 const dt = 16.67; ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.globalCompositeOperation = 'lighter'; // 自动发射:每 18-30 帧发射一颗,形成连绵不断的效果 if (frameCount % (18 + Math.floor(Math.random() * 12)) === 0) { fireworks.push(new Firework( Math.random() * canvas.width, canvas.height + 10, // 从画布外升起 canvas.height * (0.2 + Math.random() * 0.4), // 爆炸高度 colorPalettes[Math.floor(Math.random() * colorPalettes.length)] )); } // 更新所有烟花 for (let i = fireworks.length - 1; i >= 0; i--) { const result = fireworks[i].update(dt); if (result) { particles.push(...result); fireworks.splice(i, 1); } else if (fireworks[i].exploded) { fireworks.splice(i, 1); } } // 更新并绘制所有爆炸粒子 for (let i = particles.length - 1; i >= 0; i--) { const p = particles[i]; if (p.update(dt)) { p.draw(ctx, sprites); } else { particles.splice(i, 1); } } ctx.globalCompositeOperation = 'source-over'; frameCount++; requestAnimationFrame(loop); } loop();

这个“每18~30帧发射一颗”的节奏,是实测下来最稳的:既不会冷场,又不会满屏到糊。你可以把节奏参数暴露成接口,让活动方自己去调“密集程度”——有的人就是喜欢满天炸。另外,requestAnimationFrame会自动匹配屏幕刷新率,不需要自己控制帧率,能用就用,不要去写死setInterval(..., 16)。

3.4 数据驱动配置:让非技术人员也能调参

做项目最怕的不是写代码,是反复改需求。我发现把关键参数提成一个全局配置对象,能节省大量扯皮时间:

const CONFIG = { autoLaunchFrame: [18, 30], // 自动发射帧间隔范围 gravity: 0.03, // 重力 dragFactor: 0.98, // 阻力/速度衰减 explosionCount: [80, 160], // 每次爆炸粒子数 explosionSpeed: [2, 8], // 爆炸初速度范围 riseSpeed: [6, 10], // 上升速度范围 paletteGroups: ['warm', 'red', 'purple', 'cyan'] };

后来我做了一个小面板,把这些参数直接暴露成滑杆,设计师一边拖一边看效果,定稿后我再把数值固化进配置。这个过程我们内部叫“手感校准”,非常有意思:你会发现设计师调的参数和你调的完全不同,他们对“满天星光”的阈值要低得多。这个面板代码不难,但带来的协作效率和幸福感提升是巨大的。

3.5 春节氛围的进阶玩法:文字烟花与点击互动

只做普通烟花,标题里“过年气氛一秒拉满”还是弱了一点。我建议在这个基础上加两个看家玩法,成本不高,效果拔群。

文字烟花:先在一个离屏Canvas上绘制“新年快乐”“2026”“福”等文字,然后读取像素数据,每隔一定间隔取一个像素点作为目标位置,爆炸时让粒子从中心飞向目标位置。核心逻辑就几行:

function textParticles(text, canvas) { const temp = document.createElement('canvas'); temp.width = 400; temp.height = 120; const tctx = temp.getContext('2d'); tctx.font = 'bold 80px sans-serif'; tctx.textAlign = 'center'; tctx.textBaseline = 'middle'; tctx.fillText(text, 200, 60); const imgData = tctx.getImageData(0, 0, 400, 120).data; const points = []; for (let y = 0; y < 120; y += 2) { for (let x = 0; x < 400; x += 2) { if (imgData[(y * 400 + x) * 4 + 3] > 128) { points.push({ x, y }); } } } return points; }

然后把每个目标点作为粒子的“最终位移目标”,让粒子带初速度飞向对应坐标,这就是“文字烟花”的核心。注意:采样步长2决定文字密度,跳越大会稀疏,越小粒子越多性能越吃紧。做“福”或“新年快乐”这种大字号,步长2就够用了。

点击发射:给Canvas绑一个pointerdown事件,把鼠标/手指坐标作为爆炸点,直接生成一颗母粒子并让它垂直升空。这个交互让用户产生“亲手放烟花”的参与感,分享意愿会直线上升——很多用户会觉得这个效果“听我的”,特别有魔力。

4. 常见问题与排查技巧实录

4.1 手机上一卡一卡,怎么办

这个问题我见了太多次。绝大部分原因是粒子数量没做设备分级。在同一台笔记本上测试,1000粒子流畅,但手机GPU性能差了三四倍,同样1000粒子就是PPT。我的解决方案是在初始化时估算屏幕面积,动态计算粒子上限:

const maxParticles = Math.min( 1600, Math.floor(window.innerWidth * window.innerHeight / 6000) );

这个公式的含义是:每6000平方像素配1个粒子,大屏最多1600个。如果用户手机性能特别差(可以检测UA中的低端机或者看FPS实测),还可以再降档。这比写死“永远1000个粒子”科学得多。另外,尽量减少每一帧创建新对象,所有粒子尽量用对象池复用。

4.2 Canvas画布上“影分身”与拖尾糊团

当烟花拖着长长的尾巴时,画面会逐渐积累出一个“鬼影团”,这是因为粒子移动后没有清屏,或者清屏透明度设置不当。常规做法是clearRect直接清除,但如果你想要拖尾效果,可以用“半透明涂层”清屏:

ctx.fillStyle = 'rgba(0, 0, 0, 0.2)'; ctx.fillRect(0, 0, width, height);

这个黑龙潭大法会让上一帧留下的画面变暗但不会完全清除,看起来就有拖尾。注意透明度0.2是经典值:数值越大尾迹越短,越小越长。但如果你后面又要叠lighter混合模式,建议先填充半透明黑,再切 lighter 画粒子,否则背景会越叠越亮,变成灰白一片。

4.3 高分屏下边缘锯齿和模糊

这个前面提过,就是DPR适配。但还有第二层问题:绘制粒子精灵图时,32x32的图在DPR=3的屏幕上被放大到96物理像素,难免轻微模糊。所以做精灵图时建议按照最大DPR来预渲染,比如直接把精灵图设为64x64,绘制时再drawImage缩放到16或24的逻辑像素。这样边缘会更锐利。

还有一些老旧Android浏览器对createRadialGradient的渐变色支持不完全,会显示成硬边圆点。遇到这种问题,我一般直接上drawImage的预渲染精灵,天然规避。

4.4 常见问题速查表

现象可能原因解决方案
画布模糊、边缘锯齿未做DPR适配按 devicePixelRatio 放大 canvas.width/height
移动端掉帧粒子数量过多按屏幕面积动态计算粒子上限,降低 shadowBlur
尾部成片白色糊团lighter混合模式下粒子过多降低粒子透明度,或减少单次爆炸粒子数
拖尾太长太重半透明清屏透明度太低调高 rgba(0,0,0,0.2) 到 0.3~0.4
画面静止不动requestAnimationFrame被浏览器暂停页面切后台再回来要重置动画状态,别依赖后台运行
内存不断上涨粒子数组没清理死粒子每帧遍历时 splice 死亡粒子,或改用对象池

4.5 页面切后台再回来:动画状态重置

做移动端页面经常遇到:用户切到微信聊天,再切回来,发现烟花的粒子数量爆炸式增长。原因很简单:浏览器在后台时requestAnimationFrame被暂停了,但切回来时它把积压的时间一次性补给你,所有dt按真实时间计算,大量粒子在几毫秒内同时出生并涌进数组。

解决方案是记录lastTime,检测到dt > 100(即超过100ms间隙)时就认为发生了后台切换,这时候重置lastTime,不做粒子更新或者直接清场。这个处理写起来很便宜,但能让你的动画在真实手机环境里稳如老狗。

5. 应用场景与后续扩展

5.1 三种落地场景的实战适配

烟花模拟器不是只能放在网页顶部当装饰,它可以做成三种常见形态:

  • H5活动落地页背景:放在表单、抽奖按钮、倒计时模块后面,作为沉浸背景层。此时要注意可读性:粒子别挡主文案。我的建议是画布放在所有内容层的最底层,给内容层加半透明遮罩或深色渐变蒙版,既保氛围又保阅读。
  • 大屏活动背景:公司年会、活动现场的大屏幕,用烟花作为开场倒计时背景。大屏性能通常比手机好,粒子上限可上调到3000+,但要注意分辨率适配,因为大屏可能是1080P也可能是4K,一定要使用DPR适配逻辑。
  • 个人博客/网站顶部装饰:很多技术博主喜欢把烟花放在博客站点头图或404页面。此时要注意模块隔离:把烟花封装成单例模块,只在指定容器内渲染,不污染全局样式,并提供显式启停接口,以免影响页面性能评分。

5.2 下一步扩展:3D烟花、场景联动、音效配合

如果做完基础版还有余力,我建议按这个顺序升级:

  • 音效联动:Web Audio API生成“咻—啪”的模拟音效。注意浏览器自动播放策略,需要用户点击之后才可以放声音。实测下来,音效的节奏感(升空0.6秒后爆炸)比粒子视觉效果更能带动情绪,两个配合好,气氛值翻倍。
  • 3D烟花:把二维粒子升级成三维,用旋转投影或者直接上Three.js。操作复杂度和性能开销都上了一个台阶,适合对视觉效果有执念的进阶玩家。
  • AI自动排列:根据背景图片的亮度分布自动决定烟花爆炸的位置和颜色,在暗区域爆亮烟花,在亮区域避免爆黄色盲区。这个方向很有意思,等于是给烟花模拟器加了一个“场景审美模型”。

5.3 最后再分享一个小技巧

做这个项目时,我踩过了“不停加粒子”的弯路——总觉得不够炸,于是粒子越来越多,最后手机烫得像暖手宝。后来我痛定思痛,把性能监控跑起来,发现真正提升观感的是粒子大小和透明度,不是粒子数量。把粒子从2像素增加到3像素,视觉冲击力直接提升一截,而性能消耗几乎没有增加。所以我后来的做法是:粒子数量控制在千级以内,用大小和颜色来补足视觉饱满度。这个经验希望对你有用。

今年过年你要是想给网页加点年味,可以直接照这个思路做一个。一幅画布、一段物理学、一场流光溢彩,代码写出来不过几百行,但那种“满屏流光炸开”的感觉,是真的能一秒拉满节日的仪式感。

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

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

立即咨询