1. 从零手搓一个Canvas烟花:为什么我选了最笨的办法
很多人第一次做网页动画,脑子里蹦出来的第一个念头就是找个现成的粒子库,配置几个参数,烟花就炸开了。我一开始也这么想,但后来发现,用库做出来的东西,你永远不知道它内部到底发生了什么——粒子怎么发射的、重力怎么模拟的、拖尾怎么实现的,全是黑盒。所以这次我决定用最原始的方式,从一张空白HTML文件开始,一行一行把烟花效果搭出来。
这个项目的目标很简单:在浏览器里点击任意位置,从屏幕底部发射一枚烟花弹,升到一定高度后爆炸,散落出几十个带拖尾的彩色粒子,粒子受重力影响自然下落,最终淡出消失。听起来不复杂,但真正动手写的时候,你会发现每个环节都有坑。比如粒子数量一多就卡顿、拖尾效果用clearRect还是半透明填充、爆炸时粒子的速度方向怎么均匀分布,这些问题不亲自踩一遍,光看别人的代码是理解不透的。
这篇文章适合谁看?如果你已经会写基本的HTML和JavaScript,知道canvas的getContext('2d')是干嘛的,但还没做过完整的动画项目,那这篇内容就是为你准备的。我会把每个决策背后的理由讲清楚,包括我为什么不用现成的物理引擎、为什么选择requestAnimationFrame而不是setInterval、以及粒子拖尾到底该怎么实现才既好看又不掉帧。代码不会太长,但每一段我都会解释清楚它在干什么。
先说一下最终效果的技术轮廓:整个项目只有一个HTML文件,内嵌CSS和JavaScript,不依赖任何外部库。核心是一个全屏的canvas元素,通过requestAnimationFrame驱动一个持续运行的渲染循环。烟花弹和爆炸后的粒子都用同一个粒子类来表示,区别只在于初始参数不同。拖尾效果通过每帧用半透明黑色矩形覆盖整个画布来实现,而不是逐个粒子画轨迹。这个方案不是最完美的,但对初级阶段来说,它是性价比最高的选择。
2. 粒子系统的骨架:一个类搞定烟花弹和爆炸碎片
2.1 为什么只用一个Particle类
刚开始写的时候,我本能地想创建两个类:一个Firework表示上升的烟花弹,一个Particle表示爆炸后的碎片。但写到一半发现,这两者的行为高度重合——都有位置、速度、加速度,都需要每帧更新位置,都需要绘制自己,都需要判断是否该消失。唯一的区别是烟花弹不受重力影响(或者说受很小的重力),而碎片受较大的重力;烟花弹到达目标高度后要触发爆炸,碎片则不需要。
所以我把它们合并成了一个Particle类,通过一个type字段来区分。这样做的好处是更新和绘制的逻辑只需要写一遍,代码量少了将近一半。具体来说,Particle类包含以下属性:
x, y:当前位置坐标vx, vy:当前速度分量gravity:重力加速度,烟花弹设小值,碎片设大值alpha:透明度,用于淡出效果decay:每帧透明度衰减量color:颜色值,用HSL表示方便随机生成size:粒子半径type:'rocket'或'explosion'
更新逻辑很直接:每帧先根据重力更新速度,再根据速度更新位置,最后根据decay更新透明度。当透明度降到0以下或者粒子跑出画布范围时,标记为可移除。
class Particle { constructor(options) { this.x = options.x; this.y = options.y; this.vx = options.vx; this.vy = options.vy; this.gravity = options.gravity || 0.05; this.alpha = 1; this.decay = options.decay || 0.015; this.color = options.color || 0; this.size = options.size || 2; this.type = options.type || 'explosion'; } update() { this.vy += this.gravity; this.x += this.vx; this.y += this.vy; this.alpha -= this.decay; if (this.alpha < 0) this.alpha = 0; } draw(ctx) { ctx.save(); ctx.globalAlpha = this.alpha; ctx.beginPath(); ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2); ctx.fillStyle = `hsl(${this.color}, 100%, 60%)`; ctx.fill(); ctx.restore(); } get isDead() { return this.alpha <= 0 || this.y > canvas.height + 50; } }这里有一个细节值得展开说:颜色的表示方式。我一开始用的是rgb(),每次随机生成三个0-255的整数。但后来发现,爆炸时如果想让同一批粒子的颜色有统一的色调变化(比如从红色渐变到橙色),用HSL会方便得多。HSL的H值控制色相,S控制饱和度,L控制亮度。我只需要在爆炸时随机一个基础色相,然后给每个粒子的色相加一个小范围的随机偏移,就能得到既有统一感又有变化的色彩效果。
2.2 烟花弹的发射逻辑与目标高度计算
烟花弹的发射是整个效果的起点。当用户在画布上点击时,我需要做几件事:确定发射起点、确定目标高度、计算初始速度。
发射起点我固定在画布底部中央偏一点的位置,加上一个小的水平随机偏移,这样连续点击时烟花不会全部从同一个点升空。目标高度我设定为画布高度的20%到50%之间的随机值,这样有的烟花飞得高、有的飞得低,视觉上更有层次感。
初始速度的计算用到了最基础的物理公式。假设烟花弹从y0升到y1,初速度为vy,重力加速度为g,那么到达最高点时速度为零,根据运动学公式:
vy² = 2 * g * (y0 - y1)
所以vy = -Math.sqrt(2 * g * (y0 - y1)),负号表示向上。这里g我取0.05,是一个很小的值,目的是让烟花弹上升得慢一些,看起来更优雅。如果g取太大,烟花弹会像炮弹一样快速上升然后急速下坠,缺少那种缓缓升空的美感。
水平速度vx我设为一个很小的随机值,范围在-1到1之间,这样烟花弹在上升过程中会有轻微的横向漂移,不会完全垂直。
function launchFirework(targetX, targetY) { const startX = canvas.width / 2 + (Math.random() - 0.5) * 200; const startY = canvas.height; const targetHeight = canvas.height * (0.2 + Math.random() * 0.3); const gravity = 0.05; const vy = -Math.sqrt(2 * gravity * (startY - targetHeight)); const vx = (Math.random() - 0.5) * 2; particles.push(new Particle({ x: startX, y: startY, vx: vx, vy: vy, gravity: gravity, decay: 0.008, color: Math.random() * 360, size: 3, type: 'rocket' })); }注意这里decay设的是0.008,比爆炸粒子的0.015要小,因为烟花弹上升过程需要持续一段时间,如果衰减太快,还没到爆炸点就消失了。
2.3 爆炸瞬间:粒子速度的均匀分布算法
爆炸是整个效果最核心的部分。当烟花弹的vy从负值变为正值(即到达最高点开始下落)时,触发爆炸。爆炸的本质是在烟花弹当前位置生成一批粒子,每个粒子有一个随机方向、随机大小的初速度。
方向分布我采用的是角度均分加随机扰动的方式。假设生成60个粒子,那么每个粒子的基础角度就是(i / 60) * Math.PI * 2,然后在这个基础上加一个±0.2弧度的随机偏移。这样做的好处是粒子在圆周上分布均匀,不会出现某个方向粒子扎堆、另一个方向空空如也的情况。
速度大小我设了一个基础值加上随机变化。基础速度决定了爆炸的范围,速度越大粒子飞得越远。我试过几个值,最后定在3到6之间随机。太小了爆炸看起来没力气,太大了粒子瞬间飞出屏幕,都来不及欣赏。
function explode(x, y, baseColor) { const count = 60; for (let i = 0; i < count; i++) { const angle = (i / count) * Math.PI * 2 + (Math.random() - 0.5) * 0.4; const speed = 3 + Math.random() * 3; particles.push(new Particle({ x: x, y: y, vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed, gravity: 0.08, decay: 0.012 + Math.random() * 0.008, color: baseColor + (Math.random() - 0.5) * 40, size: 1.5 + Math.random() * 1.5, type: 'explosion' })); } }这里色相的偏移范围我设的是±20度(代码里写的是±40的一半,因为(Math.random() - 0.5) * 40的范围是-20到20)。这个范围能让同一批爆炸的粒子有颜色变化,但又不会偏离主色调太远。如果你想要更丰富的色彩,可以把这个值调大,但超过60度就会显得杂乱。
3. 渲染循环与拖尾效果:半透明覆盖的取舍
3.1 requestAnimationFrame为什么比setInterval靠谱
动画循环我一开始用的是setInterval(loop, 16),想着16毫秒差不多就是60帧每秒。但实际跑起来发现两个问题:第一,setInterval的时间间隔并不精确,浏览器忙的时候可能延迟到20毫秒甚至更久,导致动画卡顿;第二,当页面切到后台时,setInterval仍然在跑,浪费CPU资源。
换成requestAnimationFrame之后,这两个问题都解决了。它由浏览器统一调度,保证在每次重绘之前调用,时间间隔与显示器刷新率同步。页面切到后台时自动暂停,切回来再继续。用法也很简单:
function loop() { update(); render(); requestAnimationFrame(loop); } requestAnimationFrame(loop);这里不需要手动计算时间差,因为我的粒子更新逻辑是基于帧的(每帧速度加固定值),而不是基于时间的。对于初级阶段来说,这样写最简单,效果也够用。如果你要做更精确的物理模拟,比如不同刷新率下速度一致,那就需要引入deltaTime,但那是进阶话题了。
3.2 拖尾效果的两种实现方案对比
拖尾是烟花效果好不好看的关键。我试过两种方案:
第一种是每个粒子保存自己的历史位置,绘制时把最近N个位置连成线。这种方案拖尾精确,每个粒子的轨迹独立,但性能开销大——每个粒子要存一个数组,每帧要遍历数组画线,粒子一多就卡。
第二种是用半透明矩形覆盖整个画布。每帧渲染前,用rgba(0, 0, 0, 0.15)填充整个画布,而不是用clearRect完全清除。这样上一帧的画面会残留一部分,形成拖尾效果。透明度越低,拖尾越长;透明度越高,拖尾越短。
我最终选了第二种,原因很简单:性能好。不管有多少粒子,覆盖操作的开销是固定的,就是一次全屏填充。而且拖尾效果更柔和,粒子移动的轨迹像一条渐隐的光带,比逐粒子画线更自然。
function render() { ctx.fillStyle = 'rgba(0, 0, 0, 0.15)'; ctx.fillRect(0, 0, canvas.width, canvas.height); particles.forEach(p => p.draw(ctx)); }这里0.15这个值我调了好几次。0.1的话拖尾太长,画面容易糊成一片;0.2的话拖尾太短,粒子看起来像一个个孤立的点。0.15是一个比较平衡的值,既有明显的拖尾,又不会让画面变得浑浊。
注意:用半透明覆盖代替clearRect有一个副作用——如果你在画布上画了静态背景,它也会被逐渐覆盖变暗。解决办法是把背景画在另一个canvas层上,或者每帧重新绘制背景。初级阶段为了简单,我直接用纯黑背景,所以没有这个问题。
3.3 粒子数量控制与性能边界
粒子数量是性能的关键。一枚烟花爆炸产生60个粒子,如果同时有5枚烟花在空中,那就是300个粒子。每个粒子每帧要执行update和draw,300个粒子的开销在大多数设备上还能接受。但如果用户疯狂点击,粒子数飙升到1000以上,帧率就会明显下降。
我加了一个简单的限制:当particles数组长度超过800时,不再响应新的发射请求。同时,已经死亡的粒子要及时从数组中移除,避免数组无限增长。
function update() { for (let i = particles.length - 1; i >= 0; i--) { const p = particles[i]; p.update(); if (p.type === 'rocket' && p.vy >= 0) { explode(p.x, p.y, p.color); particles.splice(i, 1); continue; } if (p.isDead) { particles.splice(i, 1); } } }这里用倒序遍历是因为splice会改变数组长度,正序遍历会跳过元素。这是一个很常见的坑,我第一次写的时候没注意,导致部分粒子永远不被移除,内存越用越多。
另外,爆炸触发的条件是p.vy >= 0,也就是烟花弹到达最高点开始下落的那一刻。这里有一个小问题:如果重力设得比较小,烟花弹在最高点附近速度变化很慢,可能会在vy接近0的时候停留好几帧,导致爆炸触发延迟。解决办法是判断p.vy >= -0.5,给一个提前量。但这样又可能导致爆炸位置偏低。我最后用的是p.vy >= 0,配合0.05的重力,实际效果可以接受。
4. 那些让我调试了半天的坑
4.1 粒子拖尾导致的颜色叠加问题
用半透明覆盖做拖尾的时候,我发现一个现象:当多个粒子在同一区域反复经过时,那个区域会越来越亮,最后变成一团白色。原因是每个粒子的颜色是叠加的,半透明覆盖只能让旧画面变暗,但不能阻止新粒子在同一位置不断叠加亮度。
这个问题在爆炸初期特别明显,因为粒子都集中在爆炸点附近。解决办法有两个:一是降低粒子的初始透明度,让每个粒子的贡献小一点;二是给粒子加一个最大亮度限制,但canvas的2D上下文不支持这种操作。
我最后用的是第一个办法,把粒子的alpha初始值从1降到0.8,同时把decay稍微调大一点。这样粒子在爆炸点附近快速衰减,不会在同一位置叠加太多亮度。效果上,爆炸瞬间的亮度稍微降低了一点,但整体更干净,不会出现刺眼的白团。
4.2 高DPI屏幕上的模糊问题
在普通屏幕上测试没问题,但拿到高DPI屏幕(比如Retina屏)上一看,整个画面都是模糊的。原因是canvas的默认尺寸是CSS像素,但实际渲染时会被放大到物理像素,导致每个绘制点被拉伸。
解决办法是设置canvas的width和height属性为CSS尺寸乘以devicePixelRatio,然后用ctx.scale把绘制坐标系缩放回来。
function resizeCanvas() { const dpr = window.devicePixelRatio || 1; canvas.width = window.innerWidth * dpr; canvas.height = window.innerHeight * dpr; canvas.style.width = window.innerWidth + 'px'; canvas.style.height = window.innerHeight + 'px'; ctx.scale(dpr, dpr); }注意这里有一个坑:每次resize的时候如果重复调用ctx.scale,缩放会累加。所以要么在resize时重置变换矩阵,要么把scale操作放在每次render之前。我选择的是在resize时先调用ctx.setTransform(1, 0, 0, 1, 0, 0)重置,再重新scale。
4.3 点击位置与发射位置的脱节
最初我的设计是点击哪里,烟花就从哪里发射。但实际用起来发现,如果点击屏幕上方,烟花弹从底部升到点击位置需要很长时间,而且上升过程中用户可能已经忘记自己点过哪里了。如果点击屏幕下方,烟花弹还没升起来就爆炸了,效果很奇怪。
后来我改成:点击只决定烟花的水平位置和目标高度,发射点始终在屏幕底部。这样无论点哪里,烟花都是从底部升空,符合真实烟花的物理直觉。目标高度根据点击的y坐标映射到画布高度的20%到60%之间,点击越靠上,烟花飞得越高。
canvas.addEventListener('click', (e) => { const rect = canvas.getBoundingClientRect(); const clickX = e.clientX - rect.left; const clickY = e.clientY - rect.top; const targetHeight = canvas.height * (0.2 + (1 - clickY / canvas.height) * 0.4); launchFirework(clickX, targetHeight); });这个映射关系我调了几次。如果直接用clickY作为目标高度,点击屏幕顶部时烟花要飞到接近顶部才爆炸,上升时间太长。映射到20%到60%之间后,最高也就飞到画布高度的60%位置,上升时间控制在1秒左右,节奏比较舒服。
4.4 爆炸粒子的重力与衰减平衡
爆炸粒子的重力和衰减需要配合调整。如果重力太大,粒子会快速下坠,看起来像瀑布而不是烟花;如果重力太小,粒子会在空中飘太久,显得不自然。衰减也是,太快了粒子刚炸开就消失,太慢了满屏都是残留粒子。
我试过几组参数,最后定下来的是:重力0.08,衰减0.012到0.02之间随机。这个组合下,粒子从爆炸点向外飞散,大约1.5秒后基本消失。视觉上既有扩散的张力,又有下落的自然感。
这里有一个经验:不同颜色的粒子可以用不同的衰减值。暖色(红橙黄)衰减快一点,冷色(蓝紫)衰减慢一点,这样爆炸后期会呈现出从暖色到冷色的渐变,层次感更强。这个技巧是我在看别人的作品时注意到的,自己试了一下确实有效。
5. 还能怎么玩:从初级阶段往上走的方向
5.1 增加烟花类型的多样性
现在的烟花只有一种爆炸模式——圆形均匀扩散。实际上可以很容易地扩展出其他类型:
- 柳树型:粒子初速度较小,重力较大,爆炸后像柳枝一样下垂
- 棕榈型:粒子初速度大,重力小,爆炸后形成细长的放射状线条
- 菊花型:粒子初速度中等,但衰减慢,形成饱满的球形
- 双层爆炸:第一次爆炸后,部分粒子在飞行一段距离后再次爆炸
实现方式是在explode函数里加一个type参数,根据不同类型调整粒子的速度范围、重力、衰减等参数。代码结构不需要大改,只是参数不同。
5.2 用尾迹数组实现更精确的拖尾
半透明覆盖的拖尾虽然性能好,但有一个局限:所有粒子的拖尾长度是一样的,而且拖尾方向是全局的。如果想要每个粒子有独立的、跟随运动方向的拖尾,就需要用尾迹数组。
具体做法是每个粒子维护一个positions数组,每帧把当前位置push进去,如果数组长度超过设定值就shift掉最旧的。绘制时用ctx.beginPath()和ctx.lineTo()把数组里的点连成线,线条的透明度从旧到新逐渐增加。
这种方案性能开销大,但效果更精致。适合粒子数量较少(比如每枚烟花30个粒子)但要求高质量的场景。如果要做,建议把粒子数量减半,把省下来的性能预算用在尾迹绘制上。
5.3 音效与视觉的同步
加上音效会让烟花的沉浸感提升一个档次。发射时播放一个"咻"的上升音,爆炸时播放一个"啪"的爆裂音。音效文件可以用Web Audio API合成,也可以加载外部音频文件。
同步的关键是时机。发射音在launchFirework调用时播放,爆炸音在explode调用时播放。注意浏览器的自动播放策略:音频必须在用户交互(比如点击)之后才能播放,所以第一次点击之前不要尝试播放任何声音。
const audioCtx = new (window.AudioContext || window.webkitAudioContext)(); function playExplosionSound() { const oscillator = audioCtx.createOscillator(); const gainNode = audioCtx.createGain(); oscillator.connect(gainNode); gainNode.connect(audioCtx.destination); oscillator.frequency.setValueAtTime(100, audioCtx.currentTime); oscillator.frequency.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.5); gainNode.gain.setValueAtTime(0.3, audioCtx.currentTime); gainNode.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.5); oscillator.start(); oscillator.stop(audioCtx.currentTime + 0.5); }这段代码合成的是一个低频的"砰"声,通过快速降低频率和音量来模拟爆炸的衰减。实际效果肯定比不上真实录音,但胜在不需要加载外部文件,而且每次爆炸的音色可以有细微的随机变化。
5.4 自动发射模式与交互节奏
手动点击虽然有趣,但看久了会累。加一个自动发射模式,每隔随机时间自动发射一枚烟花,这样页面可以当作动态壁纸来用。
自动发射的间隔我设在800到2000毫秒之间随机,避免节奏过于规律。同时保留手动点击,两者不冲突。如果用户一段时间不点击,自动发射继续;用户点击时,手动发射的烟花和自动发射的烟花同时存在,画面更热闹。
function autoLaunch() { const x = Math.random() * canvas.width; const y = canvas.height * (0.2 + Math.random() * 0.4); launchFirework(x, y); setTimeout(autoLaunch, 800 + Math.random() * 1200); }这个递归的setTimeout比setInterval更灵活,因为每次间隔可以不同。注意在页面不可见时暂停自动发射,可以用document.hidden来判断。
5.5 移动端的触摸适配与性能降级
在手机上测试时发现两个问题:一是触摸事件和点击事件的行为不同,需要用touchstart来替代click;二是手机GPU性能有限,粒子数量需要降低。
触摸适配很简单,把click事件换成touchstart,然后从e.touches[0]里取坐标。但要注意阻止默认行为,否则可能会触发页面滚动或缩放。
性能降级我用了两个策略:一是检测屏幕宽度,小于768px时把爆炸粒子数从60降到30;二是检测帧率,如果连续几帧的间隔超过30毫秒,自动降低粒子数。帧率检测可以用performance.now()来计算。
let lastFrameTime = performance.now(); let lowPerformanceMode = false; function loop(now) { const delta = now - lastFrameTime; lastFrameTime = now; if (delta > 30) { lowPerformanceMode = true; } // ... 其余逻辑 }这个降级策略比较粗糙,但初级阶段够用了。更好的做法是用一个滑动窗口计算平均帧率,避免因为偶尔一帧卡顿就触发降级。
5.6 颜色主题与节日氛围
不同的节日可以用不同的颜色主题。比如春节用红金配色,中秋用蓝银配色,万圣节用橙紫配色。实现方式很简单,定义一个颜色数组,爆炸时从数组里随机取色,而不是完全随机色相。
const themes = { spring: [0, 30, 45, 60], midAutumn: [200, 220, 240, 280], halloween: [20, 30, 270, 290] };每个主题是一组色相值,爆炸时从这组值里随机选一个作为基础色相,再加小范围偏移。这样同一批烟花的颜色既有变化,又统一在主题色调内。
我在实际做的时候,还加了一个根据当前月份自动选择主题的逻辑。比如12月到2月用春节主题,9月到10月用中秋主题。这个逻辑很简单,但能让页面在不同时间打开时有不同的感觉,算是一个小惊喜。
5.7 代码组织与可维护性
初级阶段代码全写在一个文件里没问题,但如果要继续扩展,建议拆分成几个模块:Particle类单独一个文件,渲染循环一个文件,事件处理一个文件,配置参数一个文件。配置参数单独抽出来特别重要,因为调参是最高频的操作,如果参数散落在代码各处,改一个值要翻半天。
const CONFIG = { rocket: { gravity: 0.05, decay: 0.008, size: 3 }, explosion: { count: 60, gravity: 0.08, speedMin: 3, speedMax: 6, decayMin: 0.012, decayMax: 0.02, sizeMin: 1.5, sizeMax: 3 }, trail: { alpha: 0.15 }, maxParticles: 800 };把所有可调参数集中在一个对象里,调参的时候只需要改这一个地方。这个习惯我从这个项目开始养成,后来做任何动画项目都会先建一个CONFIG对象。
6. 写在最后:初级阶段的价值在于亲手踩坑
这个烟花效果从技术角度看确实不复杂,没有用到WebGL,没有着色器,没有复杂的物理模拟。但正是因为它简单,每个环节的问题都暴露得很清楚:粒子怎么管理、拖尾怎么实现、性能怎么优化、不同设备怎么适配。这些问题在复杂的项目里往往被各种抽象层掩盖了,你调一个库的参数就能出效果,但不知道背后发生了什么。
我自己做完这个项目最大的收获,是对canvas的渲染机制有了更直观的理解。比如半透明覆盖和clearRect的区别、globalAlpha的叠加效果、requestAnimationFrame的调度时机,这些知识点看文档也能懂,但只有亲手写一遍、调一遍,才会真正变成自己的东西。
如果你也在做类似的东西,我的建议是:先让它在你的电脑上跑起来,然后拿到手机上试试,再拿到别人的电脑上试试。不同环境下的表现差异会让你发现很多在开发机上注意不到的问题。调参的时候不要怕麻烦,每个参数都试几组值,感受一下变化。最后,代码写完了可以放一放,过几天再回来看,往往能发现当时没注意到的改进点。