ARTICLE DETAIL

资讯详情

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

Canvas粒子系统实战:烟花模拟器从零实现与优化

Canvas粒子系统实战:烟花模拟器从零实现与优化 过年那几天我窝在家里写了个“烟花模拟器”。没什么特别的理由就是想给客厅电视弄个循环播放的烟花背景亲戚来的时候不用干巴巴坐着屏幕里老有东西在炸气氛确实不一样。后来亲戚走了我自己倒是停不下来把这套代码改了又改干脆做成一个能放手机、能放电脑、能戳屏交互的小玩意。说“满屏流光炸开过年气氛一秒拉满”一点不夸张。它不是静态图也不是提前录好的视频而是一段实时演算的粒子动画每次点击鼠标或者手指一点屏幕上就会冲出一束光飞到半空炸成几十上百朵拖着尾巴的亮星重力把它们往回拽颜色从金色到粉紫渐变整个过程大概两秒到三秒视觉上非常接近真实烟花。这篇文章就围绕这个“烟花模拟器”来写把它从零到一讲透核心的粒子系统怎么做、物理轨迹怎么算、颜色怎么调得炸眼但不刺眼、性能怎么优化。适合两类人看一是想给自己的网页、小程序加点年味交互的开发者二是完全没写过代码、但想在自己的设备上跑一个现成烟花页面的朋友。后者我后面会给出一个可以直接新建HTML文件运行的完整例子复制粘贴就能看。1. 整体设计思路拆解为什么是粒子系统而不是视频先聊方案选型。市面上能看到的“烟花模拟器”大致有三类预渲染视频循环、GIF动图、代码实时绘制。视频和GIF的优势是省事缺点也明显——分辨率固定、颜色不可变、无法交互、放大糊掉。我想要的效果是亲戚戳屏幕能触发烟花电视上放着又能自动连发这只有代码绘制能做到。于是核心选型很清晰Canvas 2D 粒子系统。Canvas负责绘制每一帧的画面粒子系统负责模拟烟花的运动。所谓“粒子系统”你可以把它理解成一大堆小点每个小点都有位置、速度、寿命、颜色这几个基本属性。烟花爆炸本质上就是一瞬间生成几十上百个小点让它们朝各个方向飞出去再用重力把它们拉回地面整个过程由程序逐帧更新所以视觉上才有“炸开—上升—坠落”的完整动感。为什么不选WebGL或CSS动画也是认真考虑过的。WebGL性能上限更高一万个粒子毫无压力但对大多数人来说上手成本高写一个能跑的东西得先搞懂着色器。CSS动画做不了复杂物理模拟单个粒子拆成div看着也滑稽。Canvas 2D做了折中代码简单到新手能看懂性能又足够撑住同时几百个粒子不卡手机实测老机型也扛得住。选定Canvas后续几乎所有的画面细节都围绕它的绘图API展开。另一个关键决策是“用半透明遮罩做拖尾”。第一版烟花炸开后每个粒子都是一条完整的线画面很干净但不够花。后来我发现很多公开的烟花模拟器都有一个共同技巧不彻底清空画布而是每一帧先用半透黑色盖一层再画新粒子。这样上一个位置的粒子会残留一点点余光自然形成拖尾。但这里有个坑——如果底色是黑色半透明遮罩逐帧叠加会把整个画面越压越黑最后除了拖尾什么都看不清。我后面会用“渐暗遮罩加亮度补偿”的方式处理这个细节在第四节专门说。2. 核心技术细节解析运动、颜色、爆炸形态2.1 粒子运动模型重力、阻力与初速度烟花粒子的运动规律其实就是高中物理的抛物线。垂直方向上有重力加速度水平方向上有空气阻力造成的减速两者叠加后粒子走的就是一条从爆炸点往外抛、最后落回来的弧线。用代码表示每一帧更新位置时做三次操作// 每一帧对每个粒子执行 p.vy GRAVITY; // 重力在垂直方向持续加速 p.vx * DRAG; // 空气阻力让水平速度按比例衰减 p.x p.vx; p.y p.vy;GRAVITY通常取0.050.15之间。这个值直接影响烟花的“脆”感。重力太小炸开的碎片飘在空中太久像蒲公英重力太大粒子还没展开就全摔到地上像摔炮。我实测下来屏幕尺寸按1080p算重力取0.08最接近真实烟花——上升段保持清脆爆炸后粒子在空中停留约1.5秒短暂而欢快。初速度的分配更有讲究。真实烟花爆炸后火花不是均匀朝所有方向飞而是有一定随机性。我的做法是给每个粒子一个随机角度和一个随机速度范围const angle Math.random() * Math.PI * 2; // 随机方向 const speed 0.5 Math.random() * 2.5; // 随机速度 p.vx Math.cos(angle) * speed; p.vy Math.sin(angle) * speed - 0.8; // 略微向上偏模拟爆炸冲击角度均匀随机之后爆炸形状是一个圆。如果想要菊花形状就让粒子沿几个固定的对称角度发射如果想要“垂柳”效果就让粒子的重力更大、初速度更小。通过调整这两组参数你能从同一套代码里变出十几种烟花形态。我最终做成了可配置的爆炸形态分两种——圆球爆和菊花爆交替出现画面更丰富。2.2 颜色渲染HSL渐变与发光叠加真实烟花最迷人的是颜色过渡刚炸开时是亮白随后逐渐显现金色、粉紫、青色。用RGB硬编码去做这种渐变非常痛苦因为你很难预判中间过渡色。我换成HSL色彩模型一切变得简单。HSL把颜色拆成色相(Hue)、饱和度(Saturation)、亮度(Lightness)三个维度。烟花渐变只需要让Hue在生命周期里不断变化即可比如从40(金色)慢慢走到300(紫色)饱和度和亮度保持不变中间每个帧都会显示自然的过渡色。// 粒子的hue初始值爆炸时确定 const hue baseHue (Math.random() - 0.5) * 20; // 每帧渲染时寿命越长hue偏移越多 const currentHue (hue p.life * 15) % 360; ctx.fillStyle hsl(${currentHue}, 100%, ${60 Math.random() * 30}%);亮度部分我加了随机闪烁同一粒子的亮度在50%~80%之间抖模拟火花忽明忽暗的感觉。再用Canvas的globalCompositeOperation lighter让重叠粒子相加变亮烟花中心区域会自动过曝成白金色边缘保持鲜艳视觉上“炸得很猛”的冲击感就是这么来的。需要说明的是lighter是Canvas的混合模式它会把多个粒子叠加位置的颜色做加法混合。红色加绿色变黄再加蓝色变白所以粒子密集的地方自然变成暖白色和真实烟花的高温核心几乎一样。这个技巧是全程视觉效果的点睛之笔。2.3 粒子生命周期与淡出策略每个粒子从生成到消失大概会经历2秒。我用一个life变量从1递减到0当life小于等于0时把粒子标记为“死亡”移出数组。颜色、大小、透明度都跟life挂钩让粒子在坠落过程中逐渐暗淡、缩小直到完全消失。p.life - 0.008; p.alpha Math.max(p.life, 0); // 透明度随生命衰减 const size p.size * p.life; // 尺寸同步缩小这里有个体验上的小窍门烟花最亮的那一下只持续前20帧左右如果你不刻意保持亮度衰减慢一点观众会感觉烟花“闪一下就没了”不够过瘾。所以我把透明度曲线改成非线性前10%的寿命几乎不降中间40%快速衰减最后50%缓慢变暗到消失。这样既保留了爆发瞬间的猛烈感拖尾又能延长很久整个爆炸过程更耐看。3. 完整实操从零写一个可运行的烟花模拟器3.1 搭建页面骨架与画布准备新建一个HTML文件把下面的内容贴进去浏览器打开就能跑。这个文件是完整体验版包含自动发射和点击发射两种模式。!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0, user-scalableno title烟花模拟器/title style * { margin: 0; padding: 0; box-sizing: border-box; } body { overflow: hidden; background: #0a0e17; } canvas { display: block; cursor: crosshair; } /style /head body canvas idfireworks/canvas script src./fireworks.js/script /body /html注意user-scalableno和cursor: crosshair这两个细节。前者是给手机准备的禁止页面缩放才能保证手指点击位置和canvas坐标系对得上后者是给电脑准备的十字光标会让用户觉得“这地方可以点”算是个无言的引导。canvas的尺寸需要在JavaScript里根据窗口实时设置。这里要特别处理浏览器地址栏时隐时现的问题所以不能只用window.innerHeight我加了一个resize事件在窗口变化时重置尺寸。const canvas document.getElementById(fireworks); const ctx canvas.getContext(2d); let W, H; function resize() { W canvas.width window.innerWidth; H canvas.height window.innerHeight; } window.addEventListener(resize, resize); resize();3.2 粒子类设计携带所有状态的对象我用一个普通的JSON对象表示粒子没用class原因是为了性能。在每帧生成大量粒子时new操作和原型链查找会带来额外的内存分配开销直接操作对象字面量配合后面说的对象池复用GC压力能明显下降。function createParticle(targetX, targetY, fromX, fromY, hue) { const angle Math.random() * Math.PI * 2; const speed 0.6 Math.random() * 2.8; return { x: fromX, y: fromY, vx: (targetX - fromX) * 0.03 Math.cos(angle) * speed * 0.5, vy: (targetY - fromY) * 0.03 Math.sin(angle) * speed * 0.5, life: 1, maxLife: 1, alpha: 1, hue: hue (Math.random() - 0.5) * 15, size: 1.5 Math.random() * 2, gravity: 0.08 Math.random() * 0.02 }; }createParticle接收两组坐标targetX/targetY是爆炸点fromX/fromY是发射起点。粒子生成时不仅朝随机方向飞还会带一个指向爆炸点的初始分量这样爆炸后火花看起来是“从底部冲上来再散开”的有一个方向感比原地向外扩散更有爆发力。3.3 三级流程发射弹头、空中爆炸、碎片坠落整个烟花模拟器的主循环分为三个状态弹头上升、爆炸生成、碎片飞散。弹头是一颗独立的亮白色粒子从屏幕底部某处向上发射上升到目标高度后触发爆炸。let rockets []; // 上升中的弹头 let particles []; // 爆炸后的粒子 // 每帧主循环 function tick() { drawBackground(); updateRockets(); updateParticles(); drawParticles(); requestAnimationFrame(tick); }updateRockets里弹头每次上升都检查是否到达目标高度到了就爆炸——生成大量粒子同时把弹头自己销毁。这里有个随机延迟设计弹头到达目标高度后不立即爆炸而是等待0.3~0.5秒模拟引信延迟能防止所有烟花爆炸时间过于整齐显得假。爆炸高度我设置在屏幕高度H的0.18~0.4之间。太低烟花会被地面遮挡太高视觉冲击力不足。想要满屏效果就把发射点分散在屏幕底部横向不同位置并让每个弹头的爆炸高度差异大一点保证屏幕上下都有爆点。3.4 完整代码包与调参透参手册下面是完整的fireworks.js。我删减了不必要的分支保留核心逻辑。直接粘贴就能跑注释放在代码里方便对照修改。const FIREWORKS_COUNT 3; // 每轮自动发射几发 const PARTS_COUNT 70; // 每次爆炸粒子数 const GRAVITY 0.085; // 重力加速度调大垂得更快 const DRAG 0.985; // 空气阻力调小拖尾更短 const ALPHA_FADE 0.96; // 拖尾残留量 const MAX_ALPHA 1.0; // 粒子初始透明度 let rockets []; let particles []; let autoTimer 0; let autoInterval 120; // 自动发射间隔帧数 function createRocket() { const x W * 0.15 Math.random() * W * 0.7; const targetY H * 0.15 Math.random() * H * 0.35; const speedY (H - targetY) / 80; rockets.push({ x: x, y: H 10, vx: (Math.random() - 0.5) * 0.5, vy: -speedY, targetY: targetY, hue: Math.random() * 360, life: 1 }); } function explode(r) { const hueBase r.hue; for (let i 0; i PARTS_COUNT; i) { particles.push(createParticle(r.x, r.y, r.x, r.y, hueBase)); } } function createParticle(tx, ty, fx, fy, hue) { const angle Math.random() * Math.PI * 2; const speed 0.6 Math.random() * 2.8; return { x: fx, y: fy, vx: (tx - fx) * 0.03 Math.cos(angle) * speed * 0.5, vy: (ty - fy) * 0.03 Math.sin(angle) * speed * 0.5, life: 1, maxLife: 1, hue: hue (Math.random() - 0.5) * 20, size: 1.5 Math.random() * 2, gravity: 0.08 Math.random() * 0.02 }; } function updateRockets() { for (let i rockets.length - 1; i 0; i--) { const r rockets[i]; r.x r.vx; r.y r.vy; if (r.y r.targetY) { explode(r); rockets.splice(i, 1); } } } function updateParticles() { for (let i particles.length - 1; i 0; i--) { const p particles[i]; p.vy p.gravity; p.vx * DRAG; p.x p.vx; p.y p.vy; p.life - 0.007; if (p.life 0) { particles.splice(i, 1); } } } function drawBackground() { ctx.fillStyle rgba(10, 14, 23, ALPHA_FADE ); ctx.fillRect(0, 0, W, H); } function drawParticles() { ctx.globalCompositeOperation lighter; for (const p of particles) { const hue (p.hue (1 - p.life) * 20) % 360; const alpha Math.min(p.life * 1.5, 1.0); ctx.globalAlpha alpha; ctx.beginPath(); ctx.fillStyle hsl( hue , 100%, (55 Math.random() * 25) %); ctx.arc(p.x, p.y, p.size * p.life, 0, Math.PI * 2); ctx.fill(); } ctx.globalAlpha 1; ctx.globalCompositeOperation source-over; } function mouseHandler(e) { const rect canvas.getBoundingClientRect(); const mx e.clientX - rect.left; const my e.clientY - rect.top; rockets.push({ x: W / 2, y: H 5, vx: (Math.random() - 0.5) * 0.5, vy: -(H - my) / 80, targetY: my, hue: Math.random() * 360, life: 1 }); } canvas.addEventListener(click, mouseHandler); canvas.addEventListener(touchstart, function(e) { e.preventDefault(); const t e.touches[0]; mouseHandler({ clientX: t.clientX, clientY: t.clientY }); }, { passive: false }); function tick() { drawBackground(); autoTimer; if (autoTimer % autoInterval 0) { for (let i 0; i FIREWORKS_COUNT; i) { createRocket(); } } updateRockets(); updateParticles(); drawParticles(); requestAnimationFrame(tick); } tick();调参是这个项目最不能跳过的环节。几个关键参数的实测感受PARTS_COUNT70是普通水平。低于30显得稀稀拉拉150以上画面会满到看不清单颗烟花的形状而且低端手机会掉帧。建议先跑默认值再往上加。GRAVITY0.085配1080p窗口正好。如果你把页面放在很高的显示器上需要稍微加大到0.1否则最后的下落阶段会慢到发飘。ALPHA_FADE0.96是拖尾绵长的甜点值。改到0.98拖尾能拉得很长但底色会泛白改到0.92拖尾几乎消失画面很干净但少了那种“流光”感。autoInterval120表示每120帧自动发一波按60帧算就是2秒一次。如果同时放了好几波画面会过于拥挤我建议自动模式下的间隔不低于90帧。3.5 加上音效发射声与爆炸低频画面足够热闹之后声音是另一个拉满气氛的杠杆。我用Web Audio API直接合成音效不加载任何外部资源。发射声是一个快速上升的锯齿波爆炸声是一个低频噪声脉冲两者叠加在一起和画面节奏对齐氛围感立刻不一样。const audioCtx new (window.AudioContext || window.webkitAudioContext)(); function playLaunch() { const t audioCtx.currentTime; const osc audioCtx.createOscillator(); const gain audioCtx.createGain(); osc.type sawtooth; osc.frequency.setValueAtTime(300, t); osc.frequency.exponentialRampToValueAtTime(800, t 0.5); gain.gain.setValueAtTime(0.15, t); gain.gain.exponentialRampToValueAtTime(0.001, t 0.6); osc.connect(gain).connect(audioCtx.destination); osc.start(t); osc.stop(t 0.6); } function playExplosion() { const t audioCtx.currentTime; const bufferSize audioCtx.sampleRate * 0.5; const buffer audioCtx.createBuffer(1, bufferSize, audioCtx.sampleRate); const data buffer.getChannelData(0); for (let i 0; i bufferSize; i) { data[i] (Math.random() * 2 - 1) * Math.pow(1 - i / bufferSize, 2); } const source audioCtx.createBufferSource(); source.buffer buffer; const filter audioCtx.createBiquadFilter(); filter.type lowpass; filter.frequency.value 1800; const gain audioCtx.createGain(); gain.gain.setValueAtTime(0.3, t); gain.gain.exponentialRampToValueAtTime(0.001, t 0.5); source.connect(filter).connect(gain).connect(audioCtx.destination); source.start(t); }注意手机浏览器要求音频上下文必须在用户手势后创建否则自动播放会被拦截。所以我的做法是页面第一次点击或触摸时初始化audioCtx之后再调用playLaunch/playExplosion就没问题。如果直接自动播放烟花但有声音需求记得在页面上加一个“开启声音”按钮让用户主动触发一次。4. 常见问题与性能优化实际跑出来的坑4.1 颜色发灰混合模式与半透明遮罩的冲突这是所有Canvas烟花项目最容易踩的坑。用了lighter混合模式以后背景遮罩如果是普通的半透明黑色会让整片区域迅速变灰烟花颜色脏脏的。原因是半透明黑色叠加时等于降低了现有画面的亮度而lighter模式又把所有亮度往白色推两种操作互相打架。解决办法有两个。一是把遮罩颜色改成和背景同色系比如我的背景是#0a0e17遮罩就用rgba(10, 14, 23, 0.96)保证盖上去的颜色和底色完全一致叠加多少层颜色都不偏移。二是尽量少用纯黑背景深蓝紫色背景在lighter混合下会更有层次感烟花颜色也更鲜艳。4.2 手机卡顿粒子数量不是唯一瓶颈老手机跑起来掉帧第一反应是降粒子数量但很多时候真正的问题是每帧都新建了大量粒子对象触发频繁GC垃圾回收导致卡顿是间歇性的。优化我用两招。第一招是对象池。预先创建一批粒子对象爆炸时从池里取销毁时放回池里而不是直接删除。这样避免每一帧的反复创建和销毁。const pool []; function getParticle() { return pool.pop() || {}; } function recycle(p) { pool.push(p); }当particles.splice(i, 1)时先调用recycle(particles[i])把这个对象放回池子下次爆炸时复用内存抖动立刻降下来。第二招是降采样。把Canvas的绘制分辨率降低到实际显示分辨率的一半然后用CSS把画布撑回全屏。画面细节减少但拖尾和光晕效果不受影响肉眼几乎看不出来差别帧率能提升将近一倍。canvas.width innerWidth * 0.5; canvas.height innerHeight * 0.5; canvas.style.width innerWidth px; canvas.style.height innerHeight px;不过要注意坐标和鼠标响应逻辑都要按canvas.width重新计算。如果你用了getBoundingClientRect获取点击位置直接把clientX减掉rect.left再乘以2就能映射回实际画布坐标。4.3 爆炸位置不准弹头飞行到目标高度就炸很多初版实现会让弹头以固定速度飞结果爆炸点总是比点击位置偏一点。原因是弹头没有把目标高度纳入速度计算——它只是“往上飞到一个差不多的位置就爆”。我前面createRocket里的这段代码就是解决这个问题的const speedY (H - targetY) / 80;用“从屏幕底部到目标点的总距离除以预计飞行帧数”得到每帧垂直速度。注意这一步计算在发射时就要完成因为后面updateRockets里不会再校准误差。弹头飞行80帧约等于1.25秒正好匹配引信延迟的节奏。如果想要弹头飞得更快就把80调到60画面会变得急促一些过年气氛更热闹。4.4 排查问题速查表现象可能原因处理办法画面全黑只有拖尾背景遮罩颜色与beginPath/arc绘制区域冲突检查drawBackground的fillStyle是否在globalCompositeOperationsource-over下执行手机端点击无响应touch事件被浏览器滚动拦截给canvas监听touchstart时加e.preventDefault()并确保passive:false颜色混浊发暗lighter混合模式被多次fillRect覆盖globalCompositeOperation lighter只在绘制粒子时启用画完背景立刻恢复自动发射和手动点击重叠太密两者都在同时大量生成弹头手动点击时暂停一轮自动发射或者把autoInterval增大到180帧粒子结束时突然消失life归零但透明度没归零渲染时透明度用Math.min(p.life * 1.5, 1.0)而不是直接用p.life给粒子一个淡出缓冲背景过曝看不清烟花遮罩ALPHA_FADE值过大接近1把ALPHA_FADE从0.97降到0.94左右拖尾短一点但画面更清晰5. 扩展玩法让烟花模拟器适配更多场景5.1 网页贺卡与在线烟花墙一个简单的改法就能把网页变成“烟花墙”在画布下方放一排按钮写着“新年快乐”“万事如意”之类的祝福语点击后烟花在文字上方炸开。实现上可以绑定一个词库按点击顺序发射不同颜色和爆炸形态的烟花弹头颜色从词库对应的HSL色相里选视觉统一又有个性。还可以做一个“连续模式”让烟花按预设的时间线炸开配合背景音乐做一场20秒的小演出。我试过给烟花加轻音乐但发现画面最好跟音乐的鼓点对齐。最简单的做法是先录一段音乐分析它的节拍点然后把发射时间硬编码对应到拍点上。听起来麻烦但做出来效果非常惊艳年会、婚礼大屏上都能用。5.2 小程序与电视大屏适配小程序端运行时注意两点一是window.innerWidth在小程序里不存在要改用wx.getSystemInfoSync()获取屏幕尺寸二是小程序Canvas的接口是wx.createCanvasContext和Web的Canvas API不完全一样绘制方式要改写。电视大屏适配相对简单。电视浏览器基本都是Chromium内核直接用这套Web代码就行。但电视分辨率和宽高比差异很大4K电视上看的话要把粒子尺寸乘以2爆炸高度按比例放大否则烟花会显得特别小。另一个要点是电视没有鼠标要用遥控器方向键模拟点击事件或者干脆做成纯自动循环模式不依赖交互。5.3 根据用户输入生成个性化形态除了默认的圆球爆和菊花爆我还加了“爱心爆”的玩法。让粒子沿着心形曲线分布爆炸以后按心形点阵的方向发射。实现也很简单不生成随机角度而是预先计算一组心形曲线的坐标点每个粒子的初速度方向就是“爆炸点到心形点的方向向量”。const lovePoints []; for (let t 0; t Math.PI * 2; t 0.1) { 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); lovePoints.push([x * scale, -y * scale]); }如果你把一发烟花的所有粒子方向都定成心形曲线爆炸后会出现一个临时的爱心轮廓再随着重力坠落散掉。不同节日换成不同形状圣诞树、雪花、数字“2026”都能用同样的方式预设。这个玩法扩展性极强适合喜欢折腾的人继续深挖。最后再分享一个我从整个项目里学到的小技巧做这类视觉特效宁可把参数暴露成配置也不要写死。我把发射数量、粒子重力、寿命、色相范围、自动发射间隔全部整理成一个配置对象后面调起效果来非常方便。过年那几天我在电视上调了一个多小时参数最后发现最耐看的还是那套中规中矩的金色圆球爆——绚烂的东西看久了容易累反而是适度的留白让烟花每一次炸开都能维持住那种惊喜感。
返回列表