ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Canvas粒子系统打造烟花模拟器:从物理模型到性能优化全解析

Canvas粒子系统打造烟花模拟器:从物理模型到性能优化全解析 一到年底前端群里的热门话题基本就俩一个是抢票另一个就是“能不能给我做个烟花”。这个标题“烟花模拟器满屏流光炸开过年气氛一秒拉满”我第一眼看过去就知道它背后要解决的是节日氛围可视化的问题——不仅是过年各种活动页、大屏、婚礼现场、品牌营销节点都需要这种“一眼就有气氛”的动态效果。这篇就围绕“烟花模拟器”这个项目把它的核心思路、物理模型、代码实现、性能优化和场景扩展完整拆开。适合正在做节日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的图在DPR3的屏幕上被放大到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像素视觉冲击力直接提升一截而性能消耗几乎没有增加。所以我后来的做法是粒子数量控制在千级以内用大小和颜色来补足视觉饱满度。这个经验希望对你有用。今年过年你要是想给网页加点年味可以直接照这个思路做一个。一幅画布、一段物理学、一场流光溢彩代码写出来不过几百行但那种“满屏流光炸开”的感觉是真的能一秒拉满节日的仪式感。
返回列表